You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

253 lines
11 KiB

package comm
import (
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
)
// 应用「业务功能配置」托管(登录 / 短信 / 邮件 / 支付…)。
//
// 与「基础设施/运行配置」([[app_service_config]],存 console 公共库、按微服务分区)不同:
// 这些是应用的**独立业务配置**(微信/Google 登录、短信、邮件、微信/支付宝/苹果/Google 支付),
// 存到**应用自己的业务库**(随部署,每个区域部署各一套),不进公共库。
//
// 数据模型:一行 = 某业务模块的一个字段(module + key → value)。
// 密钥字段(AppSecret、支付商户密钥/证书、SMTP 密码…)以 AES-256-GCM 密文落库(comm.Encrypt,
// 密钥 ${FIELD_ENCRYPT_KEY}),console 与业务服务必须配同一把 key;返回前端脱敏为 EncMask。
//
// 字段清单由 ModuleCatalog 数据驱动(哪些字段、类型、是否密钥、UI 分组),
// console 后台据此渲染表单、判定加密;业务服务据 SysKey 把库值覆盖回 GetSettings().Sys[key](后置)。
// AppModuleConfig 一行 = 应用业务库里某业务模块的一个配置项。存应用业务库(mysql/pg,随部署)。
type AppModuleConfig struct {
Id uint64 `gorm:"primaryKey;autoIncrement;column:id" json:"id"`
Module string `gorm:"column:module;size:32;uniqueIndex:uidx_modcfg" json:"module"` // 模块键 wechat_login/sms/wechatpay…
Key string `gorm:"column:key;size:64;uniqueIndex:uidx_modcfg" json:"key"` // 字段键 AppID/AppSecret…
Value string `gorm:"column:value;type:text" json:"value"` // 值(密钥字段存 AES-GCM 密文)
Encrypted bool `gorm:"column:encrypted" json:"encrypted"` // 是否密钥字段(前端脱敏、落库加密)
Createtime int64 `gorm:"column:createtime" json:"createtime"`
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"`
}
func (AppModuleConfig) TableName() string { return TableAppModuleConfig }
// ModuleFieldOption select 类型字段的一个选项。
type ModuleFieldOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ModuleField 一个模块的一个配置字段的元数据(驱动前端表单 + 后端密钥判定)。
type ModuleField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // text|password|textarea|bool|select
Secret bool `json:"secret"`
Required bool `json:"required"`
Placeholder string `json:"placeholder,omitempty"`
Desc string `json:"desc,omitempty"`
Options []ModuleFieldOption `json:"options,omitempty"`
}
// ModuleDef 一个业务模块(如"微信登录")的定义。
type ModuleDef struct {
Module string `json:"module"` // 存库键
Name string `json:"name"` // 展示名
Group string `json:"group"` // UI 分组:登录 / 通知 / 支付
SysKey string `json:"sys_key"` // 业务服务 GetSettings().Sys[key](业务消费用)
Desc string `json:"desc,omitempty"`
Fields []ModuleField `json:"fields"`
}
// 分组名。
const (
ModuleGroupAuth = "登录"
ModuleGroupNotify = "通知"
ModuleGroupPay = "支付"
)
// ModuleCatalog 业务功能配置字段目录(字段依据现有 sys/* 各 options.go 的结构体,见调研)。
var ModuleCatalog = []ModuleDef{
{
Module: "wechat_login", Name: "微信登录", Group: ModuleGroupAuth, SysKey: "wechat_auth",
Desc: "微信开放平台 App/公众号登录",
Fields: []ModuleField{
{Key: "AppID", Label: "AppID", Type: "text", Required: true},
{Key: "AppSecret", Label: "AppSecret", Type: "password", Secret: true, Required: true},
},
},
{
Module: "google_login", Name: "Google 登录", Group: ModuleGroupAuth, SysKey: "google_auth",
Desc: "Firebase Admin SDK 校验 Google/Firebase 身份令牌",
Fields: []ModuleField{
{Key: "ApiKeyFile", Label: "Firebase 服务账号密钥", Type: "textarea", Secret: true, Required: true,
Desc: "直接粘贴服务账号 JSON 内容(推荐,配置全在库);或填服务器上的文件路径"},
},
},
{
Module: "sms", Name: "短信服务", Group: ModuleGroupNotify, SysKey: "sms",
Desc: "腾讯云短信",
Fields: []ModuleField{
{Key: "AppId", Label: "短信 SDK AppID", Type: "text", Required: true},
{Key: "SecretId", Label: "SecretId", Type: "text", Required: true},
{Key: "SecretKey", Label: "SecretKey", Type: "password", Secret: true, Required: true},
{Key: "SignName", Label: "短信签名", Type: "text", Required: true},
{Key: "Template1", Label: "国内模板 ID", Type: "text"},
{Key: "Template2", Label: "国际模板 ID", Type: "text"},
},
},
{
Module: "email", Name: "邮件服务", Group: ModuleGroupNotify, SysKey: "email",
Desc: "验证码等事务邮件发信;海外用户建议选 Resend(走 HTTPS API,送达率优于国内 SMTP)",
Fields: []ModuleField{
{Key: "EmailType", Label: "邮箱类型", Type: "select", Required: true, Options: []ModuleFieldOption{
{Value: "0", Label: "QQ 邮箱"}, {Value: "1", Label: "腾讯企业邮箱"},
{Value: "2", Label: "Gmail"}, {Value: "3", Label: "Resend(HTTP API,海外推荐)"},
}},
{Key: "FromEmail", Label: "发件人邮箱", Type: "text", Required: true,
Desc: "Resend 须为已在其后台验证域名下的地址(如 noreply@yourdomain.com)"},
{Key: "FromName", Label: "发件人昵称", Type: "text"},
{Key: "Password", Label: "SMTP 授权码 / Resend API Key", Type: "password", Secret: true, Required: true,
Desc: "SMTP 类型填邮箱授权码;Resend 填 re_ 开头的 API Key"},
},
},
{
Module: "wechatpay", Name: "微信支付", Group: ModuleGroupPay, SysKey: "wechatpay",
Fields: []ModuleField{
{Key: "AppID", Label: "AppID", Type: "text", Required: true},
{Key: "MchID", Label: "商户号", Type: "text", Required: true},
{Key: "ApiV3Key", Label: "APIv3 密钥", Type: "password", Secret: true, Required: true},
{Key: "PrivateKeyPath", Label: "商户私钥", Type: "textarea", Secret: true, Required: true,
Desc: "直接粘贴商户私钥 PEM 内容(推荐,配置全在库);或填 .pem 文件路径"},
{Key: "CertSerialNo", Label: "证书序列号", Type: "text", Required: true},
},
},
{
Module: "alipay", Name: "支付宝", Group: ModuleGroupPay, SysKey: "alipay",
Fields: []ModuleField{
{Key: "AppID", Label: "应用 AppID", Type: "text", Required: true},
{Key: "PrivateKey", Label: "应用私钥", Type: "textarea", Secret: true, Required: true, Desc: "PKCS8 格式"},
{Key: "PublicKey", Label: "支付宝公钥", Type: "textarea", Required: true},
},
},
{
Module: "appleiap", Name: "苹果支付", Group: ModuleGroupPay, SysKey: "appleiap",
Desc: "App Store 内购/订阅校验",
Fields: []ModuleField{
{Key: "AppStoreBundleID", Label: "Bundle ID", Type: "text", Required: true},
{Key: "SharedSecret", Label: "共享密钥", Type: "password", Secret: true, Desc: "legacy verifyReceipt 用"},
{Key: "AppStoreIssuerID", Label: "Issuer ID", Type: "text"},
{Key: "AppStoreKeyID", Label: "Key ID", Type: "text"},
{Key: "AppStorePrivateKeyPEM", Label: "App Store API 密钥(.p8)", Type: "textarea", Secret: true,
Desc: "ECDSA P-256 私钥 PEM 内容"},
{Key: "UseSandbox", Label: "强制沙盒环境", Type: "bool"},
},
},
{
Module: "googleiap", Name: "Google 支付", Group: ModuleGroupPay, SysKey: "googleiap",
Desc: "Google Play 内购/订阅校验",
Fields: []ModuleField{
{Key: "ServiceAccountJSON", Label: "服务账号 JSON", Type: "textarea", Secret: true, Required: true,
Desc: "Google 服务账号 JSON 内容"},
{Key: "ServiceAccountJSONPath", Label: "服务账号 JSON 路径", Type: "text",
Desc: "可选:本地文件路径(内容优先)"},
},
},
}
// FindModuleDef 按 module 键取模块定义(找不到返回 nil)。
func FindModuleDef(module string) *ModuleDef {
for i := range ModuleCatalog {
if ModuleCatalog[i].Module == module {
return &ModuleCatalog[i]
}
}
return nil
}
// ModuleFieldIsSecret 判断某模块某字段是否为密钥字段(落库加密、前端脱敏)。
// 以目录定义为准;目录里没有的键回退到通用命名规则 IsSecretField,防止漏加密。
func ModuleFieldIsSecret(module, key string) bool {
if def := FindModuleDef(module); def != nil {
for _, f := range def.Fields {
if f.Key == key {
return f.Secret
}
}
}
return IsSecretField(key)
}
// LoadOrSeedModuleConfig 「库优先,无则读 yaml 回写」——业务服务(home)启动时用。
//
// db 已连上的**应用业务库**(mysql/pg,本服务自己的库;整表即属本应用,无需 app_name/region 过滤)。
// encKey ${FIELD_ENCRYPT_KEY},须与 console 一致;密钥字段解密/加密用。
// sys GetSettings().Sys(map 引用);对每个模块,库有则把库值覆盖回 Sys[SysKey] 再供随后 OnInit 用。
//
// 按模块粒度处理:某模块在库里有行 → 覆盖 Sys(改配置需重启才生效);库里无该模块 → 把当前 yaml 值 seed 进库
// (source 语义由存在与否表达;密钥字段加密落库),Sys 保持 yaml 原值不动。best-effort:seed 单条失败即返回错误由调用方降级。
func LoadOrSeedModuleConfig(db mysql.ISys, encKey string, sys map[string]map[string]interface{}) error {
if db == nil {
return nil
}
if err := db.CreateTable(TableAppModuleConfig, &AppModuleConfig{}); err != nil {
return err
}
rows := make([]*AppModuleConfig, 0)
if err := db.Find(TableAppModuleConfig, &rows, ""); err != nil {
return err
}
byModule := make(map[string][]*AppModuleConfig)
for _, r := range rows {
byModule[r.Module] = append(byModule[r.Module], r)
}
for i := range ModuleCatalog {
def := &ModuleCatalog[i]
if existing := byModule[def.Module]; len(existing) > 0 {
// 库有:覆盖 Sys[SysKey](密钥解密、按 yaml 原值类型还原)。
sec := sys[def.SysKey]
if sec == nil {
sec = map[string]interface{}{}
sys[def.SysKey] = sec
}
for _, r := range existing {
val := r.Value
if r.Encrypted && val != "" {
if plain, e := Decrypt(encKey, val); e == nil {
val = plain
} else {
// 仍沿用密文(保持既有行为),但必须留痕:否则密文会被当明文送去第三方
// (如把密文当 Resend API Key 发出 → 401 API key is invalid),
// 而 console 后台密钥字段脱敏显示"已配置",几乎无从排查。
log.Errorf("[ModuleConfig] 密钥字段解密失败 module=%s key=%s err=%v —— 检查本服务 ${FIELD_ENCRYPT_KEY} 是否与 console 完全一致", r.Module, r.Key, e)
}
}
sec[r.Key] = coerceVal(val, sec[r.Key])
}
continue
}
// 库无该模块:把当前 yaml 值 seed 进库(仅 seed yaml 里确有的字段;yaml 无此段则跳过)。
sec := sys[def.SysKey]
if sec == nil {
continue
}
for _, f := range def.Fields {
v, ok := sec[f.Key]
if !ok {
continue
}
val := stringifyVal(v)
if f.Secret && val != "" {
if ct, e := Encrypt(encKey, val); e == nil {
val = ct
}
}
row := &AppModuleConfig{Module: def.Module, Key: f.Key, Value: val, Encrypted: f.Secret}
if e := db.Insert(TableAppModuleConfig, row); e != nil {
return e
}
}
}
return nil
}