Files
kefu/im/backend/internal/app/admin_ai_agents.go
T
Your NameandClaude Opus 5 a024d59827 后端接入 AI 模型并支持测试账号托管回复
按协议而不是按厂商做适配,与现有短信、对象存储的做法一致:openai 兼容格式
一个适配器即可覆盖 DeepSeek、通义、智谱、火山方舟、Ollama 等,新增厂商通常
只需在后台加一条模型记录;anthropic、gemini、webhook、debug 各一个适配器。
密钥沿用 integration.go 的 AES-GCM 加密存储。

测试账号托管的回复走 persistMessage 同一条落库和推送路径,因此未读数、
WebSocket 推送和会话排序全部复用现有逻辑,客户端无需改动。为此把
persistMessage 抽出 persistMessageContext,因为 worker 没有 *http.Request。

任务队列用数据库表而非内存:重启不丢回复,多实例不重复消费。领取用 UPDATE
打 claim_token 再回读,没有用 SELECT ... FOR UPDATE SKIP LOCKED——生产存在
MySQL 5.7 环境,那里该语法无法解析。同一会话同时只允许一个待处理任务
(pending_key 生成列 + 唯一键),所以用户连发多条消息只会得到一条回复。

闸门:仅 is_test=1 且在允许批次内的账号生效,托管账号之间不互相触发,三层
配额,调用失败默认静默。会话内是否标注 AI 身份由 ai.disclose_in_chat 控制
并默认开启,关闭前需确认所在地区的监管要求。

app.go、integration.go、im.go 三个文件同时包含本次改动之前工作区里就已存在
的未提交修改,一并带入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:31:04 +08:00

249 lines
8.9 KiB
Go

