package comm import ( "encoding/json" "fmt" "reflect" "strconv" "strings" "yunyan/lego/sys/log" "yunyan/lego/sys/postgres" ) // 应用×服务的「基础设施/运行配置」托管。 // // 目标:业务服务启动时—— // 1. 先用本地 yaml/env 连上 console 共享库(postgres); // 2. 若库中没有本(应用,区域,服务)的配置,则把当前文件里的基础设施配置(DSN/Redis/NATS/COS…) seed 进库(source=file), // 这样 console「应用的服务配置」页立刻能看到默认值; // 3. 若库中已有,则以库为准,覆盖内存里的 Sys 配置(后台改过的值生效),再继续初始化 mysql/redis/cos 等。 // // 注意:postgres 自身是 bootstrap(要先连它才能读配置),故它不纳入托管;其覆盖也不会在本次启动生效。 // 密钥字段(DSN/密码/secret/token)以 AES-256-GCM 密文落库(comm.Encrypt,密钥 ${FIELD_ENCRYPT_KEY}), // console 与业务服务必须配同一把 key。 // svcRuntimeSections 纳入托管的 Sys 子系统(基础设施/运行配置)。postgres/log/lgid 等 bootstrap 不含。 var svcRuntimeSections = []string{"mysql", "redis", "nats", "cos", "email", "translate"} // AppServiceConfig 一行 = 某应用×区域×服务 的一个配置项(扁平化点号无关,直接存 section+key)。 // 存 console 共享库(postgres.GetSys()),与 global_config 同库;console 前端读写、业务服务启动 seed/读取。 type AppServiceConfig struct { Id uint64 `gorm:"primaryKey;autoIncrement;column:id" json:"id"` AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_svcconf" json:"app_name"` // 应用名(app_registry.app_name) Region string `gorm:"column:region;size:32;uniqueIndex:uidx_svcconf" json:"region"` // 区域(app_registry.region),可空 Service string `gorm:"column:service;size:32;uniqueIndex:uidx_svcconf" json:"service"` // 服务类型 api/gateway/home… Section string `gorm:"column:section;size:32;uniqueIndex:uidx_svcconf" json:"section"` // 配置分区 mysql/redis/nats/cos… Key string `gorm:"column:key;size:64;uniqueIndex:uidx_svcconf" json:"key"` // 配置键 Dsn/Addr/Password… Value string `gorm:"column:value;type:text" json:"value"` // 值(密钥字段存 AES-GCM 密文;非标量存 JSON) Encrypted bool `gorm:"column:encrypted" json:"encrypted"` // 是否密钥字段(前端脱敏、落库加密) Source string `gorm:"column:source;size:16" json:"source"` // file=启动 seed 自文件 / db=后台改过 Description string `gorm:"column:description;size:255" json:"description"` Sort int32 `gorm:"column:sort" json:"sort"` Createtime int64 `gorm:"column:createtime" json:"createtime"` Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` } func (AppServiceConfig) TableName() string { return TableAppServiceConfig } // IsSecretField 判断某配置键是否为需加密/脱敏的密钥字段(DSN 含库密码,一并算密钥)。 func IsSecretField(key string) bool { k := strings.ToLower(key) return strings.Contains(k, "password") || strings.Contains(k, "pwd") || strings.Contains(k, "secret") || strings.Contains(k, "token") || strings.Contains(k, "privatekey") || k == "dsn" } // stringifyVal 把任意配置值转为可落库的字符串:标量用 fmt,容器(list/map)用 JSON。 func stringifyVal(v interface{}) string { switch v.(type) { case string: return v.(string) case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: return fmt.Sprint(v) default: if b, err := json.Marshal(v); err == nil { return string(b) } return fmt.Sprint(v) } } // coerceVal 把库里的字符串按内存中原值 orig 的类型还原,供覆盖 Sys map 用。orig 为 nil 时按字符串处理。 func coerceVal(s string, orig interface{}) interface{} { if orig == nil { // 库里有、文件里没有的键:尝试当 JSON,否则原样字符串。 var any interface{} if json.Unmarshal([]byte(s), &any) == nil { switch any.(type) { case []interface{}, map[string]interface{}: return any } } return s } switch orig.(type) { case string: return s case bool: if b, err := strconv.ParseBool(s); err == nil { return b } case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: if n, err := strconv.ParseInt(s, 10, 64); err == nil { return int(n) } case float32, float64: if f, err := strconv.ParseFloat(s, 64); err == nil { return f } default: // list/map:反序列化回同结构(失败则保留原值)。 nv := reflect.New(reflect.TypeOf(orig)).Interface() if json.Unmarshal([]byte(s), nv) == nil { return reflect.ValueOf(nv).Elem().Interface() } } return orig } // LoadOrSeedServiceConfig 「库优先,无则读文件回写」。返回 seeded=true 表示本次是首次 seed。 // // encKey ${FIELD_ENCRYPT_KEY},与 console 一致;用于密钥字段加解密。 // app 应用名(空则直接跳过,返回 false,nil——未接入托管的服务不产生孤儿数据)。 // region 区域(可空)。service 服务类型(如 "api")。 // sys GetSettings().Sys(map 引用),有覆盖时就地改写其内容,供随后 OnInit 使用。 func LoadOrSeedServiceConfig(encKey, app, region, service string, sys map[string]map[string]interface{}) (seeded bool, err error) { if strings.TrimSpace(app) == "" { return false, nil } if err = postgres.CreateTable(TableAppServiceConfig, &AppServiceConfig{}); err != nil { return false, err } rows := make([]*AppServiceConfig, 0) if err = postgres.Find(TableAppServiceConfig, &rows, "app_name=? AND region=? AND service=?", app, region, service); err != nil { return false, err } // 库已有:以库为准,覆盖内存 Sys(postgres 分区跳过——它是 bootstrap,本次已连)。 if len(rows) > 0 { for _, r := range rows { if r.Section == "postgres" { continue } sec := sys[r.Section] if sec == nil { sec = map[string]interface{}{} sys[r.Section] = sec } val := r.Value if r.Encrypted { if plain, e := Decrypt(encKey, r.Value); e == nil { val = plain } else { // 仍沿用密文(保持既有行为),但必须留痕:否则密文会被当明文用作 DSN/密码/token, // 表现为连不上或鉴权失败,而后台显示"已配置",无从排查。 log.Errorf("[SvcRuntime] 密钥字段解密失败 section=%s key=%s err=%v 本服务key指纹=%s %s", r.Section, r.Key, e, KeyFingerprint(encKey), DiagnoseDecryptFailure(encKey, r.Value)) } } sec[r.Key] = coerceVal(val, sec[r.Key]) } return false, nil } // 库为空:把当前文件里的基础设施配置 seed 进库(source=file)。 for _, section := range svcRuntimeSections { kv := sys[section] if kv == nil { continue } var sort int32 for k, v := range kv { enc := IsSecretField(k) val := stringifyVal(v) if enc && val != "" { if ct, e := Encrypt(encKey, val); e == nil { val = ct } } row := &AppServiceConfig{ AppName: app, Region: region, Service: service, Section: section, Key: k, Value: val, Encrypted: enc, Source: "file", Sort: sort, } if e := postgres.Insert(TableAppServiceConfig, row); e != nil { return false, e } sort++ } } return true, nil }