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

305 lines
12 KiB
Go

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<DATE_SUB(NOW(3),INTERVAL 60 SECOND)) FROM ai_reply_jobs`).Scan(&pending, &running, &failed, &overdue)
jobs["pending"], jobs["running"], jobs["failed24h"], jobs["overdue"] = pending, running, failed, overdue
recent := []map[string]any{}
lastError := ""
errorRows, errorQuery := a.db.QueryContext(ctx, `SELECT created_at,error FROM ai_call_logs WHERE status=0 ORDER BY id DESC LIMIT 5`)
if errorQuery == nil {
defer errorRows.Close()
for errorRows.Next() {
var createdAt sql.NullTime
var message string
if errorRows.Scan(&createdAt, &message) != nil {
continue
}
if lastError == "" {
lastError = message
}
entry := map[string]any{"message": message}
if createdAt.Valid {
entry["at"] = createdAt.Time.Format("2006-01-02 15:04:05")
}
recent = append(recent, entry)
}
}
enabled := a.configBool(ctx, "ai.enabled", false)
dailyLimit := a.configInt(ctx, "ai.daily_call_limit", 0)
modelName, modelReady := "", true
if model, modelErr := a.defaultAIModel(ctx); modelErr != nil {
modelReady = false
} else {
modelName = model.Name
}
var agents, managed int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*),SUM(enabled=1) FROM ai_agents`).Scan(&agents, &managed)
reply(w, map[string]any{
"today": today,
"daily": daily,
"models": models,
"jobs": jobs,
"recentErrors": recent,
"limits": map[string]any{
"enabled": enabled,
"dailyCallLimit": dailyLimit,
"agentDailyReplyLimit": a.configInt(ctx, "ai.agent_daily_reply_limit", 0),
"logRetentionDays": a.configInt(ctx, "ai.log_retention_days", 30),
"modelReady": modelReady,
"modelName": modelName,
"agents": agents,
"managedAgents": managed,
},
"alerts": aiUsageAlerts(enabled, modelReady, managed, dailyLimit, int(calls.Int64), int(calls.Int64-success.Int64), overdue, lastError),
})
}
// Alerts are computed server-side so the console and any later notifier agree
// on what counts as a problem.
func aiUsageAlerts(enabled, modelReady bool, managedAgents, dailyLimit, calls, failed, overdue int, lastError string) []map[string]string {
alerts := []map[string]string{}
if !enabled {
return alerts
}
if !modelReady {
alerts = append(alerts, map[string]string{"level": "error", "message": "总开关已打开,但没有可用的模型,测试账号不会回复"})
}
if managedAgents == 0 {
alerts = append(alerts, map[string]string{"level": "warning", "message": "还没有账号开启托管,AI 不会回复任何会话"})
}
if dailyLimit > 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<DATE_SUB(NOW(3),INTERVAL ? DAY) LIMIT 1000`, days)
if err != nil {
return
}
if affected, _ := result.RowsAffected(); affected < 1000 {
return
}
}
}
// Finished jobs are only useful while they are recent; the queue table stays
// small so the claiming UPDATE keeps hitting its index.
func (a *App) purgeAIReplyJobs(ctx context.Context) {
for attempt := 0; attempt < 20; attempt++ {
result, err := a.db.ExecContext(ctx, `DELETE FROM ai_reply_jobs WHERE status IN (2,3) AND updated_at<DATE_SUB(NOW(3),INTERVAL 7 DAY) LIMIT 1000`)
if err != nil {
return
}
if affected, _ := result.RowsAffected(); affected < 1000 {
return
}
}
}
// A job left in "processing" means the process died mid-reply; returning it to
// the queue lets another worker finish it instead of stranding the reader.
func (a *App) recoverStuckAIReplyJobs(ctx context.Context) {
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error='处理超时,任务已中断'
WHERE status=1 AND updated_at<DATE_SUB(NOW(3),INTERVAL 5 MINUTE)`)
}
func (a *App) runAIMaintenance(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
// Run once at startup so a process that restarts often still trims, and so
// the retention setting can be verified without waiting an hour.
a.recoverStuckAIReplyJobs(ctx)
a.purgeAICallLogs(ctx)
a.purgeAIReplyJobs(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.recoverStuckAIReplyJobs(ctx)
a.purgeAICallLogs(ctx)
a.purgeAIReplyJobs(ctx)
}
}
}