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