后端:资料距离、语音图片会员限制、免短信注册、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
@@ -0,0 +1,129 @@
package app
import (
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
)
// Voice and image messages can be limited to members from the admin console.
// The switch has to hold on the real send path, not only in the UI.
func TestMessageMembershipGateMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
setGate := func(key, value string) {
t.Helper()
if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES(?,?,'boolean','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, key, value); err != nil {
t.Fatal(err)
}
}
for _, media := range []struct {
url string
kind string
}{{"https://cdn.example.com/voice-1.m4a", "audio"}, {"https://cdn.example.com/image-1.jpg", "image"}} {
if _, err := db.Exec(`INSERT INTO media_assets(owner_user_id,media_type,public_url,status,moderation_status) VALUES(1,?,?,1,1)`, media.kind, media.url); err != nil {
t.Fatal(err)
}
}
var conversation struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &conversation); err != nil {
t.Fatal(err)
}
path := fmt.Sprintf("/api/v1/im/conversations/%d/messages", conversation.ID)
voice := `{"clientMsgId":%q,"type":3,"content":{"url":"https://cdn.example.com/voice-1.m4a","duration":3}}`
image := `{"clientMsgId":%q,"type":2,"content":{"url":"https://cdn.example.com/image-1.jpg"}}`
send := func(payload, key string, wantStatus int) string {
t.Helper()
w := httptest.NewRecorder()
a.sendMessageHTTP(w, imTestRequest(1, "POST", path, fmt.Sprintf(payload, key)))
if w.Code != wantStatus {
t.Fatalf("HTTP %d, want %d: %s", w.Code, wantStatus, w.Body.String())
}
return w.Body.String()
}
member := func(active bool) {
t.Helper()
if _, err := db.Exec(`DELETE FROM subscriptions WHERE user_id=1`); err != nil {
t.Fatal(err)
}
if !active {
return
}
if _, err := db.Exec(`INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(1,1,'test',1,NOW(3),DATE_ADD(NOW(3),INTERVAL 30 DAY))`); err != nil {
t.Fatal(err)
}
}
t.Run("both kinds are open while the switches are off", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "false")
setGate("membership.image_message_requires_vip", "false")
send(voice, "open-voice", 200)
send(image, "open-image", 200)
})
t.Run("a non member is refused with its own code once the switch is on", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "true")
setGate("membership.image_message_requires_vip", "true")
body := send(voice, "gated-voice", 402)
if !strings.Contains(body, "30006") || !strings.Contains(body, "发送语音消息需要开通会员") {
t.Fatalf("voice refusal = %s", body)
}
if body := send(image, "gated-image", 402); !strings.Contains(body, "发送图片消息需要开通会员") {
t.Fatalf("image refusal = %s", body)
}
// Text is never gated: the two switches must not silence the chat.
imTestCall(t, a.sendMessageHTTP, 1, "POST", path, `{"clientMsgId":"gated-text","type":1,"content":{"text":"仍然可以说话"}}`, 200)
})
t.Run("each switch only gates its own kind", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "true")
setGate("membership.image_message_requires_vip", "false")
send(voice, "half-voice", 402)
send(image, "half-image", 200)
})
t.Run("a member sends both kinds", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "true")
setGate("membership.image_message_requires_vip", "true")
member(true)
defer member(false)
send(voice, "member-voice", 200)
send(image, "member-image", 200)
})
t.Run("the membership status tells the client before it records anything", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "true")
setGate("membership.image_message_requires_vip", "false")
var status struct {
Entitlements struct {
VoiceRequiresVIP bool `json:"voiceMessageRequiresVip"`
ImageRequiresVIP bool `json:"imageMessageRequiresVip"`
CanSendVoice bool `json:"canSendVoiceMessage"`
CanSendImage bool `json:"canSendImageMessage"`
} `json:"entitlements"`
}
read := func(user int64) {
t.Helper()
if err := json.Unmarshal(imTestCall(t, a.membershipStatus, user, "GET", "/api/v1/membership/status", "", 200), &status); err != nil {
t.Fatal(err)
}
}
read(1)
if !status.Entitlements.VoiceRequiresVIP || status.Entitlements.ImageRequiresVIP {
t.Fatalf("switches not reported: %+v", status.Entitlements)
}
if status.Entitlements.CanSendVoice || !status.Entitlements.CanSendImage {
t.Fatalf("non member capabilities: %+v", status.Entitlements)
}
member(true)
defer member(false)
read(1)
if !status.Entitlements.CanSendVoice {
t.Fatalf("member must be allowed to send voice: %+v", status.Entitlements)
}
})
}