package comm import ( "errors" "fmt" "sync" "yunyan/lego/sys/log" "yunyan/lego/sys/mysql" ) // 业务功能配置的「热重载」——console 后台改完配置广播事件,业务服务据此重读业务库、 // 热替换对应的 sys 客户端,免去重启。 // // 能不能热更取决于该 sys 持有什么: // - 凭据类客户端(email/sms/…):只是拿着 key/密码的 HTTP/SMTP 客户端,可整体替换 → applied // - 未注册热重载函数的模块:如实回 restart_required,绝不谎报成功 // - 重载失败:保留旧客户端与旧配置继续服务 → failed // // 与 LoadOrSeedModuleConfig 的关键差别:本函数**只读覆盖、绝不 seed**—— // 运行时不该因为某模块库里没配就把 yaml 默认值写进库。 var ( reloaderMu sync.RWMutex // reloaders SysKey → 热重载函数。由业务服务启动时 RegisterModuleReloader 注册。 reloaders = map[string]func(map[string]interface{}) error{} ) // RegisterModuleReloader 注册某 SysKey(见 ModuleDef.SysKey) 的热重载函数。 // 约定:实现必须「校验失败返回 error 且保留旧客户端」,绝不 panic—— // 后台一次误保存不该打挂线上进程。 func RegisterModuleReloader(sysKey string, fn func(map[string]interface{}) error) { reloaderMu.Lock() reloaders[sysKey] = fn reloaderMu.Unlock() } func lookupReloader(sysKey string) (func(map[string]interface{}) error, bool) { reloaderMu.RLock() defer reloaderMu.RUnlock() fn, ok := reloaders[sysKey] return fn, ok } // HasModuleReloaders 本服务是否持有任何可热重载的业务凭据客户端。 // gateway/timer/mcp 一个都不注册——它们收到下发时如实回「本服务不承载业务配置」,而非报错。 func HasModuleReloaders() bool { reloaderMu.RLock() defer reloaderMu.RUnlock() return len(reloaders) > 0 } // ───────── 集群内下发的 rpc 载荷(rpcx 用 MsgPack 序列化,普通 struct 即可,无需 pb) ───────── // ReloadModuleConfigReq api 服务 → 各业务服务:请重载业务功能配置。 type ReloadModuleConfigReq struct { Reason string // 触发原因(save / reset),仅用于日志 } // ResetModuleConfigReq api 服务 → 配置属主(home):把某模块还原为你 yaml 里的初始值。 type ResetModuleConfigReq struct { Module string } // ModuleConfigResp 单个服务对下发的应答。Err 非空表示该服务处理失败—— // 用字段而非 rpc error 承载,好让 api 汇总时能区分「某个服务失败」与「整体调用失败」。 type ModuleConfigResp struct { Service string // 服务名(home/api…) Instance string // <服务名>@<主机名> Skipped bool // 本服务不承载业务配置,什么也没做 Results []ModuleApplyResult // 逐模块三态 Err string } // reloadContext 执行一次重载所需的全部依赖,由业务服务启动时登记。 // 有了它,HTTP 重载接口(处在某个业务模块里)不必再层层拿到服务的 Settings 与库句柄。 type reloadContext struct { db mysql.ISys encKey string sys map[string]map[string]interface{} } var ( reloadCtxMu sync.RWMutex reloadCtx *reloadContext ) // SetModuleReloadContext 登记重载上下文(业务服务启动、各 sys OnInit 之后调用)。 func SetModuleReloadContext(db mysql.ISys, encKey string, sys map[string]map[string]interface{}) { reloadCtxMu.Lock() reloadCtx = &reloadContext{db: db, encKey: encKey, sys: sys} reloadCtxMu.Unlock() } // DoReloadModuleConfig 用已登记的上下文执行一次业务配置热重载。 // 未登记(本服务不承载业务配置)时返回 error,由调用方如实上报,绝不假装成功。 func DoReloadModuleConfig() ([]ModuleApplyResult, error) { reloadCtxMu.RLock() ctx := reloadCtx reloadCtxMu.RUnlock() if ctx == nil { return nil, errors.New("本服务未登记业务配置重载上下文") } return ReloadModuleConfig(ctx.db, ctx.encKey, ctx.sys), nil } // ───────────────────────── 重置为「配置文件初始值」 ───────────────────────── // // 业务库里的配置被改脏、又没有备份时的兜底:把某模块整体还原成本服务 confs/*.yaml 里的初始值。 // // 数据来源只能是「启动时、库值覆盖之前」抓的 yaml 快照——一旦 LoadOrSeedModuleConfig 跑完, // 内存里的 Sys 就已经是库值了,那时再读就是把脏数据又写回去。 var ( fileCfgMu sync.RWMutex fileCfg map[string]map[string]interface{} // SysKey → yaml 原值副本 ) // snapshotFileModuleConfig 深拷贝各模块的 yaml 原值。由 LoadOrSeedModuleConfig 在覆盖 Sys 之前调用。 func snapshotFileModuleConfig(sys map[string]map[string]interface{}) { snap := make(map[string]map[string]interface{}, len(ModuleCatalog)) for i := range ModuleCatalog { sec := sys[ModuleCatalog[i].SysKey] if sec == nil { continue } cp := make(map[string]interface{}, len(sec)) for k, v := range sec { cp[k] = v } snap[ModuleCatalog[i].SysKey] = cp } fileCfgMu.Lock() fileCfg = snap fileCfgMu.Unlock() } func fileSnapshot(sysKey string) map[string]interface{} { fileCfgMu.RLock() defer fileCfgMu.RUnlock() return fileCfg[sysKey] } // DoResetModuleConfigToFile 把某模块在业务库里的配置整体还原为本服务配置文件(yaml)的初始值。 // yaml 里没有该模块(或本服务未登记上下文)时**不做任何改动**并返回 error—— // 否则会把该模块清空后无值可填,比"脏数据"更糟。 func DoResetModuleConfigToFile(module string) ([]ModuleApplyResult, error) { ctx := currentReloadCtx() if ctx == nil { return nil, errors.New("本服务未登记业务配置重载上下文") } def := FindModuleDef(module) if def == nil { return nil, fmt.Errorf("未知模块: %s", module) } if len(fileSnapshot(def.SysKey)) == 0 { return nil, fmt.Errorf("本服务的配置文件里没有 %s(%s) 的配置,无法还原", def.Name, def.Module) } res, err := resetOneModuleToFile(ctx, def) if err != nil { return nil, err } return []ModuleApplyResult{res}, nil } // DoResetAllModuleConfigToFile 把业务库里**所有**模块的配置整体还原为配置文件(yaml)初始值—— // 用于升级/迁移把库写脏后的整体恢复。只处理 yaml 里确有配置的模块(快照非空); // yaml 未定义的模块保持不动(避免清空后无值可填)。 // // 单个模块失败不中断其它模块:把失败作为该模块的 result 收集,尽最大努力恢复整库。 // ⚠️ 破坏性:会覆盖后台手工保存过的值(如把 email 打回 yaml 的默认发信配置),调用方须二次确认。 func DoResetAllModuleConfigToFile() ([]ModuleApplyResult, error) { ctx := currentReloadCtx() if ctx == nil { return nil, errors.New("本服务未登记业务配置重载上下文") } results := make([]ModuleApplyResult, 0, len(ModuleCatalog)) for i := range ModuleCatalog { def := &ModuleCatalog[i] if len(fileSnapshot(def.SysKey)) == 0 { continue // yaml 没定义这个模块 → 不动它 } res, err := resetOneModuleToFile(ctx, def) if err != nil { log.Errorf("[ModuleReset] 全量重置中 %s 失败: %v", def.Module, err) res = ModuleApplyResult{Module: def.Module, Status: ApplyStatusFailed, Err: err.Error()} } results = append(results, res) } if len(results) == 0 { return nil, errors.New("配置文件里没有任何业务模块配置,无法还原") } log.Infof("[ModuleReset] 已全量还原为配置文件初始值 modules=%d", len(results)) return results, nil } func currentReloadCtx() *reloadContext { reloadCtxMu.RLock() defer reloadCtxMu.RUnlock() return reloadCtx } // resetOneModuleToFile 删该模块全部行 → 按 yaml 快照重写(密钥加密、带时间戳) → 覆盖 Sys → 触发热重载。 // 调用方已保证 fileSnapshot(def.SysKey) 非空。 func resetOneModuleToFile(ctx *reloadContext, def *ModuleDef) (ModuleApplyResult, error) { snap := fileSnapshot(def.SysKey) if err := ctx.db.Delete(TableAppModuleConfig, "module=?", def.Module); err != nil { return ModuleApplyResult{}, fmt.Errorf("清除 %s 旧配置失败: %w", def.Module, err) } if err := seedModuleRows(ctx.db, ctx.encKey, def, snap); err != nil { return ModuleApplyResult{}, fmt.Errorf("写入 %s 配置文件初始值失败(该模块配置现为空,请重试): %w", def.Module, err) } // Sys 与库保持一致,再让客户端切到初始值。 next := make(map[string]interface{}, len(snap)) for k, v := range snap { next[k] = v } ctx.sys[def.SysKey] = next res := ModuleApplyResult{Module: def.Module, Status: ApplyStatusRestartRequired} if fn, ok := lookupReloader(def.SysKey); ok { if err := fn(next); err != nil { log.Errorf("[ModuleReset] 还原后热重载失败 module=%s err=%v", def.Module, err) res = ModuleApplyResult{Module: def.Module, Status: ApplyStatusFailed, Err: err.Error()} } else { res = ModuleApplyResult{Module: def.Module, Status: ApplyStatusApplied} } } log.Infof("[ModuleReset] 已还原为配置文件初始值 module=%s status=%s", def.Module, res.Status) return res, nil } // ReloadModuleConfig 重读业务库 app_module_config,对每个「库里有配置」的模块尝试热重载。 // // db 应用业务库(与 LoadOrSeedModuleConfig 同一个) // encKey ${FIELD_ENCRYPT_KEY},须与 console 一致 // sys GetSettings().Sys(map 引用);仅在该模块重载成功/待重启时才提交新值 // // 返回每个模块的结果,供上层组装 ConfigApplyAck 回给 console。 // 单个模块失败不影响其它模块,也不会改动它自己原有的客户端。 func ReloadModuleConfig(db mysql.ISys, encKey string, sys map[string]map[string]interface{}) []ModuleApplyResult { results := make([]ModuleApplyResult, 0, len(ModuleCatalog)) if db == nil { return append(results, ModuleApplyResult{Module: "*", Status: ApplyStatusFailed, Err: "业务库未连接"}) } rows := make([]*AppModuleConfig, 0) if err := db.Find(TableAppModuleConfig, &rows, ""); err != nil { return append(results, ModuleApplyResult{Module: "*", Status: ApplyStatusFailed, Err: "读取业务配置失败: " + err.Error()}) } byModule := make(map[string][]*AppModuleConfig) for _, r := range rows { byModule[r.Module] = append(byModule[r.Module], r) } for i := range ModuleCatalog { def := &ModuleCatalog[i] existing := byModule[def.Module] if len(existing) == 0 { continue // 库里没这个模块的配置 → 不动它(也不 seed) } // 先在副本上组装新配置:重载失败时 sys 保持原样,避免留下一份「生效不了的」脏配置。 cur := sys[def.SysKey] next := make(map[string]interface{}, len(cur)+len(existing)) for k, v := range cur { next[k] = v } // **fail-closed,且以模块为原子**:只要本模块有任一密钥字段解不开,就整模块放弃重载。 // 半新半旧的凭据组合比原地不动更危险;而把解不开的密文当明文塞进 next,等于让 // email.Reload 拿密文当 SMTP 密码、让 wechatpay 拿密文当私钥路径去 open—— // 后者正是 home 进程被 log.Fatal 杀死、5-in-1 容器 crash loop、全站 502 的那条路。 decryptFailed := 0 for _, r := range existing { val := r.Value if r.Encrypted && val != "" { plain, e := Decrypt(encKey, val) if e != nil { decryptFailed++ log.Errorf("[ModuleReload] 密钥字段解密失败 module=%s key=%s err=%v 本服务key指纹=%s %s", r.Module, r.Key, e, KeyFingerprint(encKey), DiagnoseDecryptFailure(encKey, r.Value)) continue } val = plain } next[r.Key] = coerceVal(val, cur[r.Key]) } if decryptFailed > 0 { results = append(results, ModuleApplyResult{Module: def.Module, Status: ApplyStatusFailed, Err: fmt.Sprintf("%d 个密钥字段解密失败,已跳过本模块重载(继续使用原有配置)——见本服务日志里的 key 指纹与诊断", decryptFailed)}) continue } fn, ok := lookupReloader(def.SysKey) if !ok { sys[def.SysKey] = next // 下次重启即生效 results = append(results, ModuleApplyResult{Module: def.Module, Status: ApplyStatusRestartRequired}) continue } if err := fn(next); err != nil { log.Errorf("[ModuleReload] 模块热重载失败 module=%s err=%v(已保留旧配置继续服务)", def.Module, err) results = append(results, ModuleApplyResult{Module: def.Module, Status: ApplyStatusFailed, Err: err.Error()}) continue } sys[def.SysKey] = next log.Infof("[ModuleReload] 模块热重载成功 module=%s", def.Module) results = append(results, ModuleApplyResult{Module: def.Module, Status: ApplyStatusApplied}) } return results }