后端:资料距离、语音图片会员限制、免短信注册、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>
This commit is contained in:
Your Name
2026-09-04 10:00:08 +08:00
co-authored by Claude Opus 5
parent 842990b0e7
commit acd8933dbb
25 changed files with 1478 additions and 45 deletions
@@ -189,14 +189,14 @@ func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) {
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`
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((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...)
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
@@ -208,17 +208,19 @@ func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) {
var publicID, nickname, avatar, clientMsgID, recipients, adminRemoveReason string
var typ, moderation int
var body []byte
var recalledAt, adminRemovedAt sql.NullTime
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, &recipients) != nil {
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})
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})
}