Files
kefu/im/backend/internal/app/integration.go
T
Your NameandClaude Opus 5 ce429afcf2 密码强度下限、离线推送下发
一、密码规则:至少 8 位且不能全是数字。短信验证可以在后台关掉,关掉之后
密码就是账号质量的唯一门槛,而此前 validUserPassword 只检查非空——"1"
也是合法密码。规则只在「设置密码」时校验(注册、找回、改密、后台建号与
后台重置),登录不再校验,已有账号照常使用。上下限都按字符数计算,否则
43 个汉字的密码会因为字节数超限被拒。各处错误提示改为直接说明规则,
而不是笼统的一句「请填写有效的密码」。

二、离线推送:此前客户端一直在上报 push token,服务端从未下发过任何东西,
App 退到后台或被杀掉时新消息完全没有提醒(MESSAGE_PUSH 只是 WebSocket
帧名)。补上服务端下发:
- 只发给「此刻不在线 + 未对该会话免打扰 + 未关闭消息通知」的接收者,
  在线的人已经从实时通道拿到了。
- 鉴权 token 按 provider 的过期时间缓存,个推的 auth 接口限流很紧。
- 失效的 cid(10001/10002)就地停用,不再每条消息重试一次。
- 整个过程在独立 goroutine 与独立 context 上进行,推送服务再慢也不会
  拖慢或拖垮一条已经发出的消息。
- 是否显示正文由 push.show_preview 控制,关闭后锁屏上不出现消息内容。
凭据在管理端「离线推送」页填写(迁移 035 先建出配置行——集成配置保存
走的是 UPDATE,行不存在会静默保存不上)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:09:33 +08:00

