后端接入 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:
co-authored by
Claude Opus 5
parent
334890171e
commit
a024d59827
@@ -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/callback;Android/iOS 打包须注册 xingyuim URL Scheme,GitHub 平台仍填写后端 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, "配置分组不存在")
|
||||
|
||||
Reference in New Issue
Block a user