后端接入 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
+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, "配置分组不存在")