From 923644db2b091b4f700a06ffb5190d7ee4416a8d Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Sat, 29 Aug 2026 17:52:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=A8=A1=E5=9E=8B=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=A7=A3=E6=9E=90=E6=94=B9=E4=B8=BA=20ErrorMessageMap?= =?UTF-8?q?ping=20=E9=85=8D=E7=BD=AE=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseModelError 按模型配置的 ErrorMessageMapping(schema 树)解析错误响应 - 成功判定: code 提取为空 / code 节点配置 defaultValue 且提取值等于它 → 成功 - 未配置 mapping 不识别错误(纯配置驱动), 删硬编码 ModelErrorResp/ModelError1Resp - DTO 加 errorMessageMapping 字段, update.sql 建 error_message_mapping JSONB 列 - 单测: parse_error_test.go 覆盖扁平/嵌套/数组/纯字符串路径/未配置/坏 JSON --- model/dto/model_call_dto.go | 12 -- model/dto/model_manage_dto.go | 2 + service/model_task_end_service.go | 4 +- service/model_task_start_service.go | 4 +- service/parse_error.go | 196 +++++++++++++++++++++++++--- service/parse_error_test.go | 146 +++++++++++++++++++++ service/session_sync.go | 2 +- update.sql | 10 +- 8 files changed, 341 insertions(+), 35 deletions(-) create mode 100644 service/parse_error_test.go diff --git a/model/dto/model_call_dto.go b/model/dto/model_call_dto.go index 1d5a07e..f33a2b8 100644 --- a/model/dto/model_call_dto.go +++ b/model/dto/model_call_dto.go @@ -59,18 +59,6 @@ type ModelCallStreamReq struct { BusinessParams map[string]any `json:"businessParams" dc:"业务参数(按业务字段名传,按 RequestBusinessFieldMapping 写入请求体)"` } -type ModelErrorResp struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` -} - -type ModelError1Resp struct { - Code int `json:"code"` - Message string `json:"message"` -} - type ModelMsg struct { TaskID int64 `json:"id" dc:"任务ID"` TotalTokens int64 `json:"totalTokens" dc:"总token"` diff --git a/model/dto/model_manage_dto.go b/model/dto/model_manage_dto.go index e4d9b33..8e08309 100644 --- a/model/dto/model_manage_dto.go +++ b/model/dto/model_manage_dto.go @@ -38,6 +38,7 @@ type CreateModelManageReq struct { MinDuration int `json:"minDuration" dc:"最小时长"` MaxDuration int `json:"maxDuration" dc:"最大时长"` LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"` + ErrorMessageMapping map[string]any `json:"errorMessageMapping" dc:"错误消息映射(schema 树,解析模型错误用)"` } type CreateModelManageRes struct { @@ -73,6 +74,7 @@ type UpdateModelManageReq struct { MinDuration int `json:"minDuration" dc:"最小时长"` MaxDuration int `json:"maxDuration" dc:"最大时长"` LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"` + ErrorMessageMapping map[string]any `json:"errorMessageMapping" dc:"错误消息映射(schema 树,解析模型错误用)"` } type DeleteModelManageReq struct { diff --git a/service/model_task_end_service.go b/service/model_task_end_service.go index e8ff345..554387f 100644 --- a/service/model_task_end_service.go +++ b/service/model_task_end_service.go @@ -246,8 +246,8 @@ LOOP: } pollErrCnt = 0 // 请求成功一次即重置连续失败计数 - // 异常响应识别:兼容 OpenAI 嵌套 error / 扁平 code 两种形态(与任务创建端一致),无错误返回空串 - if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil { + // 异常响应识别:按模型 ErrorMessageMapping 解析,无错误返回空串 + if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody, modelInfo.ErrorMessageMapping); err != nil { g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err) if pollErrCnt < maxPollErrRetries { pollErrCnt++ diff --git a/service/model_task_start_service.go b/service/model_task_start_service.go index 9bdd789..b7c26b4 100644 --- a/service/model_task_start_service.go +++ b/service/model_task_start_service.go @@ -45,8 +45,8 @@ func (s *modelTaskStartService) CreateTask(ctx context.Context, req *dto.CallMod } docMsg := new(dto.ModelCallRes) docMsg.TaskId = id - // 统一解析模型错误(兼容 OpenAI 嵌套 error / 扁平 code 两种形态),无错误返回空串 - if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil { + // 按模型 ErrorMessageMapping 解析错误响应,无错误返回空串 + if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody, modelInfo.ErrorMessageMapping); err != nil { return nil, fmt.Errorf("模型返回参数解析失败:%v", err) } if docMsg.ErrorMsg != "" { diff --git a/service/parse_error.go b/service/parse_error.go index 2dcceb3..c92851b 100644 --- a/service/parse_error.go +++ b/service/parse_error.go @@ -1,32 +1,194 @@ package service import ( - "model-gateway/model/dto" + "encoding/json" + "reflect" + + modelUtils "model-gateway/service/utils" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" ) -// parseModelError 解析模型错误响应,返回错误码与错误消息(无错误均返回空串)。 -// 兼容两种形态(与任务创建端 model_task_start_service.go 一致): -// - OpenAI 嵌套 {"error":{"code","message"}} → 取 error.code / error.message -// - 扁平 {"code","message"} → code=20000000 视为成功码,不当作错误 +// parseModelError 按模型配置的 ErrorMessageMapping 解析错误响应,返回错误码与错误消息(无错误均返回空串)。 +// ErrorMessageMapping 为 schema 树(与请求模板同格式:type/value/label/isForm/required/fieldType/defaultValue/attrs), +// 解析时剔除包装字段取 code/message 的字段路径(复用 normalizeSchemaPath 归一 attrs/数组段), +// 经 GetByPath 从响应体提取。成功判定: +// - code 提取值为空(nil/空串/数字零)→ 成功 +// - code 节点配置了 defaultValue 且提取值等于它 → 成功 +// - 否则 → 错误(返回 code + message) // +// 未配置 ErrorMessageMapping → 不识别错误,一律返回成功(纯配置驱动)。 // 解析失败返回 err,由调用方决定重试/终态。 -func parseModelError(body []byte) (code, msg string, err error) { - errMsg := new(dto.ModelErrorResp) - if err = gconv.Struct(body, errMsg); err != nil { +func parseModelError(body []byte, mapping map[string]any) (code, msg string, err error) { + if len(mapping) == 0 { + return "", "", nil + } + var respObj map[string]any + if err = json.Unmarshal(body, &respObj); err != nil { return "", "", err } - if !g.IsEmpty(errMsg.Error.Code) { - return errMsg.Error.Code, errMsg.Error.Message, nil + codePath, msgPath, hasCodeDefault, codeDefault := collectErrorMapping(mapping) + codeVal := modelUtils.GetByPathValue(respObj, codePath) + msgVal := modelUtils.GetByPathValue(respObj, msgPath) + if codePath != "" { + if isEmptyErrorCode(codeVal) { + return "", "", nil + } + if hasCodeDefault && sameErrorValue(codeVal, codeDefault) { + return "", "", nil + } + return gconv.String(codeVal), gconv.String(msgVal), nil } - flat := new(dto.ModelError1Resp) - if err = gconv.Struct(body, flat); err != nil { - return "", "", err - } - if !g.IsEmpty(flat.Code) && flat.Code != 20000000 { - return gconv.String(flat.Code), flat.Message, nil + // 未配置 code 路径:以 message 是否为空判定错误 + if !isEmptyErrorCode(msgVal) { + return "", gconv.String(msgVal), nil } return "", "", nil } + +// collectErrorMapping 遍历 ErrorMessageMapping schema 树,提取 code/message 的字段路径与 code 节点的 defaultValue。 +// 支持 schema 节点(含 type/attrs 等包装)与纯字符串路径两种形态;数组段经 normalizeSchemaPath 归一到 [*]。 +// 返回路径为已归一字段路径;未配置返回空串。 +func collectErrorMapping(mapping map[string]any) (codePath, msgPath string, hasCodeDefault bool, codeDefault any) { + var walk func(node map[string]any, prefix string) + walk = func(node map[string]any, prefix string) { + for key, val := range node { + if isErrorMetaKey(key) { + continue + } + m, ok := val.(map[string]any) + if !ok { + // 纯值形态:字段值直接是字段路径字符串 + if s, ok := val.(string); ok { + path := normalizeSchemaPath(joinErrorFieldPath(prefix, s)) + if key == "code" && codePath == "" { + codePath = path + } else if key == "message" && msgPath == "" { + msgPath = path + } + } + continue + } + nodeType, _ := m["type"].(string) + hasDef := false + var def any + if d, has := m["defaultValue"]; has && d != nil { + hasDef, def = true, d + } + switch { + case nodeType == "object": + if attrs, ok := m["attrs"].(map[string]any); ok { + walk(attrs, joinErrorFieldPath(prefix, key)) + } else if attrs, ok := m["attrs"].([]any); ok && len(attrs) > 0 { + if elm, ok := attrs[0].(map[string]any); ok { + walkErrorArrayElement(elm, joinErrorArrayPath(prefix, key), walk) + } + } else { + walk(m, joinErrorFieldPath(prefix, key)) + } + case nodeType == "array": + walkErrorArrayContainer(m, joinErrorArrayPath(prefix, key), walk) + case nodeType == "": + // 无 type 键:纯容器(字段直接作为键),递归下钻 + walk(m, joinErrorFieldPath(prefix, key)) + default: + // 标量叶子字段 + path := normalizeSchemaPath(joinErrorFieldPath(prefix, key)) + if key == "code" && codePath == "" { + codePath, hasCodeDefault, codeDefault = path, hasDef, def + } else if key == "message" && msgPath == "" { + msgPath = path + } + } + } + } + walk(mapping, "") + return +} + +// walkErrorArrayContainer 数组节点:子字段容器依次尝试 attrs([]any)/enumValues/value([]any) +func walkErrorArrayContainer(node map[string]any, prefix string, walk func(map[string]any, string)) { + for _, container := range []string{"attrs", "enumValues", "value"} { + items, ok := node[container].([]any) + if !ok || len(items) == 0 { + continue + } + if elm, ok := items[0].(map[string]any); ok { + walkErrorArrayElement(elm, prefix, walk) + } + return + } +} + +// walkErrorArrayElement 数组元素:字段在其 attrs 下(对象元素)或直接作为键(纯元素) +func walkErrorArrayElement(elm map[string]any, prefix string, walk func(map[string]any, string)) { + if attrs, ok := elm["attrs"].(map[string]any); ok { + walk(attrs, prefix) + return + } + walk(elm, prefix) +} + +// isErrorMetaKey 判断是否为 schema 节点元数据字段(非业务字段,剔除) +func isErrorMetaKey(key string) bool { + switch key { + case "type", "value", "label", "isForm", "required", "fieldType", "defaultValue", "description": + return true + } + return false +} + +// joinErrorFieldPath 拼接字段路径(数组段 [0] 由 normalizeSchemaPath 归一为 [*]) +func joinErrorFieldPath(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +// joinErrorArrayPath 数组字段路径:元素下标 [0] 由 normalizeSchemaPath 归一为 [*] +func joinErrorArrayPath(prefix, key string) string { + if prefix == "" { + return key + "[0]" + } + return prefix + "." + key + "[0]" +} + +// isEmptyErrorCode 判断错误码是否为空(空=无错误):nil / 空串 / 布尔 false / 数字零值 +func isEmptyErrorCode(v any) bool { + if v == nil { + return true + } + switch t := v.(type) { + case string: + return t == "" + case bool: + return !t + } + if isNumericType(v) { + return gconv.Float64(v) == 0 + } + return false +} + +// sameErrorValue 判断提取值是否等于配置的 defaultValue(数值/字符串跨类型兼容) +func sameErrorValue(a, b any) bool { + if isNumericType(a) && isNumericType(b) { + return gconv.Float64(a) == gconv.Float64(b) + } + return gconv.String(a) == gconv.String(b) +} + +// isNumericType 判断是否为数值类型 +func isNumericType(v any) bool { + if v == nil { + return false + } + switch reflect.TypeOf(v).Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + } + return false +} diff --git a/service/parse_error_test.go b/service/parse_error_test.go new file mode 100644 index 0000000..ee33c48 --- /dev/null +++ b/service/parse_error_test.go @@ -0,0 +1,146 @@ +package service + +import "testing" + +// flatMapping 扁平形态:code 配置 defaultValue=20000000 为成功码 +func flatMapping() map[string]any { + return map[string]any{ + "code": map[string]any{ + "type": "number", "value": 0, "label": "", "fieldType": "number", "isForm": false, "required": false, + "defaultValue": float64(20000000), + }, + "message": map[string]any{ + "type": "string", "value": "", "label": "", "fieldType": "string", "isForm": false, "required": false, + }, + } +} + +func TestParseModelErrorFlatDefaultValue(t *testing.T) { + mapping := flatMapping() + // 成功:code == defaultValue + code, msg, err := parseModelError([]byte(`{"code":20000000,"message":"ok"}`), mapping) + if err != nil || code != "" || msg != "" { + t.Fatalf("success(default): code=%q msg=%q err=%v", code, msg, err) + } + // 成功:code 数字零值(视为空) + code, msg, err = parseModelError([]byte(`{"code":0}`), mapping) + if err != nil || code != "" || msg != "" { + t.Fatalf("success(zero): code=%q msg=%q err=%v", code, msg, err) + } + // 错误:code != defaultValue + code, msg, err = parseModelError([]byte(`{"code":1234,"message":"boom"}`), mapping) + if err != nil || code != "1234" || msg != "boom" { + t.Fatalf("error: code=%q msg=%q err=%v", code, msg, err) + } +} + +func TestParseModelErrorNested(t *testing.T) { + mapping := map[string]any{ + "error": map[string]any{ + "type": "object", + "attrs": map[string]any{ + "code": map[string]any{"type": "string", "label": "", "value": "", "fieldType": "string", "isForm": false, "required": false}, + "message": map[string]any{"type": "string", "label": "", "value": "", "fieldType": "string", "isForm": false, "required": false}, + }, + "label": "", "isForm": false, "required": false, "fieldType": "string", + }, + } + // 错误响应 + code, msg, err := parseModelError([]byte(`{"error":{"code":"rate_limit_exceeded","message":"Too fast"}}`), mapping) + if err != nil || code != "rate_limit_exceeded" || msg != "Too fast" { + t.Fatalf("error: code=%q msg=%q err=%v", code, msg, err) + } + // 成功响应(无 error 字段 → code 提取为空) + code, msg, err = parseModelError([]byte(`{"id":"x","choices":[]}`), mapping) + if err != nil || code != "" || msg != "" { + t.Fatalf("success: code=%q msg=%q err=%v", code, msg, err) + } +} + +func TestParseModelErrorArray(t *testing.T) { + mapping := map[string]any{ + "errors": map[string]any{ + "type": "array", + "attrs": []any{map[string]any{ + "code": map[string]any{"type": "number"}, + "message": map[string]any{"type": "string"}, + }}, + }, + } + code, msg, err := parseModelError([]byte(`{"errors":[{"code":1001,"message":"err-a"}]}`), mapping) + if err != nil || code != "1001" || msg != "err-a" { + t.Fatalf("array error: code=%q msg=%q err=%v", code, msg, err) + } + code, msg, err = parseModelError([]byte(`{"errors":[]}`), mapping) + if err != nil || code != "" || msg != "" { + t.Fatalf("array success: code=%q msg=%q err=%v", code, msg, err) + } +} + +func TestParseModelErrorPlainStringMapping(t *testing.T) { + // 纯字符串路径形态:字段值直接是字段路径 + mapping := map[string]any{"code": "error.code", "message": "error.message"} + code, msg, err := parseModelError([]byte(`{"error":{"code":100,"message":"nope"}}`), mapping) + if err != nil || code != "100" || msg != "nope" { + t.Fatalf("plain mapping: code=%q msg=%q err=%v", code, msg, err) + } + code, msg, err = parseModelError([]byte(`{"data":"ok"}`), mapping) + if err != nil || code != "" || msg != "" { + t.Fatalf("plain success: code=%q msg=%q err=%v", code, msg, err) + } +} + +func TestParseModelErrorNoMapping(t *testing.T) { + // 未配置 mapping:不识别错误,一律成功(纯配置驱动) + code, msg, err := parseModelError([]byte(`{"error":{"code":"x"}}`), nil) + if err != nil || code != "" || msg != "" { + t.Fatalf("no mapping: code=%q msg=%q err=%v", code, msg, err) + } +} + +func TestParseModelErrorBadJSON(t *testing.T) { + mapping := map[string]any{"code": map[string]any{"type": "string"}} + if _, _, err := parseModelError([]byte(`{invalid`), mapping); err == nil { + t.Fatalf("bad json should err") + } +} + +func TestParseModelErrorOnlyMessage(t *testing.T) { + // 只配置 message:以 message 是否为空判定错误 + mapping := map[string]any{"message": map[string]any{"type": "string"}} + code, msg, err := parseModelError([]byte(`{"message":"something went wrong"}`), mapping) + if err != nil || code != "" || msg != "something went wrong" { + t.Fatalf("only-message error: code=%q msg=%q err=%v", code, msg, err) + } + code, msg, err = parseModelError([]byte(`{"data":"ok"}`), mapping) + if err != nil || code != "" || msg != "" { + t.Fatalf("only-message success: code=%q msg=%q err=%v", code, msg, err) + } +} + +func TestCollectErrorMapping(t *testing.T) { + // 扁平 + defaultValue + codePath, msgPath, hasDef, def := collectErrorMapping(flatMapping()) + if codePath != "code" || msgPath != "message" || !hasDef || def != float64(20000000) { + t.Fatalf("flat: code=%q msg=%q hasDef=%v def=%v", codePath, msgPath, hasDef, def) + } + // 嵌套 + codePath, msgPath, hasDef, def = collectErrorMapping(map[string]any{ + "error": map[string]any{"type": "object", "attrs": map[string]any{ + "code": map[string]any{"type": "string"}, + "message": map[string]any{"type": "string"}, + }}, + }) + if codePath != "error.code" || msgPath != "error.message" || hasDef { + t.Fatalf("nested: code=%q msg=%q hasDef=%v", codePath, msgPath, hasDef) + } + // 数组 → [*] + codePath, _, _, _ = collectErrorMapping(map[string]any{ + "errors": map[string]any{"type": "array", "attrs": []any{ + map[string]any{"code": map[string]any{"type": "number"}}, + }}, + }) + if codePath != "errors[*].code" { + t.Fatalf("array: code=%q", codePath) + } +} diff --git a/service/session_sync.go b/service/session_sync.go index f238731..4769821 100644 --- a/service/session_sync.go +++ b/service/session_sync.go @@ -51,7 +51,7 @@ LOOP: } docMsg := new(dto.ModelCallRes) docMsg.TaskId = id - errCode, errMsg, err := parseModelError(modelRespBody) + errCode, errMsg, err := parseModelError(modelRespBody, modelInfo.ErrorMessageMapping) if err != nil { return nil, fmt.Errorf("模型返回参数解析失败:%v", err) } diff --git a/update.sql b/update.sql index f808458..6f7f2d2 100644 --- a/update.sql +++ b/update.sql @@ -353,4 +353,12 @@ UPDATE model_gateway_model_manage r SET FROM model_gateway_model_manage s WHERE r.system_model = false AND s.system_model = true AND r.model_name = s.model_name - AND r.ref_system_model_id IS NULL; \ No newline at end of file + AND r.ref_system_model_id IS NULL; + +-- ========================= +-- 错误消息映射:model_manage 新增 error_message_mapping(JSONB,schema 树形态,解析模型错误响应用) +-- ========================= +ALTER TABLE model_gateway_model_manage + ADD COLUMN IF NOT EXISTS error_message_mapping JSONB DEFAULT NULL; +COMMENT ON COLUMN model_gateway_model_manage.error_message_mapping + IS '错误消息映射:{code,message} 的 schema 树(type/attrs/value/defaultValue),解析模型错误响应,defaultValue 为成功码'; \ No newline at end of file