651 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const maskedSecret = "••••••••"
type integrationFieldSpec struct {
Key string
Label string
Input string
Required bool
Options []string
Providers []string
Description string
}
type integrationFieldView struct {
Key string `json:"key"`
Label string `json:"label"`
Value string `json:"value"`
Input string `json:"input"`
Secret bool `json:"secret"`
HasValue bool `json:"hasValue"`
Required bool `json:"required"`
Options []string `json:"options,omitempty"`
Providers []string `json:"providers,omitempty"`
Description string `json:"description"`
}
var integrationSpecs = map[string][]integrationFieldSpec{
"oauth": {
{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: "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 地址"},
{Key: "oauth.wechat.token_url", Label: "微信令牌地址", Input: "text", Providers: []string{"wechat"}, Description: "授权码换取 access_token 的地址"},
{Key: "oauth.wechat.userinfo_url", Label: "微信用户信息地址", Input: "text", Providers: []string{"wechat"}, Description: "获取登录用户 OpenID 与资料的地址"},
{Key: "oauth.wechat.scope", Label: "微信授权范围", Input: "text", Providers: []string{"wechat"}, Description: "网站扫码登录通常为 snsapi_login"},
{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: "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 地址"},
{Key: "oauth.qq.token_url", Label: "QQ 令牌地址", Input: "text", Providers: []string{"qq"}, Description: "授权码换取 access_token 的地址"},
{Key: "oauth.qq.openid_url", Label: "QQ OpenID 地址", Input: "text", Providers: []string{"qq"}, Description: "使用 access_token 获取 QQ OpenID"},
{Key: "oauth.qq.userinfo_url", Label: "QQ 用户信息地址", Input: "text", Providers: []string{"qq"}, Description: "获取昵称和头像"},
{Key: "oauth.qq.scope", Label: "QQ 授权范围", Input: "text", Providers: []string{"qq"}, Description: "默认 get_user_info"},
{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: "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 地址"},
{Key: "oauth.github.token_url", Label: "GitHub 令牌地址", Input: "text", Providers: []string{"github"}, Description: "授权码换取 access_token 的地址"},
{Key: "oauth.github.userinfo_url", Label: "GitHub 用户信息地址", Input: "text", Providers: []string{"github"}, Description: "默认使用 /user 接口"},
{Key: "oauth.github.scope", Label: "GitHub 授权范围", Input: "text", Providers: []string{"github"}, Description: "建议仅 read:user user:email"},
{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: "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"},
{Key: "oauth.google.token_url", Label: "Google 令牌地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth token endpoint"},
{Key: "oauth.google.userinfo_url", Label: "Google UserInfo 地址", Input: "text", Providers: []string{"google"}, Description: "OpenID Connect UserInfo endpoint"},
{Key: "oauth.google.scope", Label: "Google 授权范围", Input: "text", Providers: []string{"google"}, Description: "至少包含 openid profile email"},
{Key: "oauth.google.redirect_uri", Label: "Google 回调地址", Input: "text", Providers: []string{"google"}, Description: "必须与 Google Cloud Console 登记值完全一致"},
},
"storage": {
{Key: "storage.provider", Label: "当前存储厂商", Input: "select", Required: true, Options: []string{"local", "aliyun_oss", "tencent_cos", "qiniu", "huawei_obs", "huawei_flexus"}, Description: "保存后所有新上传文件立即切换到所选存储;历史文件地址不受影响"},
{Key: "storage.object_prefix", Label: "云端对象前缀", Input: "text", Required: true, Description: "仅允许字母、数字、斜杠、下划线和短横线,例如 media"},
{Key: "storage.local.directory", Label: "本地存储目录", Input: "text", Required: true, Providers: []string{"local"}, Description: "相对路径基于后端运行目录;禁止配置为磁盘根目录"},
{Key: "storage.local.public_base_url", Label: "本地公开访问地址", Input: "text", Providers: []string{"local"}, Description: "可选,例如 https://api.example.com/uploads;留空时根据当前请求生成"},
{Key: "storage.aliyun_oss.endpoint", Label: "阿里云 OSS Endpoint", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 https://oss-cn-hangzhou.aliyuncs.com"},
{Key: "storage.aliyun_oss.region", Label: "阿里云 OSS Region", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 cn-hangzhou"},
{Key: "storage.aliyun_oss.bucket", Label: "阿里云 OSS Bucket", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "需要 oss:PutObject 权限"},
{Key: "storage.aliyun_oss.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "建议使用最小权限 RAM 用户,AES-GCM 加密保存"},
{Key: "storage.aliyun_oss.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "AES-GCM 加密保存"},
{Key: "storage.aliyun_oss.public_base_url", Label: "阿里云文件访问域名", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "Bucket 公网域名或已配置的 CDN/自定义域名,必须使用 HTTPS"},
{Key: "storage.tencent_cos.endpoint", Label: "腾讯云 COS Bucket URL", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "例如 https://bucket-appid.cos.ap-guangzhou.myqcloud.com"},
{Key: "storage.tencent_cos.bucket", Label: "腾讯云 COS Bucket", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "完整名称需包含 APPID,需要 cos:PutObject 权限"},
{Key: "storage.tencent_cos.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "建议使用最小权限 CAM 子账号密钥,AES-GCM 加密保存"},
{Key: "storage.tencent_cos.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "AES-GCM 加密保存"},
{Key: "storage.tencent_cos.public_base_url", Label: "腾讯云文件访问域名", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"},
{Key: "storage.qiniu.bucket", Label: "七牛云空间名称", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "Kodo Bucket 名称,SDK 自动发现上传区域"},
{Key: "storage.qiniu.access_key", Label: "七牛云 AccessKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "建议使用仅具备目标空间上传权限的密钥,AES-GCM 加密保存"},
{Key: "storage.qiniu.secret_key", Label: "七牛云 SecretKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "AES-GCM 加密保存"},
{Key: "storage.qiniu.public_base_url", Label: "七牛云文件访问域名", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "空间绑定域名或 CDN 域名,必须使用 HTTPS"},
{Key: "storage.huawei_obs.endpoint", Label: "华为云 OBS Endpoint", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "例如 https://obs.cn-north-4.myhuaweicloud.com"},
{Key: "storage.huawei_obs.bucket", Label: "华为云 OBS Bucket", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "需要 obs:object:PutObject 权限"},
{Key: "storage.huawei_obs.access_key", Label: "华为云 OBS Access Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 AKAES-GCM 加密保存"},
{Key: "storage.huawei_obs.secret_key", Label: "华为云 OBS Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 SKAES-GCM 加密保存"},
{Key: "storage.huawei_obs.public_base_url", Label: "华为云 OBS 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"},
{Key: "storage.huawei_flexus.endpoint", Label: "Flexus 对象存储 Endpoint", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储控制台提供的 OBS 兼容 Endpoint"},
{Key: "storage.huawei_flexus.bucket", Label: "Flexus Bucket", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储桶名称"},
{Key: "storage.huawei_flexus.access_key", Label: "Flexus Access Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"},
{Key: "storage.huawei_flexus.secret_key", Label: "Flexus Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"},
{Key: "storage.huawei_flexus.public_base_url", Label: "Flexus 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"},
},
"sms": {
{Key: "sms.enabled", Label: "启用短信服务", Input: "boolean", Required: true, Description: "关闭后将拒绝发送验证码"},
{Key: "sms.provider", Label: "当前短信厂商", Input: "select", Required: true, Options: []string{"aliyun", "tencent", "huawei", "webhook", "debug"}, Description: "保存后新验证码立即切换到所选厂商;debug 仅限本地开发"},
{Key: "sms.expire_seconds", Label: "有效期(秒)", Input: "number", Required: true, Description: "建议 120 至 600 秒"},
{Key: "sms.debug_code", Label: "调试验证码", Input: "secret", Required: true, Providers: []string{"debug"}, Description: "仅 debug 模式返回给客户端"},
{Key: "sms.aliyun.endpoint", Label: "阿里云 API 地址", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "国内短信默认 https://dysmsapi.aliyuncs.com"},
{Key: "sms.aliyun.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "建议使用仅授予短信发送权限的 RAM 用户"},
{Key: "sms.aliyun.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "AES-GCM 加密保存"},
{Key: "sms.aliyun.sign_name", Label: "阿里云短信签名", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "必须是审核通过的签名名称"},
{Key: "sms.aliyun.template_register", Label: "阿里云注册模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "注册验证码模板,例如 SMS_123456789"},
{Key: "sms.aliyun.template_login", Label: "阿里云登录模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "短信登录验证码模板"},
{Key: "sms.aliyun.template_reset", Label: "阿里云重置密码模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "找回密码验证码模板"},
{Key: "sms.aliyun.template_params", Label: "阿里云模板变量 JSON", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "支持 {{code}} 与 {{minutes}},例如 {\"code\":\"{{code}}\"}"},
{Key: "sms.tencent.endpoint", Label: "腾讯云 API 地址", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信默认 https://sms.tencentcloudapi.com"},
{Key: "sms.tencent.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "建议使用最小权限 CAM 子账号密钥"},
{Key: "sms.tencent.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "AES-GCM 加密保存"},
{Key: "sms.tencent.sdk_app_id", Label: "腾讯云短信 SdkAppId", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信控制台应用 ID"},
{Key: "sms.tencent.region", Label: "腾讯云地域", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信建议 ap-guangzhou"},
{Key: "sms.tencent.sign_name", Label: "腾讯云短信签名", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "必须是审核通过的签名内容"},
{Key: "sms.tencent.template_register", Label: "腾讯云注册模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "注册验证码模板 ID"},
{Key: "sms.tencent.template_login", Label: "腾讯云登录模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信登录验证码模板 ID"},
{Key: "sms.tencent.template_reset", Label: "腾讯云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "找回密码验证码模板 ID"},
{Key: "sms.tencent.template_params", Label: "腾讯云模板参数 JSON", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"},
{Key: "sms.huawei.endpoint", Label: "华为云 APP 接入地址", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "填写控制台提供的 HTTPS 地址,包含 /sms/batchSendSms/v1"},
{Key: "sms.huawei.app_key", Label: "华为云 Application Key", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Key"},
{Key: "sms.huawei.app_secret", Label: "华为云 Application Secret", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_SecretAES-GCM 加密保存"},
{Key: "sms.huawei.sender", Label: "华为云签名通道号", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "国内短信签名审核后分配的通道号"},
{Key: "sms.huawei.signature", Label: "华为云签名名称", Input: "text", Providers: []string{"huawei"}, Description: "通用模板需要填写已审核签名;非通用模板可留空"},
{Key: "sms.huawei.template_register", Label: "华为云注册模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "注册验证码模板 ID"},
{Key: "sms.huawei.template_login", Label: "华为云登录模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "短信登录验证码模板 ID"},
{Key: "sms.huawei.template_reset", Label: "华为云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "找回密码验证码模板 ID"},
{Key: "sms.huawei.template_params", Label: "华为云模板参数 JSON", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"},
{Key: "sms.huawei.status_callback", Label: "华为云状态回调地址", Input: "text", Providers: []string{"huawei"}, Description: "可选,接收运营商最终送达状态"},
{Key: "sms.sign_name", Label: "Webhook 短信签名", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "发送给自建网关的签名名称"},
{Key: "sms.template_register", Label: "Webhook 注册模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "注册场景模板"},
{Key: "sms.template_login", Label: "Webhook 登录模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "验证码登录场景模板"},
{Key: "sms.template_reset", Label: "Webhook 找回密码模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "重置密码场景模板"},
{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 天"},
},
"push": {
{Key: "push.enabled", Label: "启用离线推送", Input: "boolean", Required: true, Description: "App 退到后台或未运行时,新消息通过厂商通道提醒;关闭后只有在线时的实时推送"},
{Key: "push.app_id", Label: "AppID", Input: "text", Required: true, Description: "个推 / UniPush 应用的 AppID,需与客户端打包配置一致"},
{Key: "push.app_key", Label: "AppKey", Input: "text", Required: true, Description: "个推 / UniPush 应用的 AppKey"},
{Key: "push.master_secret", Label: "MasterSecret", Input: "secret", Required: true, Description: "服务端鉴权密钥,AES-GCM 加密保存;不得写入客户端"},
{Key: "push.base_url", Label: "接口地址", Input: "text", Description: "默认 https://restapi.getui.com,私有化部署时替换"},
{Key: "push.show_preview", Label: "通知显示消息内容", Input: "boolean", Required: true, Description: "关闭后只提示「给你发来一条消息」,不在锁屏上暴露正文"},
},
"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"},
{Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Description: "live 模式退款必填,生产环境必须使用 HTTPS"},
{Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Description: "live 模式必填,以 Bearer Token 调用统一支付网关"},
{Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Description: "live 模式必填,至少 32 位随机字符串,用于 HMAC-SHA256 验签"},
{Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Description: "live 模式必填,例如 https://api.example.com/api/v1/payment/notify"},
{Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Description: "H5 支付完成后返回的客户端地址"},
{Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Description: "建议 5 至 30 秒"},
{Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Description: "控制客户端支付宝入口"},
{Key: "payment.alipay.app_id", Label: "支付宝 APPID", Input: "text", Description: "开放平台应用 APPID"},
{Key: "payment.alipay.private_key", Label: "支付宝应用私钥", Input: "secret", Description: "敏感字段加密存储"},
{Key: "payment.alipay.public_key", Label: "支付宝公钥", Input: "secret", Description: "用于验签"},
{Key: "payment.alipay.notify_url", Label: "支付宝通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"},
{Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Description: "控制客户端微信支付入口"},
{Key: "payment.wechat.app_id", Label: "微信 AppID", Input: "text", Description: "移动应用或小程序 AppID"},
{Key: "payment.wechat.mch_id", Label: "微信商户号", Input: "text", Description: "微信支付商户号"},
{Key: "payment.wechat.api_v3_key", Label: "APIv3 密钥", Input: "secret", Description: "敏感字段加密存储"},
{Key: "payment.wechat.private_key", Label: "商户私钥", Input: "secret", Description: "PEM 内容,敏感字段加密存储"},
{Key: "payment.wechat.serial_no", Label: "证书序列号", Input: "text", Description: "商户 API 证书序列号"},
{Key: "payment.wechat.notify_url", Label: "微信通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"},
},
}
func (a *App) configPlain(ctx context.Context, key, fallback string) string {
var value, valueType string
if err := a.db.QueryRowContext(ctx, `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, key).Scan(&value, &valueType); err != nil {
return fallback
}
if valueType == "secret" && value != "" {
plain, err := a.decryptSecret(value)
if err != nil {
return fallback
}
return plain
}
return value
}
func (a *App) configBool(ctx context.Context, key string, fallback bool) bool {
value := strings.ToLower(a.configPlain(ctx, key, strconv.FormatBool(fallback)))
return value == "1" || value == "true" || value == "yes" || value == "on"
}
func (a *App) encryptSecret(plain string) (string, error) {
if plain == "" {
return "", nil
}
key := sha256.Sum256([]byte(a.integrationEncryptionKey()))
block, err := aes.NewCipher(key[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plain), nil)
return "enc:v1:" + base64.RawStdEncoding.EncodeToString(sealed), nil
}
func (a *App) decryptSecret(value string) (string, error) {
if value == "" {
return "", nil
}
if !strings.HasPrefix(value, "enc:v1:") {
return value, nil
}
encoded := strings.TrimPrefix(value, "enc:v1:")
payload, err := base64.RawStdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
key := sha256.Sum256([]byte(a.integrationEncryptionKey()))
block, err := aes.NewCipher(key[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil || len(payload) < gcm.NonceSize() {
return "", fmt.Errorf("invalid encrypted secret")
}
returnValue, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil)
if err == nil {
return string(returnValue), nil
}
if a.config.ConfigEncryptionKey != "" {
legacyKey := sha256.Sum256([]byte(a.config.JWTSecret + ":integration-config"))
legacyBlock, legacyErr := aes.NewCipher(legacyKey[:])
if legacyErr == nil {
legacyGCM, legacyErr := cipher.NewGCM(legacyBlock)
if legacyErr == nil && len(payload) >= legacyGCM.NonceSize() {
returnValue, legacyErr = legacyGCM.Open(nil, payload[:legacyGCM.NonceSize()], payload[legacyGCM.NonceSize():], nil)
if legacyErr == nil {
return string(returnValue), nil
}
}
}
}
return "", err
}
func (a *App) integrationEncryptionKey() string {
if a.config.ConfigEncryptionKey != "" {
return a.config.ConfigEncryptionKey
}
// Development-only compatibility for databases created before the dedicated key existed.
return a.config.JWTSecret + ":integration-config"
}
func integrationGroup(r *http.Request) string {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
for index, part := range parts {
if part == "integrations" && index+1 < len(parts) {
return parts[index+1]
}
}
return ""
}
func (a *App) adminIntegration(w http.ResponseWriter, r *http.Request) {
group := integrationGroup(r)
specs, ok := integrationSpecs[group]
if !ok {
fail(w, http.StatusNotFound, 30001, "配置分组不存在")
return
}
fields := make([]integrationFieldView, 0, len(specs))
configured := true
activeProvider := ""
if group == "sms" {
activeProvider = a.configPlain(r.Context(), "sms.provider", "debug")
configured = a.configBool(r.Context(), "sms.enabled", false)
} else if group == "storage" {
activeProvider = a.configPlain(r.Context(), "storage.provider", "local")
} else if group == "oauth" {
configured = a.oauthConfigurationReady(r.Context())
}
for _, spec := range specs {
var value, valueType string
_ = a.db.QueryRowContext(r.Context(), `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, spec.Key).Scan(&value, &valueType)
secret := valueType == "secret" || spec.Input == "secret"
hasValue := value != ""
displayValue := value
if secret {
displayValue = ""
}
applies := len(spec.Providers) == 0 || containsString(spec.Providers, activeProvider)
if group == "oauth" {
applies = false // OAuth 必填项由各渠道的启用状态独立校验。
}
if applies && spec.Required && !hasValue {
configured = false
}
fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Description: spec.Description})
}
reply(w, map[string]any{"group": group, "configured": configured, "fields": fields, "secretMask": maskedSecret})
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func (a *App) adminUpdateIntegration(w http.ResponseWriter, r *http.Request) {
group := integrationGroup(r)
specs, ok := integrationSpecs[group]
if !ok {
fail(w, http.StatusNotFound, 30001, "配置分组不存在")
return
}
var req struct {
Values map[string]string `json:"values"`
ClearSecrets []string `json:"clearSecrets"`
}
if decode(r, &req) != nil || req.Values == nil {
fail(w, http.StatusBadRequest, 20001, "配置格式错误")
return
}
allowed := map[string]integrationFieldSpec{}
for _, spec := range specs {
allowed[spec.Key] = spec
}
for key, value := range req.Values {
spec, exists := allowed[key]
if !exists {
fail(w, http.StatusBadRequest, 20001, "配置项不允许修改")
return
}
switch spec.Input {
case "select":
if !containsString(spec.Options, value) {
fail(w, http.StatusBadRequest, 20001, spec.Label+"选项无效")
return
}
case "boolean":
if value != "true" && value != "false" {
fail(w, http.StatusBadRequest, 20001, spec.Label+"必须为 true 或 false")
return
}
case "number":
number, parseErr := strconv.Atoi(value)
if parseErr != nil {
fail(w, http.StatusBadRequest, 20001, spec.Label+"必须是数字")
return
}
if key == "sms.expire_seconds" && (number < 60 || number > 1800) {
fail(w, http.StatusBadRequest, 20001, "短信有效期必须在 60 到 1800 秒之间")
return
}
if key == "payment.gateway.timeout_seconds" && (number < 3 || number > 30) {
fail(w, http.StatusBadRequest, 20001, "支付网关超时必须在 3 到 30 秒之间")
return
}
}
}
clearSet := map[string]bool{}
for _, key := range req.ClearSecrets {
spec, exists := allowed[key]
if !exists || spec.Input != "secret" {
fail(w, http.StatusBadRequest, 20001, "清除的配置项不是允许的密钥字段")
return
}
clearSet[key] = true
}
if group == "oauth" {
if err := a.validateAdminOAuthConfigValues(r.Context(), req.Values, clearSet); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, 50001, "保存失败")
return
}
defer func() { _ = tx.Rollback() }()
changed := []string{}
for key, value := range req.Values {
spec, exists := allowed[key]
if !exists {
continue
}
isSecret := spec.Input == "secret"
if isSecret && value == "" && !clearSet[key] {
continue
}
if isSecret && !clearSet[key] {
value, err = a.encryptSecret(value)
if err != nil {
fail(w, 500, 50001, "加密敏感配置失败")
return
}
}
if clearSet[key] {
value = ""
}
if _, err = tx.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, value, key); err != nil {
fail(w, 500, 50001, "保存失败")
return
}
changed = append(changed, key)
}
if err = tx.Commit(); err != nil {
fail(w, 500, 50001, "保存失败")
return
}
a.audit(r, "update", group+"_integration", 0, map[string]any{"changedKeys": changed})
reply(w, map[string]any{"success": true, "changedKeys": changed})
}
func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) {
group := integrationGroup(r)
switch group {
case "sms":
if !a.configBool(r.Context(), "sms.enabled", false) {
fail(w, 400, 20001, "短信服务当前未启用")
return
}
provider := a.configPlain(r.Context(), "sms.provider", "debug")
if err := a.validateSMSProviderConfig(r.Context()); err != nil {
fail(w, 400, 20001, err.Error())
return
}
reply(w, map[string]any{"success": true, "message": cloudSMSProviderName(provider) + "短信配置校验通过", "provider": provider})
case "payment":
mode := a.configPlain(r.Context(), "payment.mode", "sandbox")
if a.config.Environment == "production" && mode != "live" {
fail(w, 400, 20001, "生产环境必须使用 live 支付模式")
return
}
if mode == "live" && !a.paymentGatewayConfigured(r.Context()) {
fail(w, 400, 20001, "统一支付网关配置不完整,请检查下单/退款/回调 HTTPS 地址、令牌和至少 32 位回调密钥")
return
}
channels := a.availablePaymentChannels(r.Context())
if len(channels) == 0 {
fail(w, 400, 20001, "至少启用一个支付渠道")
return
}
if mode == "live" {
for _, channel := range channels {
if channel["configured"] != true {
fail(w, 400, 20001, fmt.Sprintf("%s 的生产参数不完整", channel["name"]))
return
}
}
}
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 {
fail(w, 400, 20001, err.Error())
return
}
reply(w, map[string]any{"success": true, "message": storageProviderName(provider) + "配置校验通过", "provider": provider})
case "oauth":
adminProviders, err := a.enabledAdminOAuthProviders(r.Context())
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
userProviders, err := a.enabledUserOAuthProviders(r.Context())
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
appProviders, err := a.enabledAppOAuthProviders(r.Context())
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
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
}
items := make([]map[string]string, 0, len(adminProviders)+len(userProviders))
for _, provider := range adminProviders {
items = append(items, map[string]string{"audience": "admin", "code": provider.Code, "name": provider.Name})
}
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, "配置分组不存在")
}
}
func (a *App) dispatchSMS(ctx context.Context, phone, scene, code string) error {
provider := a.configPlain(ctx, "sms.provider", "debug")
if err := a.validateSMSProviderConfig(ctx); err != nil {
return err
}
switch provider {
case "debug":
return nil
case "aliyun":
_, err := a.sendAliyunSMS(ctx, phone, scene, code)
return err
case "tencent":
_, err := a.sendTencentSMS(ctx, phone, scene, code)
return err
case "huawei":
_, err := a.sendHuaweiSMS(ctx, phone, scene, code)
return err
case "webhook":
return a.sendWebhookSMS(ctx, phone, scene, code)
default:
return fmt.Errorf("不支持的短信提供商")
}
}
func (a *App) sendWebhookSMS(ctx context.Context, phone, scene, code string) error {
endpoint := a.configPlain(ctx, "sms.webhook_url", "")
if endpoint == "" {
return fmt.Errorf("短信 Webhook 未配置")
}
templateID, err := a.smsTemplateID(ctx, "webhook", scene)
if err != nil {
return err
}
payload, _ := json.Marshal(map[string]any{"phone": phone, "scene": scene, "code": code, "signName": a.configPlain(ctx, "sms.sign_name", "星遇社交"), "templateId": templateID})
request, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
if token := a.configPlain(ctx, "sms.webhook_token", ""); token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{Timeout: 8 * time.Second}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("短信网关连接失败: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("短信网关返回 HTTP %d", response.StatusCode)
}
return nil
}