package console import ( "fmt" "sort" "strconv" "strings" "time" "yunyan/comm" "yunyan/lego/sys/log" "yunyan/lego/sys/mysql" "yunyan/lego/sys/postgres" "yunyan/pb" ) // ============================ 应用参数归一:业务库 config 表 → svc_config ============================ // // 2026-09-14 后台「服务与环境配置」的「应用参数」标签页下线。它是业务库 config 表的裸 key/value 编辑器, // 而 user_getappconfig 原先把这张表**整张**塞进 env 下发——COS 密钥、灵犀产品密钥、连「会员与算力」页 // 写进去的 COMPUTE_RATE_* 都会明文发到每个客户端手里。逐项核过两台机的 22 行之后的处置: // // - 客户端还在读的键搬进 svc_config,字段带 env_key、仍按老键名下发(客户端零改动即可): // AGENT_TYPE / MOBILE_ELF_AGENT_ID / MOBILE_ELF_PRODUCT_ID / MOBILE_ELF_PRODUCT_KEY / YIDONG_PID // → 服务 llm_mobile_elf(中国移动 灵犀 / 移动精灵) // iapCustomerServiceQQ → 服务 app_params(应用参数) // - 谁都不读的死键直接删(appCfgDeadKeys:音乐 / Spotify / 公码 / 导航 / 腾讯 COS / MeetServers); // COS 那 5 个是腾讯云直传时代的密钥,上传 2026-09-04 起走 OSS 预签名,且已确认线上没有老包在读。 // - 服务端自用的(算力系数 / 运营参数 / 迁移标记)留在表里,由「会员与算力」页维护,**不再下发**。 // - 其余不认识的键原样保留并告警:它们已经没有任何后台入口、也不下发了, // 要么加进 appParamsPlan 落点表,要么手工清。 // // 服务实例照内置模板建(builtinSvcTemplates 里的 llm_mobile_elf / app_params),值从 config 表取。 // 幂等、每次启动跑:服务已存在只补空字段;config 表里的行只在**落库成功之后**才删。 // // 必须排在 migrateGlobalScopeToApps 之后:直接写 app_name=<应用名> 的行。作用域键同那里一样是 // app_registry.app_name(不是部署名 name)。同一应用有多个部署(多套业务库)时按 id 升序, // 第一个部署的值为准,后面的只清表。 // appParamFieldSpec config 表里的一个键在服务上的落点。ConfigKey 同时也是字段的下发键名(env_key)。 type appParamFieldSpec struct { ConfigKey string FieldKey string } // appParamSvcSpec 一个落点服务。模板 id 指向 builtinSvcTemplates,名称/类别/字段定义都从模板取。 type appParamSvcSpec struct { TplId string CreateIfEmpty bool // config 表里一个值都没有时是否也建:app_params 要建出来让运营有地方填;移动精灵没值就不建 Fields []appParamFieldSpec } var appParamsPlan = []appParamSvcSpec{ { TplId: "llm_mobile_elf", Fields: []appParamFieldSpec{ {ConfigKey: "AGENT_TYPE", FieldKey: "agent_type"}, {ConfigKey: "MOBILE_ELF_AGENT_ID", FieldKey: "agent_id"}, {ConfigKey: "MOBILE_ELF_PRODUCT_ID", FieldKey: "product_id"}, {ConfigKey: "MOBILE_ELF_PRODUCT_KEY", FieldKey: "product_key"}, {ConfigKey: "YIDONG_PID", FieldKey: "pid"}, }, }, { TplId: "app_params", CreateIfEmpty: true, Fields: []appParamFieldSpec{ {ConfigKey: "iapCustomerServiceQQ", FieldKey: "iap_customer_service_qq"}, }, }, } // appCfgDeadKeys 确认服务端、客户端(含原生插件)、后台都没有读取方的键 → 原因。 // 判定时间 2026-09-14,方法是对 apps/services、apps/client/lib、local_plugins、apps/admin 全文 grep。 var appCfgDeadKeys = map[string]string{ "APP_MUSICSERVICE_TYPE": "音乐模块已删", "APP_ANDROID_MUSICSERVICE_TYPE": "音乐模块已删", "APP_IOS_MUSICSERVICE_TYPE": "音乐模块已删", "SPOTIFY_CLIENT_ID": "音乐模块已删", "SPOTIFY_REDIRECT_URL": "音乐模块已删", "APP_AUTHCODE_OPEN": "厂家公码已下线", "APP_AUTHCODE_JUMP": "厂家公码已下线", "APP_NAVIGATION_MODE": "老 agent 模块已删,无人读取", "COS_APP_ID": "上传已改 OSS 预签名,且无老包", "COS_BUCKET_NAME": "上传已改 OSS 预签名,且无老包", "COS_REGION": "上传已改 OSS 预签名,且无老包", "COS_SECRET_ID": "上传已改 OSS 预签名,且无老包", "COS_SECRET_KEY": "上传已改 OSS 预签名,且无老包", "MeetServers": "会议选型走 echomeet_orch,无人读取", } // appCfgServerKeys 服务端自用、要留在 config 表里的键(由「会员与算力」页维护;迁移标记由 home 写)。 func appCfgServerKeys() map[string]bool { return map[string]bool{ comm.ConfigKeyComputeRateTranslate: true, comm.ConfigKeyComputeRateMeeting: true, comm.ConfigKeyComputeRateAIChat: true, comm.ConfigKeyComputeGate: true, comm.ConfigKeyNewUserGiftVipDays: true, comm.ConfigKeyNewUserGiftCompute: true, comm.ConfigKeyVipWarnDays: true, comm.ConfigKeyComputeWarn: true, "COMPUTE_LEGACY_MIGRATED": true, // modules/user/model_user.go legacyComputeMigratedKey } } func migrateAppParamsToSvc(encKey string, apps []*AppRegistry) { sort.Slice(apps, func(i, j int) bool { return apps[i].Id < apps[j].Id }) seenScope := map[string]bool{} for _, app := range apps { if !app.Enabled || app.ServiceDsn == "" { continue } scope := strings.TrimSpace(app.AppName) if scope == "" { log.Warnf("console.appparams: 部署 %s 未归属应用(app_name 为空),它的 config 表不处理", app.Name) continue } db, err := openDriver(app.ServiceDsn) if err != nil { log.Warnf("console.appparams: 部署 %s 业务库连接失败,跳过: %v", app.Name, err) continue } items := make([]*pb.DBAppConfigItem, 0) if err := db.Find(comm.TableAppConfig, &items, ""); err != nil { log.Warnf("console.appparams: 部署 %s 读 config 表失败,跳过: %v", app.Name, err) continue } if len(items) == 0 { continue } vals := map[string]string{} for _, it := range items { k, v := normalizeAppCfgRow(it) if k == "" { continue } if old, ok := vals[k]; !ok || (strings.TrimSpace(old) == "" && strings.TrimSpace(v) != "") { vals[k] = v } } migrated := map[string]bool{} for _, spec := range appParamsPlan { // 同一应用的第二个部署:服务行已由第一个部署建好,这里只把键标成已迁走去清表,不再覆盖值。 keys, err := upsertAppParamSvc(scope, spec, vals, encKey, seenScope[scope]) if err != nil { log.Warnf("console.appparams: 应用 %s 服务 %s 迁移失败: %v", scope, spec.TplId, err) continue } for k := range keys { migrated[k] = true } } seenScope[scope] = true cleanAppParamRows(app.Name, db, items, migrated) } } // upsertAppParamSvc 建或补一个落点服务,返回这次确认已落到 svc_config 里的 config 键。 // - 服务不存在:按模板建;config 里一个值都没有且 !CreateIfEmpty 则不建(返回空)。 // - 服务已存在:只给空着的字段填值、补缺失的字段与 env_key;运营填过的值不覆盖。 // - 只要字段在服务上存在(不管值是取自 config 还是运营填的),对应 config 键就算迁走—— // svc_config 从此是唯一出处,config 表那行留着只会误导。 // // ⚠️ 返回的键只在落库成功之后才有效,调用方拿它去删 config 表——提前返回会让两张表都没值。 func upsertAppParamSvc(scope string, spec appParamSvcSpec, vals map[string]string, encKey string, scopeSeen bool) (map[string]bool, error) { tpl := findBuiltinTemplate(spec.TplId) if tpl == nil { return nil, fmt.Errorf("内置模板 %s 不存在", spec.TplId) } hasValue := false for _, f := range spec.Fields { if strings.TrimSpace(vals[f.ConfigKey]) != "" { hasValue = true break } } now := time.Now().Unix() svc := &comm.ThirdSvcConfig{} exists := true if err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", scope, tpl.Id); err != nil { if err != postgres.ErrNoDocuments { return nil, err } exists = false if !hasValue && !spec.CreateIfEmpty { return nil, nil } fields := make([]comm.SvcField, len(tpl.Fields)) copy(fields, tpl.Fields) svc = &comm.ThirdSvcConfig{ AppName: scope, Id: tpl.Id, Name: tpl.Name, Provider: tpl.Provider, Categories: strconv.Itoa(int(tpl.Category)), Description: tpl.Description, Enable: true, Fields: fields, Createtime: now, } } idx := map[string]int{} for i, f := range svc.Fields { idx[f.Key] = i } done := map[string]bool{} changed := !exists // 同一应用的后续部署只清表不写值——除非服务是这次才建的(首个部署没值没建)。 takeValues := !scopeSeen || !exists for _, fs := range spec.Fields { i, ok := idx[fs.FieldKey] if !ok { // 模板里没有的字段(模板被运营改过):按明文补一个,别让值丢了。 svc.Fields = append(svc.Fields, comm.SvcField{Key: fs.FieldKey, Description: fs.ConfigKey, Sort: int32(len(svc.Fields))}) i = len(svc.Fields) - 1 idx[fs.FieldKey] = i changed = true } f := &svc.Fields[i] if f.EnvKey == "" { f.EnvKey = fs.ConfigKey changed = true } if v := vals[fs.ConfigKey]; takeValues && strings.TrimSpace(v) != "" && strings.TrimSpace(f.DefValue) == "" { stored, err := storeVal(v, f.Encrypted, encKey) if err != nil { return nil, fmt.Errorf("字段 %s 加密失败: %w", fs.FieldKey, err) } f.DefValue = stored changed = true } done[fs.ConfigKey] = true } // 服务停用 = 这些键一个都不下发。既然 config 表里有值在用,就得让它继续生效(同 migrate_envtosvc 对 stt_azure 的处理)。 if exists && !svc.Enable && hasValue && takeValues { svc.Enable = true changed = true log.Infof("console.appparams: 应用 %s 的服务 %s 原为停用,因承接了 config 表在用的键已启用", scope, tpl.Id) } if changed { svc.Updatetime = now var err error if exists { err = postgres.Save(comm.TableSvcConfig, svc) } else { err = postgres.Insert(comm.TableSvcConfig, svc) } if err != nil { return nil, err } log.Infof("console.appparams: 应用 %s 服务 %s %s(承接 config 键 %d 个)", scope, tpl.Id, map[bool]string{true: "已补字段", false: "已建"}[exists], len(done)) } return done, nil } // findBuiltinTemplate 按 id 取内置模板定义(代码里的,不查库——模板表里的可能被运营改过)。 func findBuiltinTemplate(id string) *ThirdSvcTemplate { for _, t := range builtinSvcTemplates() { if t.Id == id { tt := t return &tt } } return nil } // cleanAppParamRows 删已迁走的与确认死掉的行;服务端自用的留下;不认识的留下并告警。 func cleanAppParamRows(deployName string, db mysql.ISys, items []*pb.DBAppConfigItem, migrated map[string]bool) { serverKeys := appCfgServerKeys() dropIds := make([]uint64, 0) dropKeys := make([]string, 0) unknown := make([]string, 0) for _, it := range items { k, _ := normalizeAppCfgRow(it) if k == "" { continue } reason := "" switch { case migrated[k]: reason = "已迁入 svc_config" case appCfgDeadKeys[k] != "": reason = appCfgDeadKeys[k] case serverKeys[k]: continue default: unknown = append(unknown, k) continue } dropIds = append(dropIds, it.Id) dropKeys = append(dropKeys, k+"("+reason+")") } if len(dropIds) > 0 { if err := db.Delete(comm.TableAppConfig, "id IN ?", dropIds); err != nil { log.Warnf("console.appparams: 部署 %s 清理 config 表失败: %v", deployName, err) } else { log.Infof("console.appparams: 部署 %s 的 config 表清掉 %d 行:%s", deployName, len(dropKeys), strings.Join(dropKeys, ", ")) } } if len(unknown) > 0 { log.Warnf("console.appparams: 部署 %s 的 config 表还有 %d 个后台已无入口、也不再下发的键:%s——"+ "要么加进 migrate_appparams.go 的落点表,要么手工删", deployName, len(unknown), strings.Join(unknown, ", ")) } }