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.
 
 
 
 
 
 

638 lines
28 KiB

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, ", "))
}