后端:资料距离、语音图片会员限制、免短信注册、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:
co-authored by
Claude Opus 5
parent
842990b0e7
commit
acd8933dbb
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user