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.
 
 
 
 
 
 

723 lines
27 KiB

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)
if !this.svcScopeOK(c, req.AppName) {
return
}
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
}
if !this.svcScopeOK(c, m.AppName) {
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
}
if !this.svcScopeOK(c, m.AppName) {
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 !this.svcScopeOK(c, req.AppName) {
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
}
if !this.svcScopeOK(c, req.AppName) {
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)
}
if !this.svcScopeOK(c, req.AppName) {
return
}
// 读服务字段定义(同作用域),确定哪些字段需要加密、哪些是合法的默认字段 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
}
if !this.svcScopeOK(c, req.AppName) {
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})
}
// svcScopeOK 第三方服务的作用域校验:app_name 必填 + 账号权限。
//
// 2026-09-14 起没有「全局默认」层了(见 migrate_scope.go),每个应用一套配置互不干涉。
// 空 app_name 一律拒绝,否则前端某处漏传就会在一个谁也看不见的作用域里建出服务。
// 权限沿用 requireAppNameScope:有应用绑定的账号只能碰自己名下的应用。
func (this *serverComp) svcScopeOK(c *gin.Context, appName string) bool {
if strings.TrimSpace(appName) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填:第三方服务按应用隔离,请先选择应用")
return false
}
return this.requireAppNameScope(c, appName)
}
// copySvcConfig 把一个应用的服务(含区域分叉)复制到另一个应用。
//
// 各应用互不干涉之后,「两个应用用同一套凭据」这种最常见的情况得靠它,不然运营要逐个服务
// 抄一遍密文——而密文在后台是打码的,根本抄不出来。复制走库里的密文原样搬,不经过前端。
//
// ids 为空 = 复制源应用的全部服务;overwrite=false 时目标已有同 id 的跳过。
func (this *serverComp) copySvcConfig(c *gin.Context) {
var req struct {
SrcApp string `json:"src_app"`
DstApp string `json:"dst_app"`
Ids []string `json:"ids"`
Overwrite bool `json:"overwrite"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
req.SrcApp, req.DstApp = strings.TrimSpace(req.SrcApp), strings.TrimSpace(req.DstApp)
if req.SrcApp == "" || req.DstApp == "" || req.SrcApp == req.DstApp {
writeErr(c, pb.ErrorCode_ReqParameterError, "src_app 与 dst_app 必填且不能相同")
return
}
// 目标应用要有写权限;源应用只读,能看到列表即可(读权限在 getSvcConfigs 那边已按账号过滤)。
if !this.svcScopeOK(c, req.DstApp) {
return
}
srcs := make([]*ThirdSvcConfig, 0)
q, args := "app_name=?", []interface{}{req.SrcApp}
if len(req.Ids) > 0 {
q += " AND id IN ?"
args = append(args, req.Ids)
}
if err := postgres.Find(comm.TableSvcConfig, &srcs, q, args...); err != nil && err != postgres.ErrNoDocuments {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
copied, skipped := 0, 0
for _, src := range srcs {
exist := &ThirdSvcConfig{}
has := postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", req.DstApp, src.Id) == nil
if has && !req.Overwrite {
skipped++
continue
}
cp := *src
cp.AppName = req.DstApp
cp.Updatetime = time.Now().UnixMilli()
if has {
cp.RowId = exist.RowId
cp.Createtime = exist.Createtime
if err := postgres.Save(comm.TableSvcConfig, &cp); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
// 覆盖模式下目标的旧分叉整体换成源的,免得两边的区域配置拼在一起说不清
_ = postgres.Delete(comm.TableSvcRegionOverride, "app_name=? AND svc_id=?", req.DstApp, src.Id)
} else {
cp.RowId = 0
cp.Createtime = cp.Updatetime
if err := postgres.Insert(comm.TableSvcConfig, &cp); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
ovrs := make([]*SvcRegionOverride, 0)
_ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", req.SrcApp, src.Id)
for _, o := range ovrs {
oc := *o
oc.Id = 0
oc.AppName = req.DstApp
oc.Updatetime = time.Now().Unix()
_ = postgres.Insert(comm.TableSvcRegionOverride, &oc)
}
copied++
}
broadcastConfigChanged(comm.ConfigKindThirdSvc, "copy", 0)
writeOK(c, gin.H{"copied": copied, "skipped": skipped})
}