Files
kefu/im/backend/internal/app/admin_commerce_messages.go
T
Your NameandClaude Opus 5 acd8933dbb 后端:资料距离、语音图片会员限制、免短信注册、AI 托管回复修复
- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和
  聊天头部因此无内容可显示。沿用同一套 haversine 与隐私开关。
- 语音/图片消息可限定会员发送,两个开关在管理端「运营配置」中修改
  (迁移 032)。校验放在 persistMessageContext,HTTP 与 WebSocket
  两条发送路径都覆盖;文本消息永不受限。
- 短信服务关闭时注册不再要求验证码:关掉之后没人能拿到验证码,继续
  要求就等于关闭注册通道。重置密码不做同样放宽,那里缺验证码等于
  凭手机号夺号。app/config 增加 smsVerification 供客户端决定表单形态。
- 修复 AI 托管账号之间不回复:原规则按「发送方是否托管账号」拦截,
  把真人操作测试号的正常对话也挡了。改为标记 worker 自己写入的回复,
  只对 AI 生成的消息跳过入队。
- ai.default_model_id 同时接受模型 ID 与名称,填名称时不再被 MySQL
  静默转成 0 而使配置失效。
- 聊天媒体留存管理与清理任务(迁移 033,两台线上均已应用)。

新增集成测试均针对真实 MySQL:会员限制、免短信注册、AI 入队规则、
默认模型解析、资料距离。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:00:08 +08:00

287 lines
13 KiB
Go

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})
}