后端接入 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>
This commit is contained in:
Your Name
2026-09-03 08:31:04 +08:00
co-authored by Claude Opus 5
parent 334890171e
commit a024d59827
14 changed files with 3239 additions and 146 deletions
+140
View File
@@ -0,0 +1,140 @@
# 测试账号 AI 托管
测试账号(`users.is_test=1`)在收到真实用户的私信后,由后台配置的大模型生成回复并以该账号身份发出。目标是让新用户在冷启动阶段能得到真实的对话反馈,同时把模型接入做成后台可配置、可多协议共存的能力。
本文只描述设计与实施边界,未实现的部分标注为阶段目标。
## 协议抽象
模型厂商众多但线上协议只有少数几种,因此按**协议**而不是按厂商建适配器,与现有短信(`sms_providers.go`)和对象存储(`storage_providers.go`)的做法一致:
| 协议 | 覆盖范围 | 关键差异 |
| --- | --- | --- |
| `openai` | OpenAI、DeepSeek、通义千问兼容模式、智谱、Moonshot、火山方舟、SiliconFlow、Ollama、vLLM、one-api/new-api 网关 | `POST {base_url}/chat/completions``Authorization: Bearer`system 作为首条 message |
| `anthropic` | Claude | `POST {base_url}/v1/messages``x-api-key` + `anthropic-version`system 是顶层字段 |
| `gemini` | Google Gemini | `POST {base_url}/v1beta/models/{model}:generateContent``contents[].parts[].text`assistant 角色写作 `model`;密钥用 `x-goog-api-key` 头而不是查询参数,避免写进代理日志 |
| `webhook` | Coze、Dify、n8n、自建编排 | 后端 POST 规范化 JSON,期望返回 `{"text": "..."}`,不为每个编排平台写代码 |
| `debug` | 开发与测试 | 不出网,返回确定性文案,供本地联调和单元测试使用 |
统一接口(`internal/app/ai_providers.go`):
```go
type aiChatMessage struct{ Role, Text string } // role: user | assistant
type aiChatRequest struct {
System string
Messages []aiChatMessage
MaxTokens int
Temperature float64
}
type aiChatResult struct {
Text string
PromptTokens, CompletionTokens int
FinishReason, RequestID string
}
type aiProvider interface {
Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error)
}
```
新增一家厂商时,若走 OpenAI 兼容格式则只需在后台新增一条模型记录,不改代码。
## 数据模型
`migrations/030_ai_models.sql`P0,已落地):
- `ai_models``id / name / protocol / base_url / model_name / api_key_cipher / temperature / max_tokens / timeout_ms / status / is_default / extra_json`。密钥复用 `integration.go``encryptSecret`/`decryptSecret`AES-GCM),与 `system_configs` 的 secret 字段同一套密钥。允许同时存在多条不同协议的记录,`is_default` 在写入时互斥。
- `ai_call_logs``model_id / scene / agent_user_id / conversation_id / latency_ms / prompt_tokens / completion_tokens / status / error / prompt_digest / reply_text`,用于成本核算和事后审核,按 `ai.log_retention_days` 定期清理。后台探活也会写入(`scene='probe'`)。
P1 迁移新增:
- `ai_agents``user_id`(唯一,必须 `is_test=1`/ `model_id` / `persona`(系统提示词)/ `enabled` / `daily_reply_limit` / `reply_delay_min_ms` / `reply_delay_max_ms`。未单独配置 persona 时由 `user_profiles` 的昵称、年龄、城市、bio、标签拼装,保证人设和资料一致。
- `ai_reply_jobs``id / conversation_id / agent_user_id / trigger_message_id / status(0待处理 1处理中 2成功 3失败) / attempts / run_after / claim_token / last_error`。同一会话同时只允许一个待回复任务:`pending_key``IF(status=0, conversation_id, NULL)` 的 STORED 生成列,加唯一键后,待处理状态天然互斥,离开该状态即为 NULL 不再约束。
全局开关放进 `integrationSpecs` 新增的 `ai` 组(`system_configs` 表),后台现成的通用面板即可渲染:`ai.enabled``ai.default_model_id``ai.max_concurrency``ai.daily_call_limit``ai.reply_delay_min/max_ms``ai.allow_batches`(限定 `test_batch`)、`ai.disclose_in_chat``ai.log_retention_days``ai.fallback_text`
## 托管流程
1. `persistMessage` 成功写库并广播后,判断该会话对端是否为已启用的 AI 托管测试账号;是则写入 `ai_reply_jobs``run_after = now + rand(delay_min, delay_max)`)。判定和入队都不阻塞发送方的响应。
2. 同一会话已有待处理任务时不新建,只把 `run_after` 后移(上限 15 秒)并更新 `trigger_message_id`。用户连发三条会得到一条回复,而不是三条。
3. `Run()` 中新增 worker goroutine(与现有 `processDueAccountClosures` 同样的 ticker 模式),每秒领取到期任务。领取方式是一条 `UPDATE ... SET status=1,claim_token=? WHERE status=0 AND run_after<=NOW(3) ORDER BY run_after LIMIT n` 再按 token 回读:每行只能离开待处理状态一次,多实例不会重复消费。**没有用 `SELECT ... FOR UPDATE SKIP LOCKED`**——生产 compose 是 MySQL 8.4,但开发环境仍在 5.7,那里该语法直接解析失败。
4. 组装上下文:取该会话最近 20 条消息,对端消息映射为 `user`、托管账号自己的消息映射为 `assistant`;图片和语音降级为 `[图片]` `[语音]` 占位符。系统提示词 = persona + 硬性约束(中文、口语化、不超过 40 字、不加 emoji 堆砌、不索取联系方式、不涉及金钱和线下见面、不输出链接)。
5. 调用模型(超时取 `ai_models.timeout_ms`,失败重试一次并退避)。成功后清洗输出(去 Markdown、截断、过滤空回复),走 `persistMessageContext` 以托管账号身份发送。
6. 回复经过与真人完全相同的落库和推送路径,因此未读数、WebSocket 推送、离线推送、会话列表排序全部复用现有逻辑,**客户端零改动**。
## 闸门
- `ai.enabled` 为总开关;生产环境禁止使用 `debug` 协议(与 `validateSMSProviderConfig` 中"生产环境禁止 debug 短信提供商"同一规则)。
- 仅对 `is_test=1` 且在 `ai.allow_batches` 内的账号生效;AI 账号之间不互相触发,避免自激对话。
- 三层配额:单账号每日回复上限、全局每日调用上限、并发信号量。任一超限即丢弃任务并记日志,不做排队堆积。
- 发送前复查会话最后一条消息,若已是托管账号自己发的则跳过,避免重复回复。
- `persistMessage` 已有的禁言检查(`isSanctionActive`)和媒体归属校验对 AI 同样生效。发送频控要用独立限流键,避免托管账号触发按人设计的"每日活跃聊天上限"(`dailyActiveChatLimitError`)。
- 模型调用失败时默认静默,不发兜底话术;`ai.fallback_text` 非空时才发送,避免机械感。
## 后台
- `GET/POST/PUT/DELETE /admin/v1/ai/models``POST /admin/v1/ai/models/:id/test`(发一条探活提示词,返回延迟、tokens 和实际回复)。
- `GET/PUT /admin/v1/ai/agents`:列出测试账号及其托管状态,支持按批次批量开关和批量绑定模型。
- `GET /admin/v1/ai/logs`:调用日志,可按模型、场景、结果和时间范围筛选。
- `GET /admin/v1/ai/usage`:今日用量、近 7 天按日与按模型汇总、任务队列状态、最近错误,以及服务端计算的告警列表。
- `GET/PUT /admin/v1/integrations/ai`:全局开关,复用现有通用配置面板。
权限沿用 `permit("system:manage", ...)`;模型和托管开关的变更走 `a.audit(...)` 落审计日志。管理端前端只需新增一个 `ai-config.vue`(复用 `IntegrationConfigPanel`)、一个模型列表页,并把 `socialApi.integration` 的 group 联合类型加上 `'ai'`
## 需要改动的既有代码
- `persistMessage(r *http.Request, ...)` 抽出 `persistMessageContext(ctx context.Context, ...)`,HTTP 处理器保留薄封装。worker 没有 `*http.Request`,这是唯一侵入式改造。
- `app.go``Run()` 增加 worker goroutine 和退出时的优雅停止。
- `integrationSpecs` 增加 `ai` 组,`adminTestIntegration` 增加 `ai` 分支。
## 分期
1. **P0 骨架(已完成)**:迁移、`ai_models` CRUD、五种协议适配器、后台配置页与探活按钮。不接 IM,可独立验证。
2. **P1 托管闭环(已完成)**`ai_agents` 绑定、任务表与 worker、`persistMessageContext` 重构、延迟与合并。默认 `ai.enabled=false` 发布。
3. **P2 补齐(已完成)**:调用日志页、用量汇总与告警、日志与任务清理。
4. **P3 可选**:AI 主动打招呼、动态与评论生成、多模型灰度路由。
## 风险与待定
- **合规**:测试账号资料页已标注"AI生成虚拟资料",但会话中是否显式标注由 `ai.disclose_in_chat` 控制,默认开启。对真实用户隐瞒机器人身份在部分地区属于监管红线,关闭前需要产品确认。
- **成本**:单条回复约 500~1500 tokens。上线前应先用 `debug` 协议跑通链路,再用真实模型小批量放量,靠 `ai_call_logs` 观察日用量。
- **数据留存**`ai_call_logs.reply_text` 会保存对话内容,属于用户数据。默认只存托管账号自己的回复和用户消息摘要(`prompt_digest`),完整 prompt 不落库。
## P0 落地记录
已实现的文件:
- `migrations/030_ai_models.sql`:两张表和 11 个 `ai.*` 配置项。
- `internal/app/ai_providers.go``aiProvider` 接口与五个协议适配器,含地址校验(禁止账号、查询参数、片段,生产环境强制 HTTPS)和 512 KiB 响应上限。
- `internal/app/admin_ai.go`:模型 CRUD、探活、调用日志写入、`validateAIConfig`
- `internal/app/integration.go``ai` 配置分组与 `POST /admin/v1/integrations/ai/test`(用默认模型真实调用一次)。
- 管理端:`ai-config.vue`(复用通用配置面板)、`ai-models.vue`(模型表格、编辑弹窗、测试按钮)、两条路由。
验证方式:`internal/app/ai_providers_test.go``httptest` 覆盖四种线协议的请求组装与响应解析(system 提示词位置、鉴权头、assistant→model 角色映射、错误体与非 JSON 响应、密钥不进查询参数),以及地址校验、工厂守卫和 debug 协议的确定性。本地起服务后走通了「登录→建模型→探活→改配置→连通性测试→删除」,`ai_call_logs``admin_audit_logs` 均有对应记录。
P0 不改动任何既有业务路径:`persistMessage` 尚未重构,IM 流程没有接入点,总开关默认关闭。
## P1 落地记录
新增 `migrations/031_ai_agents.sql``ai_agents` + `ai_reply_jobs`)、`internal/app/ai_agent.go`(入队、worker、上下文组装、提示词、清洗、配额)、`internal/app/admin_ai_agents.go`(账号绑定与任务查询),并把 `persistMessage` 抽成 `persistMessageContext`。管理端新增 `ai-agents.vue`
真库联调结果(本地 MySQL 5.7 + debug 协议):
- 真实用户连发三条消息 → **只入队一个任务**`trigger_message_id` 指向最后一条)→ 托管账号回一条,`ai_reply_jobs.status=2``ai_call_logs``scene='chat'` 记录、`ai_agents.reply_count` 递增。
- 新会话里托管账号的第一条回复带 `[AI 助手]` 前缀(`ai.disclose_in_chat` 控制)。
- 托管账号之间互发消息不入队;关闭绑定后不再入队。
一处设计修正:原计划用 `SELECT ... FOR UPDATE SKIP LOCKED` 领取任务,实测开发环境的 MySQL 5.7.26 直接报 `ERROR 1064 ... near 'SKIP LOCKED'`,已改为 claim_token 方案,5.7 与 8.x 都可用。
**尚未接入**:P2 的调用日志页、配额告警和日志清理任务;`ai.fallback_text` 已实现但默认留空(失败静默)。
## P2 落地记录
新增 `internal/app/admin_ai_logs.go`(日志查询、用量汇总、告警计算、清理任务)与管理端 `ai-logs.vue`
**告警**在服务端计算(`aiUsageAlerts`),前端只负责显示,这样以后接入通知渠道时判定逻辑只有一份。触发条件:总开关已开但无可用模型(error)、没有任何账号开启托管(warning)、今日调用达到或超过配额 80%(warning/error)、当日调用 ≥5 次且失败率 >30%(error,附最近一条错误)、有任务超过 60 秒未处理即 worker 可能没在跑(warning)。总开关关闭时不产生任何告警。
**清理**由 `runAIMaintenance` 每小时执行一次,进程启动时也先跑一遍:按 `ai.log_retention_days` 删除过期调用日志、删除 7 天前的已完成/失败任务、把卡在「处理中」超过 5 分钟的任务标记为失败(进程在回复中途被杀时不会永久占用会话的待处理名额)。删除都按 1000 条一批,避免长事务。
实测确认:保留 30 天时,40 天和 31 天前的日志被清理、29 天前的保留;8 天前的已完成任务被清理、2 天前的失败任务保留;卡住 10 分钟的任务被标记为「处理超时,任务已中断」。用量接口在造出 7 次调用(含 1 次失败、耗时非零)后返回正确的今日汇总、按模型汇总和最近错误;非法筛选参数(`days=999``scene=hack`)返回 400。
一处实现修正:`AVG(latency_ms)` 在 MySQL 里返回 DECIMAL,扫进 `sql.NullInt64` 会失败——按模型汇总因此每行都被静默跳过、今日平均耗时恒为 0。已改为 `sql.NullFloat64` 后取整。
+424
View File
@@ -0,0 +1,424 @@
package app
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
)
type aiModelRequest struct {
Name string `json:"name"`
Protocol string `json:"protocol"`
BaseURL string `json:"baseUrl"`
ModelName string `json:"modelName"`
APIKey string `json:"apiKey"`
ClearAPIKey bool `json:"clearApiKey"`
Temperature *float64 `json:"temperature"`
MaxTokens *int `json:"maxTokens"`
TimeoutMS *int `json:"timeoutMs"`
Status *int `json:"status"`
IsDefault *bool `json:"isDefault"`
}
const aiModelColumns = `id,name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default,created_at,updated_at`
func (a *App) scanAIModel(scan func(dest ...any) error) (aiModel, error) {
var model aiModel
var cipher string
var created, updated time.Time
if err := scan(&model.ID, &model.Name, &model.Protocol, &model.BaseURL, &model.ModelName, &cipher,
&model.Temperature, &model.MaxTokens, &model.TimeoutMS, &model.Status, &model.IsDefault, &created, &updated); err != nil {
return aiModel{}, err
}
model.HasAPIKey = cipher != ""
if cipher != "" {
plain, err := a.decryptSecret(cipher)
if err != nil {
// A key that cannot be decrypted (rotated encryption key) must not
// silently become an empty key that reaches the provider.
return model, fmt.Errorf("模型 %s 的密钥无法解密,请重新填写", model.Name)
}
model.APIKey = plain
}
model.CreatedAt = created.Format(time.RFC3339)
model.UpdatedAt = updated.Format(time.RFC3339)
return model, nil
}
func (a *App) loadAIModel(ctx context.Context, id int64) (aiModel, error) {
row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE id=?`, id)
return a.scanAIModel(row.Scan)
}
// The default model is what the reply worker uses when an agent has no explicit
// binding: the configured id first, then the flagged row.
func (a *App) defaultAIModel(ctx context.Context) (aiModel, error) {
if configured := strings.TrimSpace(a.configPlain(ctx, "ai.default_model_id", "")); configured != "" {
row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE id=? AND status=1`, configured)
if model, err := a.scanAIModel(row.Scan); err == nil {
return model, nil
}
}
row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE status=1 ORDER BY is_default DESC,id ASC LIMIT 1`)
model, err := a.scanAIModel(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return aiModel{}, fmt.Errorf("尚未配置可用的 AI 模型")
}
return model, err
}
func (a *App) adminAIModels(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT `+aiModelColumns+` FROM ai_models ORDER BY is_default DESC,id ASC`)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败")
return
}
defer rows.Close()
items := []aiModel{}
for rows.Next() {
model, scanErr := a.scanAIModel(rows.Scan)
if scanErr != nil && model.ID == 0 {
fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败")
return
}
model.APIKey = ""
items = append(items, model)
}
if rows.Err() != nil {
fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败")
return
}
protocols := make([]map[string]string, 0, len(aiProtocols))
for _, protocol := range aiProtocols {
protocols = append(protocols, map[string]string{"value": protocol, "label": aiProtocolName(protocol)})
}
reply(w, map[string]any{
"items": items,
"protocols": protocols,
"enabled": a.configBool(r.Context(), "ai.enabled", false),
"secretMask": maskedSecret,
})
}
func (a *App) normalizeAIModelRequest(req *aiModelRequest, existing *aiModel) error {
req.Name = strings.TrimSpace(req.Name)
req.Protocol = strings.ToLower(strings.TrimSpace(req.Protocol))
req.BaseURL = strings.TrimSpace(req.BaseURL)
req.ModelName = strings.TrimSpace(req.ModelName)
if existing != nil {
if req.Name == "" {
req.Name = existing.Name
}
if req.Protocol == "" {
req.Protocol = existing.Protocol
}
}
if req.Name == "" || len([]rune(req.Name)) > 60 {
return fmt.Errorf("模型名称须为 160 字")
}
if !containsString(aiProtocols, req.Protocol) {
return fmt.Errorf("不支持的模型协议")
}
if a.config.Environment == "production" && req.Protocol == "debug" {
return fmt.Errorf("生产环境禁止使用本地调试模型")
}
if len(req.BaseURL) > 300 || len([]rune(req.ModelName)) > 120 {
return fmt.Errorf("接口地址最多 300 字,模型名称最多 120 字")
}
if _, err := parseAIBaseURL(req.Protocol, req.BaseURL, a.config.Environment == "production"); err != nil {
return err
}
if req.Protocol != "debug" && req.Protocol != "webhook" && req.ModelName == "" {
return fmt.Errorf("请填写模型名称,例如 gpt-4o-mini 或 deepseek-chat")
}
if req.Temperature != nil && (*req.Temperature < 0 || *req.Temperature > 2) {
return fmt.Errorf("温度必须在 0 到 2 之间")
}
if req.MaxTokens != nil && (*req.MaxTokens < 16 || *req.MaxTokens > 4096) {
return fmt.Errorf("最大回复长度必须在 16 到 4096 之间")
}
if req.TimeoutMS != nil && (*req.TimeoutMS < 1000 || *req.TimeoutMS > 60000) {
return fmt.Errorf("超时必须在 1000 到 60000 毫秒之间")
}
if req.Status != nil && *req.Status != 0 && *req.Status != 1 {
return fmt.Errorf("状态无效")
}
return nil
}
func (a *App) adminCreateAIModel(w http.ResponseWriter, r *http.Request) {
var req aiModelRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "模型配置格式不正确")
return
}
if err := a.normalizeAIModelRequest(&req, nil); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
cipher, err := a.encryptSecret(strings.TrimSpace(req.APIKey))
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "加密模型密钥失败")
return
}
temperature, maxTokens, timeout, status := 0.8, 256, 15000, 1
if req.Temperature != nil {
temperature = *req.Temperature
}
if req.MaxTokens != nil {
maxTokens = *req.MaxTokens
}
if req.TimeoutMS != nil {
timeout = *req.TimeoutMS
}
if req.Status != nil {
status = *req.Status
}
isDefault := req.IsDefault != nil && *req.IsDefault
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
defer func() { _ = tx.Rollback() }()
if isDefault {
if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET is_default=0 WHERE is_default=1`); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
}
result, err := tx.ExecContext(r.Context(), `INSERT INTO ai_models
(name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
req.Name, req.Protocol, req.BaseURL, req.ModelName, cipher, temperature, maxTokens, timeout, status, btoi(isDefault))
if err != nil {
if isDuplicateAIModelName(err) {
fail(w, http.StatusBadRequest, 20001, "模型名称已存在")
return
}
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
id, _ := result.LastInsertId()
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
a.audit(r, "create", "ai_model", id, map[string]any{"name": req.Name, "protocol": req.Protocol})
reply(w, map[string]any{"id": id})
}
func (a *App) adminUpdateAIModel(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "模型不存在")
return
}
existing, err := a.loadAIModel(r.Context(), id)
if errors.Is(err, sql.ErrNoRows) {
fail(w, http.StatusNotFound, 30001, "模型不存在")
return
}
var req aiModelRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "模型配置格式不正确")
return
}
if err = a.normalizeAIModelRequest(&req, &existing); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
// An empty key means "keep the stored one"; clearing is explicit.
cipherClause, cipherArgs := "", []any{}
if req.ClearAPIKey {
cipherClause = ",api_key_cipher=''"
} else if strings.TrimSpace(req.APIKey) != "" {
cipher, encryptErr := a.encryptSecret(strings.TrimSpace(req.APIKey))
if encryptErr != nil {
fail(w, http.StatusInternalServerError, 50001, "加密模型密钥失败")
return
}
cipherClause = ",api_key_cipher=?"
cipherArgs = append(cipherArgs, cipher)
}
temperature, maxTokens, timeout, status := existing.Temperature, existing.MaxTokens, existing.TimeoutMS, existing.Status
if req.Temperature != nil {
temperature = *req.Temperature
}
if req.MaxTokens != nil {
maxTokens = *req.MaxTokens
}
if req.TimeoutMS != nil {
timeout = *req.TimeoutMS
}
if req.Status != nil {
status = *req.Status
}
isDefault := existing.IsDefault
if req.IsDefault != nil {
isDefault = *req.IsDefault
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
defer func() { _ = tx.Rollback() }()
if isDefault {
if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET is_default=0 WHERE is_default=1 AND id<>?`, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
}
args := append([]any{req.Name, req.Protocol, req.BaseURL, req.ModelName, temperature, maxTokens, timeout, status, btoi(isDefault)}, cipherArgs...)
args = append(args, id)
if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET name=?,protocol=?,base_url=?,model_name=?,temperature=?,max_tokens=?,timeout_ms=?,status=?,is_default=?`+cipherClause+` WHERE id=?`, args...); err != nil {
if isDuplicateAIModelName(err) {
fail(w, http.StatusBadRequest, 20001, "模型名称已存在")
return
}
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
a.audit(r, "update", "ai_model", id, map[string]any{"name": req.Name, "protocol": req.Protocol, "status": status})
reply(w, map[string]any{"success": true})
}
func (a *App) adminDeleteAIModel(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "模型不存在")
return
}
if _, err = a.db.ExecContext(r.Context(), `DELETE FROM ai_models WHERE id=?`, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "删除模型失败")
return
}
a.audit(r, "delete", "ai_model", id, nil)
reply(w, map[string]any{"success": true})
}
// A probe call from the console: the operator sees latency, tokens and the
// actual reply before any conversation depends on the configuration.
func (a *App) adminTestAIModel(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "模型不存在")
return
}
model, err := a.loadAIModel(r.Context(), id)
if errors.Is(err, sql.ErrNoRows) {
fail(w, http.StatusNotFound, 30001, "模型不存在")
return
}
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
provider, err := newAIProvider(model, a.config.Environment == "production")
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
request := aiChatRequest{
System: "你在参与一次接口连通性测试,请直接给出简短的中文回复。",
Messages: []aiChatMessage{{Role: "user", Text: aiProbePrompt}},
}
started := time.Now()
result, err := provider.Chat(r.Context(), request)
latency := int(time.Since(started).Milliseconds())
a.logAICall(r.Context(), model.ID, "probe", 0, 0, latency, result, err)
if err != nil {
fail(w, http.StatusBadGateway, 50002, err.Error())
return
}
a.audit(r, "test", "ai_model", id, map[string]any{"latencyMs": latency})
reply(w, map[string]any{
"reply": result.Text,
"latencyMs": latency,
"promptTokens": result.PromptTokens,
"completionTokens": result.CompletionTokens,
"finishReason": result.FinishReason,
})
}
func (a *App) logAICall(ctx context.Context, modelID int64, scene string, agentUserID, conversationID int64, latencyMS int, result aiChatResult, callErr error) {
status, message := 1, ""
if callErr != nil {
status = 0
message = callErr.Error()
if len([]rune(message)) > 500 {
message = string([]rune(message)[:500])
}
}
text := result.Text
if len([]rune(text)) > 1000 {
text = string([]rune(text)[:1000])
}
digest := ""
if scene != "" {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", scene, agentUserID, conversationID)))
digest = hex.EncodeToString(sum[:])[:32]
}
_, _ = a.db.ExecContext(ctx, `INSERT INTO ai_call_logs
(model_id,scene,agent_user_id,conversation_id,latency_ms,prompt_tokens,completion_tokens,status,error,prompt_digest,reply_text)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
modelID, scene, agentUserID, conversationID, latencyMS, result.PromptTokens, result.CompletionTokens, status, message, digest, text)
}
func isDuplicateAIModelName(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 && strings.Contains(mysqlErr.Message, "uk_ai_models_name")
}
// Global switches are validated together because several of them only make
// sense as a pair, and the console tests them before the worker ever runs.
func (a *App) validateAIConfig(ctx context.Context) error {
if !a.configBool(ctx, "ai.enabled", false) {
return fmt.Errorf("AI 托管当前未启用")
}
minDelay := a.configInt(ctx, "ai.reply_delay_min_ms", 2000)
maxDelay := a.configInt(ctx, "ai.reply_delay_max_ms", 6000)
if minDelay < 0 || maxDelay < minDelay || maxDelay > 120000 {
return fmt.Errorf("回复延迟区间无效,最大延迟须不小于最小延迟且不超过 120000 毫秒")
}
if concurrency := a.configInt(ctx, "ai.max_concurrency", 4); concurrency < 1 || concurrency > 64 {
return fmt.Errorf("并发调用上限必须在 1 到 64 之间")
}
if limit := a.configInt(ctx, "ai.daily_call_limit", 0); limit < 0 {
return fmt.Errorf("每日调用上限不能为负数")
}
if limit := a.configInt(ctx, "ai.agent_daily_reply_limit", 0); limit < 0 {
return fmt.Errorf("单账号每日回复上限不能为负数")
}
if days := a.configInt(ctx, "ai.log_retention_days", 30); days < 1 || days > 365 {
return fmt.Errorf("调用日志保留天数必须在 1 到 365 之间")
}
for _, batch := range strings.Split(a.configPlain(ctx, "ai.allow_batches", ""), ",") {
if len(strings.TrimSpace(batch)) > 64 {
return fmt.Errorf("测试批次名称长度不能超过 64 位")
}
}
return nil
}
func (a *App) configInt(ctx context.Context, key string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(a.configPlain(ctx, key, "")))
if err != nil {
return fallback
}
return value
}
+248
View File
@@ -0,0 +1,248 @@
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})
}
+304
View File
@@ -0,0 +1,304 @@
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)
}
}
}
@@ -0,0 +1,65 @@
package app
import "testing"
func alertMessages(alerts []map[string]string) string {
joined := ""
for _, alert := range alerts {
joined += alert["level"] + ":" + alert["message"] + "\n"
}
return joined
}
func TestAIUsageAlerts(t *testing.T) {
// Nothing is wrong while the feature is off, however bad the numbers look.
if got := aiUsageAlerts(false, false, 0, 100, 500, 400, 9, "boom"); len(got) != 0 {
t.Fatalf("a disabled feature must not raise alerts: %v", got)
}
healthy := aiUsageAlerts(true, true, 3, 100, 10, 0, 0, "")
if len(healthy) != 0 {
t.Fatalf("a healthy setup must be quiet: %v", healthy)
}
tests := []struct {
name string
alerts []map[string]string
contains string
}{
{name: "no model", alerts: aiUsageAlerts(true, false, 3, 0, 0, 0, 0, ""), contains: "没有可用的模型"},
{name: "no agents", alerts: aiUsageAlerts(true, true, 0, 0, 0, 0, 0, ""), contains: "还没有账号开启托管"},
{name: "quota reached", alerts: aiUsageAlerts(true, true, 2, 100, 100, 0, 0, ""), contains: "今日调用已达上限"},
{name: "quota near", alerts: aiUsageAlerts(true, true, 2, 100, 80, 0, 0, ""), contains: "超过配额的 80%"},
{name: "failure rate", alerts: aiUsageAlerts(true, true, 2, 0, 10, 4, 0, "上游 429"), contains: "失败率超过 30%"},
{name: "stuck worker", alerts: aiUsageAlerts(true, true, 2, 0, 0, 0, 3, ""), contains: "worker 是否在运行"},
}
for _, test := range tests {
if !contains(alertMessages(test.alerts), test.contains) {
t.Errorf("%s: expected an alert mentioning %q, got %s", test.name, test.contains, alertMessages(test.alerts))
}
}
// The most recent error is quoted so the console shows why calls fail.
if !contains(alertMessages(aiUsageAlerts(true, true, 2, 0, 10, 4, 0, "上游 429")), "上游 429") {
t.Error("the failure alert must quote the latest error")
}
// A couple of failures out of a handful of calls is noise, not an alert.
if got := aiUsageAlerts(true, true, 2, 0, 4, 2, 0, ""); len(got) != 0 {
t.Errorf("a tiny sample must not trigger the failure alert: %v", got)
}
if got := aiUsageAlerts(true, true, 2, 0, 10, 2, 0, ""); len(got) != 0 {
t.Errorf("a 20%% failure rate must stay below the threshold: %v", got)
}
}
func contains(haystack, needle string) bool {
return len(needle) > 0 && len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
}
func indexOf(haystack, needle string) int {
for index := 0; index+len(needle) <= len(haystack); index++ {
if haystack[index:index+len(needle)] == needle {
return index
}
}
return -1
}
+439
View File
@@ -0,0 +1,439 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"math/rand"
"strings"
"sync"
"time"
)
const (
aiHistoryMessages = 20
aiReplyMaxRunes = 120
aiJobMaxAttempts = 2
// A burst of incoming messages collapses into one reply, but the reader
// should not wait longer than this for it.
aiReplyMaxDelaySeconds = 15
)
type aiAgentBinding struct {
UserID int64
ModelID int64
Persona string
DailyReplyLimit int
Nickname string
Age int
City string
Bio string
}
type aiReplyJob struct {
ID int64
ConversationID int64
AgentUserID int64
Attempts int
}
// Called right after a message is committed, from every send path. It must stay
// cheap and must never fail the send: a missing reply is better than a failed
// message.
func (a *App) enqueueAIReply(ctx context.Context, conversationID, senderID int64, members []int64, messageID int64) {
if !a.configBool(ctx, "ai.enabled", false) {
return
}
peerID := int64(0)
for _, member := range members {
if member != senderID {
if peerID != 0 {
return // Group conversations have no single managed recipient.
}
peerID = member
}
}
if peerID == 0 {
return
}
var agentUserID int64
var testBatch string
// The sender must not itself be managed, otherwise two agents would keep
// each other talking forever.
err := a.db.QueryRowContext(ctx, `SELECT g.user_id,u.test_batch
FROM ai_agents g JOIN users u ON u.id=g.user_id
WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL
AND NOT EXISTS(SELECT 1 FROM ai_agents s WHERE s.user_id=? AND s.enabled=1)`,
peerID, senderID).Scan(&agentUserID, &testBatch)
if err != nil || agentUserID == 0 {
return
}
if !aiBatchAllowed(a.configPlain(ctx, "ai.allow_batches", ""), testBatch) {
return
}
delay := a.aiReplyDelay(ctx)
if _, err = a.db.ExecContext(ctx, `INSERT INTO ai_reply_jobs(conversation_id,agent_user_id,trigger_message_id,run_after)
VALUES(?,?,?,DATE_ADD(NOW(3),INTERVAL ? MICROSECOND))
ON DUPLICATE KEY UPDATE trigger_message_id=VALUES(trigger_message_id),
run_after=LEAST(DATE_ADD(created_at,INTERVAL ? SECOND),VALUES(run_after))`,
conversationID, agentUserID, messageID, delay.Microseconds(), aiReplyMaxDelaySeconds); err != nil {
log.Printf("ai: 入队失败 conversation=%d: %v", conversationID, err)
}
}
// An empty allow list means every test account; otherwise the account's batch
// has to be named explicitly.
func aiBatchAllowed(allowed, batch string) bool {
allowed = strings.TrimSpace(allowed)
if allowed == "" {
return true
}
for _, item := range strings.Split(allowed, ",") {
if strings.TrimSpace(item) == strings.TrimSpace(batch) && batch != "" {
return true
}
}
return false
}
func (a *App) aiReplyDelay(ctx context.Context) time.Duration {
minDelay := a.configInt(ctx, "ai.reply_delay_min_ms", 2000)
maxDelay := a.configInt(ctx, "ai.reply_delay_max_ms", 6000)
if minDelay < 0 {
minDelay = 0
}
if maxDelay <= minDelay {
maxDelay = minDelay
}
spread := maxDelay - minDelay
if spread > 0 {
minDelay += rand.Intn(spread)
}
return time.Duration(minDelay) * time.Millisecond
}
// The worker follows the same ticker shape as processDueAccountClosures.
func (a *App) runAIReplyWorker(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.processDueAIReplies(ctx)
}
}
}
func (a *App) processDueAIReplies(ctx context.Context) {
if !a.configBool(ctx, "ai.enabled", false) {
return
}
concurrency := a.configInt(ctx, "ai.max_concurrency", 4)
if concurrency < 1 || concurrency > 64 {
concurrency = 4
}
jobs, err := a.claimAIReplyJobs(ctx, concurrency)
if err != nil || len(jobs) == 0 {
return
}
var wait sync.WaitGroup
for _, job := range jobs {
wait.Add(1)
go func(job aiReplyJob) {
defer wait.Done()
if replyErr := a.processAIReplyJob(ctx, job); replyErr != nil {
a.failAIReplyJob(ctx, job, replyErr)
return
}
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=2,last_error='' WHERE id=?`, job.ID)
}(job)
}
wait.Wait()
}
// Claiming stamps a token with a single UPDATE and reads the rows back: each
// row can only leave status 0 once, so two instances never take the same job.
// SELECT ... FOR UPDATE SKIP LOCKED would be the obvious alternative, but it
// does not parse on MySQL 5.7, which some deployments still run.
func (a *App) claimAIReplyJobs(ctx context.Context, limit int) ([]aiReplyJob, error) {
token := randomToken()[:32]
result, err := a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=1,attempts=attempts+1,claim_token=?
WHERE status=0 AND run_after<=NOW(3) ORDER BY run_after LIMIT ?`, token, limit)
if err != nil {
return nil, err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return nil, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id,conversation_id,agent_user_id,attempts FROM ai_reply_jobs WHERE claim_token=? AND status=1`, token)
if err != nil {
return nil, err
}
defer rows.Close()
jobs := []aiReplyJob{}
for rows.Next() {
var job aiReplyJob
if rows.Scan(&job.ID, &job.ConversationID, &job.AgentUserID, &job.Attempts) == nil {
jobs = append(jobs, job)
}
}
return jobs, rows.Err()
}
func (a *App) failAIReplyJob(ctx context.Context, job aiReplyJob, cause error) {
message := cause.Error()
if len([]rune(message)) > 500 {
message = string([]rune(message)[:500])
}
if job.Attempts >= aiJobMaxAttempts {
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error=? WHERE id=?`, message, job.ID)
return
}
// Returning the job to the queue can collide with a newer pending job for
// the same conversation; that newer job supersedes this one.
if _, err := a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=0,claim_token='',last_error=?,run_after=DATE_ADD(NOW(3),INTERVAL 5 SECOND) WHERE id=?`, message, job.ID); err != nil {
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error=? WHERE id=?`, message, job.ID)
}
}
func (a *App) processAIReplyJob(ctx context.Context, job aiReplyJob) error {
binding, err := a.loadAIAgent(ctx, job.AgentUserID)
if err != nil {
return err
}
if err = a.checkAIQuota(ctx, binding); err != nil {
return err
}
history, lastSenderID, err := a.aiConversationHistory(ctx, job.ConversationID, job.AgentUserID)
if err != nil {
return err
}
if len(history) == 0 || lastSenderID == job.AgentUserID {
// Someone already answered, or there is nothing to answer.
return nil
}
model, err := a.aiModelForAgent(ctx, binding)
if err != nil {
return err
}
provider, err := newAIProvider(model, a.config.Environment == "production")
if err != nil {
return err
}
first, err := a.aiFirstReply(ctx, job.ConversationID, job.AgentUserID)
if err != nil {
return err
}
request := aiChatRequest{
System: aiSystemPrompt(binding, a.configBool(ctx, "ai.disclose_in_chat", true)),
Messages: history,
}
callCtx, cancel := context.WithTimeout(ctx, time.Duration(model.TimeoutMS+2000)*time.Millisecond)
defer cancel()
started := time.Now()
result, callErr := provider.Chat(callCtx, request)
latency := int(time.Since(started).Milliseconds())
a.logAICall(ctx, model.ID, "chat", job.AgentUserID, job.ConversationID, latency, result, callErr)
if callErr != nil {
if fallback := strings.TrimSpace(a.configPlain(ctx, "ai.fallback_text", "")); fallback != "" {
_ = a.sendAIReply(ctx, job, fallback)
}
return callErr
}
text := sanitizeAIReply(result.Text)
if text == "" {
return fmt.Errorf("模型返回内容为空")
}
if first && a.configBool(ctx, "ai.disclose_in_chat", true) {
text = "[AI 助手] " + text
}
if err = a.sendAIReply(ctx, job, text); err != nil {
return err
}
_, _ = a.db.ExecContext(ctx, `UPDATE ai_agents SET reply_count=reply_count+1,last_reply_at=NOW(3) WHERE user_id=?`, job.AgentUserID)
return nil
}
func (a *App) sendAIReply(ctx context.Context, job aiReplyJob, text string) error {
item, members, err := a.persistMessageContext(ctx, job.ConversationID, job.AgentUserID,
"ai-"+randomToken()[:20], 1, map[string]any{"text": text})
if err != nil {
return err
}
// The reply reaches clients through the same push the sender's own messages
// use, so unread counts and conversation ordering need no special casing.
a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item})
return nil
}
func (a *App) loadAIAgent(ctx context.Context, userID int64) (aiAgentBinding, error) {
binding := aiAgentBinding{UserID: userID}
var birthday sql.NullTime
err := a.db.QueryRowContext(ctx, `SELECT g.model_id,g.persona,g.daily_reply_limit,p.nickname,p.birthday,p.city_name,p.bio
FROM ai_agents g JOIN user_profiles p ON p.user_id=g.user_id JOIN users u ON u.id=g.user_id
WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL`, userID).
Scan(&binding.ModelID, &binding.Persona, &binding.DailyReplyLimit, &binding.Nickname, &birthday, &binding.City, &binding.Bio)
if errors.Is(err, sql.ErrNoRows) {
return binding, fmt.Errorf("账号未开启 AI 托管")
}
if birthday.Valid {
binding.Age = age(birthday.Time)
}
return binding, err
}
func (a *App) aiModelForAgent(ctx context.Context, binding aiAgentBinding) (aiModel, error) {
if binding.ModelID > 0 {
model, err := a.loadAIModel(ctx, binding.ModelID)
if err == nil && model.Status == 1 {
return model, nil
}
}
return a.defaultAIModel(ctx)
}
// Quotas are counted from the call log so a restart cannot reset them.
func (a *App) checkAIQuota(ctx context.Context, binding aiAgentBinding) error {
if limit := a.configInt(ctx, "ai.daily_call_limit", 0); limit > 0 {
var used int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ai_call_logs WHERE scene='chat' AND created_at>=CURRENT_DATE()`).Scan(&used)
if used >= limit {
return fmt.Errorf("已达到今日全局调用上限 %d", limit)
}
}
limit := binding.DailyReplyLimit
if limit <= 0 {
limit = a.configInt(ctx, "ai.agent_daily_reply_limit", 0)
}
if limit > 0 {
var used int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ai_call_logs WHERE scene='chat' AND agent_user_id=? AND status=1 AND created_at>=CURRENT_DATE()`, binding.UserID).Scan(&used)
if used >= limit {
return fmt.Errorf("账号已达到今日回复上限 %d", limit)
}
}
return nil
}
func (a *App) aiFirstReply(ctx context.Context, conversationID, agentUserID int64) (bool, error) {
var replied int
err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM im_messages WHERE conversation_id=? AND sender_id=?)`, conversationID, agentUserID).Scan(&replied)
return replied == 0, err
}
// Only the tail of the conversation is sent: enough for continuity, bounded in
// cost. Media turns become placeholders because the model gets text only.
func (a *App) aiConversationHistory(ctx context.Context, conversationID, agentUserID int64) ([]aiChatMessage, int64, error) {
rows, err := a.db.QueryContext(ctx, `SELECT sender_id,message_type,body,recalled_at,admin_removed_at
FROM im_messages WHERE conversation_id=? ORDER BY seq DESC LIMIT ?`, conversationID, aiHistoryMessages)
if err != nil {
return nil, 0, err
}
defer rows.Close()
ordered := []aiChatMessage{}
lastSenderID := int64(0)
for rows.Next() {
var senderID int64
var messageType int
var body []byte
var recalledAt, removedAt sql.NullTime
if rows.Scan(&senderID, &messageType, &body, &recalledAt, &removedAt) != nil {
continue
}
if lastSenderID == 0 {
lastSenderID = senderID
}
if recalledAt.Valid || removedAt.Valid {
continue
}
text := aiMessageText(messageType, body)
if text == "" {
continue
}
role := "user"
if senderID == agentUserID {
role = "assistant"
}
ordered = append([]aiChatMessage{{Role: role, Text: text}}, ordered...)
}
return ordered, lastSenderID, rows.Err()
}
func aiMessageText(messageType int, body []byte) string {
switch messageType {
case 2:
return "[图片]"
case 3:
return "[语音]"
}
var content struct {
Text string `json:"text"`
}
if json.Unmarshal(body, &content) != nil {
return ""
}
return strings.TrimSpace(content.Text)
}
func aiSystemPrompt(binding aiAgentBinding, disclose bool) string {
persona := strings.TrimSpace(binding.Persona)
if persona == "" {
// Falling back to the profile keeps the replies consistent with what the
// other person can see on the page.
details := []string{}
if binding.Nickname != "" {
details = append(details, "昵称"+binding.Nickname)
}
if binding.Age > 0 {
details = append(details, fmt.Sprintf("%d 岁", binding.Age))
}
if binding.City != "" {
details = append(details, "在"+binding.City)
}
if bio := strings.TrimSpace(binding.Bio); bio != "" {
details = append(details, "个人简介:"+bio)
}
persona = "你是一位社交软件用户," + strings.Join(details, "") + "。"
}
rules := []string{
"用中文口语回复,像真人聊天一样简短自然,不超过 40 个字。",
"一次只说一件事,不要分点、不要 Markdown、不要堆叠表情。",
"不索取或提供手机号、微信等联系方式,不谈金钱、转账、投资。",
"不主动约线下见面,不输出链接。",
"不知道的事就说不知道,不要编造经历细节。",
}
if disclose {
rules = append(rules, "如果对方询问你是不是机器人或 AI,必须如实说明你是 AI 助手。")
}
return persona + "\n\n对话要求:\n- " + strings.Join(rules, "\n- ")
}
// Models drift into assistant formatting; the chat bubble wants one plain line.
func sanitizeAIReply(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
if index := strings.Index(text, "```"); index >= 0 {
text = text[:index]
}
replacer := strings.NewReplacer("**", "", "__", "", "##", "", "> ", "", "\r", "\n")
text = replacer.Replace(text)
lines := []string{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*•0123456789. "))
if line != "" {
lines = append(lines, line)
}
}
text = strings.TrimSpace(strings.Join(lines, " "))
if runes := []rune(text); len(runes) > aiReplyMaxRunes {
text = strings.TrimSpace(string(runes[:aiReplyMaxRunes]))
}
return text
}
+87
View File
@@ -0,0 +1,87 @@
package app
import (
"encoding/json"
"strings"
"testing"
)
func TestSanitizeAIReplyFlattensAssistantFormatting(t *testing.T) {
tests := []struct {
name, input, want string
}{
{name: "trim", input: " 在的 ", want: "在的"},
{name: "markdown", input: "**在的**__刚看到__", want: "在的,刚看到"},
{name: "bullets", input: "- 第一点\n- 第二点", want: "第一点 第二点"},
{name: "numbered", input: "1. 先这样\n2. 再那样", want: "先这样 再那样"},
{name: "quote", input: "> 引用\n正文", want: "引用 正文"},
{name: "code fence", input: "好的\n```go\nfmt.Println()\n```", want: "好的"},
{name: "blank", input: "\n\n \n", want: ""},
}
for _, test := range tests {
if got := sanitizeAIReply(test.input); got != test.want {
t.Errorf("sanitizeAIReply(%s) = %q, want %q", test.name, got, test.want)
}
}
long := strings.Repeat("字", aiReplyMaxRunes+40)
if got := []rune(sanitizeAIReply(long)); len(got) != aiReplyMaxRunes {
t.Errorf("a long reply must be cut to %d runes, got %d", aiReplyMaxRunes, len(got))
}
}
func TestAISystemPromptFallsBackToProfile(t *testing.T) {
binding := aiAgentBinding{Nickname: "季若宁", Age: 27, City: "青岛", Bio: "喜欢电影与绘画"}
prompt := aiSystemPrompt(binding, true)
for _, fragment := range []string{"季若宁", "27 岁", "青岛", "喜欢电影与绘画"} {
if !strings.Contains(prompt, fragment) {
t.Errorf("profile-derived persona must mention %q: %s", fragment, prompt)
}
}
// Disclosure is a monitored requirement, so it has to be switchable and
// present by default.
if !strings.Contains(prompt, "AI 助手") {
t.Error("disclosure must add the honesty rule")
}
if strings.Contains(aiSystemPrompt(binding, false), "如果对方询问") {
t.Error("disclosure off must drop the rule")
}
custom := aiSystemPrompt(aiAgentBinding{Persona: "你是一个健身教练", Nickname: "忽略我"}, false)
if !strings.Contains(custom, "健身教练") || strings.Contains(custom, "忽略我") {
t.Errorf("an explicit persona must replace the profile text: %s", custom)
}
if !strings.Contains(custom, "不索取或提供手机号") {
t.Error("safety rules must survive a custom persona")
}
}
func TestAIMessageTextDegradesMedia(t *testing.T) {
text, _ := json.Marshal(map[string]any{"text": " 你好 "})
if got := aiMessageText(1, text); got != "你好" {
t.Errorf("text message = %q", got)
}
if got := aiMessageText(2, []byte(`{"url":"https://example.com/a.jpg"}`)); got != "[图片]" {
t.Errorf("image placeholder = %q", got)
}
if got := aiMessageText(3, []byte(`{"url":"https://example.com/a.m4a","duration":3}`)); got != "[语音]" {
t.Errorf("audio placeholder = %q", got)
}
if got := aiMessageText(1, []byte(`not json`)); got != "" {
t.Errorf("a broken body must be skipped, got %q", got)
}
}
func TestAIBatchAllowList(t *testing.T) {
if !aiBatchAllowed("", "any-batch") || !aiBatchAllowed("", "") {
t.Error("an empty allow list must accept every test account")
}
if !aiBatchAllowed("batch-a, batch-b", "batch-b") {
t.Error("a listed batch must be accepted")
}
if aiBatchAllowed("batch-a", "batch-c") {
t.Error("an unlisted batch must be refused")
}
// An account with no batch must not slip through a non-empty allow list.
if aiBatchAllowed("batch-a", "") {
t.Error("an empty batch must not match a configured allow list")
}
}
+488
View File
@@ -0,0 +1,488 @@
package app
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
aiResponseLimit = 512 << 10
aiProbePrompt = "用一句不超过 20 个字的中文打个招呼。"
)
// Vendors are many but wire protocols are few, so adapters are written per
// protocol. Anything speaking the OpenAI chat format needs no new code, only a
// new row in ai_models.
var aiProtocols = []string{"openai", "anthropic", "gemini", "webhook", "debug"}
type aiChatMessage struct {
Role string // user 或 assistant
Text string
}
type aiChatRequest struct {
System string
Messages []aiChatMessage
MaxTokens int
Temperature float64
}
type aiChatResult struct {
Text string
PromptTokens int
CompletionTokens int
FinishReason string
RequestID string
}
type aiProvider interface {
Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error)
}
type aiModel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Protocol string `json:"protocol"`
BaseURL string `json:"baseUrl"`
ModelName string `json:"modelName"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"maxTokens"`
TimeoutMS int `json:"timeoutMs"`
Status int `json:"status"`
IsDefault bool `json:"isDefault"`
APIKey string `json:"-"`
HasAPIKey bool `json:"hasApiKey"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func aiProtocolName(protocol string) string {
switch protocol {
case "openai":
return "OpenAI 兼容"
case "anthropic":
return "Anthropic Messages"
case "gemini":
return "Google Gemini"
case "webhook":
return "自定义 Webhook"
case "debug":
return "本地调试"
}
return protocol
}
// The endpoint is operator-supplied, so it is validated the same way the SMS
// endpoints are: no credentials, no query string, no fragment.
func parseAIBaseURL(protocol, raw string, production bool) (*url.URL, error) {
trimmed := strings.TrimSpace(raw)
if protocol == "debug" {
return nil, nil
}
if trimmed == "" {
return nil, fmt.Errorf("%s 需要填写接口地址", aiProtocolName(protocol))
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("接口地址必须是无账号、查询参数和片段的有效地址")
}
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && !production) {
return nil, fmt.Errorf("接口地址必须使用 HTTPS")
}
return parsed, nil
}
func aiEndpoint(base *url.URL, suffix string) string {
trimmed := strings.TrimRight(base.String(), "/")
if suffix == "" || strings.HasSuffix(trimmed, suffix) {
return trimmed
}
return trimmed + suffix
}
func newAIProvider(model aiModel, production bool) (aiProvider, error) {
if !containsString(aiProtocols, model.Protocol) {
return nil, fmt.Errorf("不支持的模型协议 %q", model.Protocol)
}
if model.Protocol == "debug" {
if production {
return nil, fmt.Errorf("生产环境禁止使用本地调试模型")
}
return debugAIProvider{}, nil
}
base, err := parseAIBaseURL(model.Protocol, model.BaseURL, production)
if err != nil {
return nil, err
}
if strings.TrimSpace(model.APIKey) == "" && model.Protocol != "webhook" {
return nil, fmt.Errorf("%s 需要填写密钥", aiProtocolName(model.Protocol))
}
if strings.TrimSpace(model.ModelName) == "" && model.Protocol != "webhook" {
return nil, fmt.Errorf("请填写模型名称")
}
timeout := time.Duration(model.TimeoutMS) * time.Millisecond
if timeout < time.Second || timeout > time.Minute {
timeout = 15 * time.Second
}
client := &http.Client{Timeout: timeout}
switch model.Protocol {
case "openai":
return openAIProvider{model: model, base: base, client: client}, nil
case "anthropic":
return anthropicProvider{model: model, base: base, client: client}, nil
case "gemini":
return geminiProvider{model: model, base: base, client: client}, nil
}
return webhookAIProvider{model: model, base: base, client: client}, nil
}
func aiRequestTemperature(req aiChatRequest, model aiModel) float64 {
if req.Temperature > 0 {
return req.Temperature
}
return model.Temperature
}
func aiRequestMaxTokens(req aiChatRequest, model aiModel) int {
if req.MaxTokens > 0 {
return req.MaxTokens
}
if model.MaxTokens > 0 {
return model.MaxTokens
}
return 256
}
func aiPostJSON(ctx context.Context, client *http.Client, endpoint string, headers map[string]string, payload any) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
for key, value := range headers {
request.Header.Set(key, value)
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("模型服务请求失败:%w", err)
}
defer response.Body.Close()
raw, err := io.ReadAll(io.LimitReader(response.Body, aiResponseLimit))
if err != nil {
return nil, fmt.Errorf("读取模型响应失败:%w", err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("模型服务返回 HTTP %d%s", response.StatusCode, aiErrorSnippet(raw))
}
return raw, nil
}
// Provider error bodies are quoted back to the operator, so they are trimmed to
// something a config page can display.
func aiErrorSnippet(raw []byte) string {
text := strings.TrimSpace(string(raw))
text = strings.Join(strings.Fields(text), " ")
if len([]rune(text)) > 200 {
return string([]rune(text)[:200]) + "…"
}
if text == "" {
return "无响应内容"
}
return text
}
type openAIProvider struct {
model aiModel
base *url.URL
client *http.Client
}
func (p openAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
messages := make([]map[string]string, 0, len(req.Messages)+1)
if strings.TrimSpace(req.System) != "" {
messages = append(messages, map[string]string{"role": "system", "content": req.System})
}
for _, message := range req.Messages {
messages = append(messages, map[string]string{"role": message.Role, "content": message.Text})
}
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/chat/completions"),
map[string]string{"Authorization": "Bearer " + p.model.APIKey},
map[string]any{
"model": p.model.ModelName,
"messages": messages,
"max_tokens": aiRequestMaxTokens(req, p.model),
"temperature": aiRequestTemperature(req, p.model),
"stream": false,
})
if err != nil {
return aiChatResult{}, err
}
var payload struct {
ID string `json:"id"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON%s", aiErrorSnippet(raw))
}
if payload.Error.Message != "" {
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
}
if len(payload.Choices) == 0 {
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
}
return aiChatResult{
Text: strings.TrimSpace(payload.Choices[0].Message.Content),
PromptTokens: payload.Usage.PromptTokens,
CompletionTokens: payload.Usage.CompletionTokens,
FinishReason: payload.Choices[0].FinishReason,
RequestID: payload.ID,
}, nil
}
type anthropicProvider struct {
model aiModel
base *url.URL
client *http.Client
}
func (p anthropicProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
messages := make([]map[string]any, 0, len(req.Messages))
for _, message := range req.Messages {
messages = append(messages, map[string]any{
"role": message.Role,
"content": []map[string]string{{"type": "text", "text": message.Text}},
})
}
payloadBody := map[string]any{
"model": p.model.ModelName,
"messages": messages,
"max_tokens": aiRequestMaxTokens(req, p.model),
"temperature": aiRequestTemperature(req, p.model),
}
// Anthropic carries the system prompt outside the message list.
if strings.TrimSpace(req.System) != "" {
payloadBody["system"] = req.System
}
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/v1/messages"),
map[string]string{"x-api-key": p.model.APIKey, "anthropic-version": "2023-06-01"}, payloadBody)
if err != nil {
return aiChatResult{}, err
}
var payload struct {
ID string `json:"id"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON%s", aiErrorSnippet(raw))
}
if payload.Error.Message != "" {
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
}
text := ""
for _, block := range payload.Content {
if block.Type == "text" {
text += block.Text
}
}
if strings.TrimSpace(text) == "" {
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
}
return aiChatResult{
Text: strings.TrimSpace(text),
PromptTokens: payload.Usage.InputTokens,
CompletionTokens: payload.Usage.OutputTokens,
FinishReason: payload.StopReason,
RequestID: payload.ID,
}, nil
}
type geminiProvider struct {
model aiModel
base *url.URL
client *http.Client
}
func (p geminiProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
contents := make([]map[string]any, 0, len(req.Messages))
for _, message := range req.Messages {
role := "user"
if message.Role == "assistant" {
role = "model"
}
contents = append(contents, map[string]any{
"role": role,
"parts": []map[string]string{{"text": message.Text}},
})
}
payloadBody := map[string]any{
"contents": contents,
"generationConfig": map[string]any{
"temperature": aiRequestTemperature(req, p.model),
"maxOutputTokens": aiRequestMaxTokens(req, p.model),
},
}
if strings.TrimSpace(req.System) != "" {
payloadBody["systemInstruction"] = map[string]any{"parts": []map[string]string{{"text": req.System}}}
}
endpoint := fmt.Sprintf("%s/v1beta/models/%s:generateContent", strings.TrimRight(p.base.String(), "/"), url.PathEscape(p.model.ModelName))
// The key travels in a header rather than the query string so it stays out
// of proxy logs; Gemini accepts both.
raw, err := aiPostJSON(ctx, p.client, endpoint, map[string]string{"x-goog-api-key": p.model.APIKey}, payloadBody)
if err != nil {
return aiChatResult{}, err
}
var payload struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
FinishReason string `json:"finishReason"`
} `json:"candidates"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
} `json:"usageMetadata"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON%s", aiErrorSnippet(raw))
}
if payload.Error.Message != "" {
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
}
if len(payload.Candidates) == 0 {
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
}
text := ""
for _, part := range payload.Candidates[0].Content.Parts {
text += part.Text
}
return aiChatResult{
Text: strings.TrimSpace(text),
PromptTokens: payload.UsageMetadata.PromptTokenCount,
CompletionTokens: payload.UsageMetadata.CandidatesTokenCount,
FinishReason: payload.Candidates[0].FinishReason,
}, nil
}
// Orchestration platforms (Coze, Dify, self-hosted flows) each have their own
// shape, so they get one normalised contract instead of one adapter each.
type webhookAIProvider struct {
model aiModel
base *url.URL
client *http.Client
}
func (p webhookAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
messages := make([]map[string]string, 0, len(req.Messages))
for _, message := range req.Messages {
messages = append(messages, map[string]string{"role": message.Role, "content": message.Text})
}
headers := map[string]string{}
if strings.TrimSpace(p.model.APIKey) != "" {
headers["Authorization"] = "Bearer " + p.model.APIKey
}
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, ""), headers, map[string]any{
"model": p.model.ModelName,
"system": req.System,
"messages": messages,
"maxTokens": aiRequestMaxTokens(req, p.model),
"temperature": aiRequestTemperature(req, p.model),
})
if err != nil {
return aiChatResult{}, err
}
var payload struct {
Text string `json:"text"`
Reply string `json:"reply"`
Content string `json:"content"`
Usage struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
} `json:"usage"`
Error string `json:"error"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON%s", aiErrorSnippet(raw))
}
if payload.Error != "" {
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error)
}
text := payload.Text
if text == "" {
text = payload.Reply
}
if text == "" {
text = payload.Content
}
if strings.TrimSpace(text) == "" {
return aiChatResult{}, fmt.Errorf("Webhook 必须返回 text 字段")
}
return aiChatResult{
Text: strings.TrimSpace(text),
PromptTokens: payload.Usage.PromptTokens,
CompletionTokens: payload.Usage.CompletionTokens,
}, nil
}
// Deterministic and offline: local development and tests exercise the whole
// pipeline without credentials or network access.
type debugAIProvider struct{}
func (debugAIProvider) Chat(_ context.Context, req aiChatRequest) (aiChatResult, error) {
last := ""
for index := len(req.Messages) - 1; index >= 0; index-- {
if req.Messages[index].Role == "user" {
last = strings.TrimSpace(req.Messages[index].Text)
break
}
}
reply := "在的,刚看到消息"
if last != "" {
runes := []rune(last)
if len(runes) > 12 {
runes = runes[:12]
}
reply = "收到:" + string(runes)
}
return aiChatResult{Text: reply, PromptTokens: len([]rune(last)), CompletionTokens: len([]rune(reply)), FinishReason: "stop"}, nil
}
@@ -0,0 +1,218 @@
package app
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func captureAIRequest(t *testing.T, response string, status int) (*httptest.Server, *map[string]any, *http.Header) {
t.Helper()
body := map[string]any{}
header := http.Header{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
_ = json.Unmarshal(raw, &body)
for key, values := range r.Header {
header[key] = values
}
body["__path"] = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(response))
}))
t.Cleanup(server.Close)
return server, &body, &header
}
func testModel(protocol, baseURL string) aiModel {
return aiModel{
ID: 1, Name: "test", Protocol: protocol, BaseURL: baseURL, ModelName: "demo-model",
APIKey: "secret-key", Temperature: 0.5, MaxTokens: 128, TimeoutMS: 5000, Status: 1,
}
}
func chatOnce(t *testing.T, protocol, baseURL string) (aiChatResult, error) {
t.Helper()
provider, err := newAIProvider(testModel(protocol, baseURL), false)
if err != nil {
t.Fatalf("newAIProvider(%s) error: %v", protocol, err)
}
return provider.Chat(context.Background(), aiChatRequest{
System: "你是测试人设",
Messages: []aiChatMessage{{Role: "user", Text: "在吗"}, {Role: "assistant", Text: "在的"}, {Role: "user", Text: "吃了吗"}},
})
}
func TestOpenAIProviderRequestAndResponse(t *testing.T) {
server, body, header := captureAIRequest(t, `{"id":"cmpl-1","choices":[{"message":{"content":" 吃过啦 "},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":4}}`, http.StatusOK)
result, err := chatOnce(t, "openai", server.URL)
if err != nil {
t.Fatalf("openai chat failed: %v", err)
}
if result.Text != "吃过啦" || result.PromptTokens != 12 || result.CompletionTokens != 4 || result.FinishReason != "stop" {
t.Fatalf("unexpected openai result: %+v", result)
}
if (*body)["__path"] != "/chat/completions" {
t.Fatalf("unexpected openai path: %v", (*body)["__path"])
}
if header.Get("Authorization") != "Bearer secret-key" {
t.Fatalf("unexpected openai authorization: %q", header.Get("Authorization"))
}
// The system prompt is the first message for this protocol.
messages, _ := (*body)["messages"].([]any)
if len(messages) != 4 {
t.Fatalf("expected system + 3 messages, got %d", len(messages))
}
first, _ := messages[0].(map[string]any)
if first["role"] != "system" || first["content"] != "你是测试人设" {
t.Fatalf("unexpected first message: %v", first)
}
if (*body)["max_tokens"] != float64(128) || (*body)["temperature"] != 0.5 {
t.Fatalf("model defaults were not applied: %v %v", (*body)["max_tokens"], (*body)["temperature"])
}
}
func TestAnthropicProviderUsesTopLevelSystem(t *testing.T) {
server, body, header := captureAIRequest(t, `{"id":"msg-1","content":[{"type":"text","text":"吃过啦"}],"stop_reason":"end_turn","usage":{"input_tokens":9,"output_tokens":3}}`, http.StatusOK)
result, err := chatOnce(t, "anthropic", server.URL)
if err != nil {
t.Fatalf("anthropic chat failed: %v", err)
}
if result.Text != "吃过啦" || result.PromptTokens != 9 || result.CompletionTokens != 3 {
t.Fatalf("unexpected anthropic result: %+v", result)
}
if (*body)["__path"] != "/v1/messages" {
t.Fatalf("unexpected anthropic path: %v", (*body)["__path"])
}
if header.Get("X-Api-Key") != "secret-key" || header.Get("Anthropic-Version") == "" {
t.Fatalf("unexpected anthropic headers: %v", *header)
}
if (*body)["system"] != "你是测试人设" {
t.Fatalf("system prompt must stay outside the message list: %v", (*body)["system"])
}
if messages, _ := (*body)["messages"].([]any); len(messages) != 3 {
t.Fatalf("expected 3 messages, got %d", len(messages))
}
}
func TestGeminiProviderMapsAssistantToModel(t *testing.T) {
server, body, header := captureAIRequest(t, `{"candidates":[{"content":{"parts":[{"text":"吃过啦"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":2}}`, http.StatusOK)
result, err := chatOnce(t, "gemini", server.URL)
if err != nil {
t.Fatalf("gemini chat failed: %v", err)
}
if result.Text != "吃过啦" || result.PromptTokens != 7 || result.CompletionTokens != 2 {
t.Fatalf("unexpected gemini result: %+v", result)
}
if (*body)["__path"] != "/v1beta/models/demo-model:generateContent" {
t.Fatalf("unexpected gemini path: %v", (*body)["__path"])
}
// The key must not travel in the query string where proxies log it.
if header.Get("X-Goog-Api-Key") != "secret-key" {
t.Fatalf("gemini key must be sent as a header: %v", *header)
}
contents, _ := (*body)["contents"].([]any)
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d", len(contents))
}
second, _ := contents[1].(map[string]any)
if second["role"] != "model" {
t.Fatalf("assistant turns must be mapped to role model: %v", second["role"])
}
}
func TestWebhookProviderAcceptsAlternateReplyFields(t *testing.T) {
server, _, _ := captureAIRequest(t, `{"reply":"吃过啦","usage":{"promptTokens":5,"completionTokens":2}}`, http.StatusOK)
result, err := chatOnce(t, "webhook", server.URL)
if err != nil {
t.Fatalf("webhook chat failed: %v", err)
}
if result.Text != "吃过啦" || result.PromptTokens != 5 {
t.Fatalf("unexpected webhook result: %+v", result)
}
}
func TestAIProviderSurfacesUpstreamErrors(t *testing.T) {
server, _, _ := captureAIRequest(t, `{"error":{"message":"insufficient quota"}}`, http.StatusTooManyRequests)
if _, err := chatOnce(t, "openai", server.URL); err == nil || !strings.Contains(err.Error(), "HTTP 429") {
t.Fatalf("upstream status must be reported: %v", err)
}
okServer, _, _ := captureAIRequest(t, `{"error":{"message":"model not found"}}`, http.StatusOK)
_, err := chatOnce(t, "openai", okServer.URL)
if err == nil || !strings.Contains(err.Error(), "model not found") {
t.Fatalf("an error body with HTTP 200 must still fail: %v", err)
}
emptyServer, _, _ := captureAIRequest(t, `{"choices":[]}`, http.StatusOK)
if _, err = chatOnce(t, "openai", emptyServer.URL); err == nil {
t.Fatal("an empty choice list must fail rather than send an empty reply")
}
brokenServer, _, _ := captureAIRequest(t, `not json`, http.StatusOK)
if _, err = chatOnce(t, "openai", brokenServer.URL); err == nil {
t.Fatal("a non-JSON body must fail")
}
}
func TestParseAIBaseURL(t *testing.T) {
tests := []struct {
protocol string
endpoint string
production bool
valid bool
}{
{protocol: "openai", endpoint: "https://api.openai.com/v1", production: true, valid: true},
{protocol: "openai", endpoint: "http://127.0.0.1:11434/v1", production: false, valid: true},
{protocol: "openai", endpoint: "http://127.0.0.1:11434/v1", production: true, valid: false},
{protocol: "openai", endpoint: "https://user:pass@api.example.com/v1", production: true, valid: false},
{protocol: "openai", endpoint: "https://api.example.com/v1?key=secret", production: true, valid: false},
{protocol: "openai", endpoint: "", production: false, valid: false},
{protocol: "debug", endpoint: "", production: false, valid: true},
}
for _, test := range tests {
_, err := parseAIBaseURL(test.protocol, test.endpoint, test.production)
if (err == nil) != test.valid {
t.Errorf("parseAIBaseURL(%q, %q, production=%v) error = %v", test.protocol, test.endpoint, test.production, err)
}
}
}
func TestAIProviderFactoryGuards(t *testing.T) {
if _, err := newAIProvider(testModel("debug", ""), true); err == nil {
t.Fatal("production must refuse the debug protocol")
}
if _, err := newAIProvider(testModel("unknown", "https://example.com"), false); err == nil {
t.Fatal("unknown protocols must be refused")
}
missingKey := testModel("openai", "https://example.com")
missingKey.APIKey = ""
if _, err := newAIProvider(missingKey, false); err == nil {
t.Fatal("a missing key must be refused before the request is made")
}
// A webhook may be protected by network rules instead of a key.
if _, err := newAIProvider(func() aiModel { m := testModel("webhook", "https://example.com/hook"); m.APIKey = ""; return m }(), false); err != nil {
t.Fatalf("webhook without a key must be allowed: %v", err)
}
}
func TestDebugProviderIsDeterministicAndOffline(t *testing.T) {
provider, err := newAIProvider(testModel("debug", ""), false)
if err != nil {
t.Fatalf("debug provider unavailable: %v", err)
}
request := aiChatRequest{Messages: []aiChatMessage{{Role: "user", Text: "今天天气怎么样呀在不在"}}}
first, err := provider.Chat(context.Background(), request)
if err != nil {
t.Fatalf("debug chat failed: %v", err)
}
second, _ := provider.Chat(context.Background(), request)
if first.Text != second.Text || first.Text == "" {
t.Fatalf("debug replies must be deterministic: %q vs %q", first.Text, second.Text)
}
if empty, _ := provider.Chat(context.Background(), aiChatRequest{}); empty.Text == "" {
t.Fatal("debug provider must answer even without history")
}
}
+118 -65
View File
@@ -97,6 +97,23 @@ func New(config Config) (*App, error) {
func (a *App) Close() { _ = a.db.Close() }
func (a *App) Run() {
workerContext, stopWorkers := context.WithCancel(context.Background())
defer stopWorkers()
go func() {
a.processDueAccountClosures(workerContext)
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-workerContext.Done():
return
case <-ticker.C:
a.processDueAccountClosures(workerContext)
}
}
}()
go a.runAIReplyWorker(workerContext)
go a.runAIMaintenance(workerContext)
server := rest.MustNewServer(rest.RestConf{
Host: a.config.Host,
Port: a.config.Port,
@@ -156,6 +173,7 @@ func (a *App) routes() []rest.Route {
{Method: http.MethodPost, Path: "/api/v1/auth/login/sms", Handler: a.loginSMS},
{Method: http.MethodGet, Path: "/api/v1/auth/oauth/providers", Handler: a.userOAuthProviders},
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/start", Handler: a.userOAuthStart},
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/native", Handler: a.userOAuthNative},
{Method: http.MethodGet, Path: "/api/v1/auth/oauth/callback", Handler: a.oauthCallback},
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/exchange", Handler: a.userOAuthExchange},
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/link", Handler: a.userOAuthLink},
@@ -169,6 +187,7 @@ func (a *App) routes() []rest.Route {
{Method: http.MethodHead, Path: "/uploads/:name", Handler: a.serveMedia},
{Method: http.MethodPost, Path: "/admin/v1/auth/login", Handler: a.adminLogin},
{Method: http.MethodPost, Path: "/admin/v1/auth/refresh", Handler: a.adminRefresh},
{Method: http.MethodPost, Path: "/admin/v1/auth/logout", Handler: a.adminLogout},
{Method: http.MethodGet, Path: "/admin/v1/auth/oauth/providers", Handler: a.adminOAuthProviders},
{Method: http.MethodPost, Path: "/admin/v1/auth/oauth/start", Handler: a.adminOAuthStart},
{Method: http.MethodGet, Path: "/admin/v1/auth/oauth/callback", Handler: a.oauthCallback},
@@ -187,13 +206,32 @@ func (a *App) userRoutes() []rest.Route {
{Method: http.MethodGet, Path: "/api/v1/me", Handler: auth(a.me)},
{Method: http.MethodGet, Path: "/api/v1/me/profile", Handler: auth(a.me)},
{Method: http.MethodPatch, Path: "/api/v1/me/profile", Handler: auth(a.updateProfile)},
{Method: http.MethodPut, Path: "/api/v1/me/password", Handler: auth(a.changeUserPassword)},
{Method: http.MethodPut, Path: "/api/v1/me/phone", Handler: auth(a.changeUserPhone)},
{Method: http.MethodGet, Path: "/api/v1/me/devices", Handler: auth(a.myDevices)},
{Method: http.MethodDelete, Path: "/api/v1/me/devices/:id", Handler: auth(a.revokeDevice)},
{Method: http.MethodGet, Path: "/api/v1/me/blocks", Handler: auth(a.blockedUsers)},
{Method: http.MethodGet, Path: "/api/v1/me/notification-settings", Handler: auth(a.notificationSettings)},
{Method: http.MethodPut, Path: "/api/v1/me/notification-settings", Handler: auth(a.updateNotificationSettings)},
{Method: http.MethodPost, Path: "/api/v1/me/push-tokens", Handler: auth(a.registerPushToken)},
{Method: http.MethodDelete, Path: "/api/v1/me/push-tokens", Handler: auth(a.deletePushToken)},
{Method: http.MethodGet, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)},
{Method: http.MethodPost, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)},
{Method: http.MethodGet, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)},
{Method: http.MethodPost, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)},
{Method: http.MethodDelete, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)},
{Method: http.MethodGet, Path: "/api/v1/me/data-export", Handler: auth(a.exportMyData)},
{Method: http.MethodPost, Path: "/api/v1/me/consents", Handler: auth(a.recordConsent)},
{Method: http.MethodGet, Path: "/api/v1/users/search", Handler: auth(a.searchUsers)},
{Method: http.MethodGet, Path: "/api/v1/tags", Handler: auth(a.availableTags)},
{Method: http.MethodGet, Path: "/api/v1/users/:id", Handler: auth(a.userProfile)},
{Method: http.MethodGet, Path: "/api/v1/me/following", Handler: auth(a.followingList)},
{Method: http.MethodGet, Path: "/api/v1/me/followers", Handler: auth(a.followerList)},
{Method: http.MethodGet, Path: "/api/v1/me/visitors", Handler: auth(a.visitorList)},
{Method: http.MethodGet, Path: "/api/v1/me/privacy", Handler: auth(a.getPrivacy)},
{Method: http.MethodPut, Path: "/api/v1/me/privacy", Handler: auth(a.updatePrivacy)},
{Method: http.MethodGet, Path: "/api/v1/me/verification", Handler: auth(a.myVerification)},
{Method: http.MethodPost, Path: "/api/v1/me/verification", Handler: auth(a.submitVerification)},
{Method: http.MethodGet, Path: "/api/v1/discover/recommendations", Handler: auth(a.discover)},
{Method: http.MethodGet, Path: "/api/v1/nearby/users", Handler: auth(a.nearby)},
{Method: http.MethodPut, Path: "/api/v1/location", Handler: auth(a.updateLocation)},
@@ -207,76 +245,114 @@ func (a *App) userRoutes() []rest.Route {
{Method: http.MethodGet, Path: "/api/v1/posts/:id", Handler: auth(a.postDetail)},
{Method: http.MethodGet, Path: "/api/v1/users/:id/posts", Handler: auth(a.userPosts)},
{Method: http.MethodPost, Path: "/api/v1/posts", Handler: auth(a.createPost)},
{Method: http.MethodPatch, Path: "/api/v1/posts/:id", Handler: auth(a.updatePost)},
{Method: http.MethodDelete, Path: "/api/v1/posts/:id", Handler: auth(a.deleteOwnPost)},
{Method: http.MethodPost, Path: "/api/v1/media/upload", Handler: auth(a.uploadMedia)},
{Method: http.MethodPost, Path: "/api/v1/posts/:id/like", Handler: auth(a.likePost)},
{Method: http.MethodDelete, Path: "/api/v1/posts/:id/like", Handler: auth(a.unlikePost)},
{Method: http.MethodGet, Path: "/api/v1/posts/:id/comments", Handler: auth(a.comments)},
{Method: http.MethodPost, Path: "/api/v1/posts/:id/comments", Handler: auth(a.createComment)},
{Method: http.MethodDelete, Path: "/api/v1/comments/:id", Handler: auth(a.deleteOwnComment)},
{Method: http.MethodPost, Path: "/api/v1/im/conversations/direct", Handler: auth(a.directConversation)},
{Method: http.MethodGet, Path: "/api/v1/im/conversations", Handler: auth(a.conversations)},
{Method: http.MethodGet, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.messages)},
{Method: http.MethodPost, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.sendMessageHTTP)},
{Method: http.MethodPost, Path: "/api/v1/im/messages/:id/recall", Handler: auth(a.recallMessage)},
{Method: http.MethodPatch, Path: "/api/v1/im/conversations/:id/settings", Handler: auth(a.conversationSettings)},
{Method: http.MethodGet, Path: "/api/v1/membership/status", Handler: auth(a.membershipStatus)},
{Method: http.MethodPost, Path: "/api/v1/orders", Handler: auth(a.createOrder)},
{Method: http.MethodPost, Path: "/api/v1/orders/:id/pay", Handler: auth(a.payOrder)},
{Method: http.MethodGet, Path: "/api/v1/orders/:id", Handler: auth(a.orderStatus)},
{Method: http.MethodPost, Path: "/api/v1/orders/:id/close", Handler: auth(a.closeOwnOrder)},
{Method: http.MethodPost, Path: "/api/v1/orders/:id/refund", Handler: auth(a.requestOrderRefund)},
{Method: http.MethodGet, Path: "/api/v1/me/orders", Handler: auth(a.myOrders)},
{Method: http.MethodGet, Path: "/api/v1/notifications", Handler: auth(a.notifications)},
{Method: http.MethodPost, Path: "/api/v1/notifications/read-all", Handler: auth(a.readAllNotifications)},
{Method: http.MethodPost, Path: "/api/v1/notifications/:id/read", Handler: auth(a.readNotification)},
{Method: http.MethodPost, Path: "/api/v1/reports", Handler: auth(a.createReport)},
{Method: http.MethodGet, Path: "/api/v1/me/reports", Handler: auth(a.myReports)},
{Method: http.MethodGet, Path: "/ws", Handler: a.websocket},
}
}
func (a *App) adminRoutes() []rest.Route {
auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("admin", next) }
permit := func(permission string, next http.HandlerFunc) http.HandlerFunc {
return a.requireAdminPermission(permission, next)
}
return []rest.Route{
{Method: http.MethodPost, Path: "/admin/v1/auth/logout", Handler: auth(a.adminLogout)},
{Method: http.MethodPut, Path: "/admin/v1/me/password", Handler: auth(a.adminChangePassword)},
{Method: http.MethodGet, Path: "/admin/v1/auth/codes", Handler: auth(a.adminCodes)},
{Method: http.MethodGet, Path: "/admin/v1/user/info", Handler: auth(a.adminInfo)},
{Method: http.MethodGet, Path: "/admin/v1/dashboard/overview", Handler: auth(a.dashboard)},
{Method: http.MethodGet, Path: "/admin/v1/users", Handler: auth(a.adminUsers)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: auth(a.adminUserDetail)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: auth(a.adminUpdateUserProfile)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: auth(a.adminUpdateVerification)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: auth(a.adminUpdateMembership)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: auth(a.adminResetUserPassword)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: auth(a.adminForceLogout)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: auth(a.adminUserSanctions)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: auth(a.adminUserSanctions)},
{Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: auth(a.adminRevokeSanction)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: auth(a.adminUserStatus)},
{Method: http.MethodGet, Path: "/admin/v1/posts", Handler: auth(a.adminPosts)},
{Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: auth(a.adminPostDetail)},
{Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: auth(a.adminDeletePost)},
{Method: http.MethodGet, Path: "/admin/v1/reports", Handler: auth(a.adminReports)},
{Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: auth(a.adminHandleReport)},
{Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: auth(a.adminRiskUsers)},
{Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: auth(a.adminRiskEvents)},
{Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: auth(a.adminPlans)},
{Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: auth(a.adminCreatePlan)},
{Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminUpdatePlan)},
{Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminUpdatePlan)},
{Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminDeletePlan)},
{Method: http.MethodGet, Path: "/admin/v1/orders", Handler: auth(a.adminOrders)},
{Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: auth(a.adminUpdateOrder)},
{Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: auth(a.adminDeleteOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: auth(a.adminMarkOrderPaid)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: auth(a.adminCloseOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: auth(a.adminRefund)},
{Method: http.MethodGet, Path: "/admin/v1/messages", Handler: auth(a.adminMessages)},
{Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: auth(a.adminConfigs)},
{Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: auth(a.adminUpdateConfig)},
{Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: auth(a.adminUpdateConfig)},
{Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: auth(a.adminIntegration)},
{Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: auth(a.adminUpdateIntegration)},
{Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: auth(a.adminTestIntegration)},
{Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: auth(a.adminAuditLogs)},
{Method: http.MethodGet, Path: "/admin/v1/dashboard/overview", Handler: permit("dashboard:view", a.dashboard)},
{Method: http.MethodGet, Path: "/admin/v1/users", Handler: permit("users:view", a.adminUsers)},
{Method: http.MethodPost, Path: "/admin/v1/users", Handler: permit("users:create", a.adminCreateUser)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: permit("users:view", a.adminUserDetail)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: permit("users:manage", a.adminUpdateUserProfile)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: permit("verification:manage", a.adminUpdateVerification)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: permit("users:manage", a.adminUpdateMembership)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: permit("users:security", a.adminResetUserPassword)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: permit("users:security", a.adminForceLogout)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)},
{Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: permit("violations:manage", a.adminRevokeSanction)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: permit("violations:manage", a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: permit("violations:manage", a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: permit("violations:manage", a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: permit("violations:manage", a.adminUserStatus)},
{Method: http.MethodGet, Path: "/admin/v1/posts", Handler: permit("content:view", a.adminPosts)},
{Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: permit("content:view", a.adminPostDetail)},
{Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: permit("content:manage", a.adminDeletePost)},
{Method: http.MethodGet, Path: "/admin/v1/reports", Handler: permit("reports:handle", a.adminReports)},
{Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: permit("reports:handle", a.adminHandleReport)},
{Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: permit("risk:view", a.adminRiskUsers)},
{Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: permit("risk:view", a.adminRiskEvents)},
{Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminPlans)},
{Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminCreatePlan)},
{Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)},
{Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)},
{Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminDeletePlan)},
{Method: http.MethodGet, Path: "/admin/v1/orders", Handler: permit("orders:view", a.adminOrders)},
{Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminUpdateOrder)},
{Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminDeleteOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: permit("orders:manage", a.adminMarkOrderPaid)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: permit("orders:manage", a.adminCloseOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)},
{Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)},
{Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)},
{Method: http.MethodGet, Path: "/admin/v1/client-feedback", Handler: permit("users:view", a.adminFeedback)},
{Method: http.MethodPut, Path: "/admin/v1/client-feedback/:id", Handler: permit("users:manage", a.adminFeedback)},
{Method: http.MethodGet, Path: "/admin/v1/account-closures", Handler: permit("users:view", a.adminAccountClosures)},
{Method: http.MethodPut, Path: "/admin/v1/account-closures/:id", Handler: permit("users:manage", a.adminAccountClosures)},
{Method: http.MethodGet, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)},
{Method: http.MethodPost, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)},
{Method: http.MethodPut, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)},
{Method: http.MethodDelete, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)},
{Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: permit("system:manage", a.adminConfigs)},
{Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)},
{Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)},
{Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminIntegration)},
{Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminUpdateIntegration)},
{Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: permit("system:manage", a.adminTestIntegration)},
{Method: http.MethodGet, Path: "/admin/v1/ai/models", Handler: permit("system:manage", a.adminAIModels)},
{Method: http.MethodPost, Path: "/admin/v1/ai/models", Handler: permit("system:manage", a.adminCreateAIModel)},
{Method: http.MethodPut, Path: "/admin/v1/ai/models/:id", Handler: permit("system:manage", a.adminUpdateAIModel)},
{Method: http.MethodDelete, Path: "/admin/v1/ai/models/:id", Handler: permit("system:manage", a.adminDeleteAIModel)},
{Method: http.MethodPost, Path: "/admin/v1/ai/models/:id/test", Handler: permit("system:manage", a.adminTestAIModel)},
{Method: http.MethodGet, Path: "/admin/v1/ai/agents", Handler: permit("system:manage", a.adminAIAgents)},
{Method: http.MethodPut, Path: "/admin/v1/ai/agents", Handler: permit("system:manage", a.adminUpdateAIAgents)},
{Method: http.MethodGet, Path: "/admin/v1/ai/reply-jobs", Handler: permit("system:manage", a.adminAIReplyJobs)},
{Method: http.MethodGet, Path: "/admin/v1/ai/logs", Handler: permit("system:manage", a.adminAILogs)},
{Method: http.MethodGet, Path: "/admin/v1/ai/usage", Handler: permit("system:manage", a.adminAIUsage)},
{Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: permit("system:manage", a.adminAuditLogs)},
{Method: http.MethodGet, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminAccounts)},
{Method: http.MethodPost, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminCreateAccount)},
{Method: http.MethodPut, Path: "/admin/v1/admin-users/:id", Handler: permit("system:manage", a.adminUpdateAccount)},
{Method: http.MethodGet, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminRoles)},
{Method: http.MethodPost, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminCreateRole)},
{Method: http.MethodPut, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminUpdateRole)},
{Method: http.MethodDelete, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminDeleteRole)},
{Method: http.MethodGet, Path: "/admin/v1/admin-permissions", Handler: permit("system:manage", a.adminPermissions)},
}
}
@@ -394,32 +470,9 @@ func validateConfig(config Config) error {
return fmt.Errorf("生产环境来源必须使用 HTTPS: %s", origin)
}
}
if config.BootstrapAdminPassword != "" && !strongAdminPassword(config.BootstrapAdminPassword) {
return fmt.Errorf("IM_BOOTSTRAP_ADMIN_PASSWORD 至少 12 位,且必须包含大小写字母、数字和特殊字符")
}
return nil
}
func strongAdminPassword(value string) bool {
if len(value) < 12 {
return false
}
var lower, upper, digit, special bool
for _, char := range value {
switch {
case char >= 'a' && char <= 'z':
lower = true
case char >= 'A' && char <= 'Z':
upper = true
case char >= '0' && char <= '9':
digit = true
default:
special = true
}
}
return lower && upper && digit && special
}
func (a *App) originAllowed(origin string) bool {
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
if origin == "" {
+533 -73
View File
@@ -1,14 +1,20 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/gorilla/websocket"
)
@@ -21,15 +27,107 @@ type messageView struct {
ClientMsgID string `json:"clientMsgId"`
Type int `json:"type"`
Content any `json:"content"`
Recalled bool `json:"recalled"`
CreatedAt time.Time `json:"createdAt"`
}
const (
maxMessageBodyBytes = 16 << 10
maxMessageTextRunes = 2000
)
var clientMessageIDPattern = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,64}$`)
func validMessageMediaURL(raw string, production bool) bool {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > 2048 {
return false
}
if strings.HasPrefix(raw, "/uploads/") {
return !strings.Contains(raw, "..")
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return false
}
return !production || parsed.Scheme == "https"
}
func numericDuration(value any) (int, bool) {
switch duration := value.(type) {
case float64:
return int(duration), duration == float64(int(duration))
case int:
return duration, true
case int64:
return int(duration), true
case json.Number:
parsed, err := strconv.Atoi(duration.String())
return parsed, err == nil
default:
return 0, false
}
}
func (a *App) validateMessagePayload(clientMsgID string, messageType int, content any) (string, any, []byte, error) {
clientMsgID = strings.TrimSpace(clientMsgID)
if clientMsgID == "" {
clientMsgID = randomToken()[:26]
}
if !clientMessageIDPattern.MatchString(clientMsgID) {
return "", nil, nil, fmt.Errorf("客户端消息 ID 格式无效")
}
if messageType == 0 {
messageType = 1
}
data, ok := content.(map[string]any)
if !ok {
return "", nil, nil, fmt.Errorf("消息内容格式错误")
}
switch messageType {
case 1:
text, ok := data["text"].(string)
text = strings.TrimSpace(text)
if !ok || text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > maxMessageTextRunes {
return "", nil, nil, fmt.Errorf("文本消息应为 1 至 %d 个字符", maxMessageTextRunes)
}
data = map[string]any{"text": text}
case 2:
mediaURL, ok := data["url"].(string)
if !ok || !validMessageMediaURL(mediaURL, a.config.Environment == "production") {
return "", nil, nil, fmt.Errorf("图片地址无效")
}
data = map[string]any{"url": strings.TrimSpace(mediaURL)}
case 3:
mediaURL, ok := data["url"].(string)
duration, durationOK := numericDuration(data["duration"])
if !ok || !validMessageMediaURL(mediaURL, a.config.Environment == "production") || !durationOK || duration < 1 || duration > 60 {
return "", nil, nil, fmt.Errorf("语音消息地址或时长无效")
}
data = map[string]any{"duration": duration, "url": strings.TrimSpace(mediaURL)}
default:
return "", nil, nil, fmt.Errorf("不支持的消息类型")
}
body, err := json.Marshal(data)
if err != nil || len(body) > maxMessageBodyBytes {
return "", nil, nil, fmt.Errorf("消息内容过大")
}
return clientMsgID, data, body, nil
}
type wsClient struct {
userID int64
conn *websocket.Conn
mu sync.Mutex
}
func (c *wsClient) writeJSON(payload any) error {
c.mu.Lock()
defer c.mu.Unlock()
_ = c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
return c.conn.WriteJSON(payload)
}
type Hub struct {
mu sync.RWMutex
clients map[int64]map[*wsClient]struct{}
@@ -37,22 +135,47 @@ type Hub struct {
func NewHub() *Hub { return &Hub{clients: make(map[int64]map[*wsClient]struct{})} }
func (h *Hub) add(client *wsClient) {
func (h *Hub) add(client *wsClient) bool {
h.mu.Lock()
defer h.mu.Unlock()
becameOnline := len(h.clients[client.userID]) == 0
if h.clients[client.userID] == nil {
h.clients[client.userID] = make(map[*wsClient]struct{})
}
h.clients[client.userID][client] = struct{}{}
return becameOnline
}
func (h *Hub) remove(client *wsClient) {
func (h *Hub) remove(client *wsClient) bool {
h.mu.Lock()
defer h.mu.Unlock()
if _, exists := h.clients[client.userID][client]; !exists {
return false
}
delete(h.clients[client.userID], client)
if len(h.clients[client.userID]) == 0 {
delete(h.clients, client.userID)
return true
}
return false
}
func (h *Hub) online(userID int64) bool {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients[userID]) > 0
}
func (h *Hub) onlineUsers() []int64 {
h.mu.RLock()
defer h.mu.RUnlock()
users := make([]int64, 0, len(h.clients))
for userID, clients := range h.clients {
if len(clients) > 0 {
users = append(users, userID)
}
}
return users
}
func (h *Hub) broadcast(userIDs []int64, payload any) {
@@ -65,9 +188,7 @@ func (h *Hub) broadcast(userIDs []int64, payload any) {
}
h.mu.RUnlock()
for _, client := range targets {
client.mu.Lock()
_ = client.conn.WriteJSON(payload)
client.mu.Unlock()
_ = client.writeJSON(payload)
}
}
@@ -86,6 +207,41 @@ func (h *Hub) disconnect(userID int64) {
}
}
func (a *App) onlineNow(userID int64) bool {
return a.hub.online(userID)
}
func (a *App) publishPresence(userID int64, online bool) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var visible int
if err := a.db.QueryRowContext(ctx, `SELECT online_visible FROM user_privacy_settings WHERE user_id=?`, userID).Scan(&visible); err != nil {
return
}
rows, err := a.db.QueryContext(ctx, `SELECT DISTINCT peer.user_id
FROM im_conversation_members mine JOIN im_conversation_members peer
ON peer.conversation_id=mine.conversation_id AND peer.user_id<>mine.user_id
JOIN im_conversations conversation ON conversation.id=mine.conversation_id
WHERE mine.user_id=? AND mine.status=1 AND peer.status=1 AND conversation.status=1`, userID)
if err != nil {
return
}
defer rows.Close()
peers := []int64{}
for rows.Next() {
var peerID int64
if rows.Scan(&peerID) == nil {
peers = append(peers, peerID)
}
}
if len(peers) == 0 {
return
}
a.hub.broadcast(peers, map[string]any{"command": "PRESENCE", "data": map[string]any{
"userId": userID, "online": online && visible == 1, "lastActiveAt": time.Now(),
}})
}
func (a *App) directConversation(w http.ResponseWriter, r *http.Request) {
var req struct {
UserID int64 `json:"userId"`
@@ -98,16 +254,47 @@ func (a *App) directConversation(w http.ResponseWriter, r *http.Request) {
if first > second {
first, second = second, first
}
var targetStatus, allowStranger int
if err := a.db.QueryRowContext(r.Context(), `SELECT u.status,p.allow_stranger_message FROM users u JOIN user_privacy_settings p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, req.UserID).Scan(&targetStatus, &allowStranger); err != nil || targetStatus != 1 {
fail(w, http.StatusNotFound, 30001, "聊天对象不存在或不可用")
return
}
var blocked int
if err := a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_blocks WHERE (user_id=? AND blocked_user_id=?) OR (user_id=? AND blocked_user_id=?))`, current(r).ID, req.UserID, req.UserID, current(r).ID).Scan(&blocked); err != nil || blocked == 1 {
fail(w, http.StatusForbidden, 30002, "当前无法与该用户聊天")
return
}
var id int64
err := a.db.QueryRowContext(r.Context(), `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id)
if err == nil {
reply(w, map[string]any{"id": id})
return
}
tx, _ := a.db.BeginTx(r.Context(), nil)
if allowStranger == 0 {
var mutualFollow int
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(
SELECT 1 FROM user_follows mine JOIN user_follows target
ON target.user_id=mine.target_user_id AND target.target_user_id=mine.user_id
WHERE mine.user_id=? AND mine.target_user_id=?)`, current(r).ID, req.UserID).Scan(&mutualFollow)
if mutualFollow != 1 {
fail(w, http.StatusForbidden, 30002, "对方仅允许互相关注的人发起私信")
return
}
}
tx, beginErr := a.db.BeginTx(r.Context(), nil)
if beginErr != nil {
fail(w, http.StatusInternalServerError, 50001, "创建会话失败")
return
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(r.Context(), `INSERT INTO im_conversations(conversation_type)VALUES(1)`)
if err != nil {
_ = tx.Rollback()
// Concurrent requests may have created the same unique direct pair.
if a.db.QueryRowContext(r.Context(), `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) == nil {
reply(w, map[string]any{"id": id})
return
}
fail(w, 500, 50001, "创建会话失败")
return
}
@@ -127,12 +314,16 @@ func (a *App) directConversation(w http.ResponseWriter, r *http.Request) {
func (a *App) conversations(w http.ResponseWriter, r *http.Request) {
who := current(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.last_seq,c.last_message_at,m.read_seq,m.pinned,m.muted,
other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),'')
// A sequence is shared by both senders, so last_seq-read_seq is not an unread count.
rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.last_seq,c.last_message_at,
(SELECT COUNT(*) FROM im_messages unread_msg WHERE unread_msg.conversation_id=c.id
AND unread_msg.seq>GREATEST(m.read_seq,m.clear_seq,m.join_seq) AND unread_msg.sender_id<>m.user_id
AND unread_msg.recalled_at IS NULL AND unread_msg.admin_removed_at IS NULL),m.pinned,m.muted,
other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,privacy.online_visible,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),''),msg.recalled_at,msg.admin_removed_at
FROM im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id
JOIN im_conversation_members other ON other.conversation_id=c.id AND other.user_id<>m.user_id
JOIN user_profiles p ON p.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id
WHERE m.user_id=? AND m.status=1 ORDER BY m.pinned DESC,c.last_message_at DESC`, who.ID)
JOIN user_profiles p ON p.user_id=other.user_id JOIN user_privacy_settings privacy ON privacy.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id
WHERE m.user_id=? AND m.status=1 AND c.status=1 ORDER BY m.pinned DESC,c.last_message_at DESC`, who.ID)
if err != nil {
fail(w, 500, 50001, err.Error())
return
@@ -140,20 +331,31 @@ func (a *App) conversations(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, lastSeq, readSeq, otherID int64
var id, lastSeq, unread, otherID int64
var lastAt sql.NullTime
var pinned, muted, vip int
var nick, avatar, body string
var active sql.NullTime
_ = rows.Scan(&id, &lastSeq, &lastAt, &readSeq, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &body)
var recalledAt, adminRemovedAt sql.NullTime
var onlineVisible int
if err := rows.Scan(&id, &lastSeq, &lastAt, &unread, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &onlineVisible, &body, &recalledAt, &adminRemovedAt); err != nil {
fail(w, 500, 50001, "读取会话失败")
return
}
preview := "开始聊天吧"
var content map[string]any
if json.Unmarshal([]byte(body), &content) == nil {
if recalledAt.Valid || adminRemovedAt.Valid {
preview = "消息已撤回"
} else if json.Unmarshal([]byte(body), &content) == nil {
if text, ok := content["text"].(string); ok {
preview = text
}
}
items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": max64(lastSeq-readSeq, 0), "lastMessageAt": lastAt, "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "vip": vip == 1, "online": active.Valid && time.Since(active.Time) < 15*time.Minute}})
items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": unread, "lastMessageAt": nullableTime(lastAt), "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "avatarThumbnail": avatarThumbnailURL(avatar), "vip": vip == 1, "online": onlineVisible == 1 && a.onlineNow(otherID)}})
}
if err := rows.Err(); err != nil {
fail(w, 500, 50001, "读取会话失败")
return
}
reply(w, map[string]any{"items": items, "total": len(items)})
}
@@ -168,7 +370,31 @@ func (a *App) messages(w http.ResponseWriter, r *http.Request) {
fail(w, 403, 30002, "不是会话成员")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,created_at FROM im_messages WHERE conversation_id=? AND recalled_at IS NULL ORDER BY seq DESC LIMIT 100`, id)
beforeSeq, _ := strconv.ParseInt(r.URL.Query().Get("beforeSeq"), 10, 64)
afterSeq, _ := strconv.ParseInt(r.URL.Query().Get("afterSeq"), 10, 64)
if beforeSeq > 0 && afterSeq > 0 {
fail(w, http.StatusBadRequest, 20001, "beforeSeq 和 afterSeq 不能同时使用")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
query := `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE conversation_id=?`
args := []any{id}
if beforeSeq > 0 {
query += ` AND seq<?`
args = append(args, beforeSeq)
}
if afterSeq > 0 {
query += ` AND seq>?`
args = append(args, afterSeq)
query += ` ORDER BY seq ASC LIMIT ?`
} else {
query += ` ORDER BY seq DESC LIMIT ?`
}
args = append(args, limit+1)
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
fail(w, 500, 50001, "查询消息失败")
return
@@ -178,18 +404,47 @@ func (a *App) messages(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var item messageView
var body []byte
_ = rows.Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &item.CreatedAt)
var recalledAt, adminRemovedAt sql.NullTime
if err := rows.Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt); err != nil {
fail(w, 500, 50001, "读取消息失败")
return
}
var content any
_ = json.Unmarshal(body, &content)
item.Recalled = recalledAt.Valid || adminRemovedAt.Valid
if !item.Recalled {
_ = json.Unmarshal(body, &content)
}
item.Content = content
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool { return items[i].Seq < items[j].Seq })
if err := rows.Err(); err != nil {
fail(w, 500, 50001, "读取消息失败")
return
}
_ = rows.Close()
hasMore := len(items) > limit
if hasMore {
items = items[:limit]
}
if afterSeq <= 0 {
sort.Slice(items, func(i, j int) bool { return items[i].Seq < items[j].Seq })
}
if len(items) > 0 {
last := items[len(items)-1].Seq
_, _ = a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, last, id, current(r).ID)
if _, _, err := a.markConversationRead(r.Context(), id, current(r).ID, last); err != nil {
fail(w, 500, 50001, "更新已读状态失败")
return
}
}
reply(w, map[string]any{"items": items, "hasMore": false})
nextBeforeSeq := int64(0)
if len(items) > 0 {
nextBeforeSeq = items[0].Seq
}
nextAfterSeq := afterSeq
if len(items) > 0 {
nextAfterSeq = items[len(items)-1].Seq
}
reply(w, map[string]any{"items": items, "hasMore": hasMore, "nextBeforeSeq": nextBeforeSeq, "nextAfterSeq": nextAfterSeq})
}
func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) {
@@ -207,16 +462,6 @@ func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) {
fail(w, 400, 20001, "消息格式错误")
return
}
if req.ClientMsgID == "" {
req.ClientMsgID = fmt.Sprintf("%026d", time.Now().UnixNano())
}
if len(req.ClientMsgID) > 64 {
fail(w, 400, 20001, "客户端消息 ID 长度不能超过 64 个字符")
return
}
if req.Type == 0 {
req.Type = 1
}
item, members, err := a.persistMessage(r, conversationID, current(r).ID, req.ClientMsgID, req.Type, req.Content)
if err != nil {
var limitErr *dailyActiveChatLimitError
@@ -231,78 +476,195 @@ func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) {
reply(w, item)
}
func (a *App) persistMessage(r *http.Request, conversationID, senderID int64, clientMsgID string, messageType int, content any) (messageView, []int64, error) {
item := messageView{}
if !a.allowRequest(r.Context(), "message_send", fmt.Sprintf("%d", senderID), 120, time.Minute) {
return item, nil, fmt.Errorf("消息发送过于频繁,请稍后再试")
func (a *App) recallMessage(w http.ResponseWriter, r *http.Request) {
messageID, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "消息编号无效")
return
}
if a.isSanctionActive(r.Context(), senderID, "MUTE") {
return item, nil, fmt.Errorf("账号处于禁言期,暂时无法发送消息")
window, _ := strconv.Atoi(a.configPlain(r.Context(), "im.recall_seconds", "120"))
if window < 1 || window > 86400 {
window = 120
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, 50001, "撤回失败")
return
}
defer func() { _ = tx.Rollback() }()
var conversationID, seq int64
var senderID int64
var created time.Time
var recalled sql.NullTime
if err = tx.QueryRowContext(r.Context(), `SELECT conversation_id,seq,sender_id,created_at,recalled_at FROM im_messages WHERE id=? FOR UPDATE`, messageID).Scan(&conversationID, &seq, &senderID, &created, &recalled); err != nil {
fail(w, 404, 30001, "消息不存在")
return
}
if senderID != current(r).ID {
fail(w, 403, 30002, "只能撤回自己发送的消息")
return
}
if recalled.Valid {
reply(w, map[string]bool{"success": true})
return
}
if time.Since(created) > time.Duration(window)*time.Second {
fail(w, 409, 30004, "已超过消息撤回时限")
return
}
_, err = tx.ExecContext(r.Context(), `UPDATE im_messages SET recalled_at=NOW(3) WHERE id=?`, messageID)
if err != nil || tx.Commit() != nil {
fail(w, 500, 50001, "撤回失败")
return
}
rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID)
members := []int64{}
if rows != nil {
defer rows.Close()
for rows.Next() {
var id int64
_ = rows.Scan(&id)
members = append(members, id)
}
}
a.hub.broadcast(members, map[string]any{"command": "MESSAGE_RECALLED", "data": map[string]any{"id": messageID, "conversationId": conversationID, "seq": seq}})
reply(w, map[string]bool{"success": true})
}
// The reply worker has no *http.Request, so message writing is expressed in
// terms of a context and the HTTP handlers wrap it.
func (a *App) persistMessage(r *http.Request, conversationID, senderID int64, clientMsgID string, messageType int, content any) (messageView, []int64, error) {
return a.persistMessageContext(r.Context(), conversationID, senderID, clientMsgID, messageType, content)
}
func (a *App) persistMessageContext(ctx context.Context, conversationID, senderID int64, clientMsgID string, messageType int, content any) (messageView, []int64, error) {
item := messageView{}
clientMsgID, content, body, err := a.validateMessagePayload(clientMsgID, messageType, content)
if err != nil {
return item, nil, err
}
if messageType == 0 {
messageType = 1
}
if messageType == 2 || messageType == 3 {
contentMap, _ := content.(map[string]any)
mediaURL, _ := contentMap["url"].(string)
expectedType := "image"
if messageType == 3 {
expectedType = "audio"
}
var owned int
if queryErr := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type=? AND status=1 AND moderation_status=1)`, senderID, mediaURL, expectedType).Scan(&owned); queryErr != nil || owned != 1 {
return item, nil, fmt.Errorf("消息媒体必须由当前账号上传")
}
}
if !a.allowRequest(ctx, "message_send", fmt.Sprintf("%d", senderID), 120, time.Minute) {
return item, nil, fmt.Errorf("消息发送过于频繁,请稍后再试")
}
if a.isSanctionActive(ctx, senderID, "MUTE") {
return item, nil, fmt.Errorf("账号处于禁言期,暂时无法发送消息")
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return item, nil, err
}
defer func() { _ = tx.Rollback() }()
var lastSeq int64
if err = tx.QueryRowContext(r.Context(), `SELECT last_seq FROM im_conversations WHERE id=? AND status=1 FOR UPDATE`, conversationID).Scan(&lastSeq); err != nil {
if err = tx.QueryRowContext(ctx, `SELECT last_seq FROM im_conversations WHERE id=? AND status=1 FOR UPDATE`, conversationID).Scan(&lastSeq); err != nil {
return item, nil, fmt.Errorf("会话不存在")
}
var memberCount int
if err = tx.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_conversation_members WHERE conversation_id=? AND user_id=? AND status=1`, conversationID, senderID).Scan(&memberCount); err != nil || memberCount == 0 {
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_conversation_members WHERE conversation_id=? AND user_id=? AND status=1`, conversationID, senderID).Scan(&memberCount); err != nil || memberCount == 0 {
return item, nil, fmt.Errorf("不是会话成员")
}
if err = a.reserveDailyActiveChat(r.Context(), tx, conversationID, senderID); err != nil {
var blocked int
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM im_direct_conversations d JOIN user_blocks b
ON (b.user_id=d.user1_id AND b.blocked_user_id=d.user2_id) OR (b.user_id=d.user2_id AND b.blocked_user_id=d.user1_id)
WHERE d.conversation_id=?)`, conversationID).Scan(&blocked); err != nil {
return item, nil, err
}
body, err := json.Marshal(content)
if err != nil {
return item, nil, fmt.Errorf("消息内容错误")
if blocked == 1 {
return item, nil, fmt.Errorf("当前无法向该用户发送消息")
}
if err = a.reserveDailyActiveChat(ctx, tx, conversationID, senderID); err != nil {
return item, nil, err
}
seq := lastSeq + 1
result, err := tx.ExecContext(r.Context(), `INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body)VALUES(?,?,?,?,?,?)`, conversationID, seq, senderID, clientMsgID, messageType, body)
result, err := tx.ExecContext(ctx, `INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body)VALUES(?,?,?,?,?,?)`, conversationID, seq, senderID, clientMsgID, messageType, body)
if err != nil {
var existingID, existingSeq int64
existingErr := tx.QueryRowContext(r.Context(), `SELECT id,seq FROM im_messages WHERE sender_id=? AND client_msg_id=?`, senderID, clientMsgID).Scan(&existingID, &existingSeq)
existingErr := tx.QueryRowContext(ctx, `SELECT id,seq FROM im_messages WHERE sender_id=? AND client_msg_id=?`, senderID, clientMsgID).Scan(&existingID, &existingSeq)
if existingErr == nil {
_ = tx.Rollback()
return a.loadMessage(r, existingID), nil, nil
return a.loadMessageContext(ctx, existingID), nil, nil
}
return item, nil, err
}
messageID, _ := result.LastInsertId()
_, err = tx.ExecContext(r.Context(), `UPDATE im_conversations SET last_seq=?,last_message_id=?,last_message_at=NOW(3) WHERE id=?`, seq, messageID, conversationID)
_, err = tx.ExecContext(ctx, `UPDATE im_conversations SET last_seq=?,last_message_id=?,last_message_at=NOW(3) WHERE id=?`, seq, messageID, conversationID)
if err != nil {
return item, nil, err
}
_, _ = tx.ExecContext(r.Context(), `UPDATE im_conversation_members SET delivered_seq=GREATEST(delivered_seq,?),updated_at=NOW(3) WHERE conversation_id=?`, seq, conversationID)
memberRows, err := tx.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID)
if _, err = tx.ExecContext(ctx, `UPDATE im_conversation_members SET delivered_seq=GREATEST(delivered_seq,?),updated_at=NOW(3) WHERE conversation_id=?`, seq, conversationID); err != nil {
return item, nil, err
}
memberRows, err := tx.QueryContext(ctx, `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID)
if err != nil {
return item, nil, err
}
members := []int64{}
for memberRows.Next() {
var userID int64
_ = memberRows.Scan(&userID)
if err = memberRows.Scan(&userID); err != nil {
_ = memberRows.Close()
return item, nil, err
}
members = append(members, userID)
}
_ = memberRows.Close()
if err = memberRows.Err(); err != nil {
_ = memberRows.Close()
return item, nil, err
}
if err = memberRows.Close(); err != nil {
return item, nil, err
}
sort.Slice(members, func(i, j int) bool { return members[i] < members[j] })
for _, userID := range members {
var lockedUserID int64
if err = tx.QueryRowContext(ctx, `SELECT id FROM users WHERE id=? FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
return item, nil, err
}
}
for _, userID := range members {
var next int64
_ = tx.QueryRowContext(r.Context(), `SELECT COALESCE(MAX(event_seq),0)+1 FROM im_user_sync_events WHERE user_id=?`, userID).Scan(&next)
_, _ = tx.ExecContext(r.Context(), `INSERT INTO im_user_sync_events(user_id,event_seq,event_type,conversation_id,message_seq,event_data)VALUES(?, ?,12,?,?,?)`, userID, next, conversationID, seq, body)
if err = tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(event_seq),0)+1 FROM im_user_sync_events WHERE user_id=?`, userID).Scan(&next); err != nil {
return item, nil, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO im_user_sync_events(user_id,event_seq,event_type,conversation_id,message_seq,event_data)VALUES(?, ?,12,?,?,?)`, userID, next, conversationID, seq, body); err != nil {
return item, nil, err
}
}
if err = tx.Commit(); err != nil {
return item, nil, err
}
a.enqueueAIReply(ctx, conversationID, senderID, members, messageID)
return messageView{ID: messageID, ConversationID: conversationID, Seq: seq, SenderID: senderID, ClientMsgID: clientMsgID, Type: messageType, Content: content, CreatedAt: time.Now()}, members, nil
}
func (a *App) loadMessage(r *http.Request, id int64) messageView {
return a.loadMessageContext(r.Context(), id)
}
func (a *App) loadMessageContext(ctx context.Context, id int64) messageView {
var item messageView
var body []byte
_ = a.db.QueryRowContext(r.Context(), `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,created_at FROM im_messages WHERE id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &item.CreatedAt)
_ = json.Unmarshal(body, &item.Content)
var recalledAt, adminRemovedAt sql.NullTime
_ = a.db.QueryRowContext(ctx, `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt)
item.Recalled = recalledAt.Valid || adminRemovedAt.Valid
if !item.Recalled {
_ = json.Unmarshal(body, &item.Content)
}
return item
}
func (a *App) isMember(r *http.Request, conversationID, userID int64) bool {
@@ -312,13 +674,17 @@ func (a *App) isMember(r *http.Request, conversationID, userID int64) bool {
}
func (a *App) conversationSettings(w http.ResponseWriter, r *http.Request) {
id, _ := pathID(r)
id, pathErr := pathID(r)
if pathErr != nil {
fail(w, http.StatusBadRequest, 20001, "会话 ID 无效")
return
}
var req struct {
Pinned *bool `json:"pinned"`
Muted *bool `json:"muted"`
ReadSeq int64 `json:"readSeq"`
}
if decode(r, &req) != nil {
if decode(r, &req) != nil || req.ReadSeq < 0 {
fail(w, 400, 20001, "invalid settings")
return
}
@@ -329,12 +695,72 @@ func (a *App) conversationSettings(w http.ResponseWriter, r *http.Request) {
if req.Muted != nil {
muted = btoi(*req.Muted)
}
_, err := a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET pinned=IF(?>=0,?,pinned),muted=IF(?>=0,?,muted),read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, pinned, pinned, muted, muted, req.ReadSeq, id, current(r).ID)
var lastSeq int64
if err := a.db.QueryRowContext(r.Context(), `SELECT c.last_seq FROM im_conversations c JOIN im_conversation_members m ON m.conversation_id=c.id WHERE c.id=? AND m.user_id=? AND m.status=1`, id, current(r).ID).Scan(&lastSeq); err != nil {
fail(w, http.StatusForbidden, 30002, "不是会话成员")
return
}
readSeq := req.ReadSeq
if readSeq > lastSeq {
readSeq = lastSeq
}
_, err := a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET pinned=IF(?>=0,?,pinned),muted=IF(?>=0,?,muted),read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, pinned, pinned, muted, muted, readSeq, id, current(r).ID)
if err != nil {
fail(w, 500, 50001, "保存失败")
return
}
reply(w, map[string]bool{"success": true})
savedReadSeq, unread, err := a.conversationReadState(r.Context(), id, current(r).ID)
if err != nil {
fail(w, 500, 50001, "读取已读状态失败")
return
}
if req.ReadSeq > 0 {
a.publishConversationRead(id, current(r).ID, savedReadSeq, unread)
}
reply(w, map[string]any{"success": true, "readSeq": savedReadSeq, "unread": unread})
}
// Notify this user's other devices after a successful read; never notify the peer
// or advance the sender's read marker merely because they sent a message.
func (a *App) publishConversationRead(conversationID, userID, readSeq, unread int64) {
a.hub.broadcast([]int64{userID}, map[string]any{"command": "READ_ACK", "data": map[string]any{
"conversationId": conversationID, "readSeq": readSeq, "unread": unread,
}})
}
func (a *App) conversationReadState(ctx context.Context, conversationID, userID int64) (int64, int64, error) {
var savedReadSeq, lastSeq, unread int64
err := a.db.QueryRowContext(ctx, `SELECT m.read_seq,c.last_seq,
(SELECT COUNT(*) FROM im_messages unread_msg WHERE unread_msg.conversation_id=m.conversation_id
AND unread_msg.seq>GREATEST(m.read_seq,m.clear_seq,m.join_seq) AND unread_msg.sender_id<>m.user_id
AND unread_msg.recalled_at IS NULL AND unread_msg.admin_removed_at IS NULL)
FROM im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id
WHERE m.conversation_id=? AND m.user_id=? AND m.status=1`, conversationID, userID).Scan(&savedReadSeq, &lastSeq, &unread)
// Older clients compare READ_ACK with the conversation sequence, which also
// includes the user's own messages. Cover those sequences only when the
// authoritative unread count is zero; a newer incoming message stays safe.
if err == nil && unread == 0 && lastSeq > savedReadSeq {
savedReadSeq = lastSeq
}
return savedReadSeq, unread, err
}
func (a *App) markConversationRead(ctx context.Context, conversationID, userID, readSeq int64) (int64, int64, error) {
if readSeq < 0 {
return 0, 0, fmt.Errorf("已读序号无效")
}
_, err := a.db.ExecContext(ctx, `UPDATE im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id
SET m.read_seq=GREATEST(m.read_seq,LEAST(?,c.last_seq))
WHERE m.conversation_id=? AND m.user_id=? AND m.status=1`, readSeq, conversationID, userID)
if err != nil {
return 0, 0, err
}
savedReadSeq, unread, err := a.conversationReadState(ctx, conversationID, userID)
if err != nil {
return 0, 0, err
}
a.publishConversationRead(conversationID, userID, savedReadSeq, unread)
return savedReadSeq, unread, nil
}
func (a *App) websocket(w http.ResponseWriter, r *http.Request) {
@@ -342,7 +768,19 @@ func (a *App) websocket(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusForbidden, 10006, "WebSocket 来源不允许")
return
}
raw := r.URL.Query().Get("token")
selectedProtocol := ""
raw := ""
for _, protocol := range websocket.Subprotocols(r) {
if strings.HasPrefix(protocol, "xingyu.jwt.") {
selectedProtocol = protocol
raw = strings.TrimPrefix(protocol, "xingyu.jwt.")
break
}
}
// Query-token compatibility is development-only because URLs may be written to proxy logs.
if raw == "" && a.config.Environment != "production" {
raw = r.URL.Query().Get("token")
}
who, err := a.parseToken(raw)
if err != nil || who.Role != "user" {
fail(w, 401, 10001, "invalid token")
@@ -366,14 +804,27 @@ func (a *App) websocket(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{CheckOrigin: func(request *http.Request) bool {
return a.originAllowed(request.Header.Get("Origin"))
}}
if selectedProtocol != "" {
upgrader.Subprotocols = []string{selectedProtocol}
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
client := &wsClient{userID: who.ID, conn: conn}
a.hub.add(client)
defer func() { a.hub.remove(client); _ = conn.Close() }()
_ = conn.WriteJSON(map[string]any{"command": "AUTH_ACK", "data": map[string]any{"heartbeatSeconds": 25, "serverTime": time.Now().UnixMilli()}})
conn.SetReadLimit(64 << 10)
_ = conn.SetReadDeadline(time.Now().Add(75 * time.Second))
a.touchUserActivity(r.Context(), who.ID)
if a.hub.add(client) {
a.publishPresence(who.ID, true)
}
defer func() {
if a.hub.remove(client) {
a.publishPresence(who.ID, false)
}
_ = conn.Close()
}()
_ = client.writeJSON(map[string]any{"command": "AUTH_ACK", "data": map[string]any{"heartbeatSeconds": 25, "serverTime": time.Now().UnixMilli()}})
for {
var frame struct {
Command string `json:"command"`
@@ -388,24 +839,33 @@ func (a *App) websocket(w http.ResponseWriter, r *http.Request) {
}
switch frame.Command {
case "PING":
_ = conn.WriteJSON(map[string]any{"command": "PONG", "timestamp": time.Now().UnixMilli()})
a.touchUserActivity(r.Context(), who.ID)
_ = conn.SetReadDeadline(time.Now().Add(75 * time.Second))
_ = client.writeJSON(map[string]any{"command": "PONG", "timestamp": time.Now().UnixMilli()})
case "SEND_MESSAGE":
item, members, persistErr := a.persistMessage(r, frame.ConversationID, who.ID, frame.ClientMsgID, frame.Type, frame.Content)
if persistErr != nil {
_ = conn.WriteJSON(map[string]any{"command": "ERROR", "message": persistErr.Error()})
_ = client.writeJSON(map[string]any{"command": "ERROR", "message": persistErr.Error()})
continue
}
a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item})
case "READ":
_, _ = a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, frame.ReadSeq, frame.ConversationID, who.ID)
a.hub.broadcast([]int64{who.ID}, map[string]any{"command": "READ_ACK", "data": frame})
if frame.ReadSeq < 0 {
_ = client.writeJSON(map[string]any{"command": "ERROR", "message": "已读序号无效"})
continue
}
var lastSeq int64
if a.db.QueryRowContext(r.Context(), `SELECT c.last_seq FROM im_conversations c JOIN im_conversation_members m ON m.conversation_id=c.id WHERE c.id=? AND m.user_id=? AND m.status=1`, frame.ConversationID, who.ID).Scan(&lastSeq) != nil {
_ = client.writeJSON(map[string]any{"command": "ERROR", "message": "不是会话成员"})
continue
}
if frame.ReadSeq > lastSeq {
frame.ReadSeq = lastSeq
}
if _, _, err := a.markConversationRead(r.Context(), frame.ConversationID, who.ID, frame.ReadSeq); err != nil {
_ = client.writeJSON(map[string]any{"command": "ERROR", "message": "更新已读状态失败"})
continue
}
}
}
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
+73 -8
View File
@@ -44,11 +44,20 @@ type integrationFieldView struct {
var integrationSpecs = map[string][]integrationFieldSpec{
"oauth": {
{Key: "oauth.admin.frontend_callback_url", Label: "管理端登录结果页", Input: "text", Required: true, Description: "第三方授权完成后返回的管理端页面;生产环境必须使用 HTTPS"},
{Key: "oauth.user.frontend_callback_url", Label: "客户端 H5 登录结果页", Input: "text", Required: true, Description: "第三方授权完成后返回的 uni-app H5 页面;生产环境必须使用 HTTPS"},
{Key: "oauth.admin.frontend_callback_url", Label: "管理端登录结果页", Input: "text", Description: "管理端启用第三方登录时必填;生产环境必须使用 HTTPS"},
{Key: "oauth.user.frontend_callback_url", Label: "客户端 H5 登录结果页", Input: "text", Description: "H5 启用第三方登录时必填;生产环境必须使用 HTTPS"},
{Key: "oauth.app.frontend_callback_url", Label: "App GitHub 登录结果地址", Input: "text", Providers: []string{"github"}, Description: "固定为 xingyuim://oauth/callbackAndroid/iOS 打包须注册 xingyuim URL SchemeGitHub 平台仍填写后端 HTTPS 回调地址"},
{Key: "oauth.app.wechat.enabled", Label: "App 启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "后端控制 App 原生微信登录;还需在 manifest 中配置微信 SDK 并重新打包"},
{Key: "oauth.app.qq.enabled", Label: "App 启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "后端控制 App 原生 QQ 登录;还需在 manifest 中配置 QQ SDK 并重新打包"},
{Key: "oauth.app.github.enabled", Label: "App 启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "系统浏览器授权后返回 App;复用下方 GitHub OAuth 应用参数"},
{Key: "oauth.app.google.enabled", Label: "App 启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "后端控制 App 原生 Google 登录;需配置 Google SDK 和允许的客户端 ID"},
{Key: "oauth.app.wechat.client_id", Label: "App 微信 AppID", Input: "text", Providers: []string{"wechat"}, Description: "微信开放平台移动应用 AppID,需与 App 打包配置一致;独立于网站应用"},
{Key: "oauth.app.wechat.client_secret", Label: "App 微信 AppSecret", Input: "secret", Providers: []string{"wechat"}, Description: "移动应用密钥,AES-GCM 加密保存;不得写入 App 包"},
{Key: "oauth.app.qq.client_id", Label: "App QQ AppID", Input: "text", Providers: []string{"qq"}, Description: "QQ 互联移动应用 AppID,需与 App 打包配置一致"},
{Key: "oauth.app.google.client_ids", Label: "App Google Client ID 白名单", Input: "text", Providers: []string{"google"}, Description: "Android/iOS OAuth 客户端 ID,以英文逗号分隔;后端验证令牌所属应用"},
{Key: "oauth.wechat.enabled", Label: "管理端启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,管理端登录页显示微信入口"},
{Key: "oauth.user.wechat.enabled", Label: "客户端启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示微信入口"},
{Key: "oauth.user.wechat.enabled", Label: "H5 启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示微信入口"},
{Key: "oauth.wechat.client_id", Label: "微信 AppID", Input: "text", Providers: []string{"wechat"}, Description: "微信开放平台网站应用 AppID"},
{Key: "oauth.wechat.client_secret", Label: "微信 AppSecret", Input: "secret", Providers: []string{"wechat"}, Description: "AES-GCM 加密保存"},
{Key: "oauth.wechat.authorization_url", Label: "微信授权地址", Input: "text", Providers: []string{"wechat"}, Description: "默认使用微信开放平台 qrconnect 地址"},
@@ -58,7 +67,7 @@ var integrationSpecs = map[string][]integrationFieldSpec{
{Key: "oauth.wechat.redirect_uri", Label: "微信回调地址", Input: "text", Providers: []string{"wechat"}, Description: "必须与微信开放平台登记值完全一致"},
{Key: "oauth.qq.enabled", Label: "管理端启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,管理端登录页显示 QQ 入口"},
{Key: "oauth.user.qq.enabled", Label: "客户端启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 QQ 入口"},
{Key: "oauth.user.qq.enabled", Label: "H5 启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 QQ 入口"},
{Key: "oauth.qq.client_id", Label: "QQ AppID", Input: "text", Providers: []string{"qq"}, Description: "QQ 互联应用 AppID"},
{Key: "oauth.qq.client_secret", Label: "QQ AppKey", Input: "secret", Providers: []string{"qq"}, Description: "AES-GCM 加密保存"},
{Key: "oauth.qq.authorization_url", Label: "QQ 授权地址", Input: "text", Providers: []string{"qq"}, Description: "QQ OAuth 2.0 authorize 地址"},
@@ -69,7 +78,7 @@ var integrationSpecs = map[string][]integrationFieldSpec{
{Key: "oauth.qq.redirect_uri", Label: "QQ 回调地址", Input: "text", Providers: []string{"qq"}, Description: "必须与 QQ 互联登记值完全一致"},
{Key: "oauth.github.enabled", Label: "管理端启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,管理端登录页显示 GitHub 入口"},
{Key: "oauth.user.github.enabled", Label: "客户端启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 GitHub 入口"},
{Key: "oauth.user.github.enabled", Label: "H5 启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 GitHub 入口"},
{Key: "oauth.github.client_id", Label: "GitHub Client ID", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth App Client ID"},
{Key: "oauth.github.client_secret", Label: "GitHub Client Secret", Input: "secret", Providers: []string{"github"}, Description: "AES-GCM 加密保存"},
{Key: "oauth.github.authorization_url", Label: "GitHub 授权地址", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth authorize 地址"},
@@ -79,7 +88,7 @@ var integrationSpecs = map[string][]integrationFieldSpec{
{Key: "oauth.github.redirect_uri", Label: "GitHub 回调地址", Input: "text", Providers: []string{"github"}, Description: "必须与 OAuth App 的 callback URL 完全一致"},
{Key: "oauth.google.enabled", Label: "管理端启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,管理端登录页显示 Google 入口"},
{Key: "oauth.user.google.enabled", Label: "客户端启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 Google 入口"},
{Key: "oauth.user.google.enabled", Label: "H5 启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 Google 入口"},
{Key: "oauth.google.client_id", Label: "Google Client ID", Input: "text", Providers: []string{"google"}, Description: "Google OAuth 2.0 Client ID"},
{Key: "oauth.google.client_secret", Label: "Google Client Secret", Input: "secret", Providers: []string{"google"}, Description: "AES-GCM 加密保存"},
{Key: "oauth.google.authorization_url", Label: "Google 授权地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth authorization endpoint"},
@@ -169,6 +178,19 @@ var integrationSpecs = map[string][]integrationFieldSpec{
{Key: "sms.webhook_url", Label: "Webhook 地址", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "接收 JSON POST;生产环境必须使用 HTTPS"},
{Key: "sms.webhook_token", Label: "Webhook Token", Input: "secret", Providers: []string{"webhook"}, Description: "以 Bearer Token 发送,仅显示保存状态"},
},
"ai": {
{Key: "ai.enabled", Label: "启用 AI 托管", Input: "boolean", Required: true, Description: "总开关;关闭后不调用任何模型,测试账号不再自动回复"},
{Key: "ai.default_model_id", Label: "默认模型 ID", Input: "text", Description: "留空则使用模型列表中标记为默认的一条"},
{Key: "ai.max_concurrency", Label: "并发调用上限", Input: "number", Description: "同时进行的模型调用数,建议 2 到 16"},
{Key: "ai.daily_call_limit", Label: "每日调用上限", Input: "number", Description: "全局每日调用次数,0 表示不限制"},
{Key: "ai.agent_daily_reply_limit", Label: "单账号每日回复上限", Input: "number", Description: "单个托管账号每日回复条数,0 表示不限制"},
{Key: "ai.reply_delay_min_ms", Label: "回复最小延迟(毫秒)", Input: "number", Description: "避免秒回失真,建议 1000 以上"},
{Key: "ai.reply_delay_max_ms", Label: "回复最大延迟(毫秒)", Input: "number", Description: "必须不小于最小延迟"},
{Key: "ai.allow_batches", Label: "允许托管的测试批次", Input: "text", Description: "逗号分隔;留空表示全部测试账号"},
{Key: "ai.disclose_in_chat", Label: "会话内标注 AI 身份", Input: "boolean", Required: true, Description: "关闭前需确认所在地区对机器人身份披露的监管要求"},
{Key: "ai.fallback_text", Label: "失败兜底文案", Input: "text", Description: "模型调用失败时发送的内容,留空则静默不回复"},
{Key: "ai.log_retention_days", Label: "调用日志保留天数", Input: "number", Description: "建议 7 到 180 天"},
},
"payment": {
{Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Description: "sandbox 可直接完成本地支付闭环"},
{Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Description: "live 模式必填,生产环境必须使用 HTTPS"},
@@ -486,6 +508,33 @@ func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) {
}
}
reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode, "channels": channels})
case "ai":
if err := a.validateAIConfig(r.Context()); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
model, err := a.defaultAIModel(r.Context())
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
provider, err := newAIProvider(model, a.config.Environment == "production")
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
started := time.Now()
result, chatErr := provider.Chat(r.Context(), aiChatRequest{
System: "你在参与一次接口连通性测试,请直接给出简短的中文回复。",
Messages: []aiChatMessage{{Role: "user", Text: aiProbePrompt}},
})
latency := int(time.Since(started).Milliseconds())
a.logAICall(r.Context(), model.ID, "probe", 0, 0, latency, result, chatErr)
if chatErr != nil {
fail(w, http.StatusBadGateway, 50002, chatErr.Error())
return
}
reply(w, map[string]any{"success": true, "message": model.Name + " 调用成功", "model": model.Name, "protocol": model.Protocol, "latencyMs": latency, "reply": result.Text})
case "storage":
provider := a.configPlain(r.Context(), "storage.provider", "local")
if err := a.validateStorageProviderConfig(r.Context()); err != nil {
@@ -504,11 +553,24 @@ func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
if _, err = a.userOAuthFrontendURL(r.Context()); err != nil {
appProviders, err := a.enabledAppOAuthProviders(r.Context())
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
if len(adminProviders) == 0 && len(userProviders) == 0 {
if len(userProviders) > 0 {
if _, err = a.userOAuthFrontendURL(r.Context()); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
}
if len(adminProviders) > 0 {
if _, err = a.adminOAuthFrontendURL(r.Context()); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
}
if len(adminProviders) == 0 && len(userProviders) == 0 && len(appProviders) == 0 {
reply(w, map[string]any{"success": true, "message": "当前未启用第三方登录,登录页不会显示第三方入口", "providers": []any{}})
return
}
@@ -519,6 +581,9 @@ func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) {
for _, provider := range userProviders {
items = append(items, map[string]string{"audience": "user", "code": provider.Code, "name": provider.Name})
}
for _, provider := range appProviders {
items = append(items, map[string]string{"audience": "app", "code": provider.Code, "name": provider.Name})
}
reply(w, map[string]any{"success": true, "message": "第三方登录配置校验通过", "providers": items})
default:
fail(w, http.StatusNotFound, 30001, "配置分组不存在")
+58
View File
@@ -0,0 +1,58 @@
SET NAMES utf8mb4;
-- Model records rather than a single provider block: several protocols have to
-- coexist so operations can switch models without a release.
CREATE TABLE IF NOT EXISTS ai_models (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(60) NOT NULL,
protocol VARCHAR(20) NOT NULL DEFAULT 'openai',
base_url VARCHAR(300) NOT NULL DEFAULT '',
model_name VARCHAR(120) NOT NULL DEFAULT '',
api_key_cipher TEXT NOT NULL,
temperature DECIMAL(3,2) NOT NULL DEFAULT 0.80,
max_tokens INT UNSIGNED NOT NULL DEFAULT 256,
timeout_ms INT UNSIGNED NOT NULL DEFAULT 15000,
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
is_default TINYINT(1) NOT NULL DEFAULT 0,
extra_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ai_models_name (name),
KEY idx_ai_models_status (status, is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Every call is recorded for cost accounting and after-the-fact review. The
-- prompt is kept as a digest so conversation content does not accumulate here.
CREATE TABLE IF NOT EXISTS ai_call_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
model_id BIGINT UNSIGNED NOT NULL,
scene VARCHAR(30) NOT NULL DEFAULT 'chat',
agent_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
conversation_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
latency_ms INT UNSIGNED NOT NULL DEFAULT 0,
prompt_tokens INT UNSIGNED NOT NULL DEFAULT 0,
completion_tokens INT UNSIGNED NOT NULL DEFAULT 0,
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
error VARCHAR(500) NOT NULL DEFAULT '',
prompt_digest VARCHAR(64) NOT NULL DEFAULT '',
reply_text VARCHAR(1000) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_ai_call_logs_model_created (model_id, created_at),
KEY idx_ai_call_logs_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES
('ai.enabled', 'false', 'boolean', '总开关:关闭后不调用任何模型,测试账号不再自动回复'),
('ai.default_model_id', '', 'string', '默认模型 ID,留空则使用模型列表中标记为默认的一条'),
('ai.max_concurrency', '4', 'string', '同时进行的模型调用上限'),
('ai.daily_call_limit', '2000', 'string', '全局每日调用次数上限,0 表示不限制'),
('ai.agent_daily_reply_limit', '50', 'string', '单个托管账号每日回复条数上限,0 表示不限制'),
('ai.reply_delay_min_ms', '2000', 'string', '回复前的最小延迟毫秒数,避免秒回失真'),
('ai.reply_delay_max_ms', '6000', 'string', '回复前的最大延迟毫秒数'),
('ai.allow_batches', '', 'string', '允许托管的测试批次,逗号分隔;留空表示全部测试账号'),
('ai.disclose_in_chat', 'true', 'boolean', '会话内标注 AI 身份;关闭前需确认所在地区的监管要求'),
('ai.fallback_text', '', 'string', '模型调用失败时发送的兜底文案,留空则静默不回复'),
('ai.log_retention_days', '30', 'string', '调用日志保留天数')
ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type);
+44
View File
@@ -0,0 +1,44 @@
SET NAMES utf8mb4;
-- One row per managed test account. model_id 0 falls back to the configured
-- default model so operations can switch every agent at once.
CREATE TABLE IF NOT EXISTS ai_agents (
user_id BIGINT UNSIGNED NOT NULL,
model_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
persona VARCHAR(1000) NOT NULL DEFAULT '',
enabled TINYINT(1) NOT NULL DEFAULT 1,
daily_reply_limit INT UNSIGNED NOT NULL DEFAULT 0,
reply_count INT UNSIGNED NOT NULL DEFAULT 0,
last_reply_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id),
KEY idx_ai_agents_enabled (enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- A durable queue rather than an in-memory one: replies survive a restart and
-- several instances can share it. Claiming is an UPDATE that stamps claim_token
-- instead of SELECT ... FOR UPDATE SKIP LOCKED, because deployments still run
-- MySQL 5.7 where SKIP LOCKED does not parse.
-- pending_key is NULL once a job leaves the pending state, so the unique index
-- allows exactly one waiting job per conversation and collapses a burst of
-- incoming messages into a single reply.
CREATE TABLE IF NOT EXISTS ai_reply_jobs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
conversation_id BIGINT UNSIGNED NOT NULL,
agent_user_id BIGINT UNSIGNED NOT NULL,
trigger_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
status TINYINT UNSIGNED NOT NULL DEFAULT 0,
attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
run_after DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
claim_token CHAR(32) NOT NULL DEFAULT '',
last_error VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
pending_key BIGINT UNSIGNED GENERATED ALWAYS AS (IF(status = 0, conversation_id, NULL)) STORED,
PRIMARY KEY (id),
UNIQUE KEY uk_ai_reply_jobs_pending (pending_key),
KEY idx_ai_reply_jobs_due (status, run_after),
KEY idx_ai_reply_jobs_claim (claim_token),
KEY idx_ai_reply_jobs_agent (agent_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;