package console import ( "fmt" "sort" "strings" "time" "yunyan/comm" "yunyan/lego/sys/log" "yunyan/lego/sys/mysql" "yunyan/lego/sys/postgres" "yunyan/pb" ) // ============================ 凭据归一:两张扁平 env 表 → svc_config ============================ // // 2026-09-12 之前,第三方凭据同时躺在两张扁平 key/value 表里,客户端从 user_getappconfig 的 // env[KEY] 读: // // config 应用业务库,一应用一份,不分区域 ← 后台「应用环境配置」 // global_config console 主库,按区域各存一份 ← 后台「全局环境配置」,**覆盖上面那张** // // 这套结构有三个实打实的坑: // 1. 同一个 AZURE_SPEECH_REGION / OPENAI_API_KEY / VOLC_OPENSPEECH_APP_ID / XUNFEI 密钥在两张表 // 里值**不一样**,全局那张赢。运营在「应用环境配置」里改半天没有任何效果,且不报错。 // 2. global_config 按区域整份复制(14 个区域 × 31 个键 = 473 行),而其中只有 4 组键真的因区域 // 而异——剩下的全是同值复制,改一次要改 14 处。 // 3. 凭据明文落库、无加密、无类别、无巡检,跟 svc_config 那套(加密字段 + 区域覆盖 + 服务巡检) // 完全是两个世界。 // // 本迁移把凭据收进 svc_config,一个服务一行: // - 基础值(DefValue) 取**中国区**的现值(没有就退回 app_config 的值); // - 与基础值不同的区域,写一条 svc_region_override;同值的区域不写,473 行因此收敛到几十行; // - 字段带 EnvKey,下发时回填一份 env(见 modules/user 的 appEnvForUser)——**存量客户端零改动**。 // // 幂等:只新建不存在的服务、只填空着的字段值;跑过一遍后即空转,运营后来改的值不会被刷回去。 // // ⚠️ **不删 global_config 的行**。这是不可逆的线上凭据,留着是出问题时唯一的回退依据 // (与 migrateLicenseToDeviceMac 同一套取舍)。代码已不再读它,确认新链路跑稳后由人手工 // `DROP TABLE global_config;`。业务库 config 表里那些**已经被迁走**的键则会删除—— // 它们在迁移前就已经被 global_config 盖住、一直是死值,留着只会继续误导运营。 // envFieldSpec 一个 env 键在 svc_config 里的落点。 type envFieldSpec struct { EnvKey string // 老的下发键名,迁移后由 SvcField.EnvKey 继续下发 FieldKey string // 在服务里的字段名 Desc string Encrypted bool } // envSvcSpec 一个服务的落点定义。Id 已存在时只补字段、不改服务本身(名称/类别/启用状态都不动)。 type envSvcSpec struct { Id string Name string Provider string Categories string // 逗号分隔的 comm.SvcCatXxx Desc string Fields []envFieldSpec } // envToSvcPlan 迁移落点表。 // // ⚠️ 改这张表前先想清楚 EnvKey:它是**已发布客户端**读的键名,改一个字母老包就拿不到凭据, // 而且只表现为「某个能力鉴权失败」,不报配置错误。客户端读取点见 // apps/client/lib/data/services/**(搜 AppConfig.env)。 var envToSvcPlan = []envSvcSpec{ { // 已存在(默认停用、字段名就是这几个),本迁移只给它的字段补 env_key 与值。 Id: "stt_azure", Name: "微软 Azure 语音", Provider: "azure", Categories: "1", Desc: "语音识别/合成(客户端直连)", Fields: []envFieldSpec{ {EnvKey: "AZURE_SPEECH_KEY", FieldKey: "subscription_key", Desc: "订阅密钥", Encrypted: true}, {EnvKey: "AZURE_SPEECH_REGION", FieldKey: "region", Desc: "服务区域,如 eastasia"}, }, }, { Id: "mt_azure", Name: "微软 Azure 翻译", Provider: "azure", Categories: "3", Desc: "文本翻译(客户端直连,面对面/同传的翻译段)", Fields: []envFieldSpec{ {EnvKey: "AZURE_TRANSLATION_KEY", FieldKey: "subscription_key", Desc: "订阅密钥", Encrypted: true}, {EnvKey: "AZURE_TRANSLATION_REGION", FieldKey: "region", Desc: "服务区域"}, {EnvKey: "AZURE_TRANSLATION_ENDPOINT", FieldKey: "endpoint", Desc: "接口地址,留空用客户端默认"}, }, }, { Id: "ast_alibaba", Name: "阿里云 通义实时翻译", Provider: "alibaba", Categories: "4", Desc: "通话翻译端到端(qwen3.5-livetranslate),VIP 过期即停发凭据", Fields: []envFieldSpec{ {EnvKey: "ALIBABA_OPENSPEECH_APP_KEY", FieldKey: "app_key", Desc: "百炼 API Key", Encrypted: true}, {EnvKey: "ALIBABA_OPENSPEECH_APP_ID", FieldKey: "app_id", Desc: "旧版模型名/AppId(仅第 2 档用)"}, {EnvKey: "ALIBABA_OPENSPEECH_APP_URL", FieldKey: "ws_url", Desc: "WebSocket 地址(国内/国际站不同)"}, }, }, { Id: "sts_bailian", Name: "阿里云 百炼多模态", Provider: "alibaba", Categories: "7", Desc: "EMAI 助手(百炼多模态应用)。API Key 复用 ast_alibaba 下发的 ALIBABA_OPENSPEECH_APP_KEY,这里只配应用与业务空间", Fields: []envFieldSpec{ {EnvKey: "ALIBABA_BAILIAN_APP_ID", FieldKey: "app_id", Desc: "百炼应用 ID"}, {EnvKey: "ALIBABA_BAILIAN_WORKSPACE_ID", FieldKey: "workspace_id", Desc: "百炼业务空间 ID"}, }, }, { Id: "stt_iflytek", Name: "讯飞 语音识别", Provider: "iflytek", Categories: "1", Desc: "语音识别兜底档(客户端直连)", Fields: []envFieldSpec{ {EnvKey: "XUNFEI_ASR_APP_ID", FieldKey: "app_id", Desc: "应用 ID"}, {EnvKey: "XUNFEI_ASR_ACCESS_KEY_ID", FieldKey: "access_key_id", Desc: "AccessKeyId", Encrypted: true}, {EnvKey: "XUNFEI_ASR_ACCESS_KEY_SECRET", FieldKey: "access_key_secret", Desc: "AccessKeySecret", Encrypted: true}, {EnvKey: "IFLYTEK_ASR_HOST", FieldKey: "host", Desc: "接口地址,留空用客户端默认"}, }, }, { Id: "ast_bytedance", Name: "火山引擎 语音大模型", Provider: "bytedance", Categories: "4", Desc: "字节端到端语音翻译(当前客户端已不作首选,凭据仍下发)", Fields: []envFieldSpec{ {EnvKey: "VOLC_OPENSPEECH_APP_ID", FieldKey: "app_id", Desc: "应用 ID"}, {EnvKey: "VOLC_OPENSPEECH_ACCESS_TOKEN", FieldKey: "access_token", Desc: "访问令牌", Encrypted: true}, {EnvKey: "VOLC_OPENSPEECH_TRANSLATION_BIGMODEL", FieldKey: "translation_service_type", Desc: "翻译服务类型标识"}, {EnvKey: "VOLC_OPENSPEECH_BASE_URL_BIGMODEL", FieldKey: "base_url", Desc: "大模型接口地址"}, {EnvKey: "VOLC_OPENSPEECH_CLUSTER_BIGMODEL", FieldKey: "cluster", Desc: "集群标识"}, {EnvKey: "VOLC_OPENSPEECH_MODEL_NAME", FieldKey: "model_name", Desc: "模型名"}, {EnvKey: "VOLC_OPENSPEECH_MODEL_VERSION", FieldKey: "model_version", Desc: "模型版本"}, }, }, { Id: "llm_doubao", Name: "火山方舟 豆包", Provider: "volcano", Categories: "5", Desc: "客户端直连的文本/视觉大模型(键名沿用 OPENAI_*,是 OpenAI 兼容接口不是 OpenAI)", Fields: []envFieldSpec{ {EnvKey: "OPENAI_API_KEY", FieldKey: "api_key", Desc: "API Key", Encrypted: true}, {EnvKey: "OPENAI_BASE_URL", FieldKey: "base_url", Desc: "接口地址"}, {EnvKey: "OPENAI_MODEL", FieldKey: "model", Desc: "文本模型"}, {EnvKey: "OPENAI_VISION_MODEL", FieldKey: "vision_model", Desc: "视觉模型"}, {EnvKey: "OPENAI_AGENT_ID", FieldKey: "agent_id", Desc: "默认智能体标识"}, }, }, { Id: "mt_volcano", Name: "火山引擎 翻译", Provider: "volcano", Categories: "3", Desc: "火山机器翻译(当前客户端已不作首选,凭据仍下发)", Fields: []envFieldSpec{ {EnvKey: "VOLCANO_TRANSLATION_ACCESS_KEY", FieldKey: "access_key", Desc: "AccessKey", Encrypted: true}, {EnvKey: "VOLCANO_TRANSLATION_SECRET_KEY", FieldKey: "secret_key", Desc: "SecretKey", Encrypted: true}, {EnvKey: "VOLCANO_AI_API_KEY", FieldKey: "ark_api_key", Desc: "方舟 API Key", Encrypted: true}, }, }, } // envKeysDropped 确认没有任何代码读、迁移时直接丢弃的键。 // // ALIBABA_OSS_* 对象存储早已改由 svc_config 的 storage_aliyun_oss / sys_service_config 提供, // 客户端与服务端都不读这几个 env 键(服务端唯一读取点是 comm.LoadAliyunOSSConf)。 // COMMENT_* comment.voitrans.net 随 voitrans.net 一起下线,客户端无任何引用。 var envKeysDropped = map[string]string{ "ALIBABA_OSS_ACCESS_KEY_ID": "对象存储改由 storage_aliyun_oss 提供", "ALIBABA_OSS_ACCESS_KEY_SECRET": "对象存储改由 storage_aliyun_oss 提供", "ALIBABA_OSS_BUCKET_NAME": "对象存储改由 storage_aliyun_oss 提供", "ALIBABA_OSS_ENDPOINT": "对象存储改由 storage_aliyun_oss 提供", "COMMENT_SERVER_URL": "comment.voitrans.net 已下线", "COMMENT_SIGN_SECRET": "comment.voitrans.net 已下线", } // globalCovered 该键在 global_config 里有没有任何区域配了非空值。 // 这是「业务库 config 里那一行是不是死值」的判据:老的合并口径是 global 覆盖 app, // 有全局值时应用自己那份从来就没生效过,删掉不改变任何行为。 func globalCovered(byKey map[string]map[int32]string, key string) bool { for _, v := range byKey[key] { if strings.TrimSpace(v) != "" { return true } } return false } // planEnvKeys 落点表里出现过的全部 env 键名,用于识别「填错列」的历史行(见 normalizeAppCfgRow)。 func planEnvKeys() map[string]bool { m := make(map[string]bool, 32) for _, spec := range envToSvcPlan { for _, f := range spec.Fields { m[f.EnvKey] = true } } return m } // normalizeAppCfgRow 读一行业务库 config,返回它**真正表达**的 (键, 值)。 // // 绝大多数行直接就是 (key, value)。但线上存在「填错列」的历史行:运营把键名填进了 group、 // 把值填进了 key,value 留空—— // // group=ALIBABA_BAILIAN_APP_ID key=mm_982f2157… value='' // // 后果是 AppConfig.env('ALIBABA_BAILIAN_APP_ID') 恒为 null,客户端退回代码里写死的兜底值, // 后台改这两行毫无效果且不报任何错。识别条件卡得很死——**group 必须正好是落点表里的一个 // env 键名**、且 value 为空、key 非空——不满足就原样返回,不做任何猜测。 // // 保持纯函数(不打日志):日志由调用方按 key != it.Key 判断后补,便于单测直接调 // (lego 的 log 子系统在单测里没初始化,函数内部打日志会直接 panic)。 func normalizeAppCfgRow(it *pb.DBAppConfigItem) (key, value string) { if strings.TrimSpace(it.Value) == "" && strings.TrimSpace(it.Key) != "" && planEnvKeys()[it.Group] { return it.Group, it.Key } return it.Key, it.Value } // migrateEnvToSvcConfig 主入口。encKey = options.EncryptKey。 // 任何一步失败都只记日志不中断启动:迁移是幂等的,下次启动会重来; // 而 console 起不来的后果(整个后台不可用)比晚一轮迁移严重得多。 func migrateEnvToSvcConfig(encKey string, apps []*AppRegistry) { // ⓪ 作用域已按应用分配(migrate_scope.go 跑过)之后本迁移不再进场。 // 它的落点是全局层(app_name='');分配之后全局层为空,若照常跑会从 global_config 把服务 // **重新建回全局层**,接着 migrate_scope 再把它们分给首个应用时与已有行撞 (app_name,id) 唯一键。 // 新应用要这套凭据走后台「从其它应用复制」,不再从 global_config 长出来。 var scoped int64 _ = postgres.Table(comm.TableSvcConfig).Where("app_name <> ''").Count(&scoped).Error if scoped > 0 { return } // ① 把 global_config 读成 key → region → value。 type gcRow struct { Region int32 `gorm:"column:region"` Key string `gorm:"column:key"` Value string `gorm:"column:value"` } rows := make([]*gcRow, 0) if err := postgres.GetSys().Raw( `SELECT region, "key", value FROM ` + comm.TableGlobalConfig).Scan(&rows).Error; err != nil { // 表不存在(全新部署)是正常情况,不是错误。 log.Infof("console.migrate: global_config 读取跳过(%v),仅按应用业务库迁移", err) rows = nil } byKey := map[string]map[int32]string{} for _, r := range rows { k := strings.TrimSpace(r.Key) if k == "" { continue } if byKey[k] == nil { byKey[k] = map[int32]string{} } // 同 key 同 region 出现多行(历史重复录入)时后写的赢;只有空值不覆盖非空值。 if v, ok := byKey[k][r.Region]; ok && strings.TrimSpace(r.Value) == "" && v != "" { continue } byKey[k][r.Region] = r.Value } // ② app_config 的值作为「global_config 没有该 key 时」的兜底(与迁移前的合并口径一致)。 appFallback := map[string]string{} fallbackOwner := map[string]string{} // env 键 → 提供兜底值的那个应用(多应用时用于判定能不能删) appConns := map[string]mysql.ISys{} // appName → 业务库连接,第 ③ 步删键时复用 for _, app := range apps { if !app.Enabled || app.ServiceDsn == "" { continue } appName := app.Name db, err := openDriver(app.ServiceDsn) if err != nil { log.Warnf("console.migrate: 应用 %s 业务库连接失败,跳过: %v", app.Name, err) continue } items := make([]*pb.DBAppConfigItem, 0) if err := db.Find(comm.TableAppConfig, &items, ""); err != nil { log.Warnf("console.migrate: 应用 %s 读 config 表失败,跳过: %v", app.Name, err) continue } appConns[app.Name] = db for _, it := range items { k, v := normalizeAppCfgRow(it) if k == "" { continue } if k != it.Key { log.Infof("console.migrate: 应用 %s 的 config 行填错了列(group=%s 才是键名,key 里装的是值),迁移时已按正确含义读取", appName, it.Group) } if _, ok := appFallback[k]; !ok && strings.TrimSpace(v) != "" { appFallback[k] = v fallbackOwner[k] = appName } else if ok && appFallback[k] != v && strings.TrimSpace(v) != "" && !globalCovered(byKey, k) { // 两个应用对同一个「全局表里没有的」键给了不同的值:归一成一条全局服务行会让 // 其中一个应用改用别人的凭据。这里只告警不擅自选边,下面 cleanAppConfig 会保留它的行。 log.Warnf("console.migrate: 键 %s 在应用 %s 与 %s 里值不同,且 global_config 没有它——"+ "已按 %s 的值建全局服务行,%s 的配置原样保留,请到「第三方服务配置」为它建应用级覆盖", k, fallbackOwner[k], appName, fallbackOwner[k], appName) } } } // ③ 按落点表建/补服务。 migrated := map[string]bool{} // 已经真正落到 svc_config 里的 env 键 for _, spec := range envToSvcPlan { if err := upsertEnvSvc(spec, byKey, appFallback, encKey, migrated); err != nil { log.Warnf("console.migrate: 服务 %s 迁移失败: %v", spec.Id, err) } } // ④ 业务库 config 表清理:只删**确认已迁走**的键。没迁成功的一个都不碰。 for name, db := range appConns { cleanAppConfig(name, db, migrated, byKey, fallbackOwner) } if len(migrated) > 0 { keys := make([]string, 0, len(migrated)) for k := range migrated { keys = append(keys, k) } sort.Strings(keys) log.Infof("console.migrate: 凭据已归一到 svc_config(%d 个键):%s", len(keys), strings.Join(keys, ", ")) log.Infof("console.migrate: global_config 已无代码读取,确认新链路无误后可手工 DROP TABLE %s", comm.TableGlobalConfig) } } // planEnvSvcValues 纯函数:给定一个服务的落点定义与两张老表的内容,算出 // // base 该服务每个字段的基础值(fieldKey → 值) // regions 需要写成区域分叉的值(region → fieldKey → 值) // // 口径(与迁移前的下发结果对齐,这是本函数存在的全部意义): // - 基础值取**中国区**的现值 → 区域 0 → 业务库 config 表兜底。选中国区是因为当前只有一个 // 应用且 app_registry.region=cn;其余区域一律用分叉表达差异。 // - 只有**与基础值不同**的区域才写分叉。global_config 里 31 个键里有 27 个是 14 个区域同值复制 // (实测),不做这一步就会把 473 行的重复原样搬进新表,等于换个地方继续乱。 // - 空值不写:区域那行是空串时按「该区域没配」处理,退回基础值,与老的 // 「env[k] 只在 global_config 有该区域行时才覆盖」一致。 func planEnvSvcValues(spec envSvcSpec, byKey map[string]map[int32]string, appFallback map[string]string, ) (base map[string]string, regions map[int32]map[string]string) { cn := int32(pb.Region_RegionChina) base = make(map[string]string, len(spec.Fields)) regions = map[int32]map[string]string{} for _, f := range spec.Fields { m := byKey[f.EnvKey] b := "" if m != nil { if v, ok := m[cn]; ok && strings.TrimSpace(v) != "" { b = v } else if v, ok := m[0]; ok && strings.TrimSpace(v) != "" { b = v } } if b == "" { b = appFallback[f.EnvKey] } base[f.FieldKey] = b for region, v := range m { if region == 0 || region == cn || strings.TrimSpace(v) == "" || v == b { continue } if regions[region] == nil { regions[region] = map[string]string{} } regions[region][f.FieldKey] = v } } return } // hasAnyValue 整份落点一个值都取不到时不建服务——免得后台平白多出一堆空壳卡片。 func hasAnyValue(base map[string]string) bool { for _, v := range base { if strings.TrimSpace(v) != "" { return true } } return false } // upsertEnvSvc 建或补一个服务。幂等: // - 服务已存在 → 保留它的名称/类别/启用状态,只补「缺失的字段」和「字段上缺失的 env_key」; // - 字段已有非空 DefValue → 不覆盖(运营后来改过的值优先于历史 env 值)。 // // ⚠️ migrated 只在**落库成功之后**才写。调用方拿它去删业务库 config 里的对应行—— // 提前标记会让「保存失败」变成「两张表都没有这个凭据」,那是不可逆的丢数据。 func upsertEnvSvc(spec envSvcSpec, byKey map[string]map[int32]string, appFallback map[string]string, encKey string, migrated map[string]bool) error { base, regionVals := planEnvSvcValues(spec, byKey, appFallback) if !hasAnyValue(base) { return nil } now := time.Now().Unix() svc := &comm.ThirdSvcConfig{} exists := true if err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", "", spec.Id); err != nil { if err != postgres.ErrNoDocuments { return err } exists = false svc = &comm.ThirdSvcConfig{ AppName: "", Id: spec.Id, Name: spec.Name, Provider: spec.Provider, Categories: spec.Categories, Description: spec.Desc, Enable: true, Fields: []comm.SvcField{}, Createtime: now, } } have := map[string]int{} for i, f := range svc.Fields { have[f.Key] = i } done := map[string]bool{} // 本次确实带上了值的 env 键,落库成功后才并进 migrated changed := !exists for i, spf := range spec.Fields { val := base[spf.FieldKey] idx, ok := have[spf.FieldKey] if !ok { // 新字段:带上值(加密字段先加密)。 stored, err := storeVal(val, spf.Encrypted, encKey) if err != nil { return err } svc.Fields = append(svc.Fields, comm.SvcField{ Key: spf.FieldKey, Description: spf.Desc, Encrypted: spf.Encrypted, DefValue: stored, Sort: int32(i), EnvKey: spf.EnvKey, }) have[spf.FieldKey] = len(svc.Fields) - 1 changed = true if strings.TrimSpace(val) != "" { done[spf.EnvKey] = true } continue } // 已有字段:只补 env_key 与空值,不动运营填过的内容。 if svc.Fields[idx].EnvKey == "" { svc.Fields[idx].EnvKey = spf.EnvKey changed = true } if strings.TrimSpace(svc.Fields[idx].DefValue) == "" && strings.TrimSpace(val) != "" { stored, err := storeVal(val, svc.Fields[idx].Encrypted, encKey) if err != nil { return err } svc.Fields[idx].DefValue = stored changed = true } if strings.TrimSpace(svc.Fields[idx].DefValue) != "" && svc.Fields[idx].EnvKey == spf.EnvKey { done[spf.EnvKey] = true } } // ⚠️ 带着迁移来的凭据就必须是启用状态。老的 global_config 是**无条件**下发这些键的, // 而 svc_config 的 enable=false 会让 resolveThirdSvcs 整条跳过——真机上 stt_azure // 恰好是停用的,照搬这个状态就等于把 AZURE_SPEECH_KEY/REGION 从 env 里抹掉, // 表现为 Azure 语音识别/合成突然鉴权失败,而后台看上去一切正常。 // 运营要停用某个服务,迁移之后再停——那时停用的语义才是真的「不下发」。 if len(done) > 0 && !svc.Enable { svc.Enable = true changed = true log.Infof("console.migrate: 服务 %s 原为停用,因承接了老 env 键(%d 个)已置为启用——"+ "老链路是无条件下发这些凭据的,保持停用会让客户端拿不到", spec.Id, len(done)) } if changed { svc.Updatetime = now if exists { if err := postgres.Save(comm.TableSvcConfig, svc); err != nil { return err } } else if err := postgres.Insert(comm.TableSvcConfig, svc); err != nil { return err } log.Infof("console.migrate: 服务 %s 已%s(%d 字段)", spec.Id, map[bool]string{true: "补齐", false: "建立"}[exists], len(svc.Fields)) } // 走到这里说明基础值已经在库里了(changed=false 时是上一轮就写好的),可以放心标记。 for k := range done { migrated[k] = true } // 区域分叉:只为「与基础值不同」的区域写覆盖,同值区域一行都不写。 return upsertEnvSvcRegions(spec, svc, regionVals, encKey) } // upsertEnvSvcRegions 把 planEnvSvcValues 算出的区域差异落成 svc_region_override, // 并**修掉会把基础值抹成空的历史空覆盖**。 // // ⚠️ 后面这半件事是真机踩出来的:svc_config 里早就存在的 stt_azure 带着 14 行区域覆盖, // 内容是 {"region":"", "subscription_key":""}——当初建服务时留下的空壳。而 // ResolveSvcPlainFields 判的是「这个 key 在 Overrides 里**存不存在**」,存在就用, // 哪怕值是空串。于是迁移把凭据填进基础值之后,任何一个区域的用户拿到的仍然是空字符串, // 表现为 Azure 语音识别/合成鉴权失败,而后台看上去字段明明填好了。 // // 所以对**本迁移拥有的字段**(落点表里带 env_key 的那些)额外做一次清理: // - 该区域有真值 → 写进去(空覆盖视同没填,可以覆盖); // - 该区域没真值 → 把空覆盖**删掉**,让基础值生效。 // // 运营真正填过的非空覆盖一律不动。 func upsertEnvSvcRegions(spec envSvcSpec, svc *comm.ThirdSvcConfig, regionVals map[int32]map[string]string, encKey string) error { owned := make(map[string]bool, len(spec.Fields)) for _, f := range spec.Fields { owned[f.FieldKey] = true } encOf := map[string]bool{} for _, f := range svc.Fields { encOf[f.Key] = f.Encrypted } // 已存在的覆盖行也要过一遍(它们可能带着空壳覆盖),所以取「已有区域 ∪ 有差异的区域」。 existing := make([]*comm.SvcRegionOverride, 0) if err := postgres.Find(comm.TableSvcRegionOverride, &existing, "app_name=? AND svc_id=?", "", spec.Id); err != nil && err != postgres.ErrNoDocuments { return err } byRegion := make(map[int32]*comm.SvcRegionOverride, len(existing)) regionSet := map[int32]bool{} for _, o := range existing { byRegion[o.Region] = o regionSet[o.Region] = true } for r := range regionVals { regionSet[r] = true } if len(regionSet) == 0 { return nil } regions := make([]int, 0, len(regionSet)) for r := range regionSet { regions = append(regions, int(r)) } sort.Ints(regions) // map 迭代无序,排一下让写入顺序与日志可复现 written, cleaned := 0, 0 for _, ri := range regions { region := int32(ri) ovr, exists := byRegion[region] if !exists { ovr = &comm.SvcRegionOverride{AppName: "", SvcId: spec.Id, Region: region, Overrides: map[string]string{}, ExtraFields: []comm.SvcField{}, DisabledKeys: []string{}} } if ovr.Overrides == nil { ovr.Overrides = map[string]string{} } changed := !exists for fk := range owned { cur, has := ovr.Overrides[fk] blank := has && strings.TrimSpace(cur) == "" want := regionVals[region][fk] if strings.TrimSpace(want) != "" { if has && !blank { continue // 运营填过真值,不动 } stored, err := storeVal(want, encOf[fk], encKey) if err != nil { return err } ovr.Overrides[fk] = stored changed = true written++ continue } if blank { delete(ovr.Overrides, fk) // 空壳覆盖会把基础值抹成空,删掉让基础值生效 changed = true cleaned++ } } if !changed { continue } ovr.Updatetime = time.Now().Unix() if exists { if err := postgres.Save(comm.TableSvcRegionOverride, ovr); err != nil { return err } } else if err := postgres.Insert(comm.TableSvcRegionOverride, ovr); err != nil { return err } } if written > 0 || cleaned > 0 { log.Infof("console.migrate: 服务 %s 区域覆盖:写入 %d 项差异,清掉 %d 项会抹空基础值的空覆盖", spec.Id, written, cleaned) } return nil } // storeVal 按字段是否加密决定入库形态。空值不加密(加密空串会得到一段无意义密文, // 而 ResolveSvcPlainFields 对空值是直接放行的,两边口径要一致)。 func storeVal(val string, encrypted bool, encKey string) (string, error) { if strings.TrimSpace(val) == "" { return "", nil } if !encrypted { return val, nil } return comm.Encrypt(encKey, val) } // cleanAppConfig 删掉业务库 config 表里那些已经迁进 svc_config 的凭据键,以及确认无人读取的死键。 // // ⚠️ 只删「本次确认迁走了」的键。迁移没成功的键留在原地——宁可后台多显示几行, // 也不能出现「两张表都没有值」的窗口。 // ⚠️ 非凭据的应用参数(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ / COS_* / 算力与运营参数…) // 一律不碰:它们本来就该住在「应用环境配置」里。 func cleanAppConfig(appName string, db mysql.ISys, migrated map[string]bool, byKey map[string]map[int32]string, fallbackOwner map[string]string) { items := make([]*pb.DBAppConfigItem, 0) if err := db.Find(comm.TableAppConfig, &items, ""); err != nil { return } dropIds := make([]uint64, 0) dropKeys := make([]string, 0) for _, it := range items { // 填错列的历史行按它真正表达的键名判定,否则永远清不掉(key 里装的是值)。 k, _ := normalizeAppCfgRow(it) reason := "" switch { case envKeysDropped[k] != "": reason = envKeysDropped[k] case migrated[k] && globalCovered(byKey, k): // 全局表里有这个键 → 应用自己这行在迁移前就被盖住、从来没生效过,删掉不改变行为。 reason = "已迁入 svc_config(原本就被 global_config 盖住)" case migrated[k] && fallbackOwner[k] == appName: // 全局表没有,值就是从这个应用取的,已原样搬进全局服务行。 reason = "已迁入 svc_config" case migrated[k]: // 全局表没有、值也不是从本应用取的:删了就等于让本应用改用别的应用的凭据。保留。 log.Warnf("console.migrate: 应用 %s 的 %s 未清理——全局服务行里的值来自应用 %s,"+ "两者可能不同;如需按应用区分,请在「第三方服务配置」里为本应用建覆盖行", appName, k, fallbackOwner[k]) continue } if reason == "" { continue } dropIds = append(dropIds, it.Id) dropKeys = append(dropKeys, fmt.Sprintf("%s(%s)", k, reason)) } if len(dropIds) == 0 { return } if err := db.Delete(comm.TableAppConfig, "id IN ?", dropIds); err != nil { log.Warnf("console.migrate: 应用 %s 清理 config 表失败: %v", appName, err) return } log.Infof("console.migrate: 应用 %s 的 config 表清掉 %d 个已归一的键:%s", appName, len(dropKeys), strings.Join(dropKeys, ", ")) }