- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和 聊天头部因此无内容可显示。沿用同一套 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>
287 lines
13 KiB
Go
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})
|
|
}
|