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}) }