Files
kefu/im/backend/internal/app/ai_enqueue_integration_test.go
T
Your NameandClaude Opus 5 acd8933dbb 后端:资料距离、语音图片会员限制、免短信注册、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>
2026-09-04 10:00:08 +08:00

171 lines
6.3 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"testing"
)
// Who typed the message decides nothing; who receives it decides everything.
// Operating a managed test account by hand is a real conversation, and the
// account on the other side still owes an answer. Only the worker's own reply
// is barred from triggering the next one.
func TestEnqueueAIReplyMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
for _, statement := range []string{
`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('ai.enabled','true','boolean','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`,
`UPDATE users SET is_test=1,test_batch='regression' WHERE id IN (2,3)`,
`INSERT INTO ai_agents(user_id,model_id,enabled) VALUES(2,0,1),(3,0,1)`,
} {
if _, err := db.Exec(statement); err != nil {
t.Fatal(err)
}
}
conversation := func(viewer, peer int64) int64 {
t.Helper()
var created struct {
ID int64 `json:"id"`
}
body := fmt.Sprintf(`{"userId":%d}`, peer)
if err := json.Unmarshal(imTestCall(t, a.directConversation, viewer, "POST", "/api/v1/im/conversations", body, 200), &created); err != nil {
t.Fatal(err)
}
return created.ID
}
pending := func(conversationID int64) (int64, int64) {
t.Helper()
var jobs, agent int64
if err := db.QueryRow(`SELECT COUNT(*),COALESCE(MAX(agent_user_id),0) FROM ai_reply_jobs WHERE conversation_id=?`, conversationID).Scan(&jobs, &agent); err != nil {
t.Fatal(err)
}
return jobs, agent
}
send := func(ctx context.Context, conversationID, sender int64, key string) {
t.Helper()
if _, _, err := a.persistMessageContext(ctx, conversationID, sender, key, 1, map[string]any{"text": "在吗"}); err != nil {
t.Fatal(err)
}
}
t.Run("an ordinary account messaging an agent queues a reply", func(t *testing.T) {
id := conversation(1, 2)
send(context.Background(), id, 1, "human-to-agent")
jobs, agent := pending(id)
if jobs != 1 || agent != 2 {
t.Fatalf("jobs=%d agent=%d", jobs, agent)
}
})
// This is the case that looked like "AI stopped replying": the tester was
// signed in as one managed test account and messaging another.
t.Run("a person typing from a managed account still gets an answer", func(t *testing.T) {
id := conversation(2, 3)
send(context.Background(), id, 2, "agent-account-operated-by-hand")
jobs, agent := pending(id)
if jobs != 1 || agent != 3 {
t.Fatalf("托管账号之间的人工消息也应当入队: jobs=%d agent=%d", jobs, agent)
}
})
t.Run("the worker's own reply never queues another", func(t *testing.T) {
id := conversation(1, 3)
send(markAIGenerated(context.Background()), id, 3, "worker-written-reply")
if jobs, _ := pending(id); jobs != 0 {
t.Fatalf("AI 自己的回复不能再触发回复: jobs=%d", jobs)
}
// A human message in the same conversation still queues one.
send(context.Background(), id, 1, "human-follow-up")
if jobs, _ := pending(id); jobs != 1 {
t.Fatalf("jobs=%d", jobs)
}
})
t.Run("a burst of messages collapses into a single pending reply", func(t *testing.T) {
id := conversation(1, 2)
for index := 0; index < 3; index++ {
send(context.Background(), id, 1, fmt.Sprintf("burst-%d", index))
}
var pendingJobs int
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_reply_jobs WHERE conversation_id=? AND status=0`, id).Scan(&pendingJobs); err != nil {
t.Fatal(err)
}
if pendingJobs != 1 {
t.Fatalf("pending=%d, want 1", pendingJobs)
}
})
t.Run("a disabled agent, or the switch turned off, queues nothing", func(t *testing.T) {
if _, err := db.Exec(`UPDATE ai_agents SET enabled=0 WHERE user_id=2`); err != nil {
t.Fatal(err)
}
id := conversation(1, 2)
if _, err := db.Exec(`DELETE FROM ai_reply_jobs WHERE conversation_id=?`, id); err != nil {
t.Fatal(err)
}
send(context.Background(), id, 1, "disabled-agent")
if jobs, _ := pending(id); jobs != 0 {
t.Fatalf("jobs=%d", jobs)
}
if _, err := db.Exec(`UPDATE ai_agents SET enabled=1 WHERE user_id=2`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE system_configs SET config_value='false' WHERE config_key='ai.enabled'`); err != nil {
t.Fatal(err)
}
send(context.Background(), id, 1, "switch-off")
if jobs, _ := pending(id); jobs != 0 {
t.Fatalf("总开关关闭后不该入队: jobs=%d", jobs)
}
})
}
// The admin field is labelled "默认模型 ID" and a model name is an easy thing to
// type into it. MySQL turns that string into id 0, which used to mean the choice
// was silently ignored.
func TestDefaultAIModelAcceptsNameOrIDMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
for _, statement := range []string{
`INSERT INTO ai_models(id,name,protocol,base_url,model_name,api_key_cipher,status,is_default) VALUES(1,'deepseek-v4-flash','openai','https://api.deepseek.com','deepseek-v4-flash-vision-exp','',1,0)`,
`INSERT INTO ai_models(id,name,protocol,base_url,model_name,api_key_cipher,status,is_default) VALUES(2,'backup','openai','https://api.example.com','backup-model','',1,1)`,
} {
if _, err := db.Exec(statement); err != nil {
t.Fatal(err)
}
}
setDefault := func(value string) {
t.Helper()
if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('ai.default_model_id',?,'text','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, value); err != nil {
t.Fatal(err)
}
}
resolve := func() aiModel {
t.Helper()
model, err := a.defaultAIModel(context.Background())
if err != nil {
t.Fatal(err)
}
return model
}
setDefault("1")
if model := resolve(); model.ID != 1 {
t.Fatalf("id = %d, want 1", model.ID)
}
setDefault("deepseek-v4-flash")
if model := resolve(); model.ID != 1 {
t.Fatalf("按名字也要能选中: id = %d", model.ID)
}
// Anything that matches nothing still falls back to the flagged row.
setDefault("deepseek-v4-flash-vision-exp")
if model := resolve(); model.ID != 2 {
t.Fatalf("无法匹配时回退到默认标记: id = %d", model.ID)
}
setDefault("")
if model := resolve(); model.ID != 2 {
t.Fatalf("留空时使用标记为默认的一条: id = %d", model.ID)
}
}