diff --git a/common/util/mapping.go b/common/util/mapping.go index 85547dd..b46de81 100644 --- a/common/util/mapping.go +++ b/common/util/mapping.go @@ -1,37 +1,28 @@ package util import ( - "bytes" - "context" "encoding/json" "fmt" - "io" "model-gateway/model/entity" - "net/http" "net/url" - "regexp" "strings" - "time" "github.com/gogf/gf/v2/encoding/gjson" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" tgjson "github.com/tidwall/gjson" ) -// ParseAndValidate 解析模型响应,并返回标准格式 -func ParseAndValidate(raw map[string]any, model *entity.ModelGatewayModel) (map[string]any, error) { +// ======================== 响应解析 ======================== + +// ParseAndValidate 解析模型响应,校验必填字段,返回标准 rounds 格式 +func ParseAndValidate(raw map[string]any, requiredFields []string) (map[string]any, error) { contentStr := gconv.String(raw[entity.ResponseBody]) if strings.TrimSpace(contentStr) == "" { return raw, fmt.Errorf("字段 %s 为空", entity.ResponseBody) } - contentStr = strings.Map(func(r rune) rune { - if r < 32 && r != ' ' { - return -1 - } - return r - }, contentStr) + // 过滤控制字符 + contentStr = cleanControlChars(contentStr) var arr []any if err := json.Unmarshal([]byte(contentStr), &arr); err != nil { @@ -41,11 +32,17 @@ func ParseAndValidate(raw map[string]any, model *entity.ModelGatewayModel) (map[ return raw, fmt.Errorf("解析后数组为空") } - for _, field := range model.RequiredFields { + // 校验必填字段 + if len(requiredFields) > 0 { for i, r := range arr { round, _ := r.(map[string]any) - if round != nil && gjson.New(round).Get(field).IsNil() { - return raw, fmt.Errorf("rounds[%d] 缺少必填字段: %s", i, field) + if round == nil { + continue + } + for _, field := range requiredFields { + if gjson.New(round).Get(field).IsNil() { + return raw, fmt.Errorf("rounds[%d] 缺少必填字段: %s", i, field) + } } } } @@ -53,11 +50,9 @@ func ParseAndValidate(raw map[string]any, model *entity.ModelGatewayModel) (map[ return map[string]any{"total_rounds": len(arr), "rounds": arr}, nil } -// ParseStructResult 解析结构结果 +// ParseStructResult 解析结构化结果 func ParseStructResult(raw map[string]any, responseBody string) map[string]any { - contentVal := raw[responseBody] - // 是字符串,尝试解析 - contentStr := gconv.String(contentVal) + contentStr := gconv.String(raw[responseBody]) if contentStr == "" || contentStr == "0" { return map[string]any{ "total_rounds": 1, @@ -65,63 +60,38 @@ func ParseStructResult(raw map[string]any, responseBody string) map[string]any { } } - // 尝试解析为数组 - var arr []any - if err := json.Unmarshal([]byte(contentStr), &arr); err == nil && len(arr) > 0 { + if arr := tryParseArray(contentStr); arr != nil { return map[string]any{ "total_rounds": 1, "rounds": []map[string]any{{responseBody: arr}}, } } - // 尝试解析为单个对象 - var parsed any - if err := json.Unmarshal([]byte(contentStr), &parsed); err == nil { + if parsed := tryParseAny(contentStr); parsed != nil { return map[string]any{ "total_rounds": 1, "rounds": []map[string]any{{responseBody: parsed}}, } } - // 兜底:原始字符串作为内容 return map[string]any{ "total_rounds": 1, "rounds": []map[string]any{{responseBody: contentStr}}, } } -// ParseHeadMsgHeaders 从 head_msg JSON 中提取请求头 -// head_msg 格式示例: -// -// { -// "Authorization": "Bearer xxx", -// "Content-Type": "application/json", -// "X-Api-App-Id": "5147401364", -// "X-Api-Access-Key": "VCqRX7..." -// } -func ParseHeadMsgHeaders(headMsg map[string]any) map[string]string { - if len(headMsg) == 0 { - return nil - } - out := make(map[string]string, len(headMsg)) - for k, v := range headMsg { - out[k] = gconv.String(v) - } - return out -} +// ======================== 响应映射 ======================== -// MapResponsePayload 映射模型响应为标准格式 +// MapResponsePayload 将模型响应按映射规则转为标准格式 func MapResponsePayload(mapping map[string]any, result map[string]any) (map[string]any, error) { if len(mapping) == 0 { return result, nil } - // 把 result 转成 JSON 字符串,tidwall/gjson 需要字符串输入 resultBytes, _ := json.Marshal(result) resultStr := string(resultBytes) mapped := make(map[string]any) - for standardField, modelPath := range mapping { path := gconv.String(modelPath) if path == "" { @@ -132,7 +102,7 @@ func MapResponsePayload(mapping map[string]any, result map[string]any) (map[stri if !value.Exists() { continue } - // 如果是数组路径(含 #),取 Array;否则取单值 + if strings.Contains(path, "#") { var arr []any for _, v := range value.Array() { @@ -147,19 +117,108 @@ func MapResponsePayload(mapping map[string]any, result map[string]any) (map[stri return mapped, nil } -// -//// GetModelBody 获取数据库中保存的模型信息 -//func GetModelBody(v map[string]any) map[string]any { -// if v == nil { -// return nil -// } -// if p, ok := v["body"]; ok { -// return gconv.Map(p) -// } -// return v -//} +// ValidateAndParseForm 校验表单并转为嵌套 map +func ValidateAndParseForm(forms []entity.Form) (map[string]any, error) { + result := gjson.New("{}") -// BodyToQuery 将 body 转为 url.Values + for _, form := range forms { + if form.Key == "" { + continue + } + if form.Required && (form.Value == nil || gconv.String(form.Value) == "") { + return nil, fmt.Errorf("字段 %s 为必填", form.Label) + } + if form.Value == nil { + continue + } + + val, err := validateAndConvert(form) + if err != nil { + return nil, err + } + _ = result.Set(form.Key, val) + } + + return result.Map(), nil +} + +// validateAndConvert 验证表单字段并转为标准格式 +func validateAndConvert(form entity.Form) (any, error) { + val := form.Value + fc := form.FieldConstraint + switch form.Type { + case "string": + s := gconv.String(val) + if fc.MaxLength > 0 && len(s) > fc.MaxLength { + return nil, fmt.Errorf("字段 %s 超过最大长度 %d", form.Label, fc.MaxLength) + } + if fc.MinLength > 0 && len(s) < fc.MinLength { + return nil, fmt.Errorf("字段 %s 不足最小长度 %d", form.Label, fc.MinLength) + } + return s, nil + + case "number": + f := gconv.Float64(val) + if fc.Min != nil && f < gconv.Float64(fc.Min) { + return nil, fmt.Errorf("字段 %s 不能小于 %v", form.Label, fc.Min) + } + if fc.Max != nil && f > gconv.Float64(fc.Max) { + return nil, fmt.Errorf("字段 %s 不能大于 %v", form.Label, fc.Max) + } + // 根据 numberType 决定返回 int 还是 float64 + switch fc.NumberType { + case "float", "positiveFloat", "negativeFloat": + return f, nil + default: + return int(f), nil + } + + case "select", "radio": + v, ok := val.(map[string]any) + if !ok { + return nil, fmt.Errorf("字段 %s 格式错误", form.Label) + } + return v, nil + + case "upload": + var urls []string + switch v := val.(type) { + case []any: + for _, u := range v { + urls = append(urls, gconv.String(u)) + } + case []string: + urls = v + case string: + if v != "" { + urls = []string{v} + } + } + if fc.MaxCount > 0 && len(urls) > fc.MaxCount { + return nil, fmt.Errorf("字段 %s 上传数量超过上限 %d", form.Label, fc.MaxCount) + } + return urls, nil + + default: + return gconv.String(val), nil + } +} + +// ======================== 请求工具 ======================== + +// ParseHeadMsgHeaders 从 head_msg 中提取 HTTP 请求头 +func ParseHeadMsgHeaders(headMsg map[string]any) map[string]string { + if len(headMsg) == 0 { + return nil + } + out := make(map[string]string, len(headMsg)) + for k, v := range headMsg { + out[k] = gconv.String(v) + } + return out +} + +// BodyToQuery 将 body 转为 URL 查询参数 func BodyToQuery(payload map[string]any) (url.Values, error) { q := url.Values{} for k, v := range payload { @@ -171,130 +230,29 @@ func BodyToQuery(payload map[string]any) (url.Values, error) { return q, nil } -// PullTaskResult 轮询查询异步任务结果直到完成 -func PullTaskResult(ctx context.Context, body map[string]any, queryConfig map[string]any, headMsg map[string]any) (map[string]any, error) { - // 1) 解析配置 - // 1.1 提取 taskID - taskIDPath := gconv.String(queryConfig["task_id"]) - taskID := gconv.String(gjson.New(body).Get(taskIDPath).Val()) - if taskID == "" { - return nil, fmt.Errorf("无法从路径 %s 提取 taskID", taskIDPath) - } - g.Log().Infof(ctx, "[PullTaskResult] taskID=%s", taskID) +// ======================== 内部辅助 ======================== - // 1.2 请求地址,替换 {id} - queryUrl := gconv.String(queryConfig["url"]) - queryUrl = replaceURLParams(queryUrl, map[string]any{"id": taskID}) - - // 1.3 请求方式 - method := gconv.String(queryConfig["method"]) - if method == "" { - method = "GET" - } - - // 1.4 状态判断配置 - statusPath := gconv.String(queryConfig["status_path"]) - statusValues, _ := queryConfig["status_values"].(map[string]any) - if statusPath == "" { - statusPath = "status" - } - - // 1.5 轮询间隔 - interval := gconv.Int(queryConfig["interval_seconds"]) - if interval <= 0 { - interval = 2 - } - - // 1.6 请求体 - reqBodyMap := map[string]any{"task_id": taskID} - - // 2) 轮询请求 - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: +func cleanControlChars(s string) string { + return strings.Map(func(r rune) rune { + if r < 32 && r != ' ' { + return -1 } - - var reqBody io.Reader - if method == "POST" { - bs, _ := json.Marshal(reqBodyMap) - reqBody = bytes.NewReader(bs) - } - - req, err := http.NewRequestWithContext(ctx, method, queryUrl, reqBody) - if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) - } - - // 统一用 headMsg 注入请求头 - for hk, hv := range ParseHeadMsgHeaders(headMsg) { - req.Header.Set(hk, hv) - } - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - g.Log().Warningf(ctx, "[PullTaskResult] 请求失败 taskID=%s err=%v", taskID, err) - time.Sleep(time.Duration(interval) * time.Second) - continue - } - - raw, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - - g.Log().Infof(ctx, "[PullTaskResult] taskID=%s statusCode=%d body=%s", taskID, resp.StatusCode, string(raw)) - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - time.Sleep(time.Duration(interval) * time.Second) - continue - } - - var result map[string]any - _ = json.Unmarshal(raw, &result) - - statusVal := gjson.New(result).Get(statusPath).Val() - statusStr := gconv.String(statusVal) - g.Log().Infof(ctx, "[PullTaskResult] 状态 taskID=%s status=%v", taskID, statusVal) - - if matchStatus(statusStr, statusValues["succeeded"]) { - g.Log().Infof(ctx, "[PullTaskResult] 任务成功 taskID=%s", taskID) - return result, nil - } - - if matchStatus(statusStr, statusValues["failed"]) { - g.Log().Errorf(ctx, "[PullTaskResult] 任务失败 taskID=%s", taskID) - return result, fmt.Errorf("任务失败") - } - - time.Sleep(time.Duration(interval) * time.Second) - } + return r + }, s) } -func matchStatus(actual string, expected any) bool { - expectedStr := gconv.String(expected) - if actual == expectedStr { - return true +func tryParseArray(s string) []any { + var arr []any + if err := json.Unmarshal([]byte(s), &arr); err == nil && len(arr) > 0 { + return arr } - switch v := expected.(type) { - case []any: - for _, item := range v { - if actual == gconv.String(item) { - return true - } - } - } - return false + return nil } -// replaceURLParams 替换 URL 中的 {key} -func replaceURLParams(url string, params map[string]any) string { - re := regexp.MustCompile(`\{([^}]+)}`) - return re.ReplaceAllStringFunc(url, func(s string) string { - key := strings.Trim(s, "{}") - if val, ok := params[key]; ok { - return gconv.String(val) - } - return s - }) +func tryParseAny(s string) any { + var parsed any + if err := json.Unmarshal([]byte(s), &parsed); err == nil { + return parsed + } + return nil } diff --git a/common/util/pull_task.go b/common/util/pull_task.go new file mode 100644 index 0000000..bb77128 --- /dev/null +++ b/common/util/pull_task.go @@ -0,0 +1,153 @@ +package util + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// PullTaskResult 轮询查询异步任务结果 +func PullTaskResult(ctx context.Context, body map[string]any, queryConfig map[string]any, headMsg map[string]any) (map[string]any, error) { + taskID, err := extractTaskID(body, queryConfig) + if err != nil { + return nil, err + } + g.Log().Infof(ctx, "[PullTaskResult] taskID=%s", taskID) + + queryUrl := buildQueryURL(queryConfig, taskID) + method := gconv.String(queryConfig["method"]) + if method == "" { + method = "GET" + } + interval := gconv.Int(queryConfig["interval_seconds"]) + if interval <= 0 { + interval = 2 + } + + statusPath := gconv.String(queryConfig["status_path"]) + if statusPath == "" { + statusPath = "status" + } + statusValues, _ := queryConfig["status_values"].(map[string]any) + + reqBodyMap := map[string]any{"task_id": taskID} + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + result, err := doQueryRequest(ctx, method, queryUrl, reqBodyMap, headMsg) + if err != nil { + g.Log().Warningf(ctx, "[PullTaskResult] 请求失败 taskID=%s err=%v", taskID, err) + time.Sleep(time.Duration(interval) * time.Second) + continue + } + + if result == nil { + time.Sleep(time.Duration(interval) * time.Second) + continue + } + + statusStr := gconv.String(gjson.New(result).Get(statusPath).Val()) + g.Log().Infof(ctx, "[PullTaskResult] 状态 taskID=%s status=%s", taskID, statusStr) + + if matchStatus(statusStr, statusValues["succeeded"]) { + g.Log().Infof(ctx, "[PullTaskResult] 任务成功 taskID=%s", taskID) + return result, nil + } + + if matchStatus(statusStr, statusValues["failed"]) { + g.Log().Errorf(ctx, "[PullTaskResult] 任务失败 taskID=%s", taskID) + return result, fmt.Errorf("任务失败") + } + + time.Sleep(time.Duration(interval) * time.Second) + } +} + +func extractTaskID(body, queryConfig map[string]any) (string, error) { + taskIDPath := gconv.String(queryConfig["task_id"]) + taskID := gconv.String(gjson.New(body).Get(taskIDPath).Val()) + if taskID == "" { + return "", fmt.Errorf("无法从路径 %s 提取 taskID", taskIDPath) + } + return taskID, nil +} + +func buildQueryURL(queryConfig map[string]any, taskID string) string { + queryUrl := gconv.String(queryConfig["url"]) + return replaceURLParams(queryUrl, map[string]any{"id": taskID}) +} + +func doQueryRequest(ctx context.Context, method, queryUrl string, reqBodyMap map[string]any, headMsg map[string]any) (map[string]any, error) { + var reqBody io.Reader + if method == "POST" { + bs, _ := json.Marshal(reqBodyMap) + reqBody = bytes.NewReader(bs) + } + + req, err := http.NewRequestWithContext(ctx, method, queryUrl, reqBody) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + for hk, hv := range ParseHeadMsgHeaders(headMsg) { + req.Header.Set(hk, hv) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, nil + } + + var result map[string]any + _ = json.Unmarshal(raw, &result) + return result, nil +} + +func matchStatus(actual string, expected any) bool { + expectedStr := gconv.String(expected) + if actual == expectedStr { + return true + } + if arr, ok := expected.([]any); ok { + for _, item := range arr { + if actual == gconv.String(item) { + return true + } + } + } + return false +} + +func replaceURLParams(rawURL string, params map[string]any) string { + re := regexp.MustCompile(`\{([^}]+)}`) + return re.ReplaceAllStringFunc(rawURL, func(s string) string { + key := strings.Trim(s, "{}") + if val, ok := params[key]; ok { + return gconv.String(val) + } + return s + }) +} diff --git a/model/dto/model_gateway_models_dto.go b/model/dto/model_gateway_models_dto.go index 384a570..7dea9f6 100644 --- a/model/dto/model_gateway_models_dto.go +++ b/model/dto/model_gateway_models_dto.go @@ -9,36 +9,34 @@ import ( // CreateModelReq 添加模型配置 type CreateModelReq struct { - g.Meta `path:"/createModel" method:"post" tags:"模型管理" summary:"创建模型配置" dc:"添加新的模型配置"` - ModelName string `p:"modelName" json:"modelName" v:"required#模型名称不能为空" dc:"模型名称(唯一标识)"` - ModelType int `p:"modelType" json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"` - BaseURL string `p:"baseUrl" json:"baseUrl" v:"required#模型地址不能为空" dc:"模型服务地址"` - HttpMethod string `p:"httpMethod" json:"httpMethod" dc:"请求方式:GET/POST(默认POST)"` - HeadMsg map[string]any `p:"headMsg" json:"headMsg" dc:"请求头JSON结构"` - IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化:0-私有 1-公共"` - Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-停用 1-启用"` - IsChatModel *int `p:"isChatModel" json:"isChatModel" dc:"是否为对话模型:0-否 1-是"` - CallModel *int `p:"callModel" json:"callModel" dc:"调用模式:0-同步 1-异步 2-流式"` - RequiredFields []string `p:"requiredFields" json:"requiredFields" dc:"必填字段"` - IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者:0-否 1-是"` - ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥"` - Form []map[string]any `p:"form" json:"form" dc:"动态表单配置"` - RequestMapping map[string]any `p:"requestMapping" json:"requestMapping" dc:"请求映射"` - ResponseMapping map[string]any `p:"responseMapping" json:"responseMapping" dc:"返回映射"` - ResponseBody string `p:"responseBody" json:"responseBody" dc:"返回主体"` - ResponseTokenField string `p:"responseTokenField" json:"responseTokenField" dc:"响应中消耗token的字段映射"` - OperatorName string `p:"operatorName" json:"operatorName" dc:"运营商名称"` - TokenConfig map[string]any `p:"tokenConfig" json:"tokenConfig" dc:"token计算配置"` - ExtendMapping map[string]any `p:"extendMapping" json:"extendMapping" dc:"附加映射"` - QueryConfig map[string]any `p:"queryConfig" json:"queryConfig" dc:"查询/回调配置"` - StreamConfig map[string]any `p:"streamConfig" json:"streamConfig" dc:"流式输出配置"` - FirstFrame string `p:"firstFrame" json:"firstFrame" dc:"首帧图片参数"` - LastFrame string `p:"lastFrame" json:"lastFrame" dc:"尾帧图片参数"` - MaxConcurrency int `p:"maxConcurrency" json:"maxConcurrency" dc:"最大并发数(默认10)"` - TimeoutSeconds int `p:"timeoutSeconds" json:"timeoutSeconds" dc:"请求超时时间(秒,默认600)"` - RetryTimes int `p:"retryTimes" json:"retryTimes" dc:"失败重试次数(默认3)"` - AutoCleanSeconds int `p:"autoCleanSeconds" json:"autoCleanSeconds" dc:"任务完成后自动清理时间(秒,默认86400)"` - CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址"` + g.Meta `path:"/createModel" method:"post" tags:"模型管理" summary:"创建模型配置" dc:"添加新的模型配置"` + ModelName string `p:"modelName" json:"modelName" v:"required#模型名称不能为空" dc:"模型名称(唯一标识)"` + ModelType int `p:"modelType" json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"` + BaseURL string `p:"baseUrl" json:"baseUrl" v:"required#模型地址不能为空" dc:"模型服务地址"` + HttpMethod string `p:"httpMethod" json:"httpMethod" dc:"请求方式:GET/POST(默认POST)"` + HeadMsg map[string]any `p:"headMsg" json:"headMsg" dc:"请求头JSON结构"` + IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化:0-私有 1-公共"` + Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-停用 1-启用"` + IsChatModel *int `p:"isChatModel" json:"isChatModel" dc:"是否为对话模型:0-否 1-是"` + CallMode *int `p:"callMode" json:"callMode" dc:"调用模式:0-同步 1-异步 2-流式"` + RequiredFields []string `p:"requiredFields" json:"requiredFields" dc:"必填字段"` + IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者:0-否 1-是"` + ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥"` + Form []entity.Form `p:"form" json:"form" dc:"动态表单配置"` + RequestMapping map[string]any `p:"requestMapping" json:"requestMapping" dc:"请求映射"` + ResponseMapping map[string]any `p:"responseMapping" json:"responseMapping" dc:"返回映射"` + OperatorName string `p:"operatorName" json:"operatorName" dc:"运营商名称"` + TokenConfig map[string]any `p:"tokenConfig" json:"tokenConfig" dc:"token计算配置"` + ExtendMapping map[string]any `p:"extendMapping" json:"extendMapping" dc:"附加映射"` + QueryConfig map[string]any `p:"queryConfig" json:"queryConfig" dc:"查询/回调配置"` + StreamConfig map[string]any `p:"streamConfig" json:"streamConfig" dc:"流式输出配置"` + FirstFrame string `p:"firstFrame" json:"firstFrame" dc:"首帧图片参数"` + LastFrame string `p:"lastFrame" json:"lastFrame" dc:"尾帧图片参数"` + MaxConcurrency int `p:"maxConcurrency" json:"maxConcurrency" dc:"最大并发数(默认10)"` + TimeoutSeconds int `p:"timeoutSeconds" json:"timeoutSeconds" dc:"请求超时时间(秒,默认600)"` + RetryTimes int `p:"retryTimes" json:"retryTimes" dc:"失败重试次数(默认3)"` + AutoCleanSeconds int `p:"autoCleanSeconds" json:"autoCleanSeconds" dc:"任务完成后自动清理时间(秒,默认86400)"` + CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址"` } type CreateModelRes struct { @@ -46,37 +44,35 @@ type CreateModelRes struct { } type UpdateModelReq struct { - g.Meta `path:"/updateModel" method:"put" tags:"模型管理" summary:"更新模型配置" dc:"更新指定ID的模型配置"` - ID int64 `p:"id" json:"id" v:"required#id不能为空" dc:"配置ID"` - ModelName string `p:"modelName" json:"modelName" dc:"模型名称"` - ModelType int `p:"modelType" json:"modelType" dc:"模型类型"` - BaseURL string `p:"baseUrl" json:"baseUrl" dc:"模型服务地址"` - HttpMethod string `p:"httpMethod" json:"httpMethod" dc:"请求方式:GET/POST"` - HeadMsg map[string]any `p:"headMsg" json:"headMsg" dc:"请求头JSON结构"` - IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化:0-私有 1-公共"` - Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-停用 1-启用"` - IsChatModel *int `p:"isChatModel" json:"isChatModel" dc:"是否为对话模型:0-否 1-是"` - CallModel *int `p:"callModel" json:"callModel" dc:"调用模式:0-同步 1-异步 2-流式"` - RequiredFields []string `p:"requiredFields" json:"requiredFields" dc:"必填字段"` - IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者:0-否 1-是"` - ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥"` - Form []map[string]any `p:"form" json:"form" dc:"动态表单配置"` - RequestMapping map[string]any `p:"requestMapping" json:"requestMapping" dc:"请求映射"` - ResponseMapping map[string]any `p:"responseMapping" json:"responseMapping" dc:"返回映射"` - ResponseBody string `p:"responseBody" json:"responseBody" dc:"返回主体"` - ResponseTokenField string `p:"responseTokenField" json:"responseTokenField" dc:"响应中消耗token的字段映射"` - OperatorName string `p:"operatorName" json:"operatorName" dc:"运营商名称"` - TokenConfig map[string]any `p:"tokenConfig" json:"tokenConfig" dc:"token计算配置"` - ExtendMapping map[string]any `p:"extendMapping" json:"extendMapping" dc:"附加映射"` - QueryConfig map[string]any `p:"queryConfig" json:"queryConfig" dc:"查询/回调配置"` - StreamConfig map[string]any `p:"streamConfig" json:"streamConfig" dc:"流式输出配置"` - FirstFrame string `p:"firstFrame" json:"firstFrame" dc:"首帧图片参数"` - LastFrame string `p:"lastFrame" json:"lastFrame" dc:"尾帧图片参数"` - MaxConcurrency int `p:"maxConcurrency" json:"maxConcurrency" dc:"最大并发数"` - TimeoutSeconds int `p:"timeoutSeconds" json:"timeoutSeconds" dc:"请求超时时间(秒)"` - RetryTimes int `p:"retryTimes" json:"retryTimes" dc:"失败重试次数"` - AutoCleanSeconds int `p:"autoCleanSeconds" json:"autoCleanSeconds" dc:"任务完成后自动清理时间(秒)"` - CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址"` + g.Meta `path:"/updateModel" method:"put" tags:"模型管理" summary:"更新模型配置" dc:"更新指定ID的模型配置"` + ID int64 `p:"id" json:"id" v:"required#id不能为空" dc:"配置ID"` + ModelName string `p:"modelName" json:"modelName" v:"required#模型名称不能为空" dc:"模型名称(唯一标识)"` + ModelType int `p:"modelType" json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"` + BaseURL string `p:"baseUrl" json:"baseUrl" v:"required#模型地址不能为空" dc:"模型服务地址"` + HttpMethod string `p:"httpMethod" json:"httpMethod" dc:"请求方式:GET/POST(默认POST)"` + HeadMsg map[string]any `p:"headMsg" json:"headMsg" dc:"请求头JSON结构"` + IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化:0-私有 1-公共"` + Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-停用 1-启用"` + IsChatModel *int `p:"isChatModel" json:"isChatModel" dc:"是否为对话模型:0-否 1-是"` + CallMode *int `p:"callMode" json:"callMode" dc:"调用模式:0-同步 1-异步 2-流式"` + RequiredFields []string `p:"requiredFields" json:"requiredFields" dc:"必填字段"` + IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者:0-否 1-是"` + ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥"` + Form []entity.Form `p:"form" json:"form" dc:"动态表单配置"` + RequestMapping map[string]any `p:"requestMapping" json:"requestMapping" dc:"请求映射"` + ResponseMapping map[string]any `p:"responseMapping" json:"responseMapping" dc:"返回映射"` + OperatorName string `p:"operatorName" json:"operatorName" dc:"运营商名称"` + TokenConfig map[string]any `p:"tokenConfig" json:"tokenConfig" dc:"token计算配置"` + ExtendMapping map[string]any `p:"extendMapping" json:"extendMapping" dc:"附加映射"` + QueryConfig map[string]any `p:"queryConfig" json:"queryConfig" dc:"查询/回调配置"` + StreamConfig map[string]any `p:"streamConfig" json:"streamConfig" dc:"流式输出配置"` + FirstFrame string `p:"firstFrame" json:"firstFrame" dc:"首帧图片参数"` + LastFrame string `p:"lastFrame" json:"lastFrame" dc:"尾帧图片参数"` + MaxConcurrency int `p:"maxConcurrency" json:"maxConcurrency" dc:"最大并发数(默认10)"` + TimeoutSeconds int `p:"timeoutSeconds" json:"timeoutSeconds" dc:"请求超时时间(秒,默认600)"` + RetryTimes int `p:"retryTimes" json:"retryTimes" dc:"失败重试次数(默认3)"` + AutoCleanSeconds int `p:"autoCleanSeconds" json:"autoCleanSeconds" dc:"任务完成后自动清理时间(秒,默认86400)"` + CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址"` } type UpdateModelRes struct { diff --git a/model/entity/model_gateway_model.go b/model/entity/model_gateway_model.go index afdeb3e..c41117a 100644 --- a/model/entity/model_gateway_model.go +++ b/model/entity/model_gateway_model.go @@ -61,38 +61,64 @@ var ModelGatewayModelCol = modelGatewayModelCol{ StreamConfig: "stream_config", FirstFrame: "first_frame", LastFrame: "last_frame", - MaxTokens: "max_tokens", } type ModelGatewayModel struct { beans.SQLBaseDO `orm:",inline"` - ModelName string `orm:"model_name" json:"modelName"` - ModelType int `orm:"model_type" json:"modelType"` - BaseURL string `orm:"base_url" json:"baseUrl"` - HttpMethod string `orm:"http_method" json:"httpMethod"` - HeadMsg map[string]any `orm:"head_msg" json:"headMsg"` - Form []map[string]any `orm:"form_json" json:"form"` - RequestMapping map[string]any `orm:"request_mapping" json:"requestMapping"` - ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping"` - RequiredFields []string `orm:"required_fields" json:"requiredFields"` - IsPrivate *int `orm:"is_private" json:"isPrivate"` - IsChatModel *int `orm:"is_chat_model" json:"isChatModel"` - CallMode *int `orm:"call_mode" json:"callMode"` - ApiKey string `orm:"api_key" json:"apiKey"` - Enabled *int `orm:"enabled" json:"enabled"` - MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency"` - TimeoutSeconds int `orm:"timeout_seconds" json:"timeoutSeconds"` - RetryTimes int `orm:"retry_times" json:"retryTimes"` - AutoCleanSeconds int `orm:"auto_clean_seconds" json:"autoCleanSeconds"` - IsOwner *int `orm:"is_owner" json:"isOwner"` - OperatorName string `orm:"operator_name" json:"operatorName"` - TokenConfig map[string]any `orm:"token_config" json:"tokenConfig"` - ExtendMapping map[string]any `orm:"extend_mapping" json:"extendMapping"` - QueryConfig map[string]any `orm:"query_config" json:"queryConfig"` - StreamConfig map[string]any `orm:"stream_config" json:"streamConfig"` - FirstFrame string `orm:"first_frame" json:"firstFrame"` - LastFrame string `orm:"last_frame" json:"lastFrame"` - MaxTokens int `orm:"max_tokens" json:"maxTokens"` + ModelName string `orm:"model_name" json:"modelName"` + ModelType int `orm:"model_type" json:"modelType"` + BaseURL string `orm:"base_url" json:"baseUrl"` + HttpMethod string `orm:"http_method" json:"httpMethod"` + HeadMsg map[string]any `orm:"head_msg" json:"headMsg"` + Form []Form `orm:"form_json" json:"form"` + RequestMapping map[string]any `orm:"request_mapping" json:"requestMapping"` + ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping"` + RequiredFields []string `orm:"required_fields" json:"requiredFields"` + IsPrivate *int `orm:"is_private" json:"isPrivate"` + IsChatModel *int `orm:"is_chat_model" json:"isChatModel"` + CallMode *int `orm:"call_mode" json:"callMode"` + ApiKey string `orm:"api_key" json:"apiKey"` + Enabled *int `orm:"enabled" json:"enabled"` + MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency"` + TimeoutSeconds int `orm:"timeout_seconds" json:"timeoutSeconds"` + RetryTimes int `orm:"retry_times" json:"retryTimes"` + AutoCleanSeconds int `orm:"auto_clean_seconds" json:"autoCleanSeconds"` + IsOwner *int `orm:"is_owner" json:"isOwner"` + OperatorName string `orm:"operator_name" json:"operatorName"` + TokenConfig map[string]any `orm:"token_config" json:"tokenConfig"` + ExtendMapping map[string]any `orm:"extend_mapping" json:"extendMapping"` + QueryConfig map[string]any `orm:"query_config" json:"queryConfig"` + StreamConfig map[string]any `orm:"stream_config" json:"streamConfig"` + FirstFrame string `orm:"first_frame" json:"firstFrame"` + LastFrame string `orm:"last_frame" json:"lastFrame"` +} + +type Form struct { + Key string `json:"key"` // 字段名 + Value any `json:"value"` // 值 + Label string `json:"label"` // 标签 + Type string `json:"type"` // 类型:string / number / boolean / select / radio / upload / json / array + DefaultValue any `json:"defaultValue"` // 默认值 + Required bool `json:"required"` // 是否必填 + IsForm bool `json:"isForm"` // 是否作为表单(用作工作流展示) + Options []map[string]any `json:"options"` // 选项(下拉/单选) + FieldConstraint FieldConstraint `json:"fieldConstraint"` // 字段约束 +} + +type FieldConstraint struct { + // 字符串校验 + MaxLength int `json:"maxLength"` // 最大长度 + MinLength int `json:"minLength"` // 最小长度 + + // 数字校验 + NumberType string `json:"numberType"` // 数字类型:integer / float / positiveInteger / positiveFloat / negativeInteger / negativeFloat + Min any `json:"min"` // 最小值 + Max any `json:"max"` // 最大值 + + // 文件上传校验 + MaxSize int `json:"maxSize"` // 最大文件(MB) + MaxCount int `json:"maxCount"` // 最大上传数量 + Accept string `json:"accept"` // 允许格式(逗号分隔) } const ( diff --git a/service/task/task_service.go b/service/task/task_service.go index 49e22b6..7fc295d 100644 --- a/service/task/task_service.go +++ b/service/task/task_service.go @@ -191,6 +191,10 @@ func (s *taskService) JobTask(ctx context.Context, req *dto.JobTaskReq) (res *dt func (s *taskService) executeTask(ctx context.Context, task *entity.ModelGatewayTask) error { // 1) 查询模型配置 model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{ + SQLBaseDO: beans.SQLBaseDO{ + TenantId: task.TenantId, + Creator: task.Creator, + }, ModelName: task.ModelName, }) if err != nil { @@ -201,7 +205,7 @@ func (s *taskService) executeTask(ctx context.Context, task *entity.ModelGateway } // 3) 调用 handleOne - AsyncWorker.handleOne(ctx, task, model) + AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model) return nil } diff --git a/service/task/worker.go b/service/task/worker.go index 5b190e7..e4cfd7d 100644 --- a/service/task/worker.go +++ b/service/task/worker.go @@ -40,14 +40,14 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa err error ) - g.Log().Infof(ctx, "[handleOne] 开始 taskId=%s model=%s", task.TaskID, task.ModelName) + g.Log().Infof(ctx, "[任务执行] 开始 taskId=%s model=%s", task.TaskID, task.ModelName) // ============================================ // 1) 调用模型 // ============================================ for attempt := 0; ; attempt++ { if attempt > 0 { - g.Log().Infof(ctx, "[handleOne] 调模型重试 第%d次 taskId=%s", attempt, task.TaskID) + g.Log().Infof(ctx, "[任务执行] 调用模型重试 第%d次 taskId=%s", attempt, task.TaskID) time.Sleep(time.Duration(attempt) * time.Second) } @@ -76,7 +76,7 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa return } - g.Log().Warningf(ctx, "[handleOne] 调模型失败 taskId=%s attempt=%d err=%v", task.TaskID, attempt, err) + g.Log().Warningf(ctx, "[任务执行] 调用模型失败 taskId=%s 第%d次 err=%v", task.TaskID, attempt, err) } // ============================================ @@ -94,13 +94,13 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa var oss *gateway.UploadFileResponse for attempt := 0; attempt <= maxRetry; attempt++ { if attempt > 0 { - g.Log().Infof(ctx, "[handleOne] OSS上传重试 第%d/%d次 taskId=%s", attempt, maxRetry, task.TaskID) + g.Log().Infof(ctx, "[任务执行] OSS上传重试 第%d/%d次 taskId=%s", attempt, maxRetry, task.TaskID) } oss, err = gateway.UploadByTask(ctx, gjson.New(result).MustToJson(), "json") if err == nil { break } - g.Log().Errorf(ctx, "[handleOne] OSS上传失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err) + g.Log().Errorf(ctx, "[任务执行] OSS上传失败 taskId=%s 第%d/%d次 err=%v", task.TaskID, attempt, maxRetry, err) if attempt == maxRetry { w.failTask(ctx, task, startTime, fmt.Sprintf("OSS上传重试耗尽: %v", err)) return @@ -119,7 +119,7 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa } if _, err = dao.ModelGatewayTask.Update(ctx, task); err != nil { - g.Log().Errorf(ctx, "[handleOne] 更新DB失败 taskId=%s err=%v", task.TaskID, err) + g.Log().Errorf(ctx, "[任务执行] 更新数据库失败 taskId=%s err=%v", task.TaskID, err) return } @@ -128,7 +128,7 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa go gateway.TriggerPromptsCallback(util.AsyncCtx(ctx), task) } - g.Log().Infof(ctx, "[handleOne] 成功 taskId=%s duration=%ds fileType=%s", + g.Log().Infof(ctx, "[任务执行] 成功 taskId=%s 耗时=%ds 文件类型=%s", task.TaskID, task.DurationSeconds, oss.FileFormat) } @@ -141,7 +141,7 @@ type asyncResult struct { // asyncTaskChan 全局异步任务等待通道 var asyncTaskChan = sync.Map{} // taskID → chan asyncResult -func (w *asyncWorker) callModelAsync(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, body map[string]any) (map[string]any, error) { +func (w *asyncWorker) callModelAsync(ctx context.Context, model *entity.ModelGatewayModel, body map[string]any) (map[string]any, error) { // 1. 提交异步任务 body, err := w.callModel(ctx, model, body) if err != nil { @@ -235,7 +235,7 @@ func (w *asyncWorker) parseAndRetry(ctx context.Context, body map[string]any, ta var parsed map[string]any switch task.BuildType { case public.BuildTypePrompt, public.BuildTypeNode: - parsed, err = util.ParseAndValidate(mapped, model) + parsed, err = util.ParseAndValidate(mapped, model.RequiredFields) if err == nil { return parsed, nil }