package app
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
)
type aiAgentView struct {
UserID int64 `json:"userId"`
PublicID string `json:"publicId"`
Nickname string `json:"nickname"`
TestBatch string `json:"testBatch"`
Enabled bool `json:"enabled"`
ModelID int64 `json:"modelId"`
ModelName string `json:"modelName"`
Persona string `json:"persona"`
DailyReplyLimit int `json:"dailyReplyLimit"`
ReplyCount int `json:"replyCount"`
LastReplyAt string `json:"lastReplyAt"`
TodayReplies int `json:"todayReplies"`
}
// Only test accounts can be managed, so the list is the test account list with
// its binding attached rather than a separate registry.
func (a *App) adminAIAgents(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
batch := strings.TrimSpace(r.URL.Query().Get("batch"))
where, args, err := testUserFilter("test", batch)
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
if strings.TrimSpace(r.URL.Query().Get("managed")) == "1" {
where += " AND g.user_id IS NOT NULL AND g.enabled=1"
}
var total int
countArgs := append([]any{}, args...)
if err = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u
LEFT JOIN ai_agents g ON g.user_id=u.id
WHERE u.status=1 AND u.deleted_at IS NULL`+where, countArgs...).Scan(&total); err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询托管账号失败")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.public_id,u.test_batch,p.nickname,
COALESCE(g.enabled,0),COALESCE(g.model_id,0),COALESCE(g.persona,''),COALESCE(g.daily_reply_limit,0),
COALESCE(g.reply_count,0),g.last_reply_at,COALESCE(m.name,''),
(SELECT COUNT(*) FROM ai_call_logs l WHERE l.scene='chat' AND l.agent_user_id=u.id AND l.status=1 AND l.created_at>=CURRENT_DATE())
FROM users u JOIN user_profiles p ON p.user_id=u.id
LEFT JOIN ai_agents g ON g.user_id=u.id
LEFT JOIN ai_models m ON m.id=g.model_id
WHERE u.status=1 AND u.deleted_at IS NULL`+where+` ORDER BY u.id ASC LIMIT ? OFFSET ?`,
append(args, size, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询托管账号失败")
return
}
defer rows.Close()
items := []aiAgentView{}
for rows.Next() {
var item aiAgentView
var enabled int
var lastReplyAt sql.NullTime
if rows.Scan(&item.UserID, &item.PublicID, &item.TestBatch, &item.Nickname, &enabled, &item.ModelID,
&item.Persona, &item.DailyReplyLimit, &item.ReplyCount, &lastReplyAt, &item.ModelName, &item.TodayReplies) != nil {
continue
}
item.Enabled = enabled == 1
if lastReplyAt.Valid {
item.LastReplyAt = lastReplyAt.Time.Format("2006-01-02 15:04")
}
items = append(items, item)
}
reply(w, map[string]any{"items": items, "page": page, "size": size, "total": total, "enabled": a.configBool(r.Context(), "ai.enabled", false)})
}
type aiAgentRequest struct {
UserIDs []int64 `json:"userIds"`
Batch string `json:"batch"`
Enabled *bool `json:"enabled"`
ModelID *int64 `json:"modelId"`
Persona *string `json:"persona"`
DailyReplyLimit *int `json:"dailyReplyLimit"`
}
// One endpoint for a single account and for a whole batch: operations usually
// switch a seeded batch on or off in one go.
func (a *App) adminUpdateAIAgents(w http.ResponseWriter, r *http.Request) {
var req aiAgentRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "托管配置格式不正确")
return
}
if req.Persona != nil && len([]rune(*req.Persona)) > 1000 {
fail(w, http.StatusBadRequest, 20001, "人设描述最多 1000 字")
return
}
if req.DailyReplyLimit != nil && (*req.DailyReplyLimit < 0 || *req.DailyReplyLimit > 10000) {
fail(w, http.StatusBadRequest, 20001, "单账号每日回复上限必须在 0 到 10000 之间")
return
}
if req.ModelID != nil && *req.ModelID > 0 {
if _, err := a.loadAIModel(r.Context(), *req.ModelID); err != nil {
fail(w, http.StatusBadRequest, 20001, "指定的模型不存在")
return
}
}
targets, err := a.resolveAIAgentTargets(r, req)
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
if len(targets) == 0 {
fail(w, http.StatusBadRequest, 20001, "没有匹配的测试账号")
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
modelID := int64(0)
if req.ModelID != nil {
modelID = *req.ModelID
}
persona := ""
if req.Persona != nil {
persona = strings.TrimSpace(*req.Persona)
}
limit := 0
if req.DailyReplyLimit != nil {
limit = *req.DailyReplyLimit
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存托管配置失败")
return
}
defer func() { _ = tx.Rollback() }()
for _, userID := range targets {
if _, err = tx.ExecContext(r.Context(), `INSERT INTO ai_agents(user_id,model_id,persona,enabled,daily_reply_limit)
VALUES(?,?,?,?,?)
ON DUPLICATE KEY UPDATE model_id=VALUES(model_id),persona=VALUES(persona),enabled=VALUES(enabled),daily_reply_limit=VALUES(daily_reply_limit)`,
userID, modelID, persona, btoi(enabled), limit); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存托管配置失败")
return
}
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存托管配置失败")
return
}
a.audit(r, "update", "ai_agent", 0, map[string]any{"count": len(targets), "enabled": enabled, "modelId": modelID, "batch": req.Batch})
reply(w, map[string]any{"success": true, "count": len(targets)})
}
// Managed accounts must be test accounts; a batch is resolved server-side so a
// stale client list cannot bind a real user.
func (a *App) resolveAIAgentTargets(r *http.Request, req aiAgentRequest) ([]int64, error) {
batch := strings.TrimSpace(req.Batch)
if batch == "" && len(req.UserIDs) == 0 {
return nil, fmt.Errorf("请选择测试账号或填写测试批次")
}
if len(req.UserIDs) > 500 {
return nil, fmt.Errorf("单次最多操作 500 个账号")
}
query := `SELECT u.id FROM users u WHERE u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL`
args := []any{}
if batch != "" {
if len(batch) > 64 {
return nil, fmt.Errorf("测试批次长度不能超过64位")
}
query += ` AND u.test_batch=?`
args = append(args, batch)
}
if len(req.UserIDs) > 0 {
placeholders := make([]string, 0, len(req.UserIDs))
for _, userID := range req.UserIDs {
placeholders = append(placeholders, "?")
args = append(args, userID)
}
query += ` AND u.id IN (` + strings.Join(placeholders, ",") + `)`
}
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
return nil, fmt.Errorf("查询测试账号失败")
}
defer rows.Close()
targets := []int64{}
for rows.Next() {
var userID int64
if rows.Scan(&userID) == nil {
targets = append(targets, userID)
}
}
return targets, rows.Err()
}
// The queue is the first place to look when replies stop arriving.
func (a *App) adminAIReplyJobs(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
where, args := "", []any{}
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
value, err := strconv.Atoi(status)
if err != nil || value < 0 || value > 3 {
fail(w, http.StatusBadRequest, 20001, "任务状态无效")
return
}
where = " WHERE j.status=?"
args = append(args, value)
}
var total int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM ai_reply_jobs j`+where, args...).Scan(&total); err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询回复任务失败")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT j.id,j.conversation_id,j.agent_user_id,COALESCE(p.nickname,''),j.status,j.attempts,j.run_after,j.last_error,j.created_at
FROM ai_reply_jobs j LEFT JOIN user_profiles p ON p.user_id=j.agent_user_id`+where+` ORDER BY j.id DESC LIMIT ? OFFSET ?`,
append(args, size, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询回复任务失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, conversationID, agentUserID int64
var nickname, lastError string
var status, attempts int
var runAfter, createdAt sql.NullTime
if rows.Scan(&id, &conversationID, &agentUserID, &nickname, &status, &attempts, &runAfter, &lastError, &createdAt) != nil {
continue
}
item := map[string]any{
"id": id, "conversationId": conversationID, "agentUserId": agentUserID, "nickname": nickname,
"status": status, "attempts": attempts, "lastError": lastError,
}
if runAfter.Valid {
item["runAfter"] = runAfter.Time.Format("2006-01-02 15:04:05")
}
if createdAt.Valid {
item["createdAt"] = createdAt.Time.Format("2006-01-02 15:04:05")
}
items = append(items, item)
}
reply(w, map[string]any{"items": items, "page": page, "size": size, "total": total})
}