package app import ( "context" "database/sql" "net/http" "strconv" "strings" "time" ) const aiUsageDays = 7 func (a *App) adminAILogs(w http.ResponseWriter, r *http.Request) { page, size, offset := pagination(r) where := []string{} args := []any{} if modelID := strings.TrimSpace(r.URL.Query().Get("modelId")); modelID != "" { value, err := strconv.ParseInt(modelID, 10, 64) if err != nil { fail(w, http.StatusBadRequest, 20001, "模型筛选无效") return } where = append(where, "l.model_id=?") args = append(args, value) } if scene := strings.TrimSpace(r.URL.Query().Get("scene")); scene != "" { if !containsString([]string{"chat", "probe"}, scene) { fail(w, http.StatusBadRequest, 20001, "场景筛选无效") return } where = append(where, "l.scene=?") args = append(args, scene) } if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" { if status != "0" && status != "1" { fail(w, http.StatusBadRequest, 20001, "状态筛选无效") return } where = append(where, "l.status=?") args = append(args, status) } if days := strings.TrimSpace(r.URL.Query().Get("days")); days != "" { value, err := strconv.Atoi(days) if err != nil || value < 1 || value > 90 { fail(w, http.StatusBadRequest, 20001, "时间范围必须在 1 到 90 天之间") return } where = append(where, "l.created_at>=DATE_SUB(CURRENT_DATE(),INTERVAL ? DAY)") args = append(args, value-1) } clause := "" if len(where) > 0 { clause = " WHERE " + strings.Join(where, " AND ") } var total int if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM ai_call_logs l`+clause, args...).Scan(&total); err != nil { fail(w, http.StatusInternalServerError, 50001, "查询调用日志失败") return } rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.model_id,COALESCE(m.name,''),l.scene,l.agent_user_id,COALESCE(p.nickname,''), l.conversation_id,l.latency_ms,l.prompt_tokens,l.completion_tokens,l.status,l.error,l.reply_text,l.created_at FROM ai_call_logs l LEFT JOIN ai_models m ON m.id=l.model_id LEFT JOIN user_profiles p ON p.user_id=l.agent_user_id`+ clause+` ORDER BY l.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, modelID, agentUserID, conversationID int64 var modelName, scene, nickname, errText, replyText string var latency, promptTokens, completionTokens, status int var createdAt sql.NullTime if rows.Scan(&id, &modelID, &modelName, &scene, &agentUserID, &nickname, &conversationID, &latency, &promptTokens, &completionTokens, &status, &errText, &replyText, &createdAt) != nil { continue } item := map[string]any{ "id": id, "modelId": modelID, "modelName": modelName, "scene": scene, "agentUserId": agentUserID, "nickname": nickname, "conversationId": conversationID, "latencyMs": latency, "promptTokens": promptTokens, "completionTokens": completionTokens, "status": status, "error": errText, "reply": replyText, } 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}) } // Usage and health in one payload: the console needs both to answer "is it // running and what is it costing". func (a *App) adminAIUsage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() today := map[string]any{"calls": 0, "success": 0, "failed": 0, "promptTokens": 0, "completionTokens": 0, "avgLatencyMs": 0} var calls, success, promptTokens, completionTokens sql.NullInt64 // AVG() comes back as DECIMAL, which will not scan into an integer. var avgLatency sql.NullFloat64 _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*),SUM(status=1),SUM(prompt_tokens),SUM(completion_tokens),AVG(latency_ms) FROM ai_call_logs WHERE created_at>=CURRENT_DATE()`).Scan(&calls, &success, &promptTokens, &completionTokens, &avgLatency) today["calls"] = calls.Int64 today["success"] = success.Int64 today["failed"] = calls.Int64 - success.Int64 today["promptTokens"] = promptTokens.Int64 today["completionTokens"] = completionTokens.Int64 today["avgLatencyMs"] = int64(avgLatency.Float64 + 0.5) daily := []map[string]any{} dailyRows, err := a.db.QueryContext(ctx, `SELECT DATE(created_at),COUNT(*),SUM(status=1),SUM(prompt_tokens+completion_tokens) FROM ai_call_logs WHERE created_at>=DATE_SUB(CURRENT_DATE(),INTERVAL ? DAY) GROUP BY DATE(created_at) ORDER BY DATE(created_at)`, aiUsageDays-1) if err == nil { defer dailyRows.Close() for dailyRows.Next() { var date sql.NullTime var dayCalls, daySuccess, dayTokens sql.NullInt64 if dailyRows.Scan(&date, &dayCalls, &daySuccess, &dayTokens) != nil { continue } entry := map[string]any{"calls": dayCalls.Int64, "success": daySuccess.Int64, "tokens": dayTokens.Int64} if date.Valid { entry["date"] = date.Time.Format("01-02") } daily = append(daily, entry) } } models := []map[string]any{} modelRows, queryErr := a.db.QueryContext(ctx, `SELECT l.model_id,COALESCE(m.name,'已删除模型'),COUNT(*),SUM(l.status=1), AVG(l.latency_ms),SUM(l.prompt_tokens+l.completion_tokens) FROM ai_call_logs l LEFT JOIN ai_models m ON m.id=l.model_id WHERE l.created_at>=DATE_SUB(CURRENT_DATE(),INTERVAL ? DAY) GROUP BY l.model_id,m.name ORDER BY COUNT(*) DESC`, aiUsageDays-1) if queryErr == nil { defer modelRows.Close() for modelRows.Next() { var modelID sql.NullInt64 var name string var modelCalls, modelSuccess, modelTokens sql.NullInt64 var modelLatency sql.NullFloat64 if modelRows.Scan(&modelID, &name, &modelCalls, &modelSuccess, &modelLatency, &modelTokens) != nil { continue } models = append(models, map[string]any{ "modelId": modelID.Int64, "name": name, "calls": modelCalls.Int64, "success": modelSuccess.Int64, "avgLatencyMs": int64(modelLatency.Float64 + 0.5), "tokens": modelTokens.Int64, }) } } jobs := map[string]any{"pending": 0, "running": 0, "failed24h": 0, "overdue": 0} var pending, running, failed, overdue int _ = a.db.QueryRowContext(ctx, `SELECT SUM(status=0),SUM(status=1),SUM(status=3 AND updated_at>=DATE_SUB(NOW(3),INTERVAL 1 DAY)), SUM(status=0 AND run_after 0 { if calls >= dailyLimit { alerts = append(alerts, map[string]string{"level": "error", "message": "今日调用已达上限,后续消息不会再回复"}) } else if calls*10 >= dailyLimit*8 { alerts = append(alerts, map[string]string{"level": "warning", "message": "今日调用已超过配额的 80%"}) } } // A handful of failures is noise; a persistent failure rate is not. if calls >= 5 && failed*10 >= calls*3 { message := "今日调用失败率超过 30%" if lastError != "" { message += ",最近错误:" + lastError } alerts = append(alerts, map[string]string{"level": "error", "message": message}) } if overdue > 0 { alerts = append(alerts, map[string]string{"level": "warning", "message": "有回复任务超过 60 秒未处理,请检查后端 worker 是否在运行"}) } return alerts } // Call logs hold conversation replies, so they are trimmed on a schedule rather // than kept forever. Deleting in batches keeps the lock short. func (a *App) purgeAICallLogs(ctx context.Context) { days := a.configInt(ctx, "ai.log_retention_days", 30) if days < 1 || days > 365 { days = 30 } for attempt := 0; attempt < 20; attempt++ { result, err := a.db.ExecContext(ctx, `DELETE FROM ai_call_logs WHERE created_at