refactor: 模型错误解析改为 ErrorMessageMapping 配置驱动

- parseModelError 按模型配置的 ErrorMessageMapping(schema 树)解析错误响应
- 成功判定: code 提取为空 / code 节点配置 defaultValue 且提取值等于它 → 成功
- 未配置 mapping 不识别错误(纯配置驱动), 删硬编码 ModelErrorResp/ModelError1Resp
- DTO 加 errorMessageMapping 字段, update.sql 建 error_message_mapping JSONB 列
- 单测: parse_error_test.go 覆盖扁平/嵌套/数组/纯字符串路径/未配置/坏 JSON
This commit is contained in:
2026-08-29 17:52:23 +08:00
parent 7dcfb6744d
commit 923644db2b
8 changed files with 341 additions and 35 deletions
+179 -17
View File
@@ -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
}