package console import ( "sort" "strings" "time" "yunyan/comm" "yunyan/lego/sys/postgres" "yunyan/pb" "github.com/gin-gonic/gin" ) // ============================ 第三方服务配置域 (svcconfig) ============================ // // 移植自 deep_server modules/console/api_svcconfig.go,适配 yunyan-sas 的 postgres 封装与本地 struct。 // 数据存 console 主库(postgres.GetSys()),与 globalconfig/mcp/会议模板同库。 // 服务字段(fields)用 jsonb 列(serializer:json)存储;加密字段值 AES-256-GCM 加密落库, // 返回前端时脱敏为 "[encrypted]"。区域覆盖:默认值 + 各区域覆盖,缺失字段继承默认值。 // ============================== AES-256-GCM 字段加密 ============================== // // 算法实现统一收敛到 comm.Encrypt/Decrypt(console 与业务服务读写同一张加密表,密文格式必须一致)。 // 这里保留 svcEncrypt/svcDecrypt 薄封装,避免改动本包内大量既有调用点。 // svcEncrypt 用 AES-256-GCM 加密明文,返回 base64(nonce‖ciphertext)。空明文返回空串。 func svcEncrypt(key, plaintext string) (string, error) { return comm.Encrypt(key, plaintext) } // svcDecrypt 解密 svcEncrypt 产出的密文,返回明文。供业务侧内部调用(不暴露给 console 前端)。 func svcDecrypt(key, ciphertext string) (string, error) { return comm.Decrypt(key, ciphertext) } // ============================== 脱敏工具 ============================== // fieldExists 判断字段列表中是否存在指定 key。 func fieldExists(fields []SvcField, key string) bool { for _, f := range fields { if f.Key == key { return true } } return false } // maskSvcFields 把返回给前端的字段列表中 encrypted=true 的 def_value 替换为 "[encrypted]"。 func maskSvcFields(fields []SvcField) { for i := range fields { if fields[i].Encrypted && fields[i].DefValue != "" { fields[i].DefValue = "[encrypted]" } } } // encryptFieldsSlice 对字段列表中 encrypted=true 的字段做加密处理: // oldMap 不为 nil 时,空值/占位符保留 oldMap[key] 密文;oldMap 为 nil 时直接加密非空值。 func encryptFieldsSlice(key string, fields []SvcField, oldMap map[string]string) error { for i := range fields { if !fields[i].Encrypted { continue } if fields[i].DefValue == "" || fields[i].DefValue == "[encrypted]" { if oldMap != nil { fields[i].DefValue = oldMap[fields[i].Key] } else { fields[i].DefValue = "" } continue } ct, err := svcEncrypt(key, fields[i].DefValue) if err != nil { return err } fields[i].DefValue = ct } return nil } // ============================== 第三方服务配置 CRUD ============================== // getSvcConfigs 列出某作用域(app_name)下全部第三方服务,加密字段值脱敏。app_name=” 为全局默认。 // 同时返回 regions:svc_id → 已建分叉的区域枚举(升序)。后台卡片靠它一眼看出「这个服务在哪些 // 区域另配了值」——像 MCP 这种值全落在区域分叉上的服务,默认视图里否则完全看不出区域信息。 func (this *serverComp) getSvcConfigs(c *gin.Context) { var req struct { AppName string `json:"app_name"` } _ = c.ShouldBindJSON(&req) items := make([]*ThirdSvcConfig, 0) if err := postgres.Find(comm.TableSvcConfig, &items, "app_name=?", req.AppName); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } // 凭据缺失清单要在打码前算:maskSvcFields 只把**非空**密文替换成 [encrypted],空值原样是 ""。 missing := svcCredMissing(req.AppName, items) for _, item := range items { maskSvcFields(item.Fields) } writeOK(c, gin.H{"items": items, "regions": svcRegionMap(req.AppName), "cred_missing": missing}) } // svcCredMissing 算出每个服务「一个值都没有的凭据字段」,供后台把这类服务标出来。 // 判定口径与巡检 checkSvcConfig 一致(凭据=加密字段,空即运行时必然鉴权失败), // 但要把区域分叉算进去:凭据只配在某个区域分叉上是合法形态(老 mcp 表迁过来的服务就这样), // 基础行空不代表没配——只有基础行与所有分叉都没给值,才算真的缺。 // 返回 {svcId: [缺的字段名]},不缺的服务不出现在 map 里。 func svcCredMissing(appName string, items []*ThirdSvcConfig) map[string][]string { ovrs := make([]*SvcRegionOverride, 0) _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=?", appName) bySvc := make(map[string][]*SvcRegionOverride, len(ovrs)) for _, o := range ovrs { bySvc[o.SvcId] = append(bySvc[o.SvcId], o) } return svcCredMissingWith(items, bySvc) } // svcCredMissingWith 是上面的纯逻辑部分(区域分叉按 svc_id 归好传进来),便于单测。 func svcCredMissingWith(items []*ThirdSvcConfig, bySvc map[string][]*SvcRegionOverride) map[string][]string { out := make(map[string][]string, 4) for _, s := range items { miss := make([]string, 0, 2) for _, f := range s.Fields { if !f.Encrypted || strings.TrimSpace(f.DefValue) != "" { continue } filled := false for _, o := range bySvc[s.Id] { if strings.TrimSpace(o.Overrides[f.Key]) != "" { filled = true break } } if !filled { miss = append(miss, f.Key) } } if len(miss) > 0 { out[s.Id] = miss } } return out } // svcRegionMap 汇总某作用域下每个服务已建分叉的区域列表。空壳分叉(既没覆盖值、也没区域专属 // 字段和停用标记)不计入——它对配置没有任何实际影响,算进去只会让卡片显示误导人。 func svcRegionMap(appName string) map[string][]int32 { ovrs := make([]*SvcRegionOverride, 0) if err := postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=?", appName); err != nil { return map[string][]int32{} } m := make(map[string][]int32, len(ovrs)) for _, o := range ovrs { if o.Region <= 0 || len(o.Overrides)+len(o.ExtraFields)+len(o.DisabledKeys) == 0 { continue } m[o.SvcId] = append(m[o.SvcId], o.Region) } for _, rs := range m { sort.Slice(rs, func(i, j int) bool { return rs[i] < rs[j] }) } return m } // addSvcConfig 新增依赖服务。encrypted=true 的字段值加密存储。 // checkSvcRequiredFields 保存服务前的必填校验:只拦「一定会出问题」的空字段,口径同巡检 checkSvcConfig。 // // 凭据字段(encrypted) 为空 → 运行时必然鉴权失败; // MCP 服务 url 为空 → 根本连不上。 // // 三条豁免: // 1. 停用中的服务不校验——还没配好的先停用保存是正常流程,停用的服务不下发也不会被编排选中; // 2. 值配在区域分叉里的不算空——凭据按区域分别给是合法形态(老 mcp 表迁过来的服务就这样); // 3. 非加密字段一律不拦——base_url/language/tools 这类留空往往是有意的(走默认/全部),拦了只会误伤。 // // 传入的 fields 必须是**加密合并后**的值(update 时 "" / "[encrypted]" 已还原成旧密文), // 否则「保留原值」的提交会被误判成空。返回错误说明(空串=通过)。 func checkSvcRequiredFields(m *ThirdSvcConfig) string { if !m.Enable { return "" // 停用的服务先不查库,草稿态直接放行 } ovrs := make([]*SvcRegionOverride, 0) _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", m.AppName, m.Id) return checkSvcRequiredFieldsWith(m, ovrs) } // checkSvcRequiredFieldsWith 是上面的纯逻辑部分(区域分叉由调用方读好传进来),便于单测。 func checkSvcRequiredFieldsWith(m *ThirdSvcConfig, ovrs []*SvcRegionOverride) string { if !m.Enable { return "" } filledInSomeRegion := func(key string) bool { for _, o := range ovrs { if strings.TrimSpace(o.Overrides[key]) != "" { return true } } return false } empty := make([]string, 0, 2) for _, f := range m.Fields { if !f.Encrypted || strings.TrimSpace(f.DefValue) != "" || filledInSomeRegion(f.Key) { continue } empty = append(empty, f.Key) } if len(empty) > 0 { sort.Strings(empty) return "凭据字段不能为空:" + strings.Join(empty, "、") + "。若该字段按区域分别配置,请先在区域分叉里填值;还没拿到凭据的服务,请先「停用」再保存。" } if comm.CategoriesHasMCP(m.Categories) && strings.TrimSpace(svcFieldValue(m, "url")) == "" && !filledInSomeRegion("url") { return "MCP 服务必须填写 url(或在某个区域分叉里填),否则客户端连不上。" } return "" } // checkSvcEnvKeys 校验「下发键名」(env_key):同一作用域下必须唯一。 // // env_key 是发给**已发布客户端**的 env 键名(见 comm.ResolveSvcEnvKeys / modules/user 的 appEnvForUser)。 // 两个服务抢同一个键名时,下发结果取决于服务 id 的排序——表现为「凭据时对时不对」, // 而且改哪一边都像是没生效。所以在保存这一步就挡住。 // 返回空串 = 通过。 func checkSvcEnvKeys(m *ThirdSvcConfig) string { seen := map[string]bool{} for _, f := range m.Fields { k := strings.TrimSpace(f.EnvKey) if k == "" { continue } if seen[k] { return "下发键名重复:" + k + "(同一个服务里两个字段不能用同一个键名)" } seen[k] = true } if len(seen) == 0 { return "" } others := make([]*ThirdSvcConfig, 0) if err := postgres.Find(comm.TableSvcConfig, &others, "app_name=? AND id<>?", m.AppName, m.Id); err != nil && err != postgres.ErrNoDocuments { return "" // 查重本身失败不拦保存:漏一次校验好过让运营存不下配置 } for _, o := range others { for _, f := range o.Fields { if k := strings.TrimSpace(f.EnvKey); k != "" && seen[k] { return "下发键名 " + k + " 已被服务「" + o.Name + "」(" + o.Id + ") 占用。" + "同名会让客户端拿到哪一份取决于服务 id 排序,请改名或去掉其中一处。" } } } return "" } func (this *serverComp) addSvcConfig(c *gin.Context) { var m ThirdSvcConfig if err := c.ShouldBindJSON(&m); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if strings.TrimSpace(m.Id) == "" || strings.TrimSpace(m.Name) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "服务ID 和 名称 必填") return } // 作用域内(app_name,id)唯一检查 exist := &ThirdSvcConfig{} if err := postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", m.AppName, m.Id); err == nil { writeErr(c, pb.ErrorCode_ReqParameterError, "服务ID 已存在: "+m.Id) return } // 加密字段 if err := encryptFieldsSlice(this.options.EncryptKey, m.Fields, nil); err != nil { writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error()) return } if msg := checkSvcRequiredFields(&m); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } if msg := checkSvcEnvKeys(&m); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } m.RowId = 0 // 代理主键由库自增回填 now := time.Now().UnixMilli() m.Createtime = now m.Updatetime = now if err := postgres.Insert(comm.TableSvcConfig, &m); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } broadcastConfigChanged(comm.ConfigKindThirdSvc, "add", 0) maskSvcFields(m.Fields) writeOK(c, &m) } // updateSvcConfig 更新依赖服务。encrypted 字段:传 "" 或 "[encrypted]" 保留原密文,否则重新加密。 func (this *serverComp) updateSvcConfig(c *gin.Context) { var m ThirdSvcConfig if err := c.ShouldBindJSON(&m); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if strings.TrimSpace(m.Id) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "服务ID 必填") return } // 读旧记录(同作用域),取代理主键 + 已存储的加密字段密文 old := &ThirdSvcConfig{} if err := postgres.FindOne(comm.TableSvcConfig, old, "app_name=? AND id=?", m.AppName, m.Id); err != nil { writeErr(c, pb.ErrorCode_DBError, "服务不存在: "+m.Id) return } oldMap := make(map[string]string, len(old.Fields)) for _, f := range old.Fields { oldMap[f.Key] = f.DefValue } if err := encryptFieldsSlice(this.options.EncryptKey, m.Fields, oldMap); err != nil { writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error()) return } if msg := checkSvcRequiredFields(&m); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } if msg := checkSvcEnvKeys(&m); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } m.RowId = old.RowId // 按代理主键更新本行 m.Createtime = old.Createtime m.Updatetime = time.Now().UnixMilli() if err := postgres.Save(comm.TableSvcConfig, &m); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } broadcastConfigChanged(comm.ConfigKindThirdSvc, "update", 0) maskSvcFields(m.Fields) writeOK(c, &m) } // delSvcConfig 删除某作用域下的服务及其所有区域覆盖。 func (this *serverComp) delSvcConfig(c *gin.Context) { var req struct { AppName string `json:"app_name"` Id string `json:"id"` } if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } if err := postgres.Delete(comm.TableSvcConfig, "app_name=? AND id=?", req.AppName, req.Id); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } _ = postgres.Delete(comm.TableSvcRegionOverride, "app_name=? AND svc_id=?", req.AppName, req.Id) broadcastConfigChanged(comm.ConfigKindThirdSvc, "delete", 0) writeOK(c, gin.H{"id": req.Id}) } // ============================== 区域字段覆盖 ============================== // svcRegionFieldView 是返回给前端的合并视图(字段定义 + 有效值 + 覆盖/停用/区域专属标记)。 type svcRegionFieldView struct { Key string `json:"key"` Description string `json:"description"` Type string `json:"type"` // 字段类型:langs/lang 供前端渲染语言选择器(同 SvcField.Type) Encrypted bool `json:"encrypted"` IsOverridden bool `json:"is_overridden"` // 默认字段:本区域是否有值覆盖 Disabled bool `json:"disabled"` // 默认字段:本区域是否被标记为“不适用” RegionOnly bool `json:"region_only"` // 是否为本区域专属新增字段(非默认字段集) Value string `json:"value"` // 加密字段显示 [encrypted] } // sliceContains 判断字符串切片是否包含目标。 func sliceContains(s []string, target string) bool { for _, v := range s { if v == target { return true } } return false } // getSvcRegionOverride 返回某服务在指定区域的字段有效视图: // 默认字段(带 disabled 标记,值取 override→default)∪ 本区域专属字段(region_only),加密字段脱敏。 func (this *serverComp) getSvcRegionOverride(c *gin.Context) { var req struct { AppName string `json:"app_name"` SvcId string `json:"svc_id"` Region int32 `json:"region"` } if err := c.ShouldBindJSON(&req); err != nil || req.SvcId == "" || req.Region == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "svc_id 和 region 必填") return } cfg := &ThirdSvcConfig{} if err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", req.AppName, req.SvcId); err != nil { writeErr(c, pb.ErrorCode_DBError, "服务不存在: "+req.SvcId) return } ovr := &SvcRegionOverride{} _ = postgres.FindOne(comm.TableSvcRegionOverride, ovr, "app_name=? AND svc_id=? AND region=?", req.AppName, req.SvcId, req.Region) if ovr.Overrides == nil { ovr.Overrides = make(map[string]string) } views := make([]*svcRegionFieldView, 0, len(cfg.Fields)+len(ovr.ExtraFields)) // 默认字段 for _, f := range cfg.Fields { val, overridden := ovr.Overrides[f.Key] if !overridden { val = f.DefValue } if f.Encrypted && val != "" { val = "[encrypted]" } views = append(views, &svcRegionFieldView{ Key: f.Key, Description: f.Description, Type: f.Type, Encrypted: f.Encrypted, IsOverridden: overridden, Disabled: sliceContains(ovr.DisabledKeys, f.Key), Value: val, }) } // 本区域专属字段(值存在 ExtraFields[i].DefValue,加密字段脱敏) for _, f := range ovr.ExtraFields { val := f.DefValue if f.Encrypted && val != "" { val = "[encrypted]" } views = append(views, &svcRegionFieldView{ Key: f.Key, Description: f.Description, Type: f.Type, Encrypted: f.Encrypted, RegionOnly: true, Value: val, }) } writeOK(c, gin.H{"svc_id": req.SvcId, "region": req.Region, "override_id": ovr.Id, "fields": views}) } // saveSvcRegionOverride 保存(upsert)某服务在指定区域的字段分叉。 // - overrides:默认字段的值覆盖。加密字段空值/占位保留原密文;非加密字段空值表示继承(不写入)。 // - extra_fields:本区域专属字段。加密字段空值/占位保留原密文(复用 encryptFieldsSlice)。 // - disabled_keys:本区域不适用的默认字段 key(仅保留确实存在于默认字段集的 key)。 func (this *serverComp) saveSvcRegionOverride(c *gin.Context) { var req struct { AppName string `json:"app_name"` SvcId string `json:"svc_id"` Region int32 `json:"region"` Overrides map[string]string `json:"overrides"` ExtraFields []SvcField `json:"extra_fields"` DisabledKeys []string `json:"disabled_keys"` } if err := c.ShouldBindJSON(&req); err != nil || req.SvcId == "" || req.Region == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "svc_id 和 region 必填") return } if req.Overrides == nil { req.Overrides = make(map[string]string) } // 读服务字段定义(同作用域),确定哪些字段需要加密、哪些是合法的默认字段 key cfg := &ThirdSvcConfig{} if err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", req.AppName, req.SvcId); err != nil { writeErr(c, pb.ErrorCode_DBError, "服务不存在: "+req.SvcId) return } encFields := make(map[string]bool, len(cfg.Fields)) for _, f := range cfg.Fields { if f.Encrypted { encFields[f.Key] = true } } // 读旧覆盖(同作用域),用于保留旧密文(默认字段覆盖 + 区域专属字段) old := &SvcRegionOverride{} _ = postgres.FindOne(comm.TableSvcRegionOverride, old, "app_name=? AND svc_id=? AND region=?", req.AppName, req.SvcId, req.Region) if old.Overrides == nil { old.Overrides = make(map[string]string) } key := this.options.EncryptKey newOverrides := make(map[string]string, len(req.Overrides)) for k, v := range req.Overrides { if encFields[k] { if v == "" || v == "[encrypted]" { // 加密字段留空/占位:保留旧密文(若有),否则视为继承(不写入) if oldVal, ok := old.Overrides[k]; ok { newOverrides[k] = oldVal } } else { ct, err := svcEncrypt(key, v) if err != nil { writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error()) return } newOverrides[k] = ct } } else { // 非加密字段:空值表示继承/删除覆盖,不写入 newOverrides if v != "" { newOverrides[k] = v } } } // 区域专属字段:DefValue 存该区域的值,加密字段空值/占位保留旧密文(按 key 匹配旧 ExtraFields) oldExtraMap := make(map[string]string, len(old.ExtraFields)) for _, f := range old.ExtraFields { oldExtraMap[f.Key] = f.DefValue } extraFields := make([]SvcField, 0, len(req.ExtraFields)) for _, f := range req.ExtraFields { if strings.TrimSpace(f.Key) == "" { continue } extraFields = append(extraFields, f) } if err := encryptFieldsSlice(key, extraFields, oldExtraMap); err != nil { writeErr(c, pb.ErrorCode_SystemError, "区域专属字段加密失败: "+err.Error()) return } // 停用字段:仅保留确实存在于默认字段集的 key,去重 disabled := make([]string, 0, len(req.DisabledKeys)) seenDisabled := make(map[string]bool, len(req.DisabledKeys)) for _, k := range req.DisabledKeys { if fieldExists(cfg.Fields, k) && !seenDisabled[k] { seenDisabled[k] = true disabled = append(disabled, k) } } ovr := &SvcRegionOverride{ Id: old.Id, AppName: req.AppName, SvcId: req.SvcId, Region: req.Region, Overrides: newOverrides, ExtraFields: extraFields, DisabledKeys: disabled, Updatetime: time.Now().UnixMilli(), } var err error if ovr.Id == 0 { err = postgres.Insert(comm.TableSvcRegionOverride, ovr) } else { err = postgres.Save(comm.TableSvcRegionOverride, ovr) } if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } broadcastConfigChanged(comm.ConfigKindThirdSvc, "update", req.Region) writeOK(c, gin.H{"ok": true, "override_id": ovr.Id}) } // syncSvcToRegions 把某服务的字段值(来自默认或指定区域覆盖)批量推送到多个目标区域。 // src_region=0 时使用字段 def_value;>0 时使用该区域的覆盖值。 func (this *serverComp) syncSvcToRegions(c *gin.Context) { var req struct { AppName string `json:"app_name"` SvcId string `json:"svc_id"` SrcRegion int32 `json:"src_region"` TargetRegions []int32 `json:"target_regions"` } if err := c.ShouldBindJSON(&req); err != nil || req.SvcId == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "svc_id 必填") return } if len(req.TargetRegions) == 0 { writeOK(c, gin.H{"synced": 0}) return } // 确定同步源:默认字段值覆盖 + 区域专属字段 + 停用字段(同作用域) var srcOverrides map[string]string var srcExtra []SvcField var srcDisabled []string if req.SrcRegion == 0 { cfg := &ThirdSvcConfig{} if err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", req.AppName, req.SvcId); err != nil { writeErr(c, pb.ErrorCode_DBError, "服务不存在: "+req.SvcId) return } srcOverrides = make(map[string]string, len(cfg.Fields)) for _, f := range cfg.Fields { srcOverrides[f.Key] = f.DefValue } } else { src := &SvcRegionOverride{} if err := postgres.FindOne(comm.TableSvcRegionOverride, src, "app_name=? AND svc_id=? AND region=?", req.AppName, req.SvcId, req.SrcRegion); err != nil { writeErr(c, pb.ErrorCode_DBError, "源区域覆盖不存在") return } srcOverrides = src.Overrides srcExtra = src.ExtraFields srcDisabled = src.DisabledKeys } now := time.Now().UnixMilli() synced := 0 for _, tgt := range req.TargetRegions { old := &SvcRegionOverride{} _ = postgres.FindOne(comm.TableSvcRegionOverride, old, "app_name=? AND svc_id=? AND region=?", req.AppName, req.SvcId, tgt) ovr := &SvcRegionOverride{ Id: old.Id, AppName: req.AppName, SvcId: req.SvcId, Region: tgt, Overrides: srcOverrides, ExtraFields: srcExtra, DisabledKeys: srcDisabled, Updatetime: now, } var err error if ovr.Id == 0 { err = postgres.Insert(comm.TableSvcRegionOverride, ovr) } else { err = postgres.Save(comm.TableSvcRegionOverride, ovr) } if err == nil { synced++ broadcastConfigChanged(comm.ConfigKindThirdSvc, "update", tgt) } } writeOK(c, gin.H{"synced": synced}) }