package app import ( "database/sql" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" ) func (a *App) adminDeletePlan(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "套餐编号无效") return } result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET status=0,deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id) if err != nil { fail(w, http.StatusInternalServerError, 50001, "删除套餐失败") return } affected, _ := result.RowsAffected() if affected == 0 { fail(w, http.StatusNotFound, 30001, "套餐不存在或已删除") return } var subscriptions, orders int64 _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM subscriptions WHERE plan_id=?`, id).Scan(&subscriptions) _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE product_id=?`, id).Scan(&orders) a.audit(r, "delete", "membership_plan", id, map[string]any{"mode": "soft", "subscriptions": subscriptions, "orders": orders}) reply(w, map[string]any{"success": true, "archivedSubscriptions": subscriptions, "archivedOrders": orders}) } type adminOrderUpdateRequest struct { AmountCent *int `json:"amountCent"` Channel *string `json:"channel"` ProductID *int64 `json:"productId"` Status *string `json:"status"` } func validOrderStatus(status string) bool { return status == "CREATED" || status == "PAID" || status == "REFUND_REQUESTED" || status == "REFUNDING" || status == "REFUNDED" || status == "CLOSED" } func orderEntitlementActive(status string) bool { return status == "PAID" || status == "REFUND_REQUESTED" || status == "REFUNDING" } func (a *App) syncEditedOrderEntitlement(r *http.Request, tx *sql.Tx, orderID, userID, oldPlanID, newPlanID int64, oldStatus, newStatus string) error { needsRevoke := orderEntitlementActive(oldStatus) && (!orderEntitlementActive(newStatus) || oldPlanID != newPlanID) needsGrant := orderEntitlementActive(newStatus) && (!orderEntitlementActive(oldStatus) || oldPlanID != newPlanID) if needsRevoke { if _, err := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { return err } } if needsGrant { var durationDays int if err := tx.QueryRowContext(r.Context(), `SELECT duration_days FROM membership_plans WHERE id=?`, newPlanID).Scan(&durationDays); err != nil { return err } if _, err := tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, newPlanID, fmt.Sprintf("admin_order:%d", orderID), durationDays); err != nil { return err } } if needsRevoke || needsGrant { return a.recomputeMembershipTx(r.Context(), tx, userID) } return nil } func (a *App) adminUpdateOrder(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) var req adminOrderUpdateRequest if err != nil || decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "订单信息格式无效") return } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { fail(w, http.StatusInternalServerError, 50001, "保存订单失败") return } defer func() { _ = tx.Rollback() }() var userID, oldPlanID int64 var oldAmount int var oldStatus, oldChannel, providerOrderNo string if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,status,channel,provider_order_no FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &oldPlanID, &oldAmount, &oldStatus, &oldChannel, &providerOrderNo); err != nil { fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") return } newPlanID, newAmount, newStatus, newChannel := oldPlanID, oldAmount, oldStatus, oldChannel if req.ProductID != nil { newPlanID = *req.ProductID } if req.AmountCent != nil { newAmount = *req.AmountCent } if req.Status != nil { newStatus = strings.ToUpper(strings.TrimSpace(*req.Status)) } if req.Channel != nil { newChannel = strings.TrimSpace(*req.Channel) } if newPlanID <= 0 || newAmount < 0 || !validOrderStatus(newStatus) || len(newChannel) > 30 { fail(w, http.StatusBadRequest, 20001, "订单套餐、金额、渠道或状态无效") return } if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { financialFieldsChanged := newPlanID != oldPlanID || newAmount != oldAmount || newChannel != oldChannel if financialFieldsChanged && (oldStatus != "CREATED" || providerOrderNo != "") { fail(w, http.StatusBadRequest, 20001, "生产订单创建支付流水后禁止修改套餐、金额或渠道") return } if newStatus != oldStatus && !(oldStatus == "CREATED" && newStatus == "CLOSED") { fail(w, http.StatusBadRequest, 20001, "生产订单的支付与退款状态只能由已验签回调更新") return } } var planExists int planQuery := `SELECT COUNT(*) FROM membership_plans WHERE id=?` if newStatus == "PAID" || newPlanID != oldPlanID { planQuery += ` AND deleted_at IS NULL` } if err = tx.QueryRowContext(r.Context(), planQuery, newPlanID).Scan(&planExists); err != nil || planExists == 0 { fail(w, http.StatusBadRequest, 20001, "选择的会员套餐不存在") return } if err = a.syncEditedOrderEntitlement(r, tx, id, userID, oldPlanID, newPlanID, oldStatus, newStatus); err == nil { _, err = tx.ExecContext(r.Context(), `UPDATE orders SET product_id=?,amount_cent=?,status=?,channel=?,paid_at=IF(?='PAID',COALESCE(paid_at,NOW(3)),paid_at) WHERE id=?`, newPlanID, newAmount, newStatus, newChannel, newStatus, id) } if err != nil || tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "保存订单及会员权益失败") return } a.audit(r, "update", "order", id, map[string]any{"previousStatus": oldStatus, "status": newStatus, "previousPlanId": oldPlanID, "planId": newPlanID, "amountCent": newAmount, "channel": newChannel}) reply(w, map[string]bool{"success": true}) } func (a *App) adminDeleteOrder(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "订单编号无效") return } var userID int64 var status string if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,status FROM orders WHERE id=? AND deleted_at IS NULL`, id).Scan(&userID, &status); err != nil { fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") return } if status == "REFUNDING" { fail(w, http.StatusBadRequest, 20001, "退款处理中的订单不能删除") return } result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id) if err != nil { fail(w, http.StatusInternalServerError, 50001, "删除订单失败") return } affected, _ := result.RowsAffected() if affected == 0 { fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") return } a.audit(r, "delete", "order", id, map[string]any{"mode": "soft", "status": status, "userId": userID, "membershipPreserved": status == "PAID"}) reply(w, map[string]any{"success": true, "membershipPreserved": status == "PAID"}) } func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) { page, size, offset := pagination(r) keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) conversationID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("conversationId")), 10, 64) messageType, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("type"))) where := ` WHERE 1=1` args := []any{} if conversationID > 0 { where += ` AND m.conversation_id=?` args = append(args, conversationID) } if messageType > 0 { where += ` AND m.message_type=?` args = append(args, messageType) } if keyword != "" { where += ` AND (CONVERT(m.client_msg_id USING utf8mb4) LIKE ? OR CAST(m.body AS CHAR CHARACTER SET utf8mb4) LIKE ? OR sp.nickname LIKE ? OR su.public_id LIKE ? OR EXISTS (SELECT 1 FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id AND (kp.nickname LIKE ? OR ku.public_id LIKE ?)))` like := "%" + keyword + "%" args = append(args, like, like, like, like, like, like) } base := ` FROM im_messages m JOIN users su ON su.id=m.sender_id JOIN user_profiles sp ON sp.user_id=m.sender_id LEFT JOIN im_message_media mm ON mm.message_id=m.id` var total int64 if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+base+where, args...).Scan(&total); err != nil { fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") return } queryArgs := append(append([]any{}, args...), size, offset) rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.conversation_id,m.seq,m.sender_id,su.public_id,sp.nickname,sp.avatar_url,m.client_msg_id,m.message_type,m.body,m.moderation_status,m.recalled_at,m.admin_removed_at,m.admin_removed_by,m.admin_remove_reason,m.created_at,COALESCE(mm.status,-1),mm.deleted_at,COALESCE(mm.delete_reason,''),COALESCE(mm.cleanup_error,''),COALESCE((SELECT GROUP_CONCAT(CONCAT(kp.nickname,' (',ku.public_id,')') ORDER BY kp.nickname SEPARATOR '、') FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id),'')`+base+where+` ORDER BY m.created_at DESC,m.id DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") return } defer rows.Close() items := []map[string]any{} for rows.Next() { var id, convID, seq, senderID int64 var publicID, nickname, avatar, clientMsgID, recipients, adminRemoveReason string var typ, moderation int var body []byte var recalledAt, adminRemovedAt, mediaDeletedAt sql.NullTime var adminRemovedBy sql.NullInt64 var mediaStatus int var mediaDeleteReason, mediaCleanupError string var createdAt time.Time if rows.Scan(&id, &convID, &seq, &senderID, &publicID, &nickname, &avatar, &clientMsgID, &typ, &body, &moderation, &recalledAt, &adminRemovedAt, &adminRemovedBy, &adminRemoveReason, &createdAt, &mediaStatus, &mediaDeletedAt, &mediaDeleteReason, &mediaCleanupError, &recipients) != nil { continue } var content any if json.Unmarshal(body, &content) != nil { content = string(body) } items = append(items, map[string]any{"id": id, "conversationId": convID, "seq": seq, "senderId": senderID, "senderPublicId": publicID, "senderNickname": nickname, "senderAvatar": avatar, "recipients": recipients, "clientMsgId": clientMsgID, "type": typ, "content": content, "moderationStatus": moderation, "recalledAt": nullableTime(recalledAt), "adminRemovedAt": nullableTime(adminRemovedAt), "adminRemovedBy": nullableInt64(adminRemovedBy), "adminRemoveReason": adminRemoveReason, "createdAt": createdAt, "chatMedia": typ == 2 || typ == 3, "mediaStatus": mediaStatus, "mediaDeletedAt": nullableTime(mediaDeletedAt), "mediaDeleteReason": mediaDeleteReason, "mediaCleanupError": mediaCleanupError}) } reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) } func (a *App) adminModerateMessage(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "消息编号无效") return } var req struct { Action string `json:"action"` Reason string `json:"reason"` } if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "消息处置参数无效") return } req.Action = strings.ToUpper(strings.TrimSpace(req.Action)) req.Reason = strings.TrimSpace(req.Reason) if req.Action != "REMOVE" && req.Action != "RESTORE" { fail(w, http.StatusBadRequest, 20001, "消息处置动作无效") return } if req.Action == "REMOVE" && (len([]rune(req.Reason)) < 2 || len([]rune(req.Reason)) > 500) { fail(w, http.StatusBadRequest, 20001, "请填写 2-500 字下架原因") return } var conversationID, seq int64 if err = a.db.QueryRowContext(r.Context(), `SELECT conversation_id,seq FROM im_messages WHERE id=?`, id).Scan(&conversationID, &seq); err != nil { fail(w, http.StatusNotFound, 30001, "消息不存在") return } var result sql.Result command := "MESSAGE_RECALLED" if req.Action == "REMOVE" { result, err = a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=0,admin_removed_at=NOW(3),admin_removed_by=?,admin_remove_reason=? WHERE id=? AND admin_removed_at IS NULL`, current(r).ID, req.Reason, id) } else { command = "MESSAGE_RESTORED" result, err = a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=1,admin_removed_at=NULL,admin_removed_by=NULL,admin_remove_reason='' WHERE id=? AND admin_removed_at IS NOT NULL`, id) } var affected int64 if err == nil { affected, _ = result.RowsAffected() } if err != nil || affected == 0 { fail(w, http.StatusConflict, 20001, "消息状态已发生变化,请刷新后重试") return } rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) members := []int64{} if rows != nil { for rows.Next() { var memberID int64 _ = rows.Scan(&memberID) members = append(members, memberID) } _ = rows.Close() } a.hub.broadcast(members, map[string]any{"command": command, "data": map[string]any{"id": id, "conversationId": conversationID, "seq": seq}}) a.audit(r, strings.ToLower(req.Action)+"_message", "message", id, map[string]any{"reason": req.Reason}) reply(w, map[string]bool{"success": true}) }