按协议而不是按厂商做适配,与现有短信、对象存储的做法一致: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>
249 lines
8.9 KiB
Go
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})
|
|
}
|