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 }