后端:资料距离、语音图片会员限制、免短信注册、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
+15 -4
View File
@@ -138,16 +138,27 @@ func (a *App) loadProfile(r *http.Request, id, viewerID int64) (profileView, err
var birthday sql.NullTime
var active sql.NullTime
var vip int
var onlineVisible, lastActiveVisible int
err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.occupation,p.education,p.relationship_status,p.bio,p.is_vip,p.vip_level,p.last_active_at,privacy.online_visible,privacy.last_active_visible,
var onlineVisible, lastActiveVisible, distanceVisible int
var lat, lng sql.NullFloat64
err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.occupation,p.education,p.relationship_status,p.bio,p.is_vip,p.vip_level,p.last_active_at,privacy.online_visible,privacy.last_active_visible,privacy.distance_visible,l.latitude,l.longitude,
(SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND status=1),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id),
EXISTS(SELECT 1 FROM user_follows WHERE user_id=? AND target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=u.id)
FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id
FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id LEFT JOIN user_location_states l ON l.user_id=u.id
WHERE u.id=? AND u.status=1 AND (u.id=? OR NOT EXISTS(SELECT 1 FROM user_blocks blocked WHERE (blocked.user_id=? AND blocked.blocked_user_id=u.id) OR (blocked.user_id=u.id AND blocked.blocked_user_id=?)))`, viewerID, viewerID, id, viewerID, viewerID, viewerID).Scan(
&item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Occupation, &item.Education, &item.RelationshipStatus, &item.Bio, &vip, &item.VIPLevel, &active, &onlineVisible, &lastActiveVisible, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked)
&item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Occupation, &item.Education, &item.RelationshipStatus, &item.Bio, &vip, &item.VIPLevel, &active, &onlineVisible, &lastActiveVisible, &distanceVisible, &lat, &lng, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked)
if err != nil {
return item, err
}
// The profile and chat headers label distance the same way the nearby list
// does, so one extra lookup here keeps the two readings consistent.
if id != viewerID && distanceVisible == 1 && lat.Valid && lng.Valid {
var myLat, myLng sql.NullFloat64
_ = a.db.QueryRowContext(r.Context(), `SELECT latitude,longitude FROM user_location_states WHERE user_id=?`, viewerID).Scan(&myLat, &myLng)
if myLat.Valid && myLng.Valid {
item.Distance = haversine(myLat.Float64, myLng.Float64, lat.Float64, lng.Float64)
item.DistanceText = distanceText(item.Distance)
}
}
item.VIP = vip == 1
item.AvatarThumbnail = avatarThumbnailURL(item.Avatar)
if birthday.Valid {