From bc78ad2b2f7765fba702bbfdf15eb86aad3f3e1c Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Sat, 29 Aug 2026 14:36:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A8=A1=E5=9E=8B=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=B1=82=20resolveModelConfig=20+=20{apiKey}?= =?UTF-8?q?=20=E5=8D=A0=E4=BD=8D=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveModelConfig:引用行实时取系统行配置+本人 apiKey,Id 覆盖为系统模型 id - mergeReferenceConfig 纯函数:配置取系统行,个人字段取引用行,enabled 取 AND - substituteAPIPlaceholder 非破坏式替换(BaseURL/头/体/业务字段/异步映射) - 纯函数单测 4 个全过(service 包测试需临时 config.yml 兜底) Co-Authored-By: Claude --- service/model_resolve.go | 121 ++++++++++++++++++++++++++++++++ service/model_resolve_test.go | 125 ++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 service/model_resolve.go create mode 100644 service/model_resolve_test.go diff --git a/service/model_resolve.go b/service/model_resolve.go new file mode 100644 index 0000000..2c743f9 --- /dev/null +++ b/service/model_resolve.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "model-gateway/dao" + "model-gateway/model/dto" + "model-gateway/model/entity" + + "github.com/gogf/gf/v2/util/gconv" +) + +// ====================== 模型引用解析层 ====================== +// 引用行(ref_system_model_id>0) 调用时实时取系统模型配置 + 本人 apiKey 合成可执行配置; +// 系统模型调整零同步。Id 被覆盖为系统模型 id → 计价/并发键按系统模型走(会话/任务落库仍用引用行 id)。 + +const apiKeyPlaceholder = "{apiKey}" + +// replacePlaceholder 递归替换 map/slice/string 中的占位符(泛化自 task_end 的 replaceTaskPlaceholder,非破坏式)。 +func replacePlaceholder(v any, from, to string) any { + switch val := v.(type) { + case string: + return strings.ReplaceAll(val, from, to) + case map[string]any: + m := make(map[string]any, len(val)) + for k, x := range val { + m[k] = replacePlaceholder(x, from, to) + } + return m + case map[string]string: + m := make(map[string]string, len(val)) + for k, x := range val { + m[k] = strings.ReplaceAll(x, from, to) + } + return m + case []any: + arr := make([]any, len(val)) + for i, x := range val { + arr[i] = replacePlaceholder(x, from, to) + } + return arr + default: + return v + } +} + +// copyAndReplaceStringMap 非破坏式替换 map[string]string 值(新建 map,不污染入参) +func copyAndReplaceStringMap(src map[string]string, from, to string) map[string]string { + if src == nil { + return nil + } + m := make(map[string]string, len(src)) + for k, v := range src { + m[k] = strings.ReplaceAll(v, from, to) + } + return m +} + +// substituteAPIPlaceholder 把输入侧配置中的 {apiKey} 替换为生效 key(非破坏式:新建 map/struct,不污染入参)。 +// 覆盖 BaseURL / RequestHeadMapping / RequestBodyMapping / RequestBusinessFieldMapping / +// AsyncTaskMapping(Url/RequestHeadMapping/RequestBodyMapping)。 +func substituteAPIPlaceholder(m *entity.ModelManage, key string) { + m.BaseURL = strings.ReplaceAll(m.BaseURL, apiKeyPlaceholder, key) + m.RequestHeadMapping = copyAndReplaceStringMap(m.RequestHeadMapping, apiKeyPlaceholder, key) + m.RequestBusinessFieldMapping = copyAndReplaceStringMap(m.RequestBusinessFieldMapping, apiKeyPlaceholder, key) + if v, ok := replacePlaceholder(m.RequestBodyMapping, apiKeyPlaceholder, key).(map[string]any); ok { + m.RequestBodyMapping = v + } + if a := m.AsyncTaskMapping; a != nil { + ac := *a + ac.Url = strings.ReplaceAll(a.Url, apiKeyPlaceholder, key) + ac.RequestHeadMapping = copyAndReplaceStringMap(a.RequestHeadMapping, apiKeyPlaceholder, key) + if v, ok := replacePlaceholder(a.RequestBodyMapping, apiKeyPlaceholder, key).(map[string]any); ok { + ac.RequestBodyMapping = v + } + m.AsyncTaskMapping = &ac + } +} + +// mergeReferenceConfig 引用行 + 系统行 → 有效配置(纯函数,便于单测)。 +// 配置字段取系统行;个人字段(apiKey/enabled/chatModel)取引用行;enabled 取 AND(系统停用=引用失效)。 +func mergeReferenceConfig(stub, sys *entity.ModelManage) *entity.ModelManage { + out := *sys + out.ApiKey = stub.ApiKey + if stub.Enabled != nil { + out.Enabled = stub.Enabled + } + if stub.ChatModel != nil { + out.ChatModel = stub.ChatModel + } + if sys.Enabled != nil && !*sys.Enabled { + out.Enabled = gconv.PtrBool(false) + } + return &out +} + +// resolveModelConfig 把请求命中的模型行解析为可执行配置: +// 引用行 → 系统行配置 + 引用行 apiKey(Id 覆盖为系统模型 id);非引用行 → 原配置 + 自身 apiKey 替换占位。 +// 引用系统模型已删除 → 报错(调用方阻塞)。 +func resolveModelConfig(ctx context.Context, m *entity.ModelManage) (*entity.ModelManage, error) { + if m == nil { + return nil, nil + } + if m.RefSystemModelId > 0 { + sys, err := dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: m.RefSystemModelId}) + if err != nil { + return nil, err + } + if sys == nil { + return nil, fmt.Errorf("引用的系统模型已删除") + } + out := mergeReferenceConfig(m, sys) + substituteAPIPlaceholder(out, out.ApiKey) + return out, nil + } + out := *m + substituteAPIPlaceholder(&out, out.ApiKey) + return &out, nil +} diff --git a/service/model_resolve_test.go b/service/model_resolve_test.go new file mode 100644 index 0000000..7337790 --- /dev/null +++ b/service/model_resolve_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "testing" + + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/beans" + "github.com/gogf/gf/v2/util/gconv" +) + +func TestReplacePlaceholder(t *testing.T) { + if got := replacePlaceholder("Bearer {apiKey}", "{apiKey}", "sk-1"); got != "Bearer sk-1" { + t.Fatalf("string: %v", got) + } + got := replacePlaceholder(map[string]any{ + "url": "https://x.com?key={apiKey}", + "n": 1, + "sub": map[string]any{"k": "v-{apiKey}"}, + "list": []any{"a-{apiKey}", 2}, + }, "{apiKey}", "sk-9") + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("not map: %T", got) + } + if m["url"] != "https://x.com?key=sk-9" { + t.Fatalf("url: %v", m["url"]) + } + if m["n"] != 1 { + t.Fatalf("n: %v", m["n"]) + } + if m["sub"].(map[string]any)["k"] != "v-sk-9" { + t.Fatalf("sub: %v", m["sub"]) + } + if m["list"].([]any)[0] != "a-sk-9" { + t.Fatalf("list[0]: %v", m["list"]) + } +} + +func TestSubstituteAPIPlaceholder(t *testing.T) { + m := &entity.ModelManage{ + BaseURL: "https://api.xx.com?key={apiKey}", + RequestHeadMapping: map[string]string{"Authorization": "Bearer {apiKey}"}, + RequestBodyMapping: map[string]any{"model": "{apiKey}"}, + RequestBusinessFieldMapping: map[string]string{"key": "{apiKey}"}, + AsyncTaskMapping: &entity.AsyncTaskMapping{ + Url: "https://api.xx.com/task/{taskId}?key={apiKey}", + RequestBodyMapping: map[string]any{"auth": "{apiKey}"}, + }, + } + substituteAPIPlaceholder(m, "sk-user") + if m.BaseURL != "https://api.xx.com?key=sk-user" { + t.Fatalf("baseURL: %s", m.BaseURL) + } + if m.RequestHeadMapping["Authorization"] != "Bearer sk-user" { + t.Fatalf("head: %s", m.RequestHeadMapping["Authorization"]) + } + if m.RequestBodyMapping["model"] != "sk-user" { + t.Fatalf("body: %v", m.RequestBodyMapping["model"]) + } + if m.RequestBusinessFieldMapping["key"] != "sk-user" { + t.Fatalf("biz: %v", m.RequestBusinessFieldMapping["key"]) + } + if m.AsyncTaskMapping.Url != "https://api.xx.com/task/{taskId}?key=sk-user" { + t.Fatalf("async url: %s", m.AsyncTaskMapping.Url) + } + if m.AsyncTaskMapping.RequestBodyMapping["auth"] != "sk-user" { + t.Fatalf("async body: %v", m.AsyncTaskMapping.RequestBodyMapping["auth"]) + } +} + +func TestMergeReferenceConfig(t *testing.T) { + sys := &entity.ModelManage{ + SQLBaseDO: beans.SQLBaseDO{Id: 100}, BaseURL: "https://sys", SystemModel: gconv.PtrBool(true), + Enabled: gconv.PtrBool(true), RequestHeadMapping: map[string]string{"Authorization": "Bearer {apiKey}"}, + } + stub := &entity.ModelManage{RefSystemModelId: 100, ApiKey: "sk-user", Enabled: gconv.PtrBool(true), ChatModel: gconv.PtrBool(true)} + + out := mergeReferenceConfig(stub, sys) + if out.Id != 100 { + t.Fatalf("id: %d", out.Id) + } + if out.ApiKey != "sk-user" { + t.Fatalf("apiKey: %s", out.ApiKey) + } + if out.ChatModel == nil || !*out.ChatModel { + t.Fatalf("chatModel should follow stub") + } + if out.BaseURL != "https://sys" { + t.Fatalf("baseURL should follow sys: %s", out.BaseURL) + } + + // 系统停用 → 引用失效 + sysOff := &entity.ModelManage{Enabled: gconv.PtrBool(false)} + if out := mergeReferenceConfig(stub, sysOff); out.Enabled == nil || *out.Enabled { + t.Fatalf("system disabled should gate reference") + } + // 引用行停用 → 停用 + stubOff := &entity.ModelManage{Enabled: gconv.PtrBool(false)} + if out := mergeReferenceConfig(stubOff, sys); out.Enabled == nil || *out.Enabled { + t.Fatalf("stub disabled should gate") + } + // 引用行未设 enabled → 用系统行 + stubNil := &entity.ModelManage{ApiKey: "sk-user"} + if out := mergeReferenceConfig(stubNil, sys); out.Enabled == nil || !*out.Enabled { + t.Fatalf("stub nil enabled should follow sys") + } +} + +func TestResolveModelConfigNonReference(t *testing.T) { + m := &entity.ModelManage{SQLBaseDO: beans.SQLBaseDO{Id: 5}, BaseURL: "https://x?key={apiKey}", ApiKey: "sk-sys"} + out, err := resolveModelConfig(nil, m) // 非引用路径不触 DB,ctx 可为 nil + if err != nil { + t.Fatalf("err: %v", err) + } + if out.BaseURL != "https://x?key=sk-sys" { + t.Fatalf("baseURL: %s", out.BaseURL) + } + if out.Id != 5 { + t.Fatalf("id: %d", out.Id) + } + if out == m { + t.Fatalf("should return a copy, not mutate input") + } +}