后端:资料距离、语音图片会员限制、免短信注册、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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user