Files
kefu/im/backend/internal/app/ai_agent.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

459 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"math/rand"
"strings"
"sync"
"time"
)
const (
aiHistoryMessages = 20
aiReplyMaxRunes = 120
aiJobMaxAttempts = 2
// A burst of incoming messages collapses into one reply, but the reader
// should not wait longer than this for it.
aiReplyMaxDelaySeconds = 15
)
type aiAgentBinding struct {
UserID int64
ModelID int64
Persona string
DailyReplyLimit int
Nickname string
Age int
City string
Bio string
}
type aiReplyJob struct {
ID int64
ConversationID int64
AgentUserID int64
Attempts int
}
// Called right after a message is committed, from every send path. It must stay
// cheap and must never fail the send: a missing reply is better than a failed
// message.
// aiGeneratedKey marks the context in which the worker writes its own reply.
// The loop it prevents is "a reply triggers another reply", which is a property
// of the message, not of the account: a person operating a managed test account
// is having a real conversation and deserves an answer.
type aiGeneratedKey struct{}
func markAIGenerated(ctx context.Context) context.Context {
return context.WithValue(ctx, aiGeneratedKey{}, true)
}
func isAIGenerated(ctx context.Context) bool {
value, _ := ctx.Value(aiGeneratedKey{}).(bool)
return value
}
func (a *App) enqueueAIReply(ctx context.Context, conversationID, senderID int64, members []int64, messageID int64) {
if !a.configBool(ctx, "ai.enabled", false) {
return
}
if isAIGenerated(ctx) {
return // The worker's own reply must never trigger the next one.
}
peerID := int64(0)
for _, member := range members {
if member != senderID {
if peerID != 0 {
return // Group conversations have no single managed recipient.
}
peerID = member
}
}
if peerID == 0 {
return
}
var agentUserID int64
var testBatch string
// Note there is no condition on the sender: whoever typed this, the reply is
// owed by the recipient. Runaway agent-to-agent chatter is prevented above,
// where the worker's own writes are recognised and skipped, and bounded by
// the per-agent daily reply limits.
err := a.db.QueryRowContext(ctx, `SELECT g.user_id,u.test_batch
FROM ai_agents g JOIN users u ON u.id=g.user_id
WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL`,
peerID).Scan(&agentUserID, &testBatch)
if err != nil || agentUserID == 0 {
return
}
if !aiBatchAllowed(a.configPlain(ctx, "ai.allow_batches", ""), testBatch) {
return
}
delay := a.aiReplyDelay(ctx)
if _, err = a.db.ExecContext(ctx, `INSERT INTO ai_reply_jobs(conversation_id,agent_user_id,trigger_message_id,run_after)
VALUES(?,?,?,DATE_ADD(NOW(3),INTERVAL ? MICROSECOND))
ON DUPLICATE KEY UPDATE trigger_message_id=VALUES(trigger_message_id),
run_after=LEAST(DATE_ADD(created_at,INTERVAL ? SECOND),VALUES(run_after))`,
conversationID, agentUserID, messageID, delay.Microseconds(), aiReplyMaxDelaySeconds); err != nil {
log.Printf("ai: 入队失败 conversation=%d: %v", conversationID, err)
}
}
// An empty allow list means every test account; otherwise the account's batch
// has to be named explicitly.
func aiBatchAllowed(allowed, batch string) bool {
allowed = strings.TrimSpace(allowed)
if allowed == "" {
return true
}
for _, item := range strings.Split(allowed, ",") {
if strings.TrimSpace(item) == strings.TrimSpace(batch) && batch != "" {
return true
}
}
return false
}
func (a *App) aiReplyDelay(ctx context.Context) time.Duration {
minDelay := a.configInt(ctx, "ai.reply_delay_min_ms", 2000)
maxDelay := a.configInt(ctx, "ai.reply_delay_max_ms", 6000)
if minDelay < 0 {
minDelay = 0
}
if maxDelay <= minDelay {
maxDelay = minDelay
}
spread := maxDelay - minDelay
if spread > 0 {
minDelay += rand.Intn(spread)
}
return time.Duration(minDelay) * time.Millisecond
}
// The worker follows the same ticker shape as processDueAccountClosures.
func (a *App) runAIReplyWorker(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.processDueAIReplies(ctx)
}
}
}
func (a *App) processDueAIReplies(ctx context.Context) {
if !a.configBool(ctx, "ai.enabled", false) {
return
}
concurrency := a.configInt(ctx, "ai.max_concurrency", 4)
if concurrency < 1 || concurrency > 64 {
concurrency = 4
}
jobs, err := a.claimAIReplyJobs(ctx, concurrency)
if err != nil || len(jobs) == 0 {
return
}
var wait sync.WaitGroup
for _, job := range jobs {
wait.Add(1)
go func(job aiReplyJob) {
defer wait.Done()
if replyErr := a.processAIReplyJob(ctx, job); replyErr != nil {
a.failAIReplyJob(ctx, job, replyErr)
return
}
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=2,last_error='' WHERE id=?`, job.ID)
}(job)
}
wait.Wait()
}
// Claiming stamps a token with a single UPDATE and reads the rows back: each
// row can only leave status 0 once, so two instances never take the same job.
// SELECT ... FOR UPDATE SKIP LOCKED would be the obvious alternative, but it
// does not parse on MySQL 5.7, which some deployments still run.
func (a *App) claimAIReplyJobs(ctx context.Context, limit int) ([]aiReplyJob, error) {
token := randomToken()[:32]
result, err := a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=1,attempts=attempts+1,claim_token=?
WHERE status=0 AND run_after<=NOW(3) ORDER BY run_after LIMIT ?`, token, limit)
if err != nil {
return nil, err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return nil, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id,conversation_id,agent_user_id,attempts FROM ai_reply_jobs WHERE claim_token=? AND status=1`, token)
if err != nil {
return nil, err
}
defer rows.Close()
jobs := []aiReplyJob{}
for rows.Next() {
var job aiReplyJob
if rows.Scan(&job.ID, &job.ConversationID, &job.AgentUserID, &job.Attempts) == nil {
jobs = append(jobs, job)
}
}
return jobs, rows.Err()
}
func (a *App) failAIReplyJob(ctx context.Context, job aiReplyJob, cause error) {
message := cause.Error()
if len([]rune(message)) > 500 {
message = string([]rune(message)[:500])
}
if job.Attempts >= aiJobMaxAttempts {
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error=? WHERE id=?`, message, job.ID)
return
}
// Returning the job to the queue can collide with a newer pending job for
// the same conversation; that newer job supersedes this one.
if _, err := a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=0,claim_token='',last_error=?,run_after=DATE_ADD(NOW(3),INTERVAL 5 SECOND) WHERE id=?`, message, job.ID); err != nil {
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error=? WHERE id=?`, message, job.ID)
}
}
func (a *App) processAIReplyJob(ctx context.Context, job aiReplyJob) error {
binding, err := a.loadAIAgent(ctx, job.AgentUserID)
if err != nil {
return err
}
if err = a.checkAIQuota(ctx, binding); err != nil {
return err
}
history, lastSenderID, err := a.aiConversationHistory(ctx, job.ConversationID, job.AgentUserID)
if err != nil {
return err
}
if len(history) == 0 || lastSenderID == job.AgentUserID {
// Someone already answered, or there is nothing to answer.
return nil
}
model, err := a.aiModelForAgent(ctx, binding)
if err != nil {
return err
}
provider, err := newAIProvider(model, a.config.Environment == "production")
if err != nil {
return err
}
first, err := a.aiFirstReply(ctx, job.ConversationID, job.AgentUserID)
if err != nil {
return err
}
request := aiChatRequest{
System: aiSystemPrompt(binding, a.configBool(ctx, "ai.disclose_in_chat", true)),
Messages: history,
}
callCtx, cancel := context.WithTimeout(ctx, time.Duration(model.TimeoutMS+2000)*time.Millisecond)
defer cancel()
started := time.Now()
result, callErr := provider.Chat(callCtx, request)
latency := int(time.Since(started).Milliseconds())
a.logAICall(ctx, model.ID, "chat", job.AgentUserID, job.ConversationID, latency, result, callErr)
if callErr != nil {
if fallback := strings.TrimSpace(a.configPlain(ctx, "ai.fallback_text", "")); fallback != "" {
_ = a.sendAIReply(ctx, job, fallback)
}
return callErr
}
text := sanitizeAIReply(result.Text)
if text == "" {
return fmt.Errorf("模型返回内容为空")
}
if first && a.configBool(ctx, "ai.disclose_in_chat", true) {
text = "[AI 助手] " + text
}
if err = a.sendAIReply(ctx, job, text); err != nil {
return err
}
_, _ = a.db.ExecContext(ctx, `UPDATE ai_agents SET reply_count=reply_count+1,last_reply_at=NOW(3) WHERE user_id=?`, job.AgentUserID)
return nil
}
func (a *App) sendAIReply(ctx context.Context, job aiReplyJob, text string) error {
item, members, err := a.persistMessageContext(markAIGenerated(ctx), job.ConversationID, job.AgentUserID,
"ai-"+randomToken()[:20], 1, map[string]any{"text": text})
if err != nil {
return err
}
// The reply reaches clients through the same push the sender's own messages
// use, so unread counts and conversation ordering need no special casing.
a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item})
return nil
}
func (a *App) loadAIAgent(ctx context.Context, userID int64) (aiAgentBinding, error) {
binding := aiAgentBinding{UserID: userID}
var birthday sql.NullTime
err := a.db.QueryRowContext(ctx, `SELECT g.model_id,g.persona,g.daily_reply_limit,p.nickname,p.birthday,p.city_name,p.bio
FROM ai_agents g JOIN user_profiles p ON p.user_id=g.user_id JOIN users u ON u.id=g.user_id
WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL`, userID).
Scan(&binding.ModelID, &binding.Persona, &binding.DailyReplyLimit, &binding.Nickname, &birthday, &binding.City, &binding.Bio)
if errors.Is(err, sql.ErrNoRows) {
return binding, fmt.Errorf("账号未开启 AI 托管")
}
if birthday.Valid {
binding.Age = age(birthday.Time)
}
return binding, err
}
func (a *App) aiModelForAgent(ctx context.Context, binding aiAgentBinding) (aiModel, error) {
if binding.ModelID > 0 {
model, err := a.loadAIModel(ctx, binding.ModelID)
if err == nil && model.Status == 1 {
return model, nil
}
}
return a.defaultAIModel(ctx)
}
// Quotas are counted from the call log so a restart cannot reset them.
func (a *App) checkAIQuota(ctx context.Context, binding aiAgentBinding) error {
if limit := a.configInt(ctx, "ai.daily_call_limit", 0); limit > 0 {
var used int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ai_call_logs WHERE scene='chat' AND created_at>=CURRENT_DATE()`).Scan(&used)
if used >= limit {
return fmt.Errorf("已达到今日全局调用上限 %d", limit)
}
}
limit := binding.DailyReplyLimit
if limit <= 0 {
limit = a.configInt(ctx, "ai.agent_daily_reply_limit", 0)
}
if limit > 0 {
var used int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ai_call_logs WHERE scene='chat' AND agent_user_id=? AND status=1 AND created_at>=CURRENT_DATE()`, binding.UserID).Scan(&used)
if used >= limit {
return fmt.Errorf("账号已达到今日回复上限 %d", limit)
}
}
return nil
}
func (a *App) aiFirstReply(ctx context.Context, conversationID, agentUserID int64) (bool, error) {
var replied int
err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM im_messages WHERE conversation_id=? AND sender_id=?)`, conversationID, agentUserID).Scan(&replied)
return replied == 0, err
}
// Only the tail of the conversation is sent: enough for continuity, bounded in
// cost. Media turns become placeholders because the model gets text only.
func (a *App) aiConversationHistory(ctx context.Context, conversationID, agentUserID int64) ([]aiChatMessage, int64, error) {
rows, err := a.db.QueryContext(ctx, `SELECT sender_id,message_type,body,recalled_at,admin_removed_at
FROM im_messages WHERE conversation_id=? ORDER BY seq DESC LIMIT ?`, conversationID, aiHistoryMessages)
if err != nil {
return nil, 0, err
}
defer rows.Close()
ordered := []aiChatMessage{}
lastSenderID := int64(0)
for rows.Next() {
var senderID int64
var messageType int
var body []byte
var recalledAt, removedAt sql.NullTime
if rows.Scan(&senderID, &messageType, &body, &recalledAt, &removedAt) != nil {
continue
}
if lastSenderID == 0 {
lastSenderID = senderID
}
if recalledAt.Valid || removedAt.Valid {
continue
}
text := aiMessageText(messageType, body)
if text == "" {
continue
}
role := "user"
if senderID == agentUserID {
role = "assistant"
}
ordered = append([]aiChatMessage{{Role: role, Text: text}}, ordered...)
}
return ordered, lastSenderID, rows.Err()
}
func aiMessageText(messageType int, body []byte) string {
switch messageType {
case 2:
return "[图片]"
case 3:
return "[语音]"
}
var content struct {
Text string `json:"text"`
}
if json.Unmarshal(body, &content) != nil {
return ""
}
return strings.TrimSpace(content.Text)
}
func aiSystemPrompt(binding aiAgentBinding, disclose bool) string {
persona := strings.TrimSpace(binding.Persona)
if persona == "" {
// Falling back to the profile keeps the replies consistent with what the
// other person can see on the page.
details := []string{}
if binding.Nickname != "" {
details = append(details, "昵称"+binding.Nickname)
}
if binding.Age > 0 {
details = append(details, fmt.Sprintf("%d 岁", binding.Age))
}
if binding.City != "" {
details = append(details, "在"+binding.City)
}
if bio := strings.TrimSpace(binding.Bio); bio != "" {
details = append(details, "个人简介:"+bio)
}
persona = "你是一位社交软件用户," + strings.Join(details, "") + "。"
}
rules := []string{
"用中文口语回复,像真人聊天一样简短自然,不超过 40 个字。",
"一次只说一件事,不要分点、不要 Markdown、不要堆叠表情。",
"不索取或提供手机号、微信等联系方式,不谈金钱、转账、投资。",
"不主动约线下见面,不输出链接。",
"不知道的事就说不知道,不要编造经历细节。",
}
if disclose {
rules = append(rules, "如果对方询问你是不是机器人或 AI,必须如实说明你是 AI 助手。")
}
return persona + "\n\n对话要求:\n- " + strings.Join(rules, "\n- ")
}
// Models drift into assistant formatting; the chat bubble wants one plain line.
func sanitizeAIReply(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
if index := strings.Index(text, "```"); index >= 0 {
text = text[:index]
}
replacer := strings.NewReplacer("**", "", "__", "", "##", "", "> ", "", "\r", "\n")
text = replacer.Replace(text)
lines := []string{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*•0123456789. "))
if line != "" {
lines = append(lines, line)
}
}
text = strings.TrimSpace(strings.Join(lines, " "))
if runes := []rune(text); len(runes) > aiReplyMaxRunes {
text = strings.TrimSpace(string(runes[:aiReplyMaxRunes]))
}
return text
}