后端:资料距离、语音图片会员限制、免短信注册、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
+58 -16
View File
@@ -28,6 +28,7 @@ type messageView struct {
Type int `json:"type"`
Content any `json:"content"`
Recalled bool `json:"recalled"`
MediaDeleted bool `json:"mediaDeleted,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -319,10 +320,10 @@ func (a *App) conversations(w http.ResponseWriter, r *http.Request) {
(SELECT COUNT(*) FROM im_messages unread_msg WHERE unread_msg.conversation_id=c.id
AND unread_msg.seq>GREATEST(m.read_seq,m.clear_seq,m.join_seq) AND unread_msg.sender_id<>m.user_id
AND unread_msg.recalled_at IS NULL AND unread_msg.admin_removed_at IS NULL),m.pinned,m.muted,
other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,privacy.online_visible,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),''),msg.recalled_at,msg.admin_removed_at
other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,privacy.online_visible,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),''),COALESCE(msg.message_type,0),msg.recalled_at,msg.admin_removed_at,COALESCE(last_media.status,1),COALESCE((SELECT CAST(MAX(changed.updated_at) AS CHAR) FROM im_message_media changed JOIN im_messages changed_message ON changed_message.id=changed.message_id WHERE changed_message.conversation_id=c.id),'')
FROM im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id
JOIN im_conversation_members other ON other.conversation_id=c.id AND other.user_id<>m.user_id
JOIN user_profiles p ON p.user_id=other.user_id JOIN user_privacy_settings privacy ON privacy.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id
JOIN user_profiles p ON p.user_id=other.user_id JOIN user_privacy_settings privacy ON privacy.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id LEFT JOIN im_message_media last_media ON last_media.message_id=msg.id
WHERE m.user_id=? AND m.status=1 AND c.status=1 ORDER BY m.pinned DESC,c.last_message_at DESC`, who.ID)
if err != nil {
fail(w, 500, 50001, err.Error())
@@ -334,24 +335,36 @@ func (a *App) conversations(w http.ResponseWriter, r *http.Request) {
var id, lastSeq, unread, otherID int64
var lastAt sql.NullTime
var pinned, muted, vip int
var messageType, mediaStatus int
var nick, avatar, body string
var mediaRevision string
var active sql.NullTime
var recalledAt, adminRemovedAt sql.NullTime
var onlineVisible int
if err := rows.Scan(&id, &lastSeq, &lastAt, &unread, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &onlineVisible, &body, &recalledAt, &adminRemovedAt); err != nil {
if err := rows.Scan(&id, &lastSeq, &lastAt, &unread, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &onlineVisible, &body, &messageType, &recalledAt, &adminRemovedAt, &mediaStatus, &mediaRevision); err != nil {
fail(w, 500, 50001, "读取会话失败")
return
}
preview := "开始聊天吧"
var content map[string]any
if recalledAt.Valid || adminRemovedAt.Valid {
if (messageType == 2 || messageType == 3) && mediaStatus == 0 {
if messageType == 2 {
preview = "图片已清理"
} else {
preview = "语音已清理"
}
} else if recalledAt.Valid || adminRemovedAt.Valid {
preview = "消息已撤回"
} else if json.Unmarshal([]byte(body), &content) == nil {
if text, ok := content["text"].(string); ok {
preview = text
} else if messageType == 2 {
preview = "[图片]"
} else if messageType == 3 {
preview = "[语音]"
}
}
items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": unread, "lastMessageAt": nullableTime(lastAt), "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "avatarThumbnail": avatarThumbnailURL(avatar), "vip": vip == 1, "online": onlineVisible == 1 && a.onlineNow(otherID)}})
items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": unread, "lastMessageAt": nullableTime(lastAt), "mediaRevision": mediaRevision, "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "avatarThumbnail": avatarThumbnailURL(avatar), "vip": vip == 1, "online": onlineVisible == 1 && a.onlineNow(otherID)}})
}
if err := rows.Err(); err != nil {
fail(w, 500, 50001, "读取会话失败")
@@ -380,18 +393,19 @@ func (a *App) messages(w http.ResponseWriter, r *http.Request) {
if limit <= 0 || limit > 100 {
limit = 50
}
query := `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE conversation_id=?`
query := `SELECT m.id,m.conversation_id,m.seq,m.sender_id,m.client_msg_id,m.message_type,m.body,m.recalled_at,m.admin_removed_at,m.created_at,COALESCE(mm.status,1)
FROM im_messages m LEFT JOIN im_message_media mm ON mm.message_id=m.id WHERE m.conversation_id=?`
args := []any{id}
if beforeSeq > 0 {
query += ` AND seq<?`
query += ` AND m.seq<?`
args = append(args, beforeSeq)
}
if afterSeq > 0 {
query += ` AND seq>?`
query += ` AND m.seq>?`
args = append(args, afterSeq)
query += ` ORDER BY seq ASC LIMIT ?`
query += ` ORDER BY m.seq ASC LIMIT ?`
} else {
query += ` ORDER BY seq DESC LIMIT ?`
query += ` ORDER BY m.seq DESC LIMIT ?`
}
args = append(args, limit+1)
rows, err := a.db.QueryContext(r.Context(), query, args...)
@@ -405,12 +419,14 @@ func (a *App) messages(w http.ResponseWriter, r *http.Request) {
var item messageView
var body []byte
var recalledAt, adminRemovedAt sql.NullTime
if err := rows.Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt); err != nil {
var mediaStatus int
if err := rows.Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt, &mediaStatus); err != nil {
fail(w, 500, 50001, "读取消息失败")
return
}
var content any
item.Recalled = recalledAt.Valid || adminRemovedAt.Valid
item.MediaDeleted = (item.Type == 2 || item.Type == 3) && mediaStatus == 0
item.Recalled = recalledAt.Valid || adminRemovedAt.Valid || item.MediaDeleted
if !item.Recalled {
_ = json.Unmarshal(body, &content)
}
@@ -469,6 +485,13 @@ func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusTooManyRequests, 30005, limitErr.Error())
return
}
// Its own code and status: the client turns this one into an upgrade
// prompt rather than a plain "send failed" toast.
var gateErr *messageMembershipGateError
if errors.As(err, &gateErr) {
fail(w, http.StatusPaymentRequired, 30006, gateErr.Error())
return
}
fail(w, 400, 30004, err.Error())
return
}
@@ -546,6 +569,10 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI
if messageType == 0 {
messageType = 1
}
if err = a.ensureMessageMembership(ctx, senderID, messageType); err != nil {
return item, nil, err
}
var mediaAssetID int64
if messageType == 2 || messageType == 3 {
contentMap, _ := content.(map[string]any)
mediaURL, _ := contentMap["url"].(string)
@@ -553,8 +580,7 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI
if messageType == 3 {
expectedType = "audio"
}
var owned int
if queryErr := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type=? AND status=1 AND moderation_status=1)`, senderID, mediaURL, expectedType).Scan(&owned); queryErr != nil || owned != 1 {
if queryErr := a.db.QueryRowContext(ctx, `SELECT id FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type=? AND status=1 AND moderation_status=1 ORDER BY id DESC LIMIT 1`, senderID, mediaURL, expectedType).Scan(&mediaAssetID); queryErr != nil {
return item, nil, fmt.Errorf("消息媒体必须由当前账号上传")
}
}
@@ -602,6 +628,20 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI
return item, nil, err
}
messageID, _ := result.LastInsertId()
if mediaAssetID > 0 {
contentMap, _ := content.(map[string]any)
mediaURL, _ := contentMap["url"].(string)
mediaType := "image"
var durationMS any
if messageType == 3 {
mediaType = "voice"
duration, _ := numericDuration(contentMap["duration"])
durationMS = duration * 1000
}
if _, err = tx.ExecContext(ctx, `INSERT INTO im_message_media(message_id,media_asset_id,media_type,public_url,duration_ms,status) VALUES(?,?,?,?,?,1)`, messageID, mediaAssetID, mediaType, mediaURL, durationMS); err != nil {
return item, nil, err
}
}
_, err = tx.ExecContext(ctx, `UPDATE im_conversations SET last_seq=?,last_message_id=?,last_message_at=NOW(3) WHERE id=?`, seq, messageID, conversationID)
if err != nil {
return item, nil, err
@@ -660,8 +700,10 @@ func (a *App) loadMessageContext(ctx context.Context, id int64) messageView {
var item messageView
var body []byte
var recalledAt, adminRemovedAt sql.NullTime
_ = a.db.QueryRowContext(ctx, `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt)
item.Recalled = recalledAt.Valid || adminRemovedAt.Valid
var mediaStatus int
_ = a.db.QueryRowContext(ctx, `SELECT m.id,m.conversation_id,m.seq,m.sender_id,m.client_msg_id,m.message_type,m.body,m.recalled_at,m.admin_removed_at,m.created_at,COALESCE(mm.status,1) FROM im_messages m LEFT JOIN im_message_media mm ON mm.message_id=m.id WHERE m.id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt, &mediaStatus)
item.MediaDeleted = (item.Type == 2 || item.Type == 3) && mediaStatus == 0
item.Recalled = recalledAt.Valid || adminRemovedAt.Valid || item.MediaDeleted
if !item.Recalled {
_ = json.Unmarshal(body, &item.Content)
}