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