一、密码规则:至少 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>
651 lines
42 KiB
Go
651 lines
42 KiB
Go
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/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: "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 用户 AK,AES-GCM 加密保存"},
|
||
{Key: "storage.huawei_obs.secret_key", Label: "华为云 OBS Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 SK,AES-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_Secret,AES-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
|
||
}
|