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 单条失败即返回错误由调用方降级。 // applyDBValues 把库里某模块的配置行应用到对应的 Sys 配置段 sec(密钥解密、按 yaml 原值类型还原)。 // // **fail-closed**:密钥字段解密失败时跳过该字段,保留 sec 里的 yaml 默认值, // 绝不把密文当明文写进配置。历史事故:密文被沿用后当成微信商户私钥的文件路径去 open, // 触发库里的 log.Fatal → home 进程退出 → 5-in-1 容器整体 crash loop → 全站 502。 func applyDBValues(sec map[string]interface{}, rows []*AppModuleConfig, encKey string) { for _, r := range rows { val := r.Value if r.Encrypted && val != "" { plain, e := Decrypt(encKey, val) if e != nil { log.Errorf("[ModuleConfig] 密钥字段解密失败,已跳过该字段(保留默认值) module=%s key=%s err=%v —— 检查本服务 ${FIELD_ENCRYPT_KEY} 是否与 console 完全一致", r.Module, r.Key, e) continue } val = plain } sec[r.Key] = coerceVal(val, sec[r.Key]) } } 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 } applyDBValues(sec, existing, encKey) 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 }