From 4a9ae2d412e3cc3caefd02e51059446972019862 Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Sat, 29 Aug 2026 11:53:02 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20HTTP=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=B9=B6?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=86=97=E4=BD=99=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/util/convert.go | 10 - common/util/files.go | 115 ---- common/util/headers.go | 79 --- common/util/mapping.go | 303 ----------- common/util/streaming.go | 150 ------ consts/public/table_name.go | 5 - consts/task/status.go | 29 - .../model_gateway_logs_stat_controller.go | 18 - controller/model_gateway_models_controller.go | 66 --- controller/model_gateway_task_controller.go | 43 -- dao/model_gateway_logs_op.go | 23 - dao/model_gateway_logs_stat_dao.go | 52 -- dao/model_gateway_models_dao.go | 201 ------- dao/model_gateway_task_dao.go | 159 ------ main.go | 5 +- model/dto/model_call_dto.go | 8 +- model/dto/model_gateway_logs_stat_dto.go | 20 - model/dto/model_gateway_models_dto.go | 186 ------- model/dto/model_gateway_task_dto.go | 107 ---- model/entity/model_gateway_logs_op.go | 56 -- model/entity/model_gateway_logs_stat.go | 35 -- model/entity/model_gateway_model.go | 99 ---- model/entity/model_gateway_task.go | 76 --- service/gateway/gateway_http_service.go | 194 ------- service/httpclient/httpclient.go | 167 ++++++ service/httpclient/sse.go | 85 +++ service/model/model_service.go | 256 --------- service/model_call_service.go | 178 ++----- service/model_http_service.go | 232 +------- service/model_task_end_service.go | 338 ++++++------ service/model_task_start_service.go | 25 +- service/parse_error.go | 32 ++ service/queue/auto_tune.go | 198 ------- service/queue/queue_gate.go | 107 ---- service/queue/runtime_tune.go | 82 --- service/queue/semaphore.go | 57 -- service/retry.go | 53 ++ service/schema_mapping_service.go | 9 +- ...l_session_service.go => session_stream.go} | 205 +------ service/session_sync.go | 133 +++++ service/stat/stat_service.go | 34 -- service/task/task_service.go | 283 ---------- service/task/worker.go | 501 ------------------ 43 files changed, 719 insertions(+), 4295 deletions(-) delete mode 100644 common/util/convert.go delete mode 100644 common/util/files.go delete mode 100644 common/util/headers.go delete mode 100644 common/util/mapping.go delete mode 100644 common/util/streaming.go delete mode 100644 consts/task/status.go delete mode 100644 controller/model_gateway_logs_stat_controller.go delete mode 100644 controller/model_gateway_models_controller.go delete mode 100644 controller/model_gateway_task_controller.go delete mode 100644 dao/model_gateway_logs_op.go delete mode 100644 dao/model_gateway_logs_stat_dao.go delete mode 100644 dao/model_gateway_models_dao.go delete mode 100644 dao/model_gateway_task_dao.go delete mode 100644 model/dto/model_gateway_logs_stat_dto.go delete mode 100644 model/dto/model_gateway_models_dto.go delete mode 100644 model/dto/model_gateway_task_dto.go delete mode 100644 model/entity/model_gateway_logs_op.go delete mode 100644 model/entity/model_gateway_logs_stat.go delete mode 100644 model/entity/model_gateway_model.go delete mode 100644 model/entity/model_gateway_task.go delete mode 100644 service/gateway/gateway_http_service.go create mode 100644 service/httpclient/httpclient.go create mode 100644 service/httpclient/sse.go delete mode 100644 service/model/model_service.go create mode 100644 service/parse_error.go delete mode 100644 service/queue/auto_tune.go delete mode 100644 service/queue/queue_gate.go delete mode 100644 service/queue/runtime_tune.go delete mode 100644 service/queue/semaphore.go create mode 100644 service/retry.go rename service/{model_session_service.go => session_stream.go} (65%) create mode 100644 service/session_sync.go delete mode 100644 service/stat/stat_service.go delete mode 100644 service/task/task_service.go delete mode 100644 service/task/worker.go diff --git a/common/util/convert.go b/common/util/convert.go deleted file mode 100644 index 4148bdc..0000000 --- a/common/util/convert.go +++ /dev/null @@ -1,10 +0,0 @@ -package util - -import "github.com/gogf/gf/v2/util/gconv" - -// ConvertTo 转换为指定类型 -func ConvertTo[T any](v interface{}) *T { - var t T - _ = gconv.Struct(v, &t) - return &t -} diff --git a/common/util/files.go b/common/util/files.go deleted file mode 100644 index 45e28fc..0000000 --- a/common/util/files.go +++ /dev/null @@ -1,115 +0,0 @@ -package util - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" -) - -// DetectFileType 根据返回的二进制内容推断 contentType + 扩展名(尽量稳定) -func DetectFileType(data []byte) (contentType string, ext string) { - if len(data) == 0 { - return "application/octet-stream", "" - } - ct := http.DetectContentType(data) - // gateway.DetectContentType 可能带 charset 等参数:text/plain; charset=utf-8 - if idx := strings.Index(ct, ";"); idx > 0 { - ct = strings.TrimSpace(ct[:idx]) - } - switch ct { - case "audio/mpeg": - return ct, ".mp3" - case "audio/wave", "audio/wav", "audio/x-wav": - return ct, ".wav" - case "video/mp4": - return ct, ".mp4" - case "image/png": - return ct, ".png" - case "image/jpeg": - return ct, ".jpg" - case "application/pdf": - return ct, ".pdf" - case "text/plain": - return ct, ".txt" - case "application/json": - return ct, ".json" - default: - // 兜底:尝试从 ct 截取 subtype 作为后缀(例如 application/json) - if parts := strings.Split(ct, "/"); len(parts) == 2 { - sub := parts[1] - // 避免出现 "plain; charset=utf-8" 之类的后缀 - if idx := strings.Index(sub, ";"); idx > 0 { - sub = strings.TrimSpace(sub[:idx]) - } - return ct, "." + sub - } - return ct, "" - } -} - -// SaveTmpResult 将模型输出写入临时文件,用于 OSS 上传失败后的“仅重试 OSS”。 -func SaveTmpResult(taskID string, data []byte, ext string) (string, error) { - dir := filepath.Join(os.TempDir(), "model-asynch") - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - if ext == "" { - ext = ".bin" - } - if ext[0] != '.' { - ext = "." + ext - } - path := filepath.Join(dir, fmt.Sprintf("%s%s", taskID, ext)) - if err := os.WriteFile(path, data, 0o644); err != nil { - return "", err - } - return path, nil -} - -// SaveTempFileByType -// 根据传入的数据自动判断: -// 若是 []byte 且后缀为 .mp3 → 保存二进制音频 -// 若是任意结构体/map → 自动转 JSON 保存 -// 返回:新临时文件路径、错误 -func SaveTempFileByType(taskID string, data any, oldTmpFile string) (string, error) { - // 1. 先清理旧临时文件(统一逻辑) - if oldTmpFile != "" { - _ = os.Remove(oldTmpFile) - } - - var tmpPath string - var tmpErr error - - // 2. 判断是否是二进制音频([]byte + .mp3) - if audioData, ok := data.([]byte); ok { - tmpPath, tmpErr = saveTmpResult(taskID, audioData, ".mp3") - } else { - // 3. 其他类型 → 序列化为 JSON 保存 - mappedBytes, err := json.Marshal(data) - if err != nil { - return "", err - } - if len(mappedBytes) == 0 { - return "", nil - } - tmpPath, tmpErr = saveTmpResult(taskID, mappedBytes, ".json") - } - - if tmpErr != nil || tmpPath == "" { - return "", tmpErr - } - - return tmpPath, nil -} - -// saveTmpResult 你原有的底层保存文件方法(保留不动) -func saveTmpResult(taskID string, data []byte, ext string) (string, error) { - // 你原来实现,比如: - filename := taskID + ext - tmpPath := filepath.Join(os.TempDir(), filename) - err := os.WriteFile(tmpPath, data, 0644) - return tmpPath, err -} diff --git a/common/util/headers.go b/common/util/headers.go deleted file mode 100644 index 446bf63..0000000 --- a/common/util/headers.go +++ /dev/null @@ -1,79 +0,0 @@ -package util - -import ( - "context" - - "gitea.redpowerfuture.com/red-future/common/utils" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" -) - -// AsyncCtx 固化异步上下文中的 token 和用户信息,避免请求结束后丢失 -func AsyncCtx(ctx context.Context) context.Context { - asyncCtx := context.WithoutCancel(ctx) - - if r := g.RequestFromCtx(ctx); r != nil { - if token := r.Header.Get("Authorization"); token != "" { - asyncCtx = context.WithValue(asyncCtx, "token", token) - } - if userInfo := r.Header.Get("X-User-Info"); userInfo != "" { - asyncCtx = context.WithValue(asyncCtx, "xUserInfo", userInfo) - } - } - - if user, err := utils.GetUserInfo(ctx); err == nil && user != nil { - asyncCtx = context.WithValue(asyncCtx, "user", user) - } - - return asyncCtx -} - -// ForwardHeaders 透传调用链路的头信息,优先使用 ctx 中的固化值 -func ForwardHeaders(ctx context.Context) map[string]string { - headers := make(map[string]string) - SetHeaderFromContext(headers, ctx, "Authorization", "token") - SetHeaderFromContext(headers, ctx, "X-User-Info", "xUserInfo") - FallbackToRequestHeaders(headers, ctx) - return headers -} - -// SetHeaderFromContext 从上下文中设置 header -func SetHeaderFromContext(headers map[string]string, ctx context.Context, headerKey, ctxKey string) { - if value, ok := ctx.Value(ctxKey).(string); ok && value != "" { - headers[headerKey] = value - } -} - -// FallbackToRequestHeaders 从请求头中获取作为兜底 -func FallbackToRequestHeaders(headers map[string]string, ctx context.Context) { - r := g.RequestFromCtx(ctx) - if r == nil { - return - } - - if headers["Authorization"] == "" { - if token := r.Header.Get("Authorization"); token != "" { - headers["Authorization"] = token - } - } - - if headers["X-User-Info"] == "" { - if userInfo := r.Header.Get("X-User-Info"); userInfo != "" { - headers["X-User-Info"] = userInfo - } - } -} - -// SetTaskHeadersToCtx 把任务入库时保存的 header 信息注入 ctx,给 worker 调 OSS 用 -func SetTaskHeadersToCtx(ctx context.Context, headers map[string]string) context.Context { - if headers == nil { - return ctx - } - if v := gconv.String(headers["Authorization"]); v != "" { - ctx = context.WithValue(ctx, "token", v) - } - if v := gconv.String(headers["X-User-Info"]); v != "" { - ctx = context.WithValue(ctx, "xUserInfo", v) - } - return ctx -} diff --git a/common/util/mapping.go b/common/util/mapping.go deleted file mode 100644 index f00bf51..0000000 --- a/common/util/mapping.go +++ /dev/null @@ -1,303 +0,0 @@ -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) { - // 1) 解析 content 字符串为 rounds 数组 - contentVal, ok := raw[entity.ResponseBody] - if !ok { - return raw, fmt.Errorf("字段 %s 不存在", entity.ResponseBody) - } - contentStr, ok := contentVal.(string) - if !ok || strings.TrimSpace(contentStr) == "" { - return raw, fmt.Errorf("字段 %s 为空或不是字符串", entity.ResponseBody) - } - var arr []any - if err := json.Unmarshal([]byte(contentStr), &arr); err != nil { - return raw, fmt.Errorf("JSON解析失败: %w", err) - } - if len(arr) == 0 { - return raw, fmt.Errorf("解析后数组为空") - } - - // 2) 校验必填字段 - if len(model.RequiredFields) > 0 { - for i, r := range arr { - round, ok := r.(map[string]any) - if !ok { - continue - } - for _, field := range model.RequiredFields { - if gjson.New(round).Get(field).IsNil() { - return raw, fmt.Errorf("rounds[%d] 缺少必填字段: %s", i, field) - } - } - } - } - - return map[string]any{"total_rounds": len(arr), "rounds": arr}, nil -} - -// ParseStructResult 解析结构结果 -func ParseStructResult(raw map[string]any, responseBody string) map[string]any { - contentVal := raw[responseBody] - // 是字符串,尝试解析 - contentStr := gconv.String(contentVal) - if contentStr == "" || contentStr == "0" { - return map[string]any{ - "total_rounds": 1, - "rounds": []map[string]any{{responseBody: raw}}, - } - } - - // 尝试解析为数组 - var arr []any - if err := json.Unmarshal([]byte(contentStr), &arr); err == nil && len(arr) > 0 { - 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 { - 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 映射模型响应为标准格式 -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 == "" { - continue - } - - value := tgjson.Get(resultStr, path) - if !value.Exists() { - continue - } - // 如果是数组路径(含 #),取 Array;否则取单值 - if strings.Contains(path, "#") { - var arr []any - for _, v := range value.Array() { - arr = append(arr, v.Value()) - } - mapped[standardField] = arr - } else { - mapped[standardField] = value.Value() - } - } - - 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 -//} - -// BodyToQuery 将 body 转为 url.Values -func BodyToQuery(payload map[string]any) (url.Values, error) { - q := url.Values{} - for k, v := range payload { - if v == nil { - continue - } - q.Set(k, gconv.String(v)) - } - 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: - } - - 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) - } -} - -func matchStatus(actual string, expected any) bool { - expectedStr := gconv.String(expected) - if actual == expectedStr { - return true - } - switch v := expected.(type) { - case []any: - for _, item := range v { - if actual == gconv.String(item) { - return true - } - } - } - return false -} - -// 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 - }) -} diff --git a/common/util/streaming.go b/common/util/streaming.go deleted file mode 100644 index 7cb3707..0000000 --- a/common/util/streaming.go +++ /dev/null @@ -1,150 +0,0 @@ -package util - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "sort" - "strings" - - "github.com/gogf/gf/v2/encoding/gjson" -) - -// ================================================================ - -// ParseStreamResponse 流式响应解析(通用入口) -func ParseStreamResponse(rawBytes []byte, streamConfig map[string]any) (map[string]any, error) { - enabled, _ := streamConfig["enabled"].(bool) - if !enabled { - return gjson.New(string(rawBytes)).Map(), nil - } - - parser, _ := streamConfig["parser"].(string) - if parser == "base64_concat" { - return parseBase64Stream(rawBytes) - } - - return parseSSEStream(rawBytes, streamConfig) -} - -// parseBase64Stream 拼接流式 base64 并解码为二进制(TTS 等音频模型) -func parseBase64Stream(rawBytes []byte) (map[string]any, error) { - lines := strings.Split(string(rawBytes), "\n") - var audioBase64 strings.Builder - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - var chunk map[string]any - if err := json.Unmarshal([]byte(line), &chunk); err != nil { - continue - } - - if data, ok := chunk["data"].(string); ok && data != "" { - audioBase64.WriteString(data) - } - } - - cleanBase64 := strings.Map(func(r rune) rune { - if r == ' ' || r == '\n' || r == '\r' || r == '\t' { - return -1 - } - return r - }, audioBase64.String()) - - audioBytes, err := base64.StdEncoding.DecodeString(cleanBase64) - if err != nil { - audioBytes, err = base64.RawStdEncoding.DecodeString(cleanBase64) - if err != nil { - return nil, fmt.Errorf("base64 解码失败: %w", err) - } - } - - return map[string]any{"audio": audioBytes}, nil -} - -// parseSSEStream SSE 流式解析(图片模型等) -func parseSSEStream(rawBytes []byte, streamConfig map[string]any) (map[string]any, error) { - events, _ := streamConfig["events"].([]any) - if len(events) == 0 { - return gjson.New(string(rawBytes)).Map(), nil - } - - lines := strings.Split(string(rawBytes), "\n") - result := make(map[string]any) - var partials []map[string]any - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || line == "[DONE]" { - continue - } - if strings.HasPrefix(line, "event:") { - continue - } - if strings.HasPrefix(line, "data:") { - line = strings.TrimPrefix(line, "data:") - line = strings.TrimSpace(line) - } - - var chunk map[string]any - if err := json.Unmarshal([]byte(line), &chunk); err != nil { - continue - } - - chunkType, _ := chunk["type"].(string) - - for _, evt := range events { - e, _ := evt.(map[string]any) - match, _ := e["match"].(string) - if !strings.Contains(chunkType, match) { - continue - } - - fields, _ := e["fields"].(map[string]any) - aggregateTo, _ := e["aggregate_to"].(string) - evtType, _ := e["type"].(string) - - switch evtType { - case "partial": - item := make(map[string]any) - for localKey, chunkKey := range fields { - item[localKey] = chunk[chunkKey.(string)] - } - partials = append(partials, item) - - case "final": - for localKey, chunkKey := range fields { - val := gjson.New(chunk).Get(chunkKey.(string)) - if !val.IsNil() { - if _, exists := result[aggregateTo]; !exists { - result[aggregateTo] = make(map[string]any) - } - result[aggregateTo].(map[string]any)[localKey] = val.Val() - } - } - } - } - } - - if len(partials) > 0 { - for _, evt := range events { - e, _ := evt.(map[string]any) - if e["type"] == "partial" { - if orderBy, ok := e["order_by"].(string); ok { - sort.Slice(partials, func(i, j int) bool { - return fmt.Sprint(partials[i][orderBy]) < fmt.Sprint(partials[j][orderBy]) - }) - } - result[e["aggregate_to"].(string)] = partials - break - } - } - } - - mergedBytes, _ := json.Marshal(result) - return gjson.New(mergedBytes).Map(), nil -} diff --git a/consts/public/table_name.go b/consts/public/table_name.go index ae9774f..a7486f3 100644 --- a/consts/public/table_name.go +++ b/consts/public/table_name.go @@ -5,11 +5,6 @@ const ( ) const ( - TableNameModel = "model_gateway_models" // 模型表 - TableNameTask = "model_gateway_task" // 任务表 - TableNameOpLog = "model_gateway_logs_op" // 操作日志表 - TableNameStat = "model_gateway_logs_stat" // 按天统计表 - TableNameModelManage = "model_manage" TableNameModelSession = "model_session" TableNameModelTaskStart = "model_task_start" diff --git a/consts/task/status.go b/consts/task/status.go deleted file mode 100644 index b715d80..0000000 --- a/consts/task/status.go +++ /dev/null @@ -1,29 +0,0 @@ -package task - -import "github.com/gogf/gf/v2/util/gconv" - -var ( - StatusPending = newStatus(gconv.PtrInt8(1), "排队中") // 排队中 - StatusRunning = newStatus(gconv.PtrInt8(2), "执行中") // 执行中 - StatusSuccess = newStatus(gconv.PtrInt8(3), "成功") // 成功 - StatusFailed = newStatus(gconv.PtrInt8(4), "失败") // 失败 - StatusDownloaded = newStatus(gconv.PtrInt8(5), "已下载") // 已下载 -) - -type Status *int8 - -type status struct { - code Status - desc string -} - -func (s status) Code() Status { - return s.code -} -func (s status) Desc() string { - return s.desc -} - -func newStatus(code Status, desc string) status { - return status{code: code, desc: desc} -} diff --git a/controller/model_gateway_logs_stat_controller.go b/controller/model_gateway_logs_stat_controller.go deleted file mode 100644 index 636c026..0000000 --- a/controller/model_gateway_logs_stat_controller.go +++ /dev/null @@ -1,18 +0,0 @@ -package controller - -import ( - "context" - statService "model-gateway/service/stat" - - "model-gateway/model/dto" -) - -// ModelGatewayLogsStat 统计控制器 -var ModelGatewayLogsStat = new(stat) - -type stat struct{} - -// ListModelStat 统计列表 -func (c *stat) ListModelStat(ctx context.Context, req *dto.ListModelStatReq) (res *dto.ListModelStatRes, err error) { - return statService.ModelGatewayLogsStat.List(ctx, req) -} diff --git a/controller/model_gateway_models_controller.go b/controller/model_gateway_models_controller.go deleted file mode 100644 index d7a0810..0000000 --- a/controller/model_gateway_models_controller.go +++ /dev/null @@ -1,66 +0,0 @@ -package controller - -import ( - "context" - "model-gateway/model/dto" - modelService "model-gateway/service/model" - "model-gateway/service/queue" -) - -// ModelGatewayModels 模型配置控制器 -var ModelGatewayModels = new(model) - -type model struct{} - -// CreateModel 添加配置 -func (c *model) CreateModel(ctx context.Context, req *dto.CreateModelReq) (res *dto.CreateModelRes, err error) { - return modelService.ModelGatewayModels.Create(ctx, req) -} - -// UpdateModel 更改配置 -func (c *model) UpdateModel(ctx context.Context, req *dto.UpdateModelReq) (res *dto.UpdateModelRes, err error) { - err = modelService.ModelGatewayModels.Update(ctx, req) - return -} - -// DeleteModel 删除配置 -func (c *model) DeleteModel(ctx context.Context, req *dto.DeleteModelReq) (res *dto.DeleteModelRes, err error) { - err = modelService.ModelGatewayModels.Delete(ctx, req) - return -} - -// GetModel 获取配置详情 -func (c *model) GetModel(ctx context.Context, req *dto.GetModelReq) (res *dto.GetModelRes, err error) { - return modelService.ModelGatewayModels.Get(ctx, req) -} - -// ListModel 配置列表 -func (c *model) ListModel(ctx context.Context, req *dto.ListModelReq) (res *dto.ListModelRes, err error) { - return modelService.ModelGatewayModels.List(ctx, req) -} - -// AutoTune 动态调参(由上层定时任务每小时触发一次) -func (c *model) AutoTune(ctx context.Context, req *dto.AutoTuneReq) (res *dto.AutoTuneRes, err error) { - return queue.AutoTune(ctx, req) -} - -// ListType 模型类型列表 -func (c *model) ListType(ctx context.Context, req *dto.ListTypeReq) (res *dto.TypeItem, err error) { - return modelService.GetModelTypesFromConfig() -} - -// ListOperator 运营商列表 -func (c *model) ListOperator(ctx context.Context, req *dto.ListOperatorReq) (res *dto.ListOperatorRes, err error) { - return modelService.GetOperatorList() -} - -// UpdateChatModel 更新是否为聊天模型 -func (c *model) UpdateChatModel(ctx context.Context, req *dto.UpdateChatModelReq) (res *dto.UpdateChatModelRes, err error) { - err = modelService.ModelGatewayModels.UpdateChatModel(ctx, req) - return -} - -// GetIsChatModel 获取当前会话模型 -func (c *model) GetIsChatModel(ctx context.Context, req *dto.GetIsChatModelReq) (res *dto.GetIsChatModelRes, err error) { - return modelService.ModelGatewayModels.GetIsChatModel(ctx) -} diff --git a/controller/model_gateway_task_controller.go b/controller/model_gateway_task_controller.go deleted file mode 100644 index 1ca0b0c..0000000 --- a/controller/model_gateway_task_controller.go +++ /dev/null @@ -1,43 +0,0 @@ -package controller - -import ( - "context" - taskService "model-gateway/service/task" - - "model-gateway/model/dto" -) - -// ModelGatewayTask 任务控制器 -var ModelGatewayTask = new(task) - -type task struct{} - -// CreateTask 根据 modelName 创建异步任务,返回 taskId -func (c *task) CreateTask(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) { - return taskService.ModelGatewayTask.Create(ctx, req) -} - -// GetTaskResult 获取单条任务结果(返回 *dto.GetTaskResultRes) -func (c *task) GetTaskResult(ctx context.Context, req *dto.GetTaskResultReq) (res *dto.GetTaskResultRes, err error) { - return taskService.ModelGatewayTask.GetResult(ctx, req.TaskID) -} - -// GetTaskBatch 批量查询任务(返回 *[]dto.GetTaskBatchItem) -func (c *task) GetTaskBatch(ctx context.Context, req *dto.GetTaskBatchReq) (res *dto.GetTaskBatchRes, err error) { - return taskService.ModelGatewayTask.GetBatch(ctx, req) -} - -// ListTask 任务列表分页查询 -func (c *task) ListTask(ctx context.Context, req *dto.ListTaskReq) (res *dto.ListTaskRes, err error) { - return taskService.ModelGatewayTask.List(ctx, req) -} - -// ModelTaskCallback 接收模型异步任务的回调通知 —— 待调整 -func (c *task) ModelTaskCallback(ctx context.Context, req *dto.ModelTaskCallbackReq) (res *dto.ModelTaskCallbackRes, err error) { - return taskService.ModelGatewayTask.ModelTaskCallback(ctx, req) -} - -// QueryPendingTasks 批量轮询进行中的异步任务 —— 待调整 -func (c *task) QueryPendingTasks(ctx context.Context, req *dto.QueryPendingTasksReq) (res *dto.QueryPendingTasksRes, err error) { - return taskService.ModelGatewayTask.QueryPendingTasks(ctx, req) -} diff --git a/dao/model_gateway_logs_op.go b/dao/model_gateway_logs_op.go deleted file mode 100644 index 98d05e4..0000000 --- a/dao/model_gateway_logs_op.go +++ /dev/null @@ -1,23 +0,0 @@ -package dao - -import ( - "context" - - "model-gateway/consts/public" - "model-gateway/model/entity" - - "gitea.redpowerfuture.com/red-future/common/db/gfdb" -) - -var ModelGatewayLogsOp = &modelGatewayLogsOpDao{} - -type modelGatewayLogsOpDao struct{} - -// Insert 插入操作日志 -func (d *modelGatewayLogsOpDao) Insert(ctx context.Context, req *entity.ModelGatewayLogsOp) (int64, error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameOpLog).Insert(req) - if err != nil { - return 0, err - } - return r.LastInsertId() -} diff --git a/dao/model_gateway_logs_stat_dao.go b/dao/model_gateway_logs_stat_dao.go deleted file mode 100644 index 54ae746..0000000 --- a/dao/model_gateway_logs_stat_dao.go +++ /dev/null @@ -1,52 +0,0 @@ -package dao - -import ( - "context" - "time" - - "model-gateway/consts/public" - "model-gateway/model/entity" - - "gitea.redpowerfuture.com/red-future/common/db/gfdb" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" -) - -var ModelGatewayLogsStat = &modelGatewayLogsStatDao{} - -type modelGatewayLogsStatDao struct{} - -// IncRequestCount 原子累加:按天+租户+创建人+模型 +1 -func (d *modelGatewayLogsStatDao) IncRequestCount(ctx context.Context, day time.Time, tenantId uint64, creator, modelName string) error { - _, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameStat). - Data(&entity.ModelGatewayLogsStat{ - Day: gtime.New(day), - TenantId: tenantId, - Creator: creator, - ModelName: modelName, - RequestCount: 1, - }). - OnDuplicate("request_count", "request_count+1"). - Insert() - return err -} - -// List 分页查询统计 -func (d *modelGatewayLogsStatDao) List(ctx context.Context, pageNum, pageSize int, req *entity.ModelGatewayLogsStat) (list []*entity.ModelGatewayLogsStat, total int64, err error) { - model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameStat). - OmitEmpty(). - Where(entity.ModelGatewayLogsStatCols.Creator, req.Creator). - WhereLike(entity.ModelGatewayLogsStatCols.ModelName, "%"+req.ModelName+"%"). - OrderDesc(entity.ModelGatewayLogsStatCols.Day). - OrderDesc(entity.ModelGatewayLogsStatCols.RequestCount) - if pageNum > 0 && pageSize > 0 { - model = model.Page(pageNum, pageSize) - } - r, totalInt, err := model.AllAndCount(false) - if err != nil { - return nil, 0, err - } - total = gconv.Int64(totalInt) - err = r.Structs(&list) - return -} diff --git a/dao/model_gateway_models_dao.go b/dao/model_gateway_models_dao.go deleted file mode 100644 index be0c423..0000000 --- a/dao/model_gateway_models_dao.go +++ /dev/null @@ -1,201 +0,0 @@ -package dao - -import ( - "context" - "model-gateway/consts/public" - "model-gateway/model/dto" - "model-gateway/model/entity" - "strconv" - - "gitea.redpowerfuture.com/red-future/common/db/gfdb" - "github.com/gogf/gf/v2/frame/g" -) - -var ModelGatewayModels = &modelGatewayModelsDao{} - -type modelGatewayModelsDao struct{} - -// Insert 插入 -func (d *modelGatewayModelsDao) Insert(ctx context.Context, req *entity.ModelGatewayModel) (int64, error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel).Insert(req) - if err != nil { - return 0, err - } - return r.LastInsertId() -} - -// Update 更新 -func (d *modelGatewayModelsDao) Update(ctx context.Context, req *entity.ModelGatewayModel) (int64, error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel). - OmitEmpty(). - Data(req). - Where(entity.ModelGatewayModelCol.Id, req.Id). - Update() - if err != nil { - return 0, err - } - return r.RowsAffected() -} - -// Delete 删除 -func (d *modelGatewayModelsDao) Delete(ctx context.Context, req *entity.ModelGatewayModel) (int64, error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel). - OmitEmpty(). - Where(entity.ModelGatewayModelCol.Id, req.Id). - Delete() - if err != nil { - return 0, err - } - return r.RowsAffected() -} - -// Get 获取模型 -func (d *modelGatewayModelsDao) Get(ctx context.Context, req *entity.ModelGatewayModel, fields ...string) (*entity.ModelGatewayModel, error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel). - OmitEmpty(). - Where(entity.ModelGatewayModelCol.Id, req.Id). - Where(entity.ModelGatewayModelCol.Creator, req.Creator). - Where(entity.ModelGatewayModelCol.ModelName, req.ModelName). - Fields(fields).One() - if err != nil { - return nil, err - } - var m entity.ModelGatewayModel - err = r.Struct(&m) - return &m, err -} - -//// Get 按ID获取(带租户隔离,只查当前租户) -//func (d *modelGatewayModelsDao) Get(ctx context.Context, req *entity.AsynchModel, fields ...string) (m *entity.AsynchModel, err error) { -// var whereCondition strings.Builder -// var queryParams []interface{} -// if !g.IsEmpty(req.Id) { -// whereCondition.WriteString(fmt.Sprintf(" AND %s = (?) ", entity.AsynchModelCol.Id)) -// queryParams = append(queryParams, req.Id) -// } -// if !g.IsEmpty(req.Creator) { -// whereCondition.WriteString(fmt.Sprintf(" AND %s = (?) ", entity.AsynchModelCol.Creator)) -// queryParams = append(queryParams, req.Creator) -// } -// if !g.IsEmpty(req.IsChatModel) { -// whereCondition.WriteString(fmt.Sprintf(" AND %s = (?) ", entity.AsynchModelCol.IsChatModel)) -// queryParams = append(queryParams, req.IsChatModel) -// } -// if !g.IsEmpty(req.ModelName) { -// whereCondition.WriteString(fmt.Sprintf(" AND %s = (?) ", entity.AsynchModelCol.ModelName)) -// queryParams = append(queryParams, req.ModelName) -// } -// // 完整 SQL -// sql := `SELECT * FROM "asynch_models" WHERE "deleted_at" IS NULL` + whereCondition.String() -// r, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql, queryParams...) -// if err != nil { -// return -// } -// var i []*entity.AsynchModel -// if err = r.Structs(&i); err != nil { -// return nil, err -// } -// for _, item := range i { -// m = item -// } -// return -//} - -// GetByAcrossTenant 跨租户查询 -func (d *modelGatewayModelsDao) GetByAcrossTenant(ctx context.Context, req *entity.ModelGatewayModel, fields ...string) (*entity.ModelGatewayModel, error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel). - NoTenantId(ctx). - OmitEmpty(). - Where(entity.ModelGatewayModelCol.Id, req.Id). - Where(entity.ModelGatewayModelCol.Creator, req.Creator). - Where(entity.ModelGatewayModelCol.ModelName, req.ModelName). - Fields(fields).One() - if err != nil { - return nil, err - } - var m entity.ModelGatewayModel - err = r.Struct(&m) - return &m, err -} - -// GetByCreatorAndPlatform 按创建者、平台获取 -func (d *modelGatewayModelsDao) GetByCreatorAndPlatform(ctx context.Context, req *dto.ListModelReq) (list []*entity.ModelGatewayModel, total int, err error) { - sql := ` -SELECT DISTINCT ON (model_name) * -FROM asynch_models -WHERE deleted_at IS NULL - AND (? = '' OR model_name LIKE ?) -` - args := []any{ - req.ModelName, "%" + req.ModelName + "%", - } - - // modelType: 传 6 模糊匹配 6% - if req.ModelType > 0 { - prefix := strconv.Itoa(req.ModelType)[:1] // 截取第一位 - sql += ` AND model_type::text LIKE ? ` - args = append(args, prefix+"%") - } - - if !g.IsEmpty(req.IsPrivate) { - sql += ` AND is_private = ? ` - args = append(args, req.IsPrivate) - } - - if req.IsOwner != nil && *req.IsOwner == 0 { - if req.Enabled != nil && *req.Enabled == 1 { - sql += ` AND creator = ? AND is_owner = ? AND enabled=1 ` - } else if req.Enabled != nil && *req.Enabled == 0 { - sql += ` AND creator = ? AND is_owner = ? AND enabled=0 ` - } else { - sql += ` AND creator = ? AND is_owner = ? ` - } - args = append(args, req.Creator, req.IsOwner) - } else if req.IsOwner != nil && *req.IsOwner == 1 { - if req.Enabled != nil && *req.Enabled == 1 { - sql += ` AND ((creator = ? AND is_owner = ? AND enabled=1) OR (is_owner = 0 AND enabled=1)) ` - } else if req.Enabled != nil && *req.Enabled == 0 { - sql += ` AND ((creator = ? AND is_owner = ? AND enabled=0) OR (is_owner = 0 AND enabled=1)) ` - } else { - sql += ` AND ((creator = ? AND is_owner = ?) OR (is_owner = 0 AND enabled=1)) ` - } - args = append(args, req.Creator, req.IsOwner) - } - - sql += ` ORDER BY model_name, is_owner DESC, created_at DESC` - - r, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql, args...) - if err != nil { - return nil, 0, err - } - - err = r.Structs(&list) - if err != nil { - return nil, 0, err - } - - total = len(list) - return -} - -// GetByModelNameForTenant 后台任务使用:按 tenant_id + model_name 查询,不依赖 gfdb Hook/Trace/用户上下文 -func (d *modelGatewayModelsDao) GetByModelNameForTenant(ctx context.Context, tenantId uint64, modelName string) (*entity.ModelGatewayModel, error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, - "SELECT * FROM "+public.TableNameModel+" WHERE tenant_id=? AND model_name=? AND deleted_at IS NULL LIMIT 1", - tenantId, modelName, - ) - if err != nil { - return nil, err - } - if r.IsEmpty() { - return nil, nil - } - var list []*entity.ModelGatewayModel - if err := r.Structs(&list); err != nil { - return nil, err - } - if len(list) == 0 { - return nil, nil - } - return list[0], nil -} diff --git a/dao/model_gateway_task_dao.go b/dao/model_gateway_task_dao.go deleted file mode 100644 index 7d9c209..0000000 --- a/dao/model_gateway_task_dao.go +++ /dev/null @@ -1,159 +0,0 @@ -package dao - -import ( - "context" - "fmt" - "model-gateway/consts/public" - "model-gateway/model/entity" - - "gitea.redpowerfuture.com/red-future/common/db/gfdb" - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/util/gconv" -) - -var ModelGatewayTask = &modelGatewayTaskDao{} - -type modelGatewayTaskDao struct{} - -// Insert 插入 -func (d *modelGatewayTaskDao) Insert(ctx context.Context, req *entity.ModelGatewayTask) (id int64, err error) { - m := new(entity.ModelGatewayTask) - err = gconv.Struct(req, &m) - if err != nil { - return - } - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).Insert(m) - if err != nil { - return - } - return r.LastInsertId() -} - -// Update 更新(按ID) -func (d *modelGatewayTaskDao) Update(ctx context.Context, req *entity.ModelGatewayTask) (rows int64, err error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask). - OmitEmpty(). - Data(req). - Where(entity.ModelGatewayTaskCol.Id, req.Id). - Update() - if err != nil { - return - } - return r.RowsAffected() -} - -// Get 获取(按TaskID 或 ID) -func (d *modelGatewayTaskDao) Get(ctx context.Context, req *entity.ModelGatewayTask) (m *entity.ModelGatewayTask, err error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask). - OmitEmpty(). - Where(entity.ModelGatewayTaskCol.TaskID, req.TaskID). - Where(entity.ModelGatewayTaskCol.Id, req.Id). - One() - if err != nil { - return - } - err = r.Struct(&m) - return -} - -// List 分页查询 -func (d *modelGatewayTaskDao) List(ctx context.Context, pageNum, pageSize int, req *entity.ModelGatewayTask) (list []*entity.ModelGatewayTask, total int64, err error) { - model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask). - OmitEmpty(). - Where(entity.ModelGatewayTaskCol.Creator, req.Creator). - Where(entity.ModelGatewayTaskCol.ModelName, "%"+req.ModelName+"%"). - Where(entity.ModelGatewayTaskCol.BizName, req.BizName). - Where(entity.ModelGatewayTaskCol.State, req.State). - Where(entity.ModelGatewayTaskCol.TaskID, req.TaskID). - OrderDesc(entity.ModelGatewayTaskCol.CreatedAt) - if pageNum > 0 && pageSize > 0 { - model = model.Page(pageNum, pageSize) - } - r, totalInt, err := model.AllAndCount(false) - if err != nil { - return nil, 0, err - } - total = gconv.Int64(totalInt) - err = r.Structs(&list) - return -} - -// Delete 删除(软删,按ID) -func (d *modelGatewayTaskDao) Delete(ctx context.Context, req *entity.ModelGatewayTask) (rows int64, err error) { - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask). - Where(entity.ModelGatewayTaskCol.Id, req.Id). - Delete() - if err != nil { - return - } - return r.RowsAffected() -} - -// ListByTaskIDs 批量查询 -func (d *modelGatewayTaskDao) ListByTaskIDs(ctx context.Context, taskIDs []string) (list []*entity.ModelGatewayTask, err error) { - if len(taskIDs) == 0 { - return nil, nil - } - r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask). - WhereIn(entity.ModelGatewayTaskCol.TaskID, taskIDs). - All() - if err != nil { - return nil, err - } - err = r.Structs(&list) - return -} - -// MarkDownloadedByID 标记已下载 -func (d *modelGatewayTaskDao) MarkDownloadedByID(ctx context.Context, id int64) error { - _, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask). - Where(entity.ModelGatewayTaskCol.Id, id). - Where(entity.ModelGatewayTaskCol.State, 2). - Data(map[string]any{entity.ModelGatewayTaskCol.State: 4}). - Update() - return err -} - -// GetPendingAsyncTasks 获取进行中的异步任务 -func (d *modelGatewayTaskDao) GetPendingAsyncTasks(ctx context.Context, limit int) ([]*entity.ModelGatewayTask, error) { - var tasks []*entity.ModelGatewayTask - err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask). - Where(entity.ModelGatewayTaskCol.State, 1). - Limit(limit). - Scan(&tasks) - return tasks, err -} - -// ======================== 事务抢占 ======================== - -// ClaimByID 按主键抢占,返回抢占后的任务 -func (d *modelGatewayTaskDao) ClaimByID(ctx context.Context, id int64) (*entity.ModelGatewayTask, error) { - var task entity.ModelGatewayTask - err := gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - r, err := tx.Model(public.TableNameTask). - Where(entity.ModelGatewayTaskCol.Id, id). - Where(entity.ModelGatewayTaskCol.State, public.TaskStatusPending). - Limit(1). - LockUpdate(). - One() - if err != nil { - return err - } - if r.IsEmpty() { - return fmt.Errorf("任务已被抢占或不存在: id=%d", id) - } - if err := r.Struct(&task); err != nil { - return err - } - _, err = tx.Model(public.TableNameTask). - Data(&entity.ModelGatewayTask{State: public.TaskStatusRunning}). - Where(entity.ModelGatewayTaskCol.Id, id). - OmitEmpty(). - Update() - return err - }) - if err != nil { - return nil, err - } - return &task, nil -} diff --git a/main.go b/main.go index bece4fa..5245ee6 100644 --- a/main.go +++ b/main.go @@ -36,9 +36,6 @@ func main() { http.RouteRegister([]interface{}{ controller.ModelCall, controller.ModelManage, - controller.ModelGatewayModels, - controller.ModelGatewayTask, - controller.ModelGatewayLogsStat, }) gmq.GmqRegister(public.GmqMsgPluginsName, &mq.NatsConn{ @@ -57,7 +54,7 @@ func main() { } }) - // 监听退出信号,确保 Ctrl+C 能完整退出(停止 worker/cleaner 并关闭 gateway server) + // 监听退出信号,确保 Ctrl+C 能完整退出(停掉定时器与协程池,等任务执行完成再关闭) quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit diff --git a/model/dto/model_call_dto.go b/model/dto/model_call_dto.go index af5beb9..1d5a07e 100644 --- a/model/dto/model_call_dto.go +++ b/model/dto/model_call_dto.go @@ -1,8 +1,6 @@ package dto import ( - "model-gateway/consts/task" - "github.com/gogf/gf/v2/frame/g" ) @@ -19,7 +17,6 @@ type ModelCallReq struct { type ModelCallRes struct { TaskId int64 `json:"id" dc:"任务ID"` - State task.Status `json:"state" dc:"状态"` TotalTokens int64 `json:"totalTokens" dc:"总token"` PromptTokens int64 `json:"promptTokens" dc:"输入token"` CompletionTokens int64 `json:"completionTokens" dc:"输出token"` @@ -62,21 +59,20 @@ type ModelCallStreamReq struct { BusinessParams map[string]any `json:"businessParams" dc:"业务参数(按业务字段名传,按 RequestBusinessFieldMapping 写入请求体)"` } -var ModelErrorResp struct { +type ModelErrorResp struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } -var ModelError1Resp struct { +type ModelError1Resp struct { Code int `json:"code"` Message string `json:"message"` } type ModelMsg struct { TaskID int64 `json:"id" dc:"任务ID"` - State task.Status `json:"state" dc:"状态"` TotalTokens int64 `json:"totalTokens" dc:"总token"` PromptTokens int64 `json:"promptTokens" dc:"输入token"` CompletionTokens int64 `json:"completionTokens" dc:"输出token"` diff --git a/model/dto/model_gateway_logs_stat_dto.go b/model/dto/model_gateway_logs_stat_dto.go deleted file mode 100644 index 2f40f36..0000000 --- a/model/dto/model_gateway_logs_stat_dto.go +++ /dev/null @@ -1,20 +0,0 @@ -package dto - -import "github.com/gogf/gf/v2/frame/g" - -// ListModelStatReq 统计列表 -type ListModelStatReq struct { - g.Meta `path:"/listModelStat" method:"get" tags:"统计" summary:"模型请求统计列表" dc:"按天统计模型请求次数,支持分页与条件筛选"` - PageNum int `p:"pageNum" json:"pageNum" dc:"页码(默认1)"` - PageSize int `p:"pageSize" json:"pageSize" dc:"每页条数(默认10)"` - StartDay string `p:"startDay" json:"startDay" dc:"开始日期(YYYY-MM-DD,可选)"` - EndDay string `p:"endDay" json:"endDay" dc:"结束日期(YYYY-MM-DD,可选)"` - TenantID *int64 `p:"tenantId" json:"tenantId" dc:"租户ID(可选)"` - Creator string `p:"creator" json:"creator" dc:"创建人(可选,模糊匹配)"` - ModelName string `p:"modelName" json:"modelName" dc:"模型名称(可选,模糊匹配)"` -} - -type ListModelStatRes struct { - List any `json:"list" dc:"列表数据"` - Total int64 `json:"total" dc:"总数"` -} diff --git a/model/dto/model_gateway_models_dto.go b/model/dto/model_gateway_models_dto.go deleted file mode 100644 index b555064..0000000 --- a/model/dto/model_gateway_models_dto.go +++ /dev/null @@ -1,186 +0,0 @@ -package dto - -import ( - "model-gateway/model/entity" - - "gitea.redpowerfuture.com/red-future/common/beans" - "github.com/gogf/gf/v2/frame/g" -) - -// 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:"返回映射"` - 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 { - ID int64 `json:"id,string" dc:"配置ID"` -} - -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:"返回映射"` - 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:"回调地址"` -} - -type UpdateModelRes struct { - ID int64 `json:"id,string" dc:"配置ID"` -} - -// DeleteModelReq 删除模型配置 -type DeleteModelReq struct { - g.Meta `path:"/deleteModel" method:"delete" tags:"模型管理" summary:"删除模型配置" dc:"删除指定ID的模型配置"` - ID int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"` -} - -type DeleteModelRes struct { - ID int64 `json:"id,string" dc:"配置ID"` -} - -// GetModelReq 获取模型配置详情 -type GetModelReq struct { - g.Meta `path:"/getModel" method:"get" tags:"模型管理" summary:"获取模型配置" dc:"根据模型ID获取配置详情"` - ID int64 `p:"id" json:"id,string" dc:"配置ID"` - Creator string `p:"creator" json:"creator" dc:"创建人"` - IsChatModel *int `p:"isChatModel" json:"isChatModel" dc:"是否为聊天模型"` - ModelName string `p:"modelName" json:"modelName" dc:"模型名称(唯一标识)"` -} - -type GetModelRes struct { - Model *entity.ModelGatewayModel `json:"model" dc:"模型配置详情"` -} - -// ListModelReq 配置列表 -type ListModelReq struct { - g.Meta `path:"/listModel" method:"get" tags:"模型管理" summary:"模型配置列表" dc:"分页获取模型配置列表"` - Page *beans.Page `json:"page"` - ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"` - ModelType int `p:"modelType" json:"modelType" dc:"模型类型"` - Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-禁用,1-启用"` - IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化 0-私有 1-公共"` - IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者 0-否 1-是"` - Creator string `p:"creator" json:"creator" dc:"创建人"` -} - -type ListModelRes struct { - List any `json:"list" dc:"列表数据"` - Total int `json:"total" dc:"总数"` -} - -// AutoTuneReq 动态调参(由上层定时任务每小时触发一次) -type AutoTuneReq struct { - g.Meta `path:"/autoTune" method:"post" tags:"模型管理" summary:"动态调参" dc:"按 model_name 维度统计指定时间窗口内执行耗时(P90),动态生成运行时 max_concurrency/queue_limit(不超过配置上限),写入 Redis 供 Worker/CreateTask 使用;windowSeconds 不传默认 3600"` - WindowSeconds int `p:"windowSeconds" json:"windowSeconds" dc:"统计窗口秒数;不传/<=0 默认 3600(1小时)"` -} - -type AutoTuneRes struct { - List any `json:"list" dc:"调参结果列表"` -} - -type ModelTypeModelItem struct { - ID int64 `json:"id" dc:"模型主键ID"` - Name string `json:"name" dc:"模型名称"` - Form any `json:"form" dc:"动态表单配置(JSON数组),用于前端渲染"` -} - -// ListModelTypeReq 模型类型列表(分页) -type ListTypeReq struct { - g.Meta `path:"/listType" method:"get" tags:"模型类型列表" summary:"模型类型列表" dc:"分页获取模型类型列表"` -} - -type TypeItem struct { - Type map[int]string `json:"type" dc:"模型类型ID到名称的映射"` -} - -type ListOperatorReq struct { - g.Meta `path:"/listOperator" method:"get" tags:"模型管理" summary:"获取运营商列表" dc:"获取运营商列表"` -} - -type ListOperatorRes struct { - List []string `json:"list" dc:"运营商名称到ID的映射"` -} - -type UpdateChatModelReq struct { - g.Meta `path:"/updateChatModel" method:"post" tags:"模型管理" summary:"更新聊天模型" dc:"更新指定模型的聊天模型"` - Id int64 `p:"id" json:"id" v:"required#model不能为空" dc:"模型id"` -} -type UpdateChatModelRes struct { - ID int64 `json:"id,string" dc:"模型ID"` -} - -type GetIsChatModelReq struct { - g.Meta `path:"/getIsChatModel" method:"get" tags:"模型管理" summary:"获取模型是否为聊天模型" dc:"根据模型ID获取是否为聊天模型"` -} - -type GetIsChatModelRes struct { - Model any `json:"model" dc:"模型详情"` -} - -// NodeFormField 节点表单 -type NodeFormField struct { - Value any `json:"value" dc:"字段值"` - Field string `json:"field" dc:"字段标识"` - Label string `json:"label" dc:"字段标签"` - Type string `json:"type" dc:"字段类型"` - Required bool `json:"required" dc:"是否必填"` - Default any `json:"default,omitempty" dc:"默认值"` - Options []SelectOption `json:"options" dc:"下拉选项列表"` - FieldConstraint any `json:"fieldConstraint" dc:"字段约束"` -} - -type SelectOption struct { - Label string `json:"label" dc:"选项标签"` - Value string `json:"value" dc:"选项值"` -} diff --git a/model/dto/model_gateway_task_dto.go b/model/dto/model_gateway_task_dto.go deleted file mode 100644 index 8d7b0a1..0000000 --- a/model/dto/model_gateway_task_dto.go +++ /dev/null @@ -1,107 +0,0 @@ -package dto - -import ( - "github.com/gogf/gf/v2/frame/g" -) - -// CreateTaskReq 创建异步任务 -type CreateTaskReq struct { - g.Meta `path:"/createTask" method:"post" tags:"任务管理" summary:"创建异步任务" dc:"创建异步任务并返回任务ID;创建成功后会立即异步尝试执行当前任务,执行成功后按回调配置触发钩子"` - ModelName string `p:"modelName" json:"modelName" v:"required#modelName不能为空" dc:"模型名称"` - BizName string `p:"bizName" json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"` - CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址(可选,用于后续业务通知)"` - RequestPayload map[string]any `p:"requestPayload" json:"requestPayload" dc:"请求负载(透传给模型服务)"` - EpicycleId int64 `json:"epicycleId" dc:"轮次ID"` - BuildType int64 `json:"buildType" dc:"构建类型:1-提示词构建 2-节点构建"` -} - -type CreateTaskRes struct { - TaskID string `json:"taskId" dc:"任务ID"` -} - -type ModelTaskCallbackReq struct { - g.Meta `path:"/modelCallback" method:"post" tags:"异步任务" summary:"模型任务回调通知"` - TaskID string `json:"id" dc:"任务ID"` - Status string `json:"status" dc:"queued/running/succeeded/failed/expired"` - Content map[string]any `json:"content,omitempty" dc:"任务结果内容"` - Usage map[string]any `json:"usage,omitempty" dc:"token用量"` -} - -type ModelTaskCallbackRes struct { - Success bool `json:"success" dc:"是否接收成功"` -} - -// QueryPendingTasksReq 批量轮询请求 -type QueryPendingTasksReq struct { - g.Meta `path:"/queryPending" method:"get" tags:"异步任务" summary:"批量轮询进行中的任务"` - Limit int `p:"limit" json:"limit" dc:"查询数量,默认10"` -} - -// QueryPendingTasksRes 批量轮询响应 -type QueryPendingTasksRes struct { - Total int `json:"total" dc:"本次查询数量"` - Results []QueryTaskItem `json:"results" dc:"查询结果列表"` -} - -// QueryTaskItem 单个任务查询结果 -type QueryTaskItem struct { - TaskID string `json:"taskId" dc:"任务ID"` - Status string `json:"status" dc:"任务状态"` - Content map[string]any `json:"content,omitempty" dc:"结果内容"` - Usage map[string]any `json:"usage,omitempty" dc:"token用量"` -} - -// GetTaskResultReq 获取结果(只返回 oss 地址) -type GetTaskResultReq struct { - g.Meta `path:"/getTaskResult" method:"get" tags:"任务管理" summary:"获取任务结果" dc:"根据任务ID获取结果(只返回OSS地址)"` - TaskID string `p:"taskId" json:"taskId" v:"required#taskwId不能为空" dc:"任务ID"` -} - -type GetTaskResultRes struct { - OssFile string `json:"ossFile" dc:"结果文件OSS地址"` - State int `json:"state" dc:"任务状态"` -} - -// GetTaskBatchReq 批量查询任务(并对成功任务标记为已下载) -type GetTaskBatchReq struct { - g.Meta `path:"/getTaskBatch" method:"post" tags:"任务管理" summary:"批量查询任务" dc:"批量查询任务状态与OSS地址;对成功(state=2)的任务自动标记为已下载(state=4),并写入保留到期时间"` - TaskIDs []string `p:"taskIds" json:"taskIds" v:"required#taskIds不能为空" dc:"任务ID列表"` -} - -type GetTaskBatchRes struct { - List []GetTaskBatchItem `json:"list" dc:"任务列表"` -} - -type GetTaskBatchItem struct { - TaskID string `json:"taskId" dc:"任务ID"` - State int `json:"state" dc:"任务状态"` - OssFile string `json:"ossFile" dc:"结果文件OSS地址"` - TextResult map[string]any `json:"textResult" dc:"文本结果"` -} - -// ListTaskReq 任务列表分页查询 -type ListTaskReq struct { - g.Meta `path:"/listTask" method:"get" tags:"任务管理" summary:"任务列表" dc:"分页查询任务列表,支持按状态/模型名称/task_id过滤"` - PageNum int `p:"pageNum" json:"pageNum" dc:"页码(默认1)"` - PageSize int `p:"pageSize" json:"pageSize" dc:"每页条数(默认10)"` - ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊匹配)"` - BizName string `p:"bizName" json:"bizName" dc:"业务名称"` - TaskID string `p:"taskId" json:"taskId" dc:"任务ID(模糊匹配)"` - State int `p:"state" json:"state" dc:"任务状态(0/1/2/3/4,可选)"` -} - -type ListTaskRes struct { - List any `json:"list" dc:"列表数据"` - Total int64 `json:"total" dc:"总数"` -} - -// RunWorkReq 手动触发 worker 执行一次(由上层定时任务调用) -type RunWorkReq struct { - g.Meta `path:"/runWork" method:"post" tags:"任务管理" summary:"执行一次Worker" dc:"手动触发一次Worker抢占并处理排队中的任务;适合处理 createTask 立即执行时未处理到的任务以及积压队列"` - BatchSize int `p:"batchSize" json:"batchSize" dc:"本次抢占任务数量(默认10)"` - Goroutines int `p:"goroutines" json:"goroutines" dc:"本次并发数(默认1)"` -} - -type RunWorkRes struct { - Claimed int `json:"claimed" dc:"本次抢占并处理的任务数"` -} diff --git a/model/entity/model_gateway_logs_op.go b/model/entity/model_gateway_logs_op.go deleted file mode 100644 index 1cf8a2d..0000000 --- a/model/entity/model_gateway_logs_op.go +++ /dev/null @@ -1,56 +0,0 @@ -package entity - -import "gitea.redpowerfuture.com/red-future/common/beans" - -// ModelGatewayLogsOpCol 字段常量 -type modelGatewayLogsOpCol struct { - beans.SQLBaseCol - IP string - UserAgent string - APIPath string - HttpMethod string - BizName string - ModelName string - TaskID string - OpType string - Success string - ErrorMsg string - CostMs string - RequestPayload string - ResponsePayload string -} - -var ModelGatewayLogsOpCol = modelGatewayLogsOpCol{ - SQLBaseCol: beans.DefSQLBaseCol, - IP: "ip", - UserAgent: "user_agent", - APIPath: "api_path", - HttpMethod: "http_method", - BizName: "biz_name", - ModelName: "model_name", - TaskID: "task_id", - OpType: "op_type", - Success: "success", - ErrorMsg: "error_msg", - CostMs: "cost_ms", - RequestPayload: "request_payload", - ResponsePayload: "response_payload", -} - -// ModelGatewayLogsOp 操作日志 -type ModelGatewayLogsOp struct { - beans.SQLBaseDO `orm:",inline"` - IP string `orm:"ip" json:"ip"` - UserAgent string `orm:"user_agent" json:"userAgent"` - APIPath string `orm:"api_path" json:"apiPath"` - HttpMethod string `orm:"http_method" json:"httpMethod"` - BizName string `orm:"biz_name" json:"bizName"` - ModelName string `orm:"model_name" json:"modelName"` - TaskID string `orm:"task_id" json:"taskId"` - OpType string `orm:"op_type" json:"opType"` - Success int `orm:"success" json:"success"` - ErrorMsg string `orm:"error_msg" json:"errorMsg"` - CostMs int64 `orm:"cost_ms" json:"costMs"` - RequestPayload *RequestPayload `orm:"request_payload" json:"requestPayload"` - ResponsePayload map[string]any `orm:"response_payload" json:"responsePayload"` -} diff --git a/model/entity/model_gateway_logs_stat.go b/model/entity/model_gateway_logs_stat.go deleted file mode 100644 index 1277972..0000000 --- a/model/entity/model_gateway_logs_stat.go +++ /dev/null @@ -1,35 +0,0 @@ -package entity - -import "github.com/gogf/gf/v2/os/gtime" - -// ModelGatewayLogsStatCol 字段常量 -type ModelGatewayLogsStatCol struct { - Day string - TenantId string - Creator string - ModelName string - RequestCount string - CreatedAt string - UpdatedAt string -} - -var ModelGatewayLogsStatCols = ModelGatewayLogsStatCol{ - Day: "day", - TenantId: "tenant_id", - Creator: "creator", - ModelName: "model_name", - RequestCount: "request_count", - CreatedAt: "created_at", - UpdatedAt: "updated_at", -} - -// ModelGatewayLogsStat 按天统计 -type ModelGatewayLogsStat struct { - Day *gtime.Time `orm:"day" json:"day"` - TenantId uint64 `orm:"tenant_id" json:"tenantId"` - Creator string `orm:"creator" json:"creator"` - ModelName string `orm:"model_name" json:"modelName"` - RequestCount int64 `orm:"request_count" json:"requestCount"` - CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"` - UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"` -} diff --git a/model/entity/model_gateway_model.go b/model/entity/model_gateway_model.go deleted file mode 100644 index 282b7a2..0000000 --- a/model/entity/model_gateway_model.go +++ /dev/null @@ -1,99 +0,0 @@ -package entity - -import "gitea.redpowerfuture.com/red-future/common/beans" - -type modelGatewayModelCol struct { - beans.SQLBaseCol - ModelName string - ModelType string - BaseURL string - HttpMethod string - HeadMsg string - FormJSON string - RequestMapping string - ResponseMapping string - ResponseBody string - RequiredFields string - IsPrivate string - IsChatModel string - CallMode string - ApiKey string - Enabled string - MaxConcurrency string - TimeoutSeconds string - RetryTimes string - AutoCleanSeconds string - IsOwner string - OperatorName string - TokenConfig string - ExtendMapping string - QueryConfig string - StreamConfig string - FirstFrame string - LastFrame string -} - -var ModelGatewayModelCol = modelGatewayModelCol{ - SQLBaseCol: beans.DefSQLBaseCol, - ModelName: "model_name", - ModelType: "model_type", - BaseURL: "base_url", - HttpMethod: "http_method", - HeadMsg: "head_msg", - FormJSON: "form_json", - RequestMapping: "request_mapping", - ResponseMapping: "response_mapping", - RequiredFields: "required_fields", - IsPrivate: "is_private", - IsChatModel: "is_chat_model", - CallMode: "call_mode", - ApiKey: "api_key", - Enabled: "enabled", - MaxConcurrency: "max_concurrency", - TimeoutSeconds: "timeout_seconds", - RetryTimes: "retry_times", - AutoCleanSeconds: "auto_clean_seconds", - IsOwner: "is_owner", - OperatorName: "operator_name", - TokenConfig: "token_config", - ExtendMapping: "extend_mapping", - QueryConfig: "query_config", - StreamConfig: "stream_config", - FirstFrame: "first_frame", - LastFrame: "last_frame", -} - -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"` -} - -const ( //ResponseMapping 下的字段 - ResponseBody = "response_body" //返回主体 - TotalTokens = "total_tokens" //总token数 -) diff --git a/model/entity/model_gateway_task.go b/model/entity/model_gateway_task.go deleted file mode 100644 index baaa13e..0000000 --- a/model/entity/model_gateway_task.go +++ /dev/null @@ -1,76 +0,0 @@ -package entity - -import ( - "gitea.redpowerfuture.com/red-future/common/beans" -) - -type modelGatewayTaskCol struct { - beans.SQLBaseCol - ModelName string - TaskID string - BizName string - CallbackURL string - State string - Phase string - ErrorMsg string - ResultFile string - TextResult string - ExpendTokens string - DurationSeconds string - RetryCount string - TmpFile string - RequestPayload string - EpicycleId string -} - -var ModelGatewayTaskCol = modelGatewayTaskCol{ - SQLBaseCol: beans.DefSQLBaseCol, - ModelName: "model_name", - TaskID: "task_id", - BizName: "biz_name", - CallbackURL: "callback_url", - State: "state", - Phase: "phase", - ErrorMsg: "error_msg", - ResultFile: "result_file", - TextResult: "text_result", - ExpendTokens: "expend_tokens", - DurationSeconds: "duration_seconds", - RetryCount: "retry_count", - TmpFile: "tmp_file", - RequestPayload: "request_payload", - EpicycleId: "epicycle_id", -} - -// ModelGatewayTask 模型网关任务 -type ModelGatewayTask struct { - beans.SQLBaseDO `orm:",inline"` - ModelName string `orm:"model_name" json:"modelName"` - TaskID string `orm:"task_id" json:"taskId"` - BizName string `orm:"biz_name" json:"bizName"` - CallbackURL string `orm:"callback_url" json:"callbackUrl"` - State int `orm:"state" json:"state"` - Phase int `orm:"phase" json:"phase"` - ErrorMsg string `orm:"error_msg" json:"errorMsg"` - ResultFile *ResultFile `orm:"result_file" json:"resultFile"` - TextResult map[string]any `orm:"text_result" json:"text"` - ExpendTokens int64 `orm:"expend_tokens" json:"expendTokens"` - DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds"` - RetryCount int `orm:"retry_count" json:"retryCount"` - TmpFile string `orm:"tmp_file" json:"tmpFile"` - RequestPayload *RequestPayload `orm:"request_payload" json:"requestPayload"` - EpicycleId int64 `orm:"epicycle_id" json:"epicycleId"` -} - -// ResultFile OSS 结果文件 -type ResultFile struct { - OssFile string `json:"ossFile"` - FileType string `json:"fileType"` - FileSize int64 `json:"fileSize"` -} - -// RequestPayload 请求参数结构体 -type RequestPayload struct { - Headers map[string]string `json:"headers"` - Body map[string]any `json:"body"` -} diff --git a/service/gateway/gateway_http_service.go b/service/gateway/gateway_http_service.go deleted file mode 100644 index efc3c35..0000000 --- a/service/gateway/gateway_http_service.go +++ /dev/null @@ -1,194 +0,0 @@ -package gateway - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "mime/multipart" - "model-gateway/common/util" - "model-gateway/model/entity" - "time" - - commonHttp "gitea.redpowerfuture.com/red-future/common/http" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/guid" -) - -type UploadFileResponse struct { - FileURL string `json:"fileURL"` // 文件 URL - FileSize int `json:"fileSize"` // 文件大小(字节) - FileName string `json:"fileName"` // 文件名 - FileFormat string `json:"fileFormat"` // 文件格式 - FileAddressPrefix string `json:"fileAddressPrefix"` // 文件地址前缀 -} - -// UploadByTask 通过任务上传文件 -func UploadByTask(ctx context.Context, data []byte, fileExt string) (oss *UploadFileResponse, err error) { - // multipart - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - ext := fileExt - if ext == "" { - ext = ".bin" - } - if ext[0] != '.' { - ext = "." + ext - } - - filename := fmt.Sprintf("asynch_%d_%s%s", time.Now().Unix(), guid.S(), ext) - part, err := writer.CreateFormFile("file", filename) - if err != nil { - return nil, err - } - if _, err := part.Write(data); err != nil { - return nil, err - } - contentType := writer.FormDataContentType() - if err = writer.Close(); err != nil { - return nil, err - } - - headers := util.ForwardHeaders(ctx) - headers["Content-Type"] = contentType - fullURL := "oss/file/uploadFile" - g.Log().Infof(ctx, "[OSS] upload start url=%s filename=%s size=%d", fullURL, filename, len(data)) - - var resp UploadFileResponse - if err = commonHttp.Post(ctx, fullURL, headers, &resp, body.Bytes()); err != nil { - return nil, err - } - if &resp == nil { - return nil, errors.New("[OSS] 上传文件失败") - } - g.Log().Infof(ctx, "[OSS] 上传成功 url=%s size=%d format=%s", resp.FileURL, resp.FileSize, resp.FileFormat) - return &resp, nil -} - -// CallbackPayload 回调请求体 -type CallbackPayload struct { - TaskId string `json:"task_id"` - State int `json:"state"` - OssFile string `json:"oss_file"` - FileType string `json:"file_type"` - ErrorMsg string `json:"error_msg"` -} - -// TriggerCallback 任务的回调 -func TriggerCallback(ctx context.Context, t *entity.ModelGatewayTask) { - headers := util.ForwardHeaders(ctx) - var resp struct{} - payload := CallbackPayload{ - TaskId: t.TaskID, - State: t.State, - OssFile: t.ResultFile.OssFile, - FileType: t.ResultFile.FileType, - ErrorMsg: t.ErrorMsg, - } - jsonData, err := json.Marshal(payload) - if err != nil { - g.Log().Warningf(ctx, "[回调] JSON序列化失败 taskId=%s 错误=%v", t.TaskID, err) - return - } - g.Log().Infof(ctx, "[回调] 开始发送 taskId=%s 回调地址=%s 请求头数量=%d 消息体大小=%d字节", - t.TaskID, t.CallbackURL, len(headers), len(jsonData)) - - err = commonHttp.Post(ctx, t.CallbackURL, headers, &resp, jsonData) - if err != nil { - g.Log().Warningf(ctx, "[回调] 发送失败 taskId=%s 回调地址=%s 错误=%v", t.TaskID, t.CallbackURL, err) - return - } - g.Log().Infof(ctx, "[回调] 发送成功 taskId=%s 回调地址=%s 消息体大小=%d字节", t.TaskID, t.CallbackURL, len(jsonData)) -} - -// PromptsCallbackPayload 提示词回调请求体 -type PromptsCallbackPayload struct { - EpicycleId int64 `json:"epicycleId"` - Messages map[string]any `json:"messages"` -} - -// TriggerPromptsCallback 任务成功后的提示词回调 -func TriggerPromptsCallback(ctx context.Context, t *entity.ModelGatewayTask, epicycleId int64) { - callbackURL := "prompts-core/session/callback" - headers := util.ForwardHeaders(ctx) - var resp struct{} - payload := PromptsCallbackPayload{ - EpicycleId: epicycleId, - Messages: t.TextResult, - } - jsonData, err := json.Marshal(payload) - if err != nil { - g.Log().Warningf(ctx, "[提示词回调] JSON序列化失败 epicycleId=%d 错误=%v", epicycleId, err) - return - } - g.Log().Infof(ctx, "[提示词回调] 开始发送 epicycleId=%d 回调地址=%s 请求头数量=%d 消息体大小=%d字节", - t.EpicycleId, callbackURL, len(headers), len(jsonData)) - - err = commonHttp.Post(ctx, callbackURL, headers, &resp, jsonData) - if err != nil { - g.Log().Warningf(ctx, "[提示词回调] 发送失败 epicycleId=%d 回调地址=%s 错误=%v", t.EpicycleId, callbackURL, err) - return - } - g.Log().Infof(ctx, "[提示词回调] 发送成功 epicycleId=%d 回调地址=%s 消息体大小=%d字节", t.EpicycleId, callbackURL, len(jsonData)) -} - -// IsSuperAdmin 调用admin-go服务检查是否是超级管理员 -func IsSuperAdmin(ctx context.Context) (res bool, err error) { - headers := util.ForwardHeaders(ctx) - var r = make(map[string]bool) - if err = commonHttp.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headers, &r); err != nil { - return false, err - } - return r["isSuperAdmin"], err -} - -//// callback 向回调地址 POST 任务结果(与查询接口 GetTaskRes 出参一致) -//func (s *audioTaskService) callback(ctx context.Context, taskID, status, errMsg, callbackURL string) { -// if callbackURL == "" { -// return -// } -// -// task, _ := dao.TranscribeTask.GetByTaskID(ctx, taskID) -// if task == nil { -// g.Log().Errorf(ctx, "[回调 %s] 任务不存在", taskID) -// return -// } -// -// detailList, _ := dao.TranscribeTaskDetail.ListByTaskID(ctx, taskID) -// detailItems := make([]dto.TranscribeTaskDetailItem, 0, len(detailList)) -// for i := range detailList { -// detailItems = append(detailItems, dao.DetailEntityToItem(&detailList[i])) -// } -// -// // 构建与查询接口一致的 taskInfo -// taskInfo := dao.EntityToItem(task) -// -// // 兼容历史数据: 从 result 中补全 scenes 等字段 -// detailItems = enrichDetailsFromResult(task.Result, detailItems) -// -// payload := dto.CallbackPayload{ -// TaskInfo: taskInfo, -// DetailList: detailItems, -// } -// -// body, _ := json.Marshal(payload) -// -// // 透传调用方的用户信息 -// userJSON, _ := json.Marshal(beans.User{UserName: "admin", TenantId: 1}) -// -// req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) -// req.Header.Set("Content-Type", "application/json") -// req.Header.Set("X-User-Info", string(userJSON)) -// -// resp, reqErr := http.DefaultClient.Do(req) -// if reqErr != nil { -// g.Log().Errorf(ctx, "[回调 %s] 请求失败: %v", taskID, reqErr) -// return -// } -// defer resp.Body.Close() -// -// respBody, _ := io.ReadAll(resp.Body) -// g.Log().Infof(ctx, "[回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) -//} diff --git a/service/httpclient/httpclient.go b/service/httpclient/httpclient.go new file mode 100644 index 0000000..5fa6b7d --- /dev/null +++ b/service/httpclient/httpclient.go @@ -0,0 +1,167 @@ +// Package httpclient 模型网关的传输层:模型 HTTP 请求(含瞬时网络错误重试)与 SSE 流式解析。 +// 纯基础设施,不依赖 session/task/call 等业务逻辑;业务代码只通过三个导出函数使用。 +package httpclient + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + commonHttp "gitea.redpowerfuture.com/red-future/common/http" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" + "github.com/gogf/gf/v2/util/gconv" +) + +// modelCallHeaderTimeout 模型响应头等待超时。 +// commonHttp 底层 gclient 默认 ResponseHeaderTimeout 只有 30s,模型生成首字节 +// (尤其非流式、大 max_tokens)经常超过 30s,导致 http2: timeout awaiting response +// headers。模型调用必须用独立 client 并把该超时调大,与模型配置的超时保持一致。 +const modelCallHeaderTimeout = 30 * time.Minute + +// modelHTTPClient 构建模型调用专用 HTTP client: +// 克隆 commonHttp 客户端(保留 ContentJson、header 注入等行为),但把 +// ResponseHeaderTimeout 从默认 30s 调大到 modelCallHeaderTimeout。 +func modelHTTPClient() *gclient.Client { + client := commonHttp.Httpclient.Clone() + if tr, ok := client.Transport.(*http.Transport); ok { + tr = tr.Clone() // 独立拷贝,避免改动全局共享 transport + tr.ResponseHeaderTimeout = modelCallHeaderTimeout + client.Transport = tr + } + return client +} + +// modelNetRetryTimes 模型请求瞬时网络错误最大重试次数(不含首次);modelNetRetryBackoff 为退避基数。 +// 模型域名 DNS 解析失败(Docker 内 127.0.0.11 偶发 no such host)是瞬时错误,短退避重试即可恢复。 +// 重试在 HTTP 层完成,覆盖同步/异步/流式全部调用路径;流式场景发生在写 SSE 响应头之前,重试安全。 +const ( + modelNetRetryTimes = 3 + modelNetRetryBackoff = 500 * time.Millisecond +) + +// isTransientNetError 判定是否可重试的瞬时网络错误。仅命中 DNS 解析失败(no such host): +// 模型域名解析抖动可重试恢复;连接拒绝/超时等其他网络错误可能反映真实配置问题,不纳入,避免掩盖错误。 +func isTransientNetError(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "no such host") || strings.Contains(err.Error(), "timeout") +} + +// modelDoRaw 模型 HTTP 请求(等价 commonHttp.doRequestRaw,但使用调大超时的 client)。 +// DNS 解析失败等瞬时网络错误在请求层短退避重试(modelNetRetryTimes 次); +// 其余错误(含上游业务错误码)原样返回,由上层按错误码决定是否重试。 +func modelDoRaw(ctx context.Context, method string, url string, headers map[string]string, data ...any) (*gclient.Response, error) { + client := modelHTTPClient() + + if (method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete) && len(data) > 0 { + client = client.ContentJson() + } + + if len(headers) > 0 { + client.SetHeaderMap(headers) + } else if r := g.RequestFromCtx(ctx); r != nil { + client.SetHeader("Authorization", r.Request.Header.Get("Authorization")) + } + + doOnce := func() (*gclient.Response, error) { + if method == http.MethodGet && len(data) > 0 && len(data)%2 == 0 { + queryParams := make(map[string]string) + for i := 0; i < len(data); i += 2 { + if key, ok := data[i].(string); ok && i+1 < len(data) { + queryParams[key] = gconv.String(data[i+1]) + } + } + return client.DoRequest(ctx, method, url, queryParams) + } + if len(data) == 1 { + return client.DoRequest(ctx, method, url, data[0]) + } + return client.DoRequest(ctx, method, url, data...) + } + + var response *gclient.Response + var err error + for attempt := 0; ; attempt++ { + response, err = doOnce() + if err == nil || !isTransientNetError(err) { + return response, err + } + if attempt >= modelNetRetryTimes { + break + } + wait := time.Duration(1<= 300 { + bodyBytes, _ := io.ReadAll(response.Body) + response.Close() + return nil, fmt.Errorf("[HTTP][Stream] 状态码异常: %d, body=%s", response.StatusCode, string(bodyBytes)) + } + + if w != nil { + // 2) 上游连接成功,再设置 SSE 头 + h := w.Header() + h.Set("Content-Type", "text/event-stream; charset=utf-8") + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "keep-alive") + h.Set("X-Accel-Buffering", "no") + + if _, ok := w.(http.Flusher); !ok { + response.Close() + return nil, errors.New("response writer not support flush") + } + } + + // 下层统一托管关闭:用包装器保证流最终关闭 + return &autoCloseReader{r: response.Body}, nil +} + +// autoCloseReader 包装 io.ReadCloser,读取结束/销毁时自动 Close +type autoCloseReader struct { + r io.ReadCloser +} + +func (a *autoCloseReader) Read(p []byte) (int, error) { + n, err := a.r.Read(p) + // 读取完毕 / 读出错,主动关闭流 + if err != nil { + _ = a.r.Close() + } + return n, err +} diff --git a/service/httpclient/sse.go b/service/httpclient/sse.go new file mode 100644 index 0000000..e52ab64 --- /dev/null +++ b/service/httpclient/sse.go @@ -0,0 +1,85 @@ +package httpclient + +import ( + "bufio" + "context" + "encoding/json" + "io" + "strings" + + "github.com/gogf/gf/v2/frame/g" +) + +// SSE 常量 +const ( + ssePrefixData = "data:" + ssePrefixEvent = "event:" + ssePrefixComment = ":" + sseStreamDone = "[DONE]" + + scanBufInitSize = 64 * 1024 // 64KB + scanMaxLineSize = 1024 * 1024 // 单行最大 1MB +) + +// ParseSSEStream 标准 SSE 流式解析,逐分片回调,支持多行data、上下文取消 +func ParseSSEStream(ctx context.Context, respBody io.Reader, onChunk func(ctx context.Context, chunk map[string]any) error) { + scanner := bufio.NewScanner(respBody) + scanner.Buffer(make([]byte, 0, scanBufInitSize), scanMaxLineSize) + + var dataBuilder strings.Builder + + for scanner.Scan() { + // 监听上下文取消,及时终止 + select { + case <-ctx.Done(): + g.Log().Infof(ctx, "[SSE] 上下文取消,终止流读取: %v", ctx.Err()) + return + default: + } + + line := scanner.Text() + + // 跳过注释、事件行 + if strings.HasPrefix(line, ssePrefixComment) || strings.HasPrefix(line, ssePrefixEvent) { + continue + } + + lineTrim := strings.TrimSpace(line) + // 空行 = 一个SSE事件结束 + if lineTrim == "" { + if dataBuilder.Len() == 0 { + continue + } + dataStr := dataBuilder.String() + dataBuilder.Reset() + + if dataStr == sseStreamDone { + continue + } + + var chunk map[string]any + if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil { + g.Log().Debugf(ctx, "[SSE] JSON解析失败: %s, err: %v", dataStr, err) + continue + } + + if onChunk != nil { + onChunk(ctx, chunk) + } + continue + } + + // 拼接多行 data 数据 + if strings.HasPrefix(line, ssePrefixData) { + raw := strings.TrimPrefix(line, ssePrefixData) + dataBuilder.WriteString(strings.TrimSpace(raw)) + } + } + + // 捕获读取异常 + if err := scanner.Err(); err != nil { + g.Log().Errorf(ctx, "[SSE] 流读取异常: %v", err) + return + } + g.Log().Infof(ctx, "[SSE] 流式读取正常结束") +} diff --git a/service/model/model_service.go b/service/model/model_service.go deleted file mode 100644 index b28b9ff..0000000 --- a/service/model/model_service.go +++ /dev/null @@ -1,256 +0,0 @@ -package model - -import ( - "context" - "errors" - "model-gateway/common/util" - "model-gateway/consts/public" - "model-gateway/dao" - "model-gateway/model/dto" - "model-gateway/model/entity" - "model-gateway/service/gateway" - - "gitea.redpowerfuture.com/red-future/common/beans" - "gitea.redpowerfuture.com/red-future/common/db/gfdb" - "gitea.redpowerfuture.com/red-future/common/utils" - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" -) - -var ModelGatewayModels = &modelService{} - -type modelService struct{} - -// Create 创建模型 -func (s *modelService) Create(ctx context.Context, req *dto.CreateModelReq) (*dto.CreateModelRes, error) { - // 1)如果设为会话模型,先把该用户旧会话模型取消 - if !g.IsEmpty(req.IsChatModel) && *req.IsChatModel == 1 { - if err := s.clearUserChatModel(ctx); err != nil { - return nil, err - } - } - // 2)判断是否超管,决定 isOwner - req.IsOwner = gconv.PtrInt(1) - if isAdmin, _ := gateway.IsSuperAdmin(ctx); isAdmin { - req.IsOwner = gconv.PtrInt(0) - } - - // 3)入库 - id, err := dao.ModelGatewayModels.Insert(ctx, util.ConvertTo[entity.ModelGatewayModel](req)) - if err != nil { - return nil, err - } - return &dto.CreateModelRes{ID: id}, nil -} - -// Update 更新模型配置 -func (s *modelService) Update(ctx context.Context, req *dto.UpdateModelReq) error { - // 1)会话模型唯一性校验 - if req.IsChatModel != nil && *req.IsChatModel == 1 { - if err := s.checkChatModelUnique(ctx); err != nil { - return err - } - } - // 2)超管创建/普通用户更新 - req.IsOwner = gconv.PtrInt(1) - if isAdmin, _ := gateway.IsSuperAdmin(ctx); isAdmin { - req.IsOwner = gconv.PtrInt(0) - _, err := dao.ModelGatewayModels.Update(ctx, util.ConvertTo[entity.ModelGatewayModel](req)) - return err - } - // 3)跨租户判断:超管的模型不允许直接修改,走插入新记录 - model, err := dao.ModelGatewayModels.GetByAcrossTenant(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Id: req.ID}, - }) - if err != nil { - return err - } - if model.TenantId == 1 { - _, err = dao.ModelGatewayModels.Insert(ctx, util.ConvertTo[entity.ModelGatewayModel](req)) - return err - } - _, err = dao.ModelGatewayModels.Update(ctx, util.ConvertTo[entity.ModelGatewayModel](req)) - return err -} - -// Delete 删除模型 -func (s *modelService) Delete(ctx context.Context, req *dto.DeleteModelReq) error { - _, err := dao.ModelGatewayModels.Delete(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Id: req.ID}, - }) - return err -} - -// Get 获取模型详情 -func (s *modelService) Get(ctx context.Context, req *dto.GetModelReq) (*dto.GetModelRes, error) { - user, err := utils.GetUserInfo(ctx) - if err != nil { - return nil, err - } - if g.IsEmpty(req.ID) { - req.Creator = user.UserName - } - model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{ - Id: req.ID, - Creator: user.UserName, - }, - ModelName: req.ModelName, - IsChatModel: req.IsChatModel, - }) - if err != nil { - return nil, err - } - return &dto.GetModelRes{ - Model: model, - }, nil -} - -// List 获取模型列表 -func (s *modelService) List(ctx context.Context, req *dto.ListModelReq) (*dto.ListModelRes, error) { - // 1)判断超管 - req.IsOwner = gconv.PtrInt(1) - if isAdmin, _ := gateway.IsSuperAdmin(ctx); isAdmin { - req.IsOwner = gconv.PtrInt(0) - } - - // 2)获取当前用户 - user, err := utils.GetUserInfo(ctx) - if err != nil { - return nil, err - } - req.Creator = user.UserName - - // 3)查询 - models, total, err := dao.ModelGatewayModels.GetByCreatorAndPlatform(ctx, req) - if err != nil { - return nil, err - } - - return &dto.ListModelRes{List: models, Total: total}, nil -} - -// UpdateChatModel 设置会话模型 -func (s *modelService) UpdateChatModel(ctx context.Context, req *dto.UpdateChatModelReq) error { - // 1)校验新模型存在 - newModel, err := dao.ModelGatewayModels.GetByAcrossTenant(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Id: req.Id}, - }) - if err != nil || newModel == nil { - return errors.New("新会话模型不存在") - } - - // 2)获取当前用户的会话模型 - user, err := utils.GetUserInfo(ctx) - if err != nil { - return err - } - currentModel, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Creator: user.UserName}, - IsChatModel: gconv.PtrInt(1), - }) - if err != nil { - return err - } - - // 3)事务:取消旧的 + 设置新的 - return gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - if !g.IsEmpty(currentModel) { - if currentModel.ModelType != public.ModelTypeInference { - return errors.New("当前模型为非推理模型,不能设置为会话模型") - } - if currentModel.Id != req.Id { - _, err = dao.ModelGatewayModels.Update(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Id: currentModel.Id}, - IsChatModel: gconv.PtrInt(0), - }) - if err != nil { - return err - } - } - } - - _, err = dao.ModelGatewayModels.Update(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Id: req.Id}, - IsChatModel: gconv.PtrInt(1), - }) - return err - }) -} - -// GetIsChatModel 获取当前用户会话模型 -func (s *modelService) GetIsChatModel(ctx context.Context) (*dto.GetIsChatModelRes, error) { - user, err := utils.GetUserInfo(ctx) - if err != nil { - return nil, err - } - model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Creator: user.UserName}, - IsChatModel: gconv.PtrInt(1), - }) - if err != nil || model == nil { - return nil, err - } - return &dto.GetIsChatModelRes{Model: model}, nil -} - -// ==================== 辅助方法 ==================== - -// clearUserChatModel 清除当前用户旧会话模型 -func (s *modelService) clearUserChatModel(ctx context.Context) error { - user, err := utils.GetUserInfo(ctx) - if err != nil { - return err - } - model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Creator: user.UserName}, - IsChatModel: gconv.PtrInt(1), - }) - if err != nil || model == nil { - return nil - } - _, err = dao.ModelGatewayModels.Update(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Id: model.Id}, - IsChatModel: gconv.PtrInt(0), - }) - return err -} - -// checkChatModelUnique 校验用户是否已有会话模型 -func (s *modelService) checkChatModelUnique(ctx context.Context) error { - user, err := utils.GetUserInfo(ctx) - if err != nil { - return err - } - model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{ - SQLBaseDO: beans.SQLBaseDO{Creator: user.UserName}, - IsChatModel: gconv.PtrInt(1), - }) - if err != nil { - return err - } - if model != nil { - return errors.New("用户已存在会话模型") - } - return nil -} - -// GetModelTypesFromConfig 从配置文件读取模型类型 -func GetModelTypesFromConfig() (res *dto.TypeItem, err error) { - // 返回副本,避免外部修改 - types := make(map[int]string, len(public.ModelTypeName)) - for k, v := range public.ModelTypeName { - types[k] = v - } - return &dto.TypeItem{ - Type: types, - }, nil -} - -// GetOperatorList 获取运营商列表 -func GetOperatorList() (res *dto.ListOperatorRes, err error) { - return &dto.ListOperatorRes{ - List: public.OperatorList, - }, nil -} diff --git a/service/model_call_service.go b/service/model_call_service.go index d3aa2e9..c17ba5b 100644 --- a/service/model_call_service.go +++ b/service/model_call_service.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "fmt" "model-gateway/consts/model" "model-gateway/dao" @@ -13,8 +12,6 @@ import ( "time" "gitea.redpowerfuture.com/red-future/common/utils" - "github.com/gogf/gf/v2/database/gredis" - "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/glog" "github.com/gogf/gf/v2/util/gconv" @@ -238,136 +235,69 @@ func (s *modelCallService) saveModelRequestParams(ctx context.Context, now time. } func queue(ctx context.Context, modelName string, tenantId uint64, maxCon int64, f func(ctx context.Context) (err error)) (err error) { - lockKey := fmt.Sprintf("lock:tenantId-%s:model-%s", gconv.String(tenantId), modelName) - success, e := lock(ctx, lockKey, -1, int64(time.Minute.Seconds()*5), func(ctx context.Context) error { - const ( - keyExpireSec = 600 // 计数Key兜底过期时间 10min - waitInterval = 10 * time.Second // 轮询等待间隔 - ) - // Redis 操作统一使用独立上下文,避免外部 ctx canceled - redisCtx := context.WithoutCancel(ctx) - // 模型并发计数Key - concurrencyKey := fmt.Sprintf("model:concurrency:%d:%s", tenantId, modelName) + const ( + keyExpireSec = 600 // 名额Key兜底过期时间 10min(进程崩溃后自愈) + refreshStep = keyExpireSec / 3 // 执行期间续期间隔 + waitInterval = 10 * time.Second // 超限轮询等待间隔 + ) + // Redis 操作统一使用独立上下文,避免外部 ctx canceled + redisCtx := context.WithoutCancel(ctx) + concurrencyKey := fmt.Sprintf("model:concurrency:%d:%s", tenantId, modelName) - // 循环尝试获取并发名额,超限则等待重试 - var held bool // 标记当前是否持有未释放的计数 - for { - // 检测全局上下文取消 - if ctx.Err() != nil { - if held { - g.Redis().Decr(redisCtx, concurrencyKey) - } - return ctx.Err() - } - // 计数自增 - currentCon, e := g.Redis().Incr(redisCtx, concurrencyKey) - if e != nil { - if held { - g.Redis().Decr(redisCtx, concurrencyKey) - } - glog.Errorf(ctx, "redis incr concurrency key err: %v", e) - return e - } - held = true - // 首次创建Key时设置过期时间(避免重复执行EXPIRE) - exists, errr := g.Redis().Exists(redisCtx, concurrencyKey) - if errr == nil && exists == 1 { - g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec) + // 1) 原子占用并发名额:utils.SemaphoreAcquire 在 WATCH 事务内完成 判满→INCR→首设EXPIRE→超限不写, + // 不再需要旧 reserveSlot 的「分布式锁 + Incr + 回滚」组合(组合已事务化,外层锁冗余)。 + // 超限(false)按 waitInterval 轮询重试;max<=0 视为不限制(SemaphoreAcquire 内部直接放行)。 + for { + if ctx.Err() != nil { + return ctx.Err() + } + ok, e := utils.SemaphoreAcquire(redisCtx, concurrencyKey, int(maxCon), keyExpireSec) + if e != nil { + return e + } + if ok { + // 展示当前并发数(占用后 GET,与旧 reserveSlot 的 Incr 后计数值语义一致) + if v, e := g.Redis().Get(redisCtx, concurrencyKey); e == nil { + glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, v.Int64(), maxCon) } + break + } + glog.Infof(ctx, "并发超限等待: %s max=%d", concurrencyKey, maxCon) + time.Sleep(waitInterval) + } - // 未超限:跳出循环,执行业务 - if currentCon <= maxCon { - glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, currentCon, maxCon) - break - } - // 超限立刻回减,撤销本次计数 - g.Redis().Decr(redisCtx, concurrencyKey) - held = false - glog.Infof(ctx, "并发超限等待: %s %d/%d", concurrencyKey, currentCon, maxCon) - time.Sleep(waitInterval) - } - err = f(ctx) - if _, decrErr := g.Redis().Decr(redisCtx, concurrencyKey); decrErr != nil { - glog.Errorf(ctx, "redis decr concurrency key err: %v", decrErr) - } - if err != nil { - return err - } - return nil - }) - if e != nil { - return e - } - if !success { - return gerror.New("任务排队已满,请稍后再试") - } - return + // 2) 执行业务期间周期续期名额Key:SemaphoreAcquire 仅在首次占用(计数从 0 起)时设 TTL, + // 长耗时调用靠本循环持续保活——Key 过期后计数归零会突破 max 并发上限造成超发。 + stop := make(chan struct{}) + go refreshTTL(redisCtx, concurrencyKey, keyExpireSec, refreshStep, stop) + // 3) 无论业务正常返回还是 panic,都停掉续期并释放名额(幂等,计数归零自动删除 key) + defer func() { + close(stop) + _ = utils.SemaphoreRelease(redisCtx, concurrencyKey) + }() + + // 4) 执行业务 + return f(ctx) } -// lock 分布式锁 纯原生命令、无Lua、隔离上下文防 context canceled -func lock(ctx context.Context, key string, limit, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) { - if limit <= 0 { - limit = -1 +// refreshTTL 周期给名额 Key 续期,直到 stop 关闭;防止长耗时执行期间 Key 提前过期。 +func refreshTTL(redisCtx context.Context, concurrencyKey string, keyExpireSec, step int64, stop <-chan struct{}) { + interval := step + if interval < 1 { + interval = 1 } - - // 过期时间合法校验(单位:秒) - const maxExpireSec = 86400 * 7 - if expireSeconds < 1 || expireSeconds > maxExpireSec { - glog.Warningf(ctx, "锁过期时间非法,原值:%d,兜底为60秒", expireSeconds) - expireSeconds = 60 - } - - lockVal := "1" - -LOOP: - // 检测父级上下文取消,防止无限重试阻塞 goroutine - if ctx.Err() != nil { - return false, ctx.Err() - } - if limit != -1 { - if limit < 0 { - return false, errors.New("锁重试次数耗尽,获取锁失败") - } - limit-- - } - - // 核心:创建独立上下文,不受外部 ctx 取消影响 - redisCtx := context.WithoutCancel(ctx) - - // 加锁 - val, err := g.Redis().Set(redisCtx, key, lockVal, gredis.SetOption{ - TTLOption: gredis.TTLOption{ - EX: &expireSeconds, - }, - NX: true, - }) - if err != nil { - glog.Errorf(ctx, "redis set lock failed: %v", err) - time.Sleep(time.Second) - goto LOOP - } - - if val.Bool() { - // 执行业务逻辑(使用原上下文) - runErr := fn(ctx) - - // 释放锁:同样使用独立上下文 + 先GET再DEL防误删 - getRes, err := g.Redis().Get(redisCtx, key) - if err != nil { - glog.Errorf(ctx, "redis get lock value failed: %v", err) - } else if getRes.String() == lockVal { - _, delErr := g.Redis().Del(redisCtx, key) - if delErr != nil { - glog.Errorf(ctx, "redis del lock failed: %v", delErr) + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if _, err := g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec); err != nil { + glog.Errorf(context.TODO(), "redis refresh concurrency ttl err: %v", err) } + case <-stop: + return } - - return true, runErr } - - // 抢锁失败,休眠重试 - time.Sleep(time.Second) - goto LOOP } // buildChatRequestParams 按模型配置的请求模板 + 业务字段映射构建请求体(ModelCall 请求路径共用): diff --git a/service/model_http_service.go b/service/model_http_service.go index 9225755..f24e3e7 100644 --- a/service/model_http_service.go +++ b/service/model_http_service.go @@ -1,23 +1,15 @@ package service import ( - "bufio" - "bytes" "context" "encoding/json" - "errors" "fmt" - "io" - "mime/multipart" "model-gateway/model/dto" - "net/http" - "strings" - "time" commonHttp "gitea.redpowerfuture.com/red-future/common/http" + "gitea.redpowerfuture.com/red-future/common/oss" "gitea.redpowerfuture.com/red-future/common/utils" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/net/gclient" "github.com/gogf/gf/v2/util/gconv" ) @@ -80,32 +72,25 @@ func IsSuperAdmin(ctx context.Context) (res bool, err error) { return r["isSuperAdmin"], err } +// Upload 上传文件到 OSS。统一走 common/oss(multipart field=file、X-User-Info 三态注入与旧 setCtxHeader 等价)。 func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - part, err := writer.CreateFormFile("file", req.FileName) + res, err := oss.UploadFileBytes(ctx, req.FileName, req.FileBytes) if err != nil { return nil, err } - if _, err = part.Write(req.FileBytes); err != nil { - return nil, err - } - if err = writer.Close(); err != nil { - return nil, err - } - - headers := setCtxHeader(ctx) - headers["Content-Type"] = writer.FormDataContentType() - // 发起上传请求 - res := &dto.UploadFileBytesRes{} - httpUrl := "oss/file/uploadFile" - if err = commonHttp.Post(ctx, httpUrl, headers, res, body.Bytes()); err != nil { - return nil, err - } - return res, nil + return &dto.UploadFileBytesRes{ + FileURL: res.FileURL, + FileSize: res.FileSize, + FileName: res.FileName, + FileFormat: res.FileFormat, + FileAddressPrefix: res.FileAddressPrefix, + }, nil } +// setCtxHeader 构造调用方请求头透传(X-User-Info 三态注入): +// 1. 透传 HTTP 请求头(含 Authorization/X-User-Info) +// 2. ctx 无请求头时,用任务体注入的 user(异步任务 Creator/TenantId)生成 X-User-Info +// 3. 仍为空时,解析调用方 token 得到用户生成 X-User-Info(直连场景归属校验) func setCtxHeader(ctx context.Context) map[string]string { headers := make(map[string]string) if r := g.RequestFromCtx(ctx); r != nil { @@ -115,15 +100,11 @@ func setCtxHeader(ctx context.Context) map[string]string { } } } - // 后台异步任务(临时路径转存 OSS)ctx 无 HTTP 请求、无 token: - // 用任务体注入的 user(Creator/TenantId)生成 X-User-Info,供 OSS GetUserInfo 识别用户与桶名 if headers["X-User-Info"] == "" { if user := ctx.Value("user"); !g.IsNil(user) { headers["X-User-Info"] = gconv.String(user) } } - // 直连场景(请求头无 X-User-Info、ctx 未注入 user):解析调用方 token 得到用户, - // 生成 X-User-Info,供 admin-go 内部租户接口做归属校验(调用方只能操作自己所属租户) if headers["X-User-Info"] == "" { if user, err := utils.GetUserInfo(ctx); err == nil && user != nil { headers["X-User-Info"] = gconv.String(user) @@ -131,188 +112,3 @@ func setCtxHeader(ctx context.Context) map[string]string { } return headers } - -// modelCallHeaderTimeout 模型响应头等待超时。 -// commonHttp 底层 gclient 默认 ResponseHeaderTimeout 只有 30s,模型生成首字节 -// (尤其非流式、大 max_tokens)经常超过 30s,导致 http2: timeout awaiting response -// headers。模型调用必须用独立 client 并把该超时调大,与模型配置的超时保持一致。 -const modelCallHeaderTimeout = 30 * time.Minute - -// modelHTTPClient 构建模型调用专用 HTTP client: -// 克隆 commonHttp 客户端(保留 ContentJson、header 注入等行为),但把 -// ResponseHeaderTimeout 从默认 30s 调大到 modelCallHeaderTimeout。 -func modelHTTPClient() *gclient.Client { - client := commonHttp.Httpclient.Clone() - if tr, ok := client.Transport.(*http.Transport); ok { - tr = tr.Clone() // 独立拷贝,避免改动全局共享 transport - tr.ResponseHeaderTimeout = modelCallHeaderTimeout - client.Transport = tr - } - return client -} - -// modelDoRaw 模型 HTTP 请求(等价 commonHttp.doRequestRaw,但使用调大超时的 client) -func modelDoRaw(ctx context.Context, method string, url string, headers map[string]string, data ...any) (*gclient.Response, error) { - client := modelHTTPClient() - - if (method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete) && len(data) > 0 { - client = client.ContentJson() - } - - if len(headers) > 0 { - client.SetHeaderMap(headers) - } else if r := g.RequestFromCtx(ctx); r != nil { - client.SetHeader("Authorization", r.Request.Header.Get("Authorization")) - } - - var response *gclient.Response - var err error - if method == http.MethodGet && len(data) > 0 && len(data)%2 == 0 { - queryParams := make(map[string]string) - for i := 0; i < len(data); i += 2 { - if key, ok := data[i].(string); ok && i+1 < len(data) { - queryParams[key] = gconv.String(data[i+1]) - } - } - response, err = client.DoRequest(ctx, method, url, queryParams) - } else if len(data) == 1 { - response, err = client.DoRequest(ctx, method, url, data[0]) - } else { - response, err = client.DoRequest(ctx, method, url, data...) - } - return response, err -} - -// ModelHttpNormalRequest 同步/异步 普通HTTP全量请求 -func ModelHttpNormalRequest(ctx context.Context, url string, headers map[string]string, httpMethod string, body map[string]any) (res []byte, err error) { - response, err := modelDoRaw(ctx, httpMethod, url, headers, body) - if err != nil { - g.Log().Errorf(ctx, "[HttpModel] 模型请求失败 [Error]: %v", err) - return nil, fmt.Errorf("模型请求失败: %w", err) - } - defer response.Close() - return response.ReadAll(), nil -} - -// ModelHttpStreamRequest 通用流式请求 -// stream=true 时设置 SSE 头并验证 Flusher;stream=false 时只返回 Reader,不设置响应头 -func ModelHttpStreamRequest(ctx context.Context, w http.ResponseWriter, url string, headers map[string]string, httpMethod string, body map[string]any) (io.Reader, error) { - // 1) 先发起上游请求(此时还没写任何 SSE 头,失败可以正常返回 error) - response, err := modelDoRaw(ctx, httpMethod, url, headers, body) - if err != nil { - g.Log().Errorf(ctx, "[HttpModel] 模型流式请求失败 [Error]: %v", err) - return nil, fmt.Errorf("模型流式请求失败: %w", err) - } - - // 检查 HTTP 状态码 - if response.StatusCode < 200 || response.StatusCode >= 300 { - bodyBytes, _ := io.ReadAll(response.Body) - response.Close() - return nil, fmt.Errorf("[HTTP][Stream] 状态码异常: %d, body=%s", response.StatusCode, string(bodyBytes)) - } - - if w != nil { - // 2) 上游连接成功,再设置 SSE 头 - h := w.Header() - h.Set("Content-Type", "text/event-stream; charset=utf-8") - h.Set("Cache-Control", "no-cache") - h.Set("Connection", "keep-alive") - h.Set("X-Accel-Buffering", "no") - - if _, ok := w.(http.Flusher); !ok { - response.Close() - return nil, errors.New("response writer not support flush") - } - } - - // 下层统一托管关闭:用包装器保证流最终关闭 - return &autoCloseReader{r: response.Body}, nil -} - -// autoCloseReader 包装 io.ReadCloser,读取结束/销毁时自动 Close -type autoCloseReader struct { - r io.ReadCloser -} - -func (a *autoCloseReader) Read(p []byte) (int, error) { - n, err := a.r.Read(p) - // 读取完毕 / 读出错,主动关闭流 - if err != nil { - _ = a.r.Close() - } - return n, err -} - -// SSE 常量 -const ( - ssePrefixData = "data:" - ssePrefixEvent = "event:" - ssePrefixComment = ":" - sseStreamDone = "[DONE]" - - scanBufInitSize = 64 * 1024 // 64KB - scanMaxLineSize = 1024 * 1024 // 单行最大 1MB -) - -// ParseSSEStream 标准 SSE 流式解析,逐分片回调,支持多行data、上下文取消 -func ParseSSEStream(ctx context.Context, respBody io.Reader, onChunk func(ctx context.Context, chunk map[string]any) error) { - scanner := bufio.NewScanner(respBody) - scanner.Buffer(make([]byte, 0, scanBufInitSize), scanMaxLineSize) - - var dataBuilder strings.Builder - - for scanner.Scan() { - // 监听上下文取消,及时终止 - select { - case <-ctx.Done(): - g.Log().Infof(ctx, "[SSE] 上下文取消,终止流读取: %v", ctx.Err()) - return - default: - } - - line := scanner.Text() - - // 跳过注释、事件行 - if strings.HasPrefix(line, ssePrefixComment) || strings.HasPrefix(line, ssePrefixEvent) { - continue - } - - lineTrim := strings.TrimSpace(line) - // 空行 = 一个SSE事件结束 - if lineTrim == "" { - if dataBuilder.Len() == 0 { - continue - } - dataStr := dataBuilder.String() - dataBuilder.Reset() - - if dataStr == sseStreamDone { - continue - } - - var chunk map[string]any - if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil { - g.Log().Debugf(ctx, "[SSE] JSON解析失败: %s, err: %v", dataStr, err) - continue - } - - if onChunk != nil { - onChunk(ctx, chunk) - } - continue - } - - // 拼接多行 data 数据 - if strings.HasPrefix(line, ssePrefixData) { - raw := strings.TrimPrefix(line, ssePrefixData) - dataBuilder.WriteString(strings.TrimSpace(raw)) - } - } - - // 捕获读取异常 - if err := scanner.Err(); err != nil { - g.Log().Errorf(ctx, "[SSE] 流读取异常: %v", err) - return - } - g.Log().Infof(ctx, "[SSE] 流式读取正常结束") -} diff --git a/service/model_task_end_service.go b/service/model_task_end_service.go index 1c6d9b9..a0c3bcb 100644 --- a/service/model_task_end_service.go +++ b/service/model_task_end_service.go @@ -4,20 +4,19 @@ import ( "context" "encoding/json" "fmt" - "io" - "model-gateway/common/util" "model-gateway/consts/public" "model-gateway/dao" "model-gateway/model/dto" "model-gateway/model/entity" + "model-gateway/service/httpclient" modelUtils "model-gateway/service/utils" - "net/http" "regexp" - "strings" "time" "gitea.redpowerfuture.com/red-future/common/beans" "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "gitea.redpowerfuture.com/red-future/common/oss" + "gitea.redpowerfuture.com/red-future/common/utils" gmq "github.com/bjang03/gmq/core/gmq" "github.com/bjang03/gmq/mq" "github.com/bjang03/gmq/types" @@ -84,22 +83,16 @@ func (s *modelTaskEndService) GetTaskStartList(ctx context.Context) (err error) return fmt.Errorf("批量查询锁状态失败: %w", err) } - // 4. 逐个原子抢锁,筛选可执行任务 + // 4. 提交异步处理:锁在 goroutine 内抢(WithLock 单次尝试),此处 MGet 只做快速预筛 for _, key := range lockKeys { val := gconv.String(mGetRes[key]) - // 已被其他实例抢占,跳过 + // 已被其他实例抢占,跳过(MGet 只做快速预筛;真正互斥靠 goroutine 内的原子抢锁) if val != "" { continue } - tid := key[len(redisKey):] - // SET NX EX 原子抢锁,防止并发竞争 - err = g.Redis().SetEX(ctx, key, tid, 1200) + err = s.handleSingleTask(ctx, taskMap[key], key) if err != nil { - return fmt.Errorf("抢占任务锁[%s]失败: %w", tid, err) - } - err = s.handleSingleTask(ctx, taskMap[key]) - if err != nil { - g.Log().Errorf(ctx, "处理任务失败: %v", err) + g.Log().Errorf(ctx, "提交任务失败: %v", err) } remain-- // 占用一个槽位 if remain <= 0 { @@ -112,13 +105,18 @@ func (s *modelTaskEndService) GetTaskStartList(ctx context.Context) (err error) return nil } +// taskLockTTL 任务锁 TTL(秒)。utils.WithLock 自动续期锁住整个任务处理,TTL 仅作崩溃兜底: +// worker 崩溃后续期停止,TTL 过期后其他 worker 重新抢占。 +const taskLockTTL = 1200 + var urlParamReg = regexp.MustCompile(`\{.+?\}`) -// handleSingleTask 处理单个视频任务(解耦原循环逻辑) -func (s *modelTaskEndService) handleSingleTask(ctx context.Context, item *entity.ModelTaskStart) error { - // 提交异步执行 +// handleSingleTask 提交异步处理:锁在 goroutine 内抢(utils.WithLock 自动续期 + 单次尝试)。 +// 自动续期:锁持满整个任务处理,任务 >20min 不提前过期,避免其它 worker 重新抢到导致重复处理; +// 单次尝试:锁被其它 worker 持有(任务已被别人处理)时立刻跳过——等待会拿着过期 item 在行删除后 +// 重复扣费/重复回调。Submit 失败(池关闭)goroutine 不运行、从没抢锁,无锁泄漏路径。 +func (s *modelTaskEndService) handleSingleTask(ctx context.Context, item *entity.ModelTaskStart, lockKey string) error { return modelUtils.Submit(ctx, func(ctx context.Context) { - startTime := time.Now() asyncCtx := context.WithoutCancel(ctx) // OSS 桶名依赖 ctx 中的用户(GetBucketName → tenantid-{tenantId}), // 响应临时路径转存 OSS 需要用户信息,故在任务体最前面注入 @@ -126,97 +124,34 @@ func (s *modelTaskEndService) handleSingleTask(ctx context.Context, item *entity UserName: item.Creator, TenantId: item.TenantId, }) - // 任务处理结束(无论成功失败)都释放 Redis 锁,避免失败路径残留锁、 - // 在锁 TTL(1200s)内阻塞任务被其他 worker 重新获取 - defer func() { - if _, delErr := g.Redis().Del(asyncCtx, "model_video_task:"+gconv.String(item.Id)); delErr != nil { - g.Log().Errorf(asyncCtx, "清理任务锁失败: %v", delErr) - } - }() - // 按 modelId 现查模型配置(异步映射/token 映射/计费规则不随任务快照,任务完成时取当前配置) - modelInfo, err := dao.ModelManage.GetNotTenantId(asyncCtx, &dto.GetModelManageReq{Id: item.ModelId}) - if err != nil { - g.Log().Errorf(asyncCtx, "查询模型配置失败: modelId=%d err=%v", item.ModelId, err) - return - } - if modelInfo == nil { - g.Log().Errorf(asyncCtx, "模型配置不存在: modelId=%d", item.ModelId) - return - } - LOOP: - // 替换URL占位符 - url := urlParamReg.ReplaceAllString(modelInfo.AsyncTaskMapping.Url, item.TaskId) - // 组装查询请求体:POST 查询接口需要 body(从 RequestBodyMapping 出发,替换 {…} 占位符为任务 ID) - reqBody := buildAsyncTaskBody(modelInfo.AsyncTaskMapping.RequestBodyMapping, item.TaskId) - // 发起HTTP请求 - modelRespBody, err := ModelHttpNormalRequest( - asyncCtx, - url, - modelInfo.AsyncTaskMapping.RequestHeadMapping, - modelInfo.AsyncTaskMapping.HttpMethod, reqBody, - ) - if err != nil { - g.Log().Errorf(asyncCtx, "模型请求失败: %v", err) - return - } - if modelRespBody == nil { - g.Log().Errorf(asyncCtx, "模型返回参数为空") - return + ok, err := utils.WithLock(asyncCtx, lockKey, taskLockTTL, func(ctx context.Context) error { + return s.processClaimedTask(ctx, item) + }, 1) + if err != nil || !ok { + // 锁被其它实例持有或抢锁失败:跳过,任务行保留由持有方处理,下轮扫描不再命中 + g.Log().Warningf(asyncCtx, "任务锁未抢占,跳过 taskId=%d: %v", item.Id, err) } + }) +} - // 解析错误响应 - errMsg := new(dto.ModelErrorResp) - if err = gconv.Struct(modelRespBody, errMsg); err != nil { - g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err) - return - } - // 统一字段路径(GetByPath)读取基于该对象 - var respObj map[string]any - if err = json.Unmarshal(modelRespBody, &respObj); err != nil { - g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err) - return - } +// processClaimedTask 抢到任务锁后的完整处理:轮询模型结果 → 终态落库 + 发布。 +// 终态结果统一承载:成功/错误/解析失败任何路径都写进 docMsg.ErrorMsg 后走 finalize 落库+发布, +// 避免早期直接 return 把任务丢弃——任务行不删、无结果落库、调用方永远收不到通知,只会被其他 worker 反复重捡。 +func (s *modelTaskEndService) processClaimedTask(asyncCtx context.Context, item *entity.ModelTaskStart) error { + startTime := time.Now() + docMsg := new(dto.ModelMsg) + docMsg.TaskID = item.Id + var respObj map[string]any - docMsg := new(dto.ModelMsg) - docMsg.TaskID = item.Id - if errMsg.Error.Code != "" { - docMsg.ErrorMsg = errMsg.Error.Message - } else { - // 组装业务返回内容 - respBodyMap := modelUtils.CleanMapFieldPath(modelInfo.ResponseBodyMapping) - content := make(map[string]any, len(respBodyMap)) - for bizKey, jsonPath := range respBodyMap { - content[bizKey] = uploadTempURLToOSS(asyncCtx, modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(jsonPath))) - } - docMsg.Content = content - - // 解析Token - totalTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens) - promptTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens) - compTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens) - - docMsg.TotalTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, totalTokPath)) - docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, promptTokPath)) - docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, compTokPath)) - - // 按模型计费规则换算本次调用费用(未配置返回 0);媒体类型取任务创建时的快照 - docMsg.Cost = calcCostWithMediaType(docMsg.PromptTokens, docMsg.CompletionTokens, 0, item.MediaType, modelInfo.PriceConfig) - - // 判断任务状态,轮询等待 - statusPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskStatus) - status := gconv.String(modelUtils.GetByPathValue(respObj, statusPath)) - if status == modelInfo.AsyncTaskMapping.TaskStatusPending || status == modelInfo.AsyncTaskMapping.TaskStatusRunning { - time.Sleep(10 * time.Second) - goto LOOP - } - } + // 终态处理:扣费(若产生)→ 删任务行 → 插结果行(含 ErrorMsg)→ NATS 发布结果给调用方 + finalize := func() { // 按本次实际费用扣减租户余额(未产生费用不扣;异步任务无请求头,admin-go 租户接口无需鉴权可直接调用) if docMsg.Cost > 0 { if err := DeductBalance(asyncCtx, item.TenantId, docMsg.Cost); err != nil { g.Log().Errorf(asyncCtx, "[扣减余额] 异步任务扣费失败 taskId=%d cost=%.6f err=%v", item.Id, docMsg.Cost, err) } } - err = gfdb.DB(asyncCtx, public.DbNameModelGateway).Transaction(asyncCtx, func(asyncCtx context.Context, tx gdb.TX) (err error) { + err := gfdb.DB(asyncCtx, public.DbNameModelGateway).Transaction(asyncCtx, func(asyncCtx context.Context, tx gdb.TX) (err error) { // 删除视频任务 _, err = dao.ModelTaskStart.Delete(asyncCtx, &dto.DeleteModelTaskStartReq{ Id: item.Id, @@ -252,7 +187,120 @@ func (s *modelTaskEndService) handleSingleTask(ctx context.Context, item *entity if err = TaskMsgPublish(asyncCtx, item.MsgTopic, docMsg); err != nil { g.Log().Errorf(asyncCtx, "模型消息发布失败: %v", err) } - }) + } + + // 按 modelId 现查模型配置(异步映射/token 映射/计费规则不随任务快照,任务完成时取当前配置) + modelInfo, err := dao.ModelManage.GetNotTenantId(asyncCtx, &dto.GetModelManageReq{Id: item.ModelId}) + if err != nil { + g.Log().Errorf(asyncCtx, "查询模型配置失败: modelId=%d err=%v", item.ModelId, err) + docMsg.ErrorMsg = fmt.Sprintf("查询模型配置失败: %v", err) + finalize() + return nil + } + if modelInfo == nil { + g.Log().Errorf(asyncCtx, "模型配置不存在: modelId=%d", item.ModelId) + docMsg.ErrorMsg = fmt.Sprintf("模型配置不存在: modelId=%d", item.ModelId) + finalize() + return nil + } + + // 连续轮询失败上限:瞬时抖动(HTTP 错/空响应/解析失败)先有限重试,超限按终态错误落库 + const maxPollErrRetries = 3 + pollErrCnt := 0 +LOOP: + // 替换URL占位符 + url := urlParamReg.ReplaceAllString(modelInfo.AsyncTaskMapping.Url, item.TaskId) + // 组装查询请求体:POST 查询接口需要 body(从 RequestBodyMapping 出发,替换 {…} 占位符为任务 ID) + reqBody := buildAsyncTaskBody(modelInfo.AsyncTaskMapping.RequestBodyMapping, item.TaskId) + // 发起HTTP请求 + modelRespBody, err := httpclient.ModelHttpNormalRequest( + asyncCtx, + url, + modelInfo.AsyncTaskMapping.RequestHeadMapping, + modelInfo.AsyncTaskMapping.HttpMethod, reqBody, + ) + if err != nil { + g.Log().Errorf(asyncCtx, "模型请求失败: %v", err) + if pollErrCnt < maxPollErrRetries { + pollErrCnt++ + time.Sleep(10 * time.Second) + goto LOOP + } + docMsg.ErrorMsg = fmt.Sprintf("模型请求失败: %v", err) + finalize() + return nil + } + if modelRespBody == nil { + g.Log().Errorf(asyncCtx, "模型返回参数为空") + if pollErrCnt < maxPollErrRetries { + pollErrCnt++ + time.Sleep(10 * time.Second) + goto LOOP + } + docMsg.ErrorMsg = "模型返回参数为空" + finalize() + return nil + } + pollErrCnt = 0 // 请求成功一次即重置连续失败计数 + + // 异常响应识别:兼容 OpenAI 嵌套 error / 扁平 code 两种形态(与任务创建端一致),无错误返回空串 + if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil { + g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err) + if pollErrCnt < maxPollErrRetries { + pollErrCnt++ + time.Sleep(10 * time.Second) + goto LOOP + } + docMsg.ErrorMsg = fmt.Sprintf("模型返回参数解析失败: %v", err) + finalize() + return nil + } + // 统一字段路径(GetByPath)读取基于该对象 + if err = json.Unmarshal(modelRespBody, &respObj); err != nil { + g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err) + if pollErrCnt < maxPollErrRetries { + pollErrCnt++ + time.Sleep(10 * time.Second) + goto LOOP + } + docMsg.ErrorMsg = fmt.Sprintf("模型返回参数解析失败: %v", err) + finalize() + return nil + } + + // 无错误时才组装成功内容(错误响应按终态处理,跳过成功解析/轮询) + if docMsg.ErrorMsg == "" { + // 组装业务返回内容 + respBodyMap := modelUtils.CleanMapFieldPath(modelInfo.ResponseBodyMapping) + content := make(map[string]any, len(respBodyMap)) + for bizKey, jsonPath := range respBodyMap { + content[bizKey] = oss.TempURLToOSS(asyncCtx, modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(jsonPath))) + } + docMsg.Content = content + + // 解析Token + totalTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens) + promptTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens) + compTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens) + + docMsg.TotalTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, totalTokPath)) + docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, promptTokPath)) + docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, compTokPath)) + + // 按模型计费规则换算本次调用费用(未配置返回 0);媒体类型取任务创建时的快照 + docMsg.Cost = calcCostWithMediaType(docMsg.PromptTokens, docMsg.CompletionTokens, 0, item.MediaType, modelInfo.PriceConfig) + + // 判断任务状态,轮询等待 + statusPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskStatus) + status := gconv.String(modelUtils.GetByPathValue(respObj, statusPath)) + if status == modelInfo.AsyncTaskMapping.TaskStatusPending || status == modelInfo.AsyncTaskMapping.TaskStatusRunning { + time.Sleep(10 * time.Second) + goto LOOP + } + } + // 成功或已识别出错误的终态统一落库+发布(内容组装完成/错误消息已写入 docMsg) + finalize() + return nil } // buildAsyncTaskBody 组装异步任务查询请求体:从 AsyncTaskMapping.RequestBodyMapping 出发, @@ -291,94 +339,6 @@ func replaceTaskPlaceholder(v any, taskID string) any { } } -// tempDownloadTimeout 临时路径下载超时 -const tempDownloadTimeout = 5 * time.Minute - -// uploadTempURLToOSS 处理响应映射取值:模型返回的临时路径(http/https URL)会过期, -// 需下载后转存 OSS,用 OSS 完整路径替换原值。 -// - string 且以 http(s):// 开头 → 下载 → 转存 OSS → 返回 OSS 完整路径 -// - []any → 逐元素处理,任一元素被替换则返回新切片 -// - 其余类型 / 下载或上传失败 → 原样返回(失败仅记日志,不阻断任务) -func uploadTempURLToOSS(ctx context.Context, value any) any { - switch v := value.(type) { - case string: - if s, ok := uploadSingleURL(ctx, v); ok { - return s - } - case []any: - out := make([]any, len(v)) - changed := false - for i, e := range v { - if s, isStr := e.(string); isStr { - if ns, ok := uploadSingleURL(ctx, s); ok { - out[i] = ns - changed = true - continue - } - } - out[i] = e - } - if changed { - return out - } - } - return value -} - -// uploadSingleURL 下载单个临时 URL 并转存 OSS;返回 OSS 完整路径 + 是否成功替换 -func uploadSingleURL(ctx context.Context, rawURL string) (string, bool) { - rawURL = strings.TrimSpace(rawURL) - if !isHTTPURL(rawURL) { - return rawURL, false - } - data, err := downloadTempURL(ctx, rawURL) - if err != nil { - g.Log().Errorf(ctx, "临时路径下载失败: url=%s err=%v", rawURL, err) - return rawURL, false - } - ossRes, err := Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: data, - FileName: fmt.Sprintf("modelFile:%v%s", time.Now().UnixMilli(), extOfData(data)), - }) - if err != nil { - g.Log().Errorf(ctx, "临时路径转存OSS失败: url=%s err=%v", rawURL, err) - return rawURL, false - } - //ossRes.FileAddressPrefix + ossRes.FileURL, true - return ossRes.FileURL, true -} - -func isHTTPURL(s string) bool { - return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") -} - -// extOfData 按下载内容嗅探文件后缀(不依赖 URL 路径,模型返回的临时路径可能无后缀) -func extOfData(data []byte) string { - _, ext := util.DetectFileType(data) - if ext == "" || ext == ".octet-stream" { - return ".bin" - } - return ext -} - -// downloadTempURL 带超时下载 URL 内容 -func downloadTempURL(ctx context.Context, rawURL string) ([]byte, error) { - client := &http.Client{Timeout: tempDownloadTimeout} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil { - return nil, err - } - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("HTTP状态码异常: %d", resp.StatusCode) - } - return io.ReadAll(resp.Body) -} - func TaskMsgPublish(ctx context.Context, topic string, data *dto.ModelMsg) (err error) { err = gmq.GetGmq(public.GmqMsgPluginsName).GmqPublish(ctx, &mq.NatsPubMessage{ PubMessage: types.PubMessage{ diff --git a/service/model_task_start_service.go b/service/model_task_start_service.go index 2374164..9bdd789 100644 --- a/service/model_task_start_service.go +++ b/service/model_task_start_service.go @@ -6,6 +6,7 @@ import ( "fmt" "model-gateway/dao" "model-gateway/model/dto" + "model-gateway/service/httpclient" modelUtils "model-gateway/service/utils" "time" @@ -26,7 +27,7 @@ func (s *modelTaskStartService) CreateTask(ctx context.Context, req *dto.CallMod newRequestParams := req.RequestParams // 6) 模型请求 - modelRespBody, err := ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) + modelRespBody, err := httpclient.ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) if err != nil { return nil, fmt.Errorf("模型请求失败: %v", err) } @@ -44,26 +45,12 @@ func (s *modelTaskStartService) CreateTask(ctx context.Context, req *dto.CallMod } docMsg := new(dto.ModelCallRes) docMsg.TaskId = id - errMsg := new(dto.ModelErrorResp) - err = gconv.Struct(modelRespBody, errMsg) - if err != nil { + // 统一解析模型错误(兼容 OpenAI 嵌套 error / 扁平 code 两种形态),无错误返回空串 + if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil { return nil, fmt.Errorf("模型返回参数解析失败:%v", err) } - if g.IsEmpty(errMsg.Error.Code) { - errMsg1 := new(dto.ModelError1Resp) - err = gconv.Struct(modelRespBody, errMsg1) - if err != nil { - return nil, fmt.Errorf("模型返回参数解析失败:%v", err) - } - if !g.IsEmpty(errMsg1.Code) && errMsg1.Code != 20000000 { - docMsg.ErrorMsg = errMsg1.Message - updateModelReq.ErrorMsg = docMsg.ErrorMsg - } - } else { - if errMsg.Error.Code != "" { - docMsg.ErrorMsg = errMsg.Error.Message - updateModelReq.ErrorMsg = docMsg.ErrorMsg - } + if docMsg.ErrorMsg != "" { + updateModelReq.ErrorMsg = docMsg.ErrorMsg } if docMsg.ErrorMsg == "" { taskIDPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskId) diff --git a/service/parse_error.go b/service/parse_error.go new file mode 100644 index 0000000..2dcceb3 --- /dev/null +++ b/service/parse_error.go @@ -0,0 +1,32 @@ +package service + +import ( + "model-gateway/model/dto" + + "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 视为成功码,不当作错误 +// +// 解析失败返回 err,由调用方决定重试/终态。 +func parseModelError(body []byte) (code, msg string, err error) { + errMsg := new(dto.ModelErrorResp) + if err = gconv.Struct(body, errMsg); err != nil { + return "", "", err + } + if !g.IsEmpty(errMsg.Error.Code) { + return errMsg.Error.Code, errMsg.Error.Message, 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 + } + return "", "", nil +} diff --git a/service/queue/auto_tune.go b/service/queue/auto_tune.go deleted file mode 100644 index 43f4dc1..0000000 --- a/service/queue/auto_tune.go +++ /dev/null @@ -1,198 +0,0 @@ -package queue - -import ( - "context" - "errors" - "fmt" - "math" - "model-gateway/model/dto" - - "model-gateway/consts/public" - "model-gateway/model/entity" - - "gitea.redpowerfuture.com/red-future/common/db/gfdb" - "github.com/gogf/gf/v2/frame/g" -) - -// AutoTuneResult 单次调参结果(按 model_name) -type AutoTuneResult struct { - ModelName string `json:"modelName"` // 模型名称(asynch_models.model_name) - Samples int `json:"samples"` // 统计样本数(窗口内 state=2/3 且 started_at/finished_at 非空的任务数量) - P90Exec float64 `json:"p90ExecSeconds"` // 执行耗时 P90(秒),口径:finished_at - started_at - - CapMaxConcurrency int `json:"capMaxConcurrency"` // 配置上限:asynch_models.max_concurrency(cap,不会被动态调参覆盖) - OldMaxConcurrency int `json:"oldMaxConcurrency"` // 调参前运行时值(Redis),若无则等于 cap - NewMaxConcurrency int `json:"newMaxConcurrency"` // 本次计算出的运行时值(将写入 Redis),受 ±50% 约束且不超过 cap - - CapQueueLimit int `json:"capQueueLimit"` // 配置上限:asynch_models.queue_limit(cap,不会被动态调参覆盖) - OldQueueLimit int `json:"oldQueueLimit"` // 调参前运行时值(Redis),若无则等于 cap - NewQueueLimit int `json:"newQueueLimit"` // 本次计算出的运行时值(将写入 Redis),受 ±50% 约束且不超过 cap - -} - -// AutoTune 由上层定时任务通过接口触发: -// - 统计指定时间窗口内该模型任务的执行耗时(finished_at - started_at,取 P90) -// - 基于吞吐与 P90 执行耗时估算 max_concurrency 的运行时值(不超过 cap) -// - queue_limit 与 expected_seconds 绑定(允许排队时间 = expected_seconds * 2),生成运行时值(不超过 cap) -// - 单次调整幅度限制 ±50%,写入 Redis(带 TTL) -func AutoTune(ctx context.Context, req *dto.AutoTuneReq) (res *dto.AutoTuneRes, err error) { - if req == nil { - return nil, errors.New("request cannot be nil") - } - if req.WindowSeconds <= 0 { - req.WindowSeconds = 3600 // 默认1小时 - } - // 1) 读取模型配置(cap),按 model_name 聚合去重(如果表里有多租户重复数据,取较大上限) - var modelRows []*entity.ModelGatewayModel - if err := gfdb.DB(ctx).Model(ctx, public.TableNameModel). - Where("deleted_at IS NULL"). - Where(entity.ModelGatewayModelCol.Enabled, 1). - Scan(&modelRows); err != nil { - return nil, err - } - modelMap := make(map[string]*entity.ModelGatewayModel) - for _, m := range modelRows { - if m == nil || m.ModelName == "" { - continue - } - cur := modelMap[m.ModelName] - if cur == nil { - modelMap[m.ModelName] = m - continue - } - // 取更大的 cap - if m.MaxConcurrency > cur.MaxConcurrency { - cur.MaxConcurrency = m.MaxConcurrency - } - if m.MaxConcurrency*2 > cur.MaxConcurrency*2 { - cur.MaxConcurrency = m.MaxConcurrency - } - if m.TimeoutSeconds > cur.TimeoutSeconds { - cur.TimeoutSeconds = m.TimeoutSeconds - } - } - if len(modelMap) == 0 { - return nil, errors.New("no models found") - } - - // 2) 统计指定窗口:按 model_name 计算 cnt 和 P90 执行耗时 - type statRow struct { - ModelName string - Cnt int - P90Exec float64 - } - var stats []statRow - sql := fmt.Sprintf(` -SELECT model_name, - COUNT(1) AS cnt, - COALESCE(percentile_cont(0.9) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (finished_at - started_at))), 0) AS p90_exec - FROM %s - WHERE deleted_at IS NULL - AND state IN (2,3) - AND started_at IS NOT NULL - AND finished_at IS NOT NULL - AND finished_at >= (NOW() - (? || ' seconds')::interval) - GROUP BY model_name`, public.TableNameTask) - r, err := gfdb.DB(ctx).GetAll(ctx, sql, req.WindowSeconds) - if err != nil { - return nil, err - } - _ = r.Structs(&stats) - statMap := make(map[string]statRow, len(stats)) - for _, s := range stats { - statMap[s.ModelName] = s - } - - // 3) 调参计算 - const utilization = 0.8 - const maxChangeRatio = 0.5 // ±50% - const queueFactor = 2.0 // 与 expected_seconds 绑定:W_target = expected_seconds * 2 - - out := make([]AutoTuneResult, 0, len(modelMap)) - for modelName, m := range modelMap { - s := statMap[modelName] - capMax := m.MaxConcurrency - capQueue := m.MaxConcurrency * 2 - oldMax := GetRuntimeMaxConcurrency(ctx, modelName, capMax) - oldQueue := GetRuntimeQueueLimit(ctx, modelName, capQueue) - - // 默认:无样本则不调整 - if s.Cnt <= 0 || s.P90Exec <= 0 { - out = append(out, AutoTuneResult{ - ModelName: modelName, - Samples: s.Cnt, - P90Exec: s.P90Exec, - CapMaxConcurrency: capMax, - OldMaxConcurrency: oldMax, - NewMaxConcurrency: oldMax, - CapQueueLimit: capQueue, - OldQueueLimit: oldQueue, - NewQueueLimit: oldQueue, - }) - continue - } - - // arrival_rate ≈ 完成数/3600 - arrivalRate := float64(s.Cnt) / 3600.0 - - // desiredMax = ceil(arrivalRate * p90 / utilization) - desiredMax := int(math.Ceil(arrivalRate * s.P90Exec / utilization)) - if desiredMax < 1 { - desiredMax = 1 - } - // 单次变化幅度限制 - minMax := int(math.Floor(float64(oldMax) * (1 - maxChangeRatio))) - maxMax := int(math.Ceil(float64(oldMax) * (1 + maxChangeRatio))) - if minMax < 1 { - minMax = 1 - } - newMax := clampInt(desiredMax, minMax, maxMax) - if capMax > 0 { - newMax = clampInt(newMax, 1, capMax) - } - setRuntimeInt(ctx, runtimeMaxConcurrencyKey(modelName), newMax) - - // queue_limit:W_target = expected_seconds * queueFactor - exp := m.TimeoutSeconds - if exp <= 0 { - exp = 60 - } - wTarget := float64(exp) * queueFactor - desiredQueue := int(math.Ceil(arrivalRate*wTarget)) + newMax - if desiredQueue < newMax { - desiredQueue = newMax - } - - newQueue := oldQueue - if capQueue > 0 { - minQ := int(math.Floor(float64(oldQueue) * (1 - maxChangeRatio))) - maxQ := int(math.Ceil(float64(oldQueue) * (1 + maxChangeRatio))) - if minQ < newMax { - minQ = newMax - } - if maxQ < minQ { - maxQ = minQ - } - newQueue = clampInt(desiredQueue, minQ, maxQ) - newQueue = clampInt(newQueue, newMax, capQueue) - setRuntimeInt(ctx, runtimeQueueLimitKey(modelName), newQueue) - } - - out = append(out, AutoTuneResult{ - ModelName: modelName, - Samples: s.Cnt, - P90Exec: s.P90Exec, - CapMaxConcurrency: capMax, - OldMaxConcurrency: oldMax, - NewMaxConcurrency: newMax, - CapQueueLimit: capQueue, - OldQueueLimit: oldQueue, - NewQueueLimit: newQueue, - }) - } - - g.Log().Infof(ctx, "[auto_tune] done models=%d windowSeconds=%d", len(out), req.WindowSeconds) - return &dto.AutoTuneRes{ - List: out, - }, nil -} diff --git a/service/queue/queue_gate.go b/service/queue/queue_gate.go deleted file mode 100644 index 9e0eb51..0000000 --- a/service/queue/queue_gate.go +++ /dev/null @@ -1,107 +0,0 @@ -package queue - -import ( - "context" - "fmt" - "math" - "time" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" -) - -// ===== 严格 queue_limit:Redis 原子闸门 ===== -// -// 背景:原来的 queue_limit 通过“Count + Insert”做近似控制,分布式并发创建时会短暂超限。 -// 目标:以 Redis Lua 脚本实现原子校验 + 入队占位,做到严格不超限。 -// -// 计数口径与原逻辑保持一致:只统计 state=0/1(排队中/执行中)。 -// - CreateTask 成功入库后占用 1 个 slot -// - 任务成功/失败(state->2/3)释放 slot -// - 失败任务重试(state 3->0)需要再次占用 slot,若占位失败则暂不重试(留在 state=3,下次 cleaner 再尝试) -// -// 说明:为避免极端情况下“占位泄漏”导致永久占满,采用 ZSET + 过期时间的方式自动回收。 -// 只要任务实际生命周期远小于 gateTTLSeconds,就可保持严格。 - -const ( - queueGateKeyPrefix = "asynch:qgate:" // asynch:qgate:{modelName} -) - -// Lua:清理过期 slot,然后按 limit 做原子判定并占位 -var queueGateAcquireLua = ` -local key = KEYS[1] -local now = tonumber(ARGV[1]) -local limit = tonumber(ARGV[2]) -local expireAt = tonumber(ARGV[3]) -local member = ARGV[4] -local keyTTL = tonumber(ARGV[5]) - --- 先清理过期的占位 -redis.call("ZREMRANGEBYSCORE", key, "-inf", now) - -local current = tonumber(redis.call("ZCARD", key) or "0") -if current >= limit then - return 0 -end -redis.call("ZADD", key, expireAt, member) -redis.call("EXPIRE", key, keyTTL) -return 1 -` - -// Lua:释放 slot(幂等) -var queueGateReleaseLua = ` -local key = KEYS[1] -local member = ARGV[1] -redis.call("ZREM", key, member) -return 1 -` - -func queueGateKey(modelName string) string { - return fmt.Sprintf("%s%s", queueGateKeyPrefix, modelName) -} - -// calcGateTTLSeconds 计算闸门占位的“自动回收 TTL” -// 取 expectedSeconds 的倍数并做上下限,避免任务异常导致永久占位。 -func calcGateTTLSeconds(expectedSeconds int) int { - // 默认至少 1 小时;最多 24 小时 - minTTL := 3600 - maxTTL := 24 * 3600 - if expectedSeconds <= 0 { - return minTTL - } - ttl := int(math.Ceil(float64(expectedSeconds) * 10)) // 预计耗时 * 10 做兜底 - if ttl < minTTL { - ttl = minTTL - } - if ttl > maxTTL { - ttl = maxTTL - } - return ttl -} - -// AcquireQueueSlot 严格入队:原子占位(成功返回 true) -func AcquireQueueSlot(ctx context.Context, modelName, taskId string, limit int, expectedSeconds int) (bool, error) { - if limit <= 0 { - return true, nil - } - key := queueGateKey(modelName) - now := time.Now().Unix() - ttl := calcGateTTLSeconds(expectedSeconds) - expireAt := now + int64(ttl) - // keyTTL 要略大于 member TTL,避免 key 先过期导致计数丢失 - keyTTL := ttl + 60 - r, err := g.Redis().Do(ctx, "EVAL", queueGateAcquireLua, 1, key, now, limit, expireAt, taskId, keyTTL) - if err != nil { - return false, fmt.Errorf("queue gate acquire failed: %w", err) - } - return gconv.Int(r) == 1, nil -} - -// ReleaseQueueSlot 释放占位(幂等) -func ReleaseQueueSlot(ctx context.Context, modelName, taskId string) { - if taskId == "" || modelName == "" { - return - } - key := queueGateKey(modelName) - _, _ = g.Redis().Do(ctx, "EVAL", queueGateReleaseLua, 1, key, taskId) -} diff --git a/service/queue/runtime_tune.go b/service/queue/runtime_tune.go deleted file mode 100644 index ec1dd5d..0000000 --- a/service/queue/runtime_tune.go +++ /dev/null @@ -1,82 +0,0 @@ -package queue - -import ( - "context" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" -) - -// 运行时调参存储在 Redis,不修改 asynch_models 中的 cap(最大上限)。 -// 上层每小时调用 /model/autoTune 写入运行时值;Worker/CreateTask 读取运行时值生效。 - -const ( - runtimeMaxCKeyPrefix = "asynch:runtime:max_concurrency:" // + model_name - runtimeQueueKeyPrefix = "asynch:runtime:queue_limit:" // + model_name - runtimeTTLSeconds = 2 * 3600 // 2小时,避免一次调参失败导致立即回退 -) - -func runtimeMaxConcurrencyKey(modelName string) string { - return runtimeMaxCKeyPrefix + modelName -} -func runtimeQueueLimitKey(modelName string) string { - return runtimeQueueKeyPrefix + modelName -} - -func getRuntimeInt(ctx context.Context, key string) (int, bool) { - v, err := g.Redis().Do(ctx, "GET", key) - if err != nil || v == nil { - return 0, false - } - iv := gconv.Int(v) - if iv <= 0 { - return 0, false - } - return iv, true -} - -func setRuntimeInt(ctx context.Context, key string, val int) { - if val <= 0 { - return - } - // SETEX key ttl val - _, _ = g.Redis().Do(ctx, "SETEX", key, runtimeTTLSeconds, val) -} - -// GetRuntimeMaxConcurrency 返回运行时并发上限(<= cap)。若不存在运行时值,则返回 cap。 -func GetRuntimeMaxConcurrency(ctx context.Context, modelName string, cap int) int { - if cap <= 0 { - return cap - } - if v, ok := getRuntimeInt(ctx, runtimeMaxConcurrencyKey(modelName)); ok { - if v > cap { - return cap - } - return v - } - return cap -} - -// GetRuntimeQueueLimit 返回运行时队列上限(<= cap)。若不存在运行时值,则返回 cap。 -func GetRuntimeQueueLimit(ctx context.Context, modelName string, cap int) int { - if cap <= 0 { - return cap - } - if v, ok := getRuntimeInt(ctx, runtimeQueueLimitKey(modelName)); ok { - if v > cap { - return cap - } - return v - } - return cap -} - -func clampInt(v, minV, maxV int) int { - if v < minV { - return minV - } - if v > maxV { - return maxV - } - return v -} diff --git a/service/queue/semaphore.go b/service/queue/semaphore.go deleted file mode 100644 index 6f818e6..0000000 --- a/service/queue/semaphore.go +++ /dev/null @@ -1,57 +0,0 @@ -package queue - -import ( - "context" - "fmt" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" -) - -var acquireLua = ` -local current = tonumber(redis.call("GET", KEYS[1]) or "0") -local max = tonumber(ARGV[1]) -local ttl = tonumber(ARGV[2]) -if current >= max then - return 0 -end -current = redis.call("INCR", KEYS[1]) -if current == 1 then - redis.call("EXPIRE", KEYS[1], ttl) -end -if current > max then - redis.call("DECR", KEYS[1]) - return 0 -end -return 1 -` - -var releaseLua = ` -local current = tonumber(redis.call("DECR", KEYS[1]) or "0") -if current <= 0 then - redis.call("DEL", KEYS[1]) -end -return 1 -` - -// AcquireSemaphore 获取并发令牌 -func AcquireSemaphore(ctx context.Context, key string, max int, ttlSeconds int64) (bool, error) { - if max <= 0 { - // 不限制 - return true, nil - } - if ttlSeconds <= 0 { - ttlSeconds = 3600 - } - r, err := g.Redis().Do(ctx, "EVAL", acquireLua, 1, key, max, ttlSeconds) - if err != nil { - return false, fmt.Errorf("获取并发令牌失败: %w", err) - } - return gconv.Int(r) == 1, nil -} - -// ReleaseSemaphore 释放并发令牌 -func ReleaseSemaphore(ctx context.Context, key string) error { - _, err := g.Redis().Do(ctx, "EVAL", releaseLua, 1, key) - return err -} diff --git a/service/retry.go b/service/retry.go new file mode 100644 index 0000000..d66d706 --- /dev/null +++ b/service/retry.go @@ -0,0 +1,53 @@ +package service + +import ( + "context" + "strings" + "time" + + "gitea.redpowerfuture.com/red-future/common/oss" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// modelCallMaxRetries 上游调用最大重试次数 +const modelCallMaxRetries = 10 + +// retryWait 指数退避等待(第 attempt 次重试,等待 1< 0 { + return gconv.String(arr[0]) + } + return gconv.String(v) +} + +// extractChunkText 从流式分片字段取值并转存 OSS,返回首位文本(数组取首个元素,去掉首尾空白)。 +// 空值 / 仅空白 / 数组全空 返回空串。 +func extractChunkText(ctx context.Context, v any) string { + if v == nil || g.IsEmpty(v) { + return "" + } + return strings.TrimSpace(firstText(oss.TempURLToOSS(ctx, v))) +} diff --git a/service/schema_mapping_service.go b/service/schema_mapping_service.go index a12c21f..067c5da 100644 --- a/service/schema_mapping_service.go +++ b/service/schema_mapping_service.go @@ -159,11 +159,12 @@ func markPrevAsArray(out *[]string) { } } -// callLLM 调用大模型聊天接口(OpenAI 兼容格式) +// callLLM 调用大模型聊天接口(OpenAI 兼容格式)。 +// 模型地址/密钥走配置 schemaMapping 段,本地开发无配置时用默认值兜底。 func callLLM(ctx context.Context, systemPrompt, userPrompt string) (string, error) { - modelName := "doubao-seed-2-0-lite-260428" - baseURL := "https://ark.cn-beijing.volces.com/api/v3/chat/completions" - apiKey := "ark-9df744e8-a0de-4c54-9db3-18379bccd523-e6733" + modelName := g.Cfg().MustGet(ctx, "schemaMapping.modelName", "doubao-seed-2-0-lite-260428").String() + baseURL := g.Cfg().MustGet(ctx, "schemaMapping.baseUrl", "https://ark.cn-beijing.volces.com/api/v3/chat/completions").String() + apiKey := g.Cfg().MustGet(ctx, "schemaMapping.apiKey", "ark-9df744e8-a0de-4c54-9db3-18379bccd523-e6733").String() body := map[string]any{ "model": modelName, diff --git a/service/model_session_service.go b/service/session_stream.go similarity index 65% rename from service/model_session_service.go rename to service/session_stream.go index 5de63a6..4bafb06 100644 --- a/service/model_session_service.go +++ b/service/session_stream.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "fmt" - "model-gateway/consts/model" "model-gateway/dao" "model-gateway/model/domain" "model-gateway/model/dto" + "model-gateway/service/httpclient" modelUtils "model-gateway/service/utils" "net/http" "strings" @@ -17,128 +17,6 @@ import ( "github.com/gogf/gf/v2/util/gconv" ) -var ModelSession = &modelSessionService{} - -type modelSessionService struct{} - -// modelCallMaxRetries 上游调用最大重试次数 -const modelCallMaxRetries = 15 - -// CreateSession 创建会话 -func (s *modelSessionService) CreateSession(ctx context.Context, req *dto.CallModelSessionReq) (res *dto.ModelCallRes, err error) { - startTime := time.Now() - attempt := 0 - id := req.Id - modelInfo := req.ModelInfo - newRequestParams := req.RequestParams -LOOP: - // 6) 模型请求 - modelRespBody, err := ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) - if err != nil { - return nil, err - } - if modelRespBody == nil { - return nil, fmt.Errorf("模型返回参数是空") - } - // 7) 上传模型返回参数文件 - uploadOriginalResp, err := Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: modelRespBody, - FileName: fmt.Sprintf("modelRespParams:%v.json", time.Now().UnixMilli()), - }) - if err != nil { - return nil, fmt.Errorf("上传模型返回参数文件失败:%v", err) - } - // 8) 更新模型会话信息 - updateModelSessionReq := dto.UpdateModelSessionReq{ - Id: id, - OriginalResponsePath: uploadOriginalResp.FileURL, - } - errMsg := new(dto.ModelErrorResp) - err = gconv.Struct(modelRespBody, errMsg) - if err != nil { - return nil, fmt.Errorf("模型返回参数解析失败:%v", err) - } - docMsg := new(dto.ModelCallRes) - docMsg.TaskId = id - if errMsg.Error.Code != "" { - - if attempt < modelCallMaxRetries && isRetryableErrorCode(errMsg.Error.Code) { - attempt++ - wait := time.Duration(1< 0 { - realText = gconv.String(arr[0]) - } else { - realText = gconv.String(v) - } - - realText = strings.TrimSpace(realText) - if realText == "" { - continue - } - contentBuf.WriteString(realText) } // Token 累加 @@ -227,11 +89,9 @@ LOOP: attempt++ wait := time.Duration(1< 0 { - realText = gconv.String(arr[0]) - } else { - realText = gconv.String(v) - } - - realText = strings.TrimSpace(realText) - if realText == "" { - continue - } - - content[bizKey] = realText - contentBuf.WriteString(realText) } // Token 累加(记录增量:usage 常在无文本/思考的末分片出现,需据此放行推送) @@ -359,11 +204,7 @@ func (s *modelSessionService) CreateSessionStream(ctx context.Context, w http.Re var reasoningContent string if reasoningPath != "" { if v := modelUtils.GetByPathValue(chunk, reasoningPath); v != nil && !g.IsEmpty(v) { - if arr, ok := v.([]any); ok && len(arr) > 0 { - reasoningContent = gconv.String(arr[0]) - } else { - reasoningContent = gconv.String(v) - } + reasoningContent = firstText(v) } } @@ -460,16 +301,6 @@ func recordSessionError(ctx context.Context, id int64, startTime time.Time, errM } } -// isRetryableErrorCode 判定上游返回的错误码是否可重试:限流(429/limit_requests/limit_tokens/rate_limit_exceeded)与 5xx(500-503)。 -// ModelHttpNormalRequest 不返回 HTTP status,只能按响应体 error.code 字符串判定。 -func isRetryableErrorCode(code string) bool { - switch code { - case "429", "500", "501", "502", "503", "InvalidParameter", "limit_requests", "limit_tokens", "rate_limit_exceeded": - return true - } - return false -} - // streamErrorOfChunk 从流式分片提取错误码与消息:优先 OpenAI 兼容 error 事件,顶层 code 兜底。 func streamErrorOfChunk(chunk map[string]any) (code, msg string) { if errObj := gconv.Map(chunk["error"]); errObj != nil { @@ -489,7 +320,7 @@ func streamRetryCodeOfError(err error) string { return "" } msg := err.Error() - // 非 2xx 时 ModelHttpStreamRequest 返回 "[HTTP][Stream] 状态码异常: %d, body={...}" + // 非 2xx 时 httpclient.ModelHttpStreamRequest 返回 "[HTTP][Stream] 状态码异常: %d, body={...}" if idx := strings.Index(msg, "body="); idx >= 0 { body := msg[idx+len("body="):] var errResp struct { diff --git a/service/session_sync.go b/service/session_sync.go new file mode 100644 index 0000000..2d54476 --- /dev/null +++ b/service/session_sync.go @@ -0,0 +1,133 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "model-gateway/consts/model" + "model-gateway/dao" + "model-gateway/model/dto" + "model-gateway/service/httpclient" + modelUtils "model-gateway/service/utils" + "time" + + "gitea.redpowerfuture.com/red-future/common/oss" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelSession = &modelSessionService{} + +type modelSessionService struct{} + +// CreateSession 创建会话(同步调用,非流式) +func (s *modelSessionService) CreateSession(ctx context.Context, req *dto.CallModelSessionReq) (res *dto.ModelCallRes, err error) { + startTime := time.Now() + attempt := 0 + id := req.Id + modelInfo := req.ModelInfo + newRequestParams := req.RequestParams +LOOP: + // 6) 模型请求 + modelRespBody, err := httpclient.ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) + if err != nil { + return nil, err + } + if modelRespBody == nil { + return nil, fmt.Errorf("模型返回参数是空") + } + // 7) 上传模型返回参数文件 + uploadOriginalResp, err := Upload(ctx, &dto.UploadFileBytesReq{ + FileBytes: modelRespBody, + FileName: fmt.Sprintf("modelRespParams:%v.json", time.Now().UnixMilli()), + }) + if err != nil { + return nil, fmt.Errorf("上传模型返回参数文件失败:%v", err) + } + // 8) 更新模型会话信息 + updateModelSessionReq := dto.UpdateModelSessionReq{ + Id: id, + OriginalResponsePath: uploadOriginalResp.FileURL, + } + docMsg := new(dto.ModelCallRes) + docMsg.TaskId = id + errCode, errMsg, err := parseModelError(modelRespBody) + if err != nil { + return nil, fmt.Errorf("模型返回参数解析失败:%v", err) + } + if errCode != "" { + + if attempt < modelCallMaxRetries && isRetryableErrorCode(errCode) { + attempt++ + wait := time.Duration(1< 0 { - ok, err := queue.AcquireQueueSlot(ctx, req.ModelName, taskID, limit, model.TimeoutSeconds) - if err != nil { - return nil, err - } - if !ok { - return nil, errors.New("任务排队已满,请稍后再试") - } - } - - // 3) 插入任务记录 - requestPayload := entity.RequestPayload{ - Body: req.RequestPayload, - Headers: util.ParseHeadMsgHeaders(model.HeadMsg), - } - id, err := dao.ModelGatewayTask.Insert(ctx, &entity.ModelGatewayTask{ - ModelName: req.ModelName, - TaskID: taskID, - State: public.TaskStatusPending, - BizName: req.BizName, - CallbackURL: req.CallbackUrl, - RequestPayload: &requestPayload, - EpicycleId: req.EpicycleId, - }) - if err != nil { // 入库失败:回滚闸门占位 - queue.ReleaseQueueSlot(ctx, req.ModelName, taskID) - return nil, err - } - - // 4) 写操作日志(不影响主流程,失败忽略) - ip := "" - ua := "" - apiPath := "/task/createTask" - httpMethod := "POST" - if r := g.RequestFromCtx(ctx); r != nil { - ip = utils.GetLocalIP() - ua = r.UserAgent() - apiPath = r.URL.Path - httpMethod = r.Method - } - _, _ = dao.ModelGatewayLogsOp.Insert(ctx, &entity.ModelGatewayLogsOp{ - IP: ip, - UserAgent: ua, - APIPath: apiPath, - HttpMethod: httpMethod, - BizName: req.BizName, - ModelName: req.ModelName, - TaskID: taskID, - OpType: "createTask", - Success: 1, - CostMs: time.Since(time.Now()).Milliseconds(), - RequestPayload: &requestPayload, - ResponsePayload: gdb.Map{ - "taskId": taskID, - }, - }) - - // 5) 获取任务信息 - task, err := dao.ModelGatewayTask.ClaimByID(ctx, id) - if err != nil { - return nil, err - } - - // 5) 创建成功后立即异步尝试执行当前任务 - go AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model, req) - - return &dto.CreateTaskRes{TaskID: taskID}, nil -} - -// GetResult 获取任务结果 -func (s *taskService) GetResult(ctx context.Context, taskID string) (res *dto.GetTaskResultRes, err error) { - t, err := dao.ModelGatewayTask.Get(ctx, &entity.ModelGatewayTask{ - TaskID: taskID, - }) - if err != nil { - return nil, err - } - if t == nil { - return nil, errors.New("任务不存在") - } - return &dto.GetTaskResultRes{ - OssFile: t.ResultFile.OssFile, - State: t.State, - }, nil -} - -// GetBatch 批量查询任务;将成功(state=2)的任务更新为已下载(state=4),并写入过期时间 -func (s *taskService) GetBatch(ctx context.Context, req *dto.GetTaskBatchReq) (res *dto.GetTaskBatchRes, err error) { - if req == nil || len(req.TaskIDs) == 0 { - return &dto.GetTaskBatchRes{List: []dto.GetTaskBatchItem{}}, nil - } - // 1) 先查当前租户下的任务列表 - list, err := dao.ModelGatewayTask.ListByTaskIDs(ctx, req.TaskIDs) - if err != nil { - return nil, err - } - - // 2) 对成功(state=2)的任务:标记为已下载(state=4) - for _, t := range list { - if t == nil { - continue - } - if t.State != public.BuildTypeNode { - continue - } - _ = dao.ModelGatewayTask.MarkDownloadedByID(ctx, t.Id) - - // 为了本次返回一致性,内存里也更新 - t.State = public.TaskStatusDownloaded - } - - // 3) 组装返回 - items := make([]dto.GetTaskBatchItem, 0, len(list)) - for _, t := range list { - if t == nil { - continue - } - items = append(items, dto.GetTaskBatchItem{ - TaskID: t.TaskID, - State: t.State, - OssFile: t.ResultFile.OssFile, - TextResult: t.TextResult, - }) - } - return &dto.GetTaskBatchRes{List: items}, nil -} - -// List 获取任务列表 -func (s *taskService) List(ctx context.Context, req *dto.ListTaskReq) (*dto.ListTaskRes, error) { - if req.PageNum <= 0 { - req.PageNum = 1 - } - if req.PageSize <= 0 { - req.PageSize = 10 - } - user, err := utils.GetUserInfo(ctx) - if err != nil { - return nil, err - } - list, total, err := dao.ModelGatewayTask.List(ctx, req.PageNum, req.PageSize, &entity.ModelGatewayTask{ - SQLBaseDO: beans.SQLBaseDO{ - Creator: user.UserName, - }, - ModelName: req.ModelName, - BizName: req.BizName, - State: req.State, - TaskID: req.TaskID, - }) - if err != nil { - return nil, err - } - return &dto.ListTaskRes{List: list, Total: total}, nil -} - -// ModelTaskCallback 模型异步任务的回调通知 -func (s *taskService) ModelTaskCallback(ctx context.Context, req *dto.ModelTaskCallbackReq) (*dto.ModelTaskCallbackRes, error) { - g.Log().Infof(ctx, "[模型回调] 收到通知 taskID=%s status=%s", req.TaskID, req.Status) - // 1. 查本地任务 - task, err := dao.ModelGatewayTask.Get(ctx, &entity.ModelGatewayTask{ - TaskID: req.TaskID, - }) - if err != nil || task == nil { - return nil, fmt.Errorf("任务不存在: %s", req.TaskID) - } - - // 2. 成功:取 video_url 和 usage - if req.Status == "succeeded" { - result := map[string]any{ - "video_url": req.Content["video_url"], - "usage": req.Usage, - } - NotifyAsyncResult(req.TaskID, result, nil) - return &dto.ModelTaskCallbackRes{Success: true}, nil - } - - // 3. 失败/过期 - if req.Status == "failed" || req.Status == "expired" { - NotifyAsyncResult(req.TaskID, nil, fmt.Errorf("%s", req.Status)) - return &dto.ModelTaskCallbackRes{Success: true}, nil - } - - return &dto.ModelTaskCallbackRes{Success: true}, nil -} - -// QueryPendingTasks 批量轮询进行中的异步任务 -func (s *taskService) QueryPendingTasks(ctx context.Context, req *dto.QueryPendingTasksReq) (*dto.QueryPendingTasksRes, error) { - limit := req.Limit - if limit <= 0 { - limit = g.Cfg().MustGet(ctx, "asynch.queryPending.limit", 10).Int() - } - - // 1. 查 state=1(执行中)的异步任务 - tasks, err := dao.ModelGatewayTask.GetPendingAsyncTasks(ctx, limit) - if err != nil { - return nil, err - } - - // 2. 逐个查询 - var results []dto.QueryTaskItem - for _, t := range tasks { - // 拿到模型配置 - model, err := dao.ModelGatewayModels.GetByModelNameForTenant(ctx, t.TenantId, t.ModelName) - if err != nil || model == nil || model.QueryConfig == nil { - continue - } - result, err := util.PullTaskResult(ctx, nil, model.QueryConfig, model.HeadMsg) - if err != nil { - g.Log().Warningf(ctx, "[轮询] 查询失败 taskID=%s err=%v", t.TaskID, err) - continue - } - - status := gconv.String(result["status"]) - item := dto.QueryTaskItem{ - TaskID: t.TaskID, - Status: status, - Content: result["content"].(map[string]any), - Usage: result["usage"].(map[string]any), - } - results = append(results, item) - - // 如果任务完成,通知等待通道 - if status == "succeeded" || status == "failed" || status == "expired" { - NotifyAsyncResult(t.TaskID, result["content"].(map[string]any), nil) - } - } - - return &dto.QueryPendingTasksRes{ - Total: len(results), - Results: results, - }, nil -} diff --git a/service/task/worker.go b/service/task/worker.go deleted file mode 100644 index 5cdd232..0000000 --- a/service/task/worker.go +++ /dev/null @@ -1,501 +0,0 @@ -package task - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "sync" - "time" - "unicode/utf8" - - "model-gateway/common/util" - "model-gateway/consts/public" - "model-gateway/dao" - "model-gateway/model/dto" - "model-gateway/model/entity" - "model-gateway/service/gateway" - "model-gateway/service/queue" - - "gitea.redpowerfuture.com/red-future/common/beans" - "github.com/gogf/gf/v2/encoding/gjson" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" -) - -var AsyncWorker = &asyncWorker{} - -type asyncWorker struct { -} - -// handleOne 执行一次完整的任务 -func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, req *dto.CreateTaskReq) { - var ( - body = task.RequestPayload.Body - maxRetry = model.RetryTimes - startTime = time.Now() - result map[string]any - err error - ) - g.Log().Infof(ctx, "[执行任务][开始] taskId=%s model=%s", task.TaskID, task.ModelName) - - // ============================================ - // 1) 分布式并发控制 - // ============================================ - semKey := fmt.Sprintf("asynch:sem:%s", task.ModelName) - maxC := queue.GetRuntimeMaxConcurrency(ctx, task.ModelName, model.MaxConcurrency) - acquired, err := queue.AcquireSemaphore(ctx, semKey, maxC, 3600) - if err != nil { - task.DurationSeconds = int64(time.Since(startTime).Seconds()) - w.failTask(ctx, task, startTime, err.Error()) - return - } - if !acquired { - _, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{ - SQLBaseDO: beans.SQLBaseDO{Id: task.Id}, - State: public.TaskStatusPending, - }) - g.Log().Infof(ctx, "[执行任务][排队] 并发已满,放回队列 taskId=%s", task.TaskID) - return - } - defer func() { _ = queue.ReleaseSemaphore(ctx, semKey) }() - - // ============================================ - // 2) 调用模型 - // ============================================ - switch { - case model.CallMode != nil && *model.CallMode == public.CallModeStream: - rawBytes, streamErr := w.callModelStream(ctx, task, model, body) - if streamErr != nil { - w.failTask(ctx, task, startTime, streamErr.Error()) - return - } - result, err = util.ParseStreamResponse(rawBytes, model.StreamConfig) - case model.CallMode != nil && *model.CallMode == public.CallModeAsync: - result, err = w.callModel(ctx, task, model, body) - if err == nil { - result, err = util.PullTaskResult(ctx, result, model.QueryConfig, model.HeadMsg) - } - default: - result, err = w.callModel(ctx, task, model, body) - } - if err != nil { - w.failTask(ctx, task, startTime, err.Error()) - return - } - - // ============================================ - // 3) 缓存临时文件 - // ============================================ - if tmpPath, tmpErr := util.SaveTempFileByType(task.TaskID, result, task.TmpFile); tmpErr == nil && tmpPath != "" { - task.TmpFile = tmpPath - task.Phase = 1 - _, _ = dao.ModelGatewayTask.Update(ctx, task) - } - - // ============================================ - // 4) 解析校验 + 响应映射(可重试) - // ============================================ - result, err = w.parseAndRetry(ctx, result, task, model, req, maxRetry, startTime) - if err != nil { - task.TextResult = result - w.failTask(ctx, task, startTime, err.Error()) - return - } - - // ============================================ - // 5) 上传 OSS(可重试) - // ============================================ - var oss *gateway.UploadFileResponse - for attempt := 0; attempt <= maxRetry; attempt++ { - if attempt > 0 { - 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, "[执行任务][失败] OSS上传失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err) - if attempt == maxRetry { - task.State = public.TaskStatusFailed - task.ErrorMsg = err.Error() - task.Phase = 1 - _, _ = dao.ModelGatewayTask.Update(ctx, task) - w.failTask(ctx, task, startTime, fmt.Sprintf("OSS上传重试耗尽: %v", err)) - return - } - } - - // ============================================ - // 6) 成功收尾 - // ============================================ - task.State = public.TaskStatusSuccess - task.DurationSeconds = int64(time.Since(startTime).Seconds()) - task.ResultFile = &entity.ResultFile{ - OssFile: oss.FileAddressPrefix + oss.FileURL, - FileType: oss.FileFormat, - FileSize: int64(oss.FileSize), - } - task.TextResult = result - if _, err = dao.ModelGatewayTask.Update(ctx, task); err != nil { - g.Log().Errorf(ctx, "[执行任务][失败] 更新数据库失败 taskId=%s err=%v", task.TaskID, err) - return - } - - queue.ReleaseQueueSlot(ctx, task.ModelName, task.TaskID) - go gateway.TriggerCallback(context.WithoutCancel(ctx), task) - if req.EpicycleId != 0 { - go gateway.TriggerPromptsCallback(context.WithoutCancel(ctx), task, req.EpicycleId) - } - - g.Log().Infof(ctx, "[执行任务][成功] taskId=%s duration=%ds fileType=%s", - task.TaskID, task.DurationSeconds, oss.FileFormat) - - _ = os.Remove(task.TmpFile) -} - -// callModelStream 调用模型,返回原始字节(不做响应映射,用于流式输出) -func (w *asyncWorker) callModelStream(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, body map[string]any) ([]byte, error) { - var data []byte - var err error - - if task.Phase == 1 && strings.TrimSpace(task.TmpFile) != "" { - data, err = os.ReadFile(task.TmpFile) - if err != nil || len(data) == 0 { - data = nil - } - } - - if data == nil { - data, err = InvokeModel(ctx, model, body) - if err != nil { - return nil, err - } - tmpPath, tmpErr := util.SaveTmpResult(task.TaskID, data, "") - if tmpErr == nil && tmpPath != "" { - task.TmpFile = tmpPath - task.Phase = 1 - _, err = dao.ModelGatewayTask.Update(ctx, task) - if err != nil { - g.Log().Errorf(ctx, "[执行任务][失败] 临时文件保存失败 taskId=%s err=%v", task.TaskID, tmpErr) - } - } - } - - return data, nil -} - -// asyncResult 异步任务结果 -type asyncResult struct { - result map[string]any - err error -} - -// 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) { - // 1. 提交异步任务 - body, err := w.callModel(ctx, task, model, body) - if err != nil { - return nil, err - } - // 2. 拿到 task_id - taskID := gjson.New(body).Get(entity.ResponseBody).String() - - // 3. 创建等待通道 - ch := make(chan asyncResult, 1) - asyncTaskChan.Store(taskID, ch) - defer func() { - asyncTaskChan.Delete(taskID) - close(ch) - }() - - // 4. 阻塞等待回调或超时 - timeout := time.Duration(model.TimeoutSeconds) * time.Second - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - g.Log().Infof(ctx, "[异步任务] 开始等待结果 taskID=%s timeout=%v", taskID, timeout) - - select { - case res, ok := <-ch: - if !ok { - return nil, fmt.Errorf("异步任务通道已关闭: taskID=%s", taskID) - } - g.Log().Infof(ctx, "[异步任务] 获取结果成功 taskID=%s", taskID) - return res.result, res.err - case <-ctx.Done(): - return nil, fmt.Errorf("异步任务超时: taskID=%s", taskID) - } -} - -// NotifyAsyncResult 回调接口调用此方法通知结果 -func NotifyAsyncResult(taskID string, result map[string]any, err error) { - if ch, ok := asyncTaskChan.Load(taskID); ok { - ch.(chan asyncResult) <- asyncResult{result: result, err: err} - } -} - -// callModel 调用模型 + 检测文件类型 + 保存临时文件 -// 返回: 解析后的响应体, error -func (w *asyncWorker) callModel(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, body map[string]any) (map[string]any, error) { - var data []byte - var err error - - // 1) 如果已有临时文件且 phase=1,直接读取 - if task.Phase == 1 && strings.TrimSpace(task.TmpFile) != "" { - data, err = os.ReadFile(task.TmpFile) - if err != nil || len(data) == 0 { - g.Log().Warningf(ctx, "[callModel] 读取临时文件失败,重新调用模型 taskId=%s err=%v", task.TaskID, err) - data = nil - } - } - - // 2) 没有可用数据,调用模型 - if data == nil { - data, err = InvokeModel(ctx, model, body) - if err != nil { - return nil, err - } - - // 3) 检测文件类型,保存临时文件 - _, ext := util.DetectFileType(data) - tmpPath, tmpErr := util.SaveTmpResult(task.TaskID, data, ext) - if tmpErr == nil && tmpPath != "" { - task.TmpFile = tmpPath - task.Phase = 1 - _, err = dao.ModelGatewayTask.Update(ctx, task) - if err != nil { - g.Log().Errorf(ctx, "[执行任务][失败] 临时文件保存失败 taskId=%s err=%v", task.TaskID, tmpErr) - } - } - } - - // 4) 检测文件类型,提取文本结果 - contentType, _ := util.DetectFileType(data) - var textResult string - if utf8.Valid(data) && (strings.HasPrefix(contentType, "text/") || contentType == "application/json") { - textResult = string(data) - } - - // 5) 非文本内容,返回错误 - if textResult == "" { - return nil, fmt.Errorf("模型返回非文本内容,contentType=%s", contentType) - } - - // 6) 解析并返回 - return gjson.New(textResult).Map(), nil -} - -// parseAndRetry 解析模型返回结果,并重试 -func (w *asyncWorker) parseAndRetry(ctx context.Context, body map[string]any, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, req *dto.CreateTaskReq, maxRetry int, startTime time.Time) (map[string]any, error) { - for attempt := 0; attempt <= maxRetry; attempt++ { - if attempt > 0 { - g.Log().Infof(ctx, "[执行任务][重试] JSON解析 第%d/%d次 taskId=%s", attempt, maxRetry, task.TaskID) - } - - // 1) 响应映射 - mapped, err := util.MapResponsePayload(model.ResponseMapping, body) - if err != nil { - g.Log().Warningf(ctx, "[执行任务][映射失败] taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err) - if attempt == maxRetry { - return nil, fmt.Errorf("响应映射重试耗尽: %w", err) - } - continue - } - - // 2) 先存 token 到数据库,防止后续失败丢失 - if _, ok := mapped[entity.TotalTokens]; ok { - task.ExpendTokens = gconv.Int64(mapped[entity.TotalTokens]) - _, err = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{ - SQLBaseDO: beans.SQLBaseDO{Id: task.Id}, - ExpendTokens: task.ExpendTokens, - }) - } - - // 3) 解析 + 校验 - var parsed map[string]any - switch req.BuildType { - case public.BuildTypePrompt, public.BuildTypeNode: - parsed, err = util.ParseAndValidate(mapped, model) - if err == nil { - return parsed, nil - } - case public.BuildTypeStruct: - parsed = util.ParseStructResult(mapped, entity.ResponseBody) - return parsed, nil - default: - return mapped, nil - } - - g.Log().Warningf(ctx, "[执行任务][解析失败] taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err) - - if attempt == maxRetry { - return nil, fmt.Errorf("JSON解析重试耗尽: %w", err) - } - - // 4) 重新调模型(直接调,不走缓存) - task.RetryCount++ - _, _ = dao.ModelGatewayTask.Update(ctx, task) - rawData, callErr := InvokeModel(ctx, model, task.RequestPayload.Body) - - if callErr != nil { - g.Log().Warningf(ctx, "[执行任务][重调模型失败] taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, callErr) - continue - } - - // 5) 解析原始响应,覆盖 body 进入下一轮 - var rawResp map[string]any - if err = json.Unmarshal(rawData, &rawResp); err != nil { - g.Log().Warningf(ctx, "[执行任务][Unmarshal失败] taskId=%s err=%v", task.TaskID, err) - continue - } - body = rawResp - } - - return body, nil -} - -// InvokeModel 调用模型服务,返回二进制结果 -// modelKey 用于覆盖/补充模型配置 head_msg(例如每次请求携带不同的 X-API-Key) -func InvokeModel(ctx context.Context, model *entity.ModelGatewayModel, body map[string]any) ([]byte, error) { - // 1) 记录模型调用次数 - _ = dao.ModelGatewayLogsStat.IncRequestCount(ctx, time.Now(), model.TenantId, model.Creator, model.ModelName) - - // 2)请求参数映射:将标准 payload 按模型配置的 requestMapping 转为模型需要的格式 - //—— 请求映射实际处理为提示词构建请求,因为有附加字段及其他字段的拼接。这里不方便做请求映射 - //mappedPayload := util.ReverseMap(model.RequestMapping, payload) - - // 3)构建请求 URL 和超时 - baseURL := strings.TrimRight(model.BaseURL, "/") - timeout := time.Duration(model.TimeoutSeconds) * time.Second - client := &http.Client{Timeout: timeout} - method := strings.ToUpper(strings.TrimSpace(model.HttpMethod)) - - // 4)构建 HTTP 请求 - var req *http.Request - switch method { - case http.MethodGet: - q, err := util.BodyToQuery(body) - if err != nil { - return nil, err - } - if len(q) > 0 { - if strings.Contains(baseURL, "?") { - baseURL = baseURL + "&" + q.Encode() - } else { - baseURL = baseURL + "?" + q.Encode() - } - } - req, err = http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) - default: - bodyBytes, err := json.Marshal(body) - if err != nil { - return nil, err - } - req, err = http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(bodyBytes)) - } - - // 5)注入请求头:先模型静态配置,再动态 modelKey(后者可覆盖前者) - for hk, hv := range util.ParseHeadMsgHeaders(model.HeadMsg) { - req.Header.Set(hk, hv) - } - if model.ApiKey != "" { - req.Header.Set("Authorization", "Bearer "+model.ApiKey) - } - if method != http.MethodGet { - req.Header.Set("Content-Type", "application/json") - } - - // 6)发送请求 - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - // 7)读取响应体 - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - // 8)检查 HTTP 状态码 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - msg := string(b) - return nil, fmt.Errorf("模型服务返回非2xx: %d, body=%s", resp.StatusCode, msg) - } - return b, nil -} - -// // InvokeModel 调用模型服务,返回二进制结果 -// -// func InvokeModel(ctx context.Context, m *entity.AsynchModel, payload any, modelKey string) ([]byte, error) { -// if m == nil || m.BaseURL == "" { -// return nil, fmt.Errorf("模型配置不完整") -// } -// // 请求参数映射 -// mappedPayload, err := mapRequestPayload(m.RequestMapping, payload) -// if err != nil { -// return nil, fmt.Errorf("请求参数映射失败: %w", err) -// } -// // 合并请求头 -// headers := util.ForwardHeaders(ctx) -// for hk, hv := range parseHeadMsgHeaders(m.HeadMsg) { -// headers[hk] = hv -// } -// for hk, hv := range parseHeadMsgHeaders(modelKey) { -// headers[hk] = hv -// } -// -// // 设置超时 -// timeout := time.Duration(m.TimeoutSeconds) * time.Second -// if timeout <= 0 { -// timeout = 600 * time.Second -// } -// ctx, cancel := context.WithTimeout(ctx, timeout) -// defer cancel() -// -// invokeUrl := strings.TrimRight(m.BaseURL, "/") -// method := strings.ToUpper(strings.TrimSpace(m.HttpMethod)) -// if method == "" { -// method = http.MethodPost -// } -// -// var respBytes []byte -// -// switch method { -// case http.MethodGet: -// err = commonHttp.Get(ctx, invokeUrl, headers, &respBytes, mappedPayload) -// default: -// err = commonHttp.Post(ctx, invokeUrl, headers, &respBytes, mappedPayload) -// } -// if err != nil { -// return nil, err -// } -// // 响应参数映射 -// mappedResponse, err := mapResponsePayload(m.ResponseMapping, respBytes) -// if err != nil { -// g.Log().Warningf(ctx, "响应参数映射失败: %v,返回原始数据", err) -// return respBytes, nil -// } -// return mappedResponse, nil -// } - -// failTask 任务失败统一处理:更新数据库 + 释放排队 + 回调 -func (w *asyncWorker) failTask(ctx context.Context, t *entity.ModelGatewayTask, startTime time.Time, errMsg string) { - t.State = 3 - t.ErrorMsg = errMsg - t.DurationSeconds = int64(time.Since(startTime).Seconds()) - _, err := dao.ModelGatewayTask.Update(ctx, t) - if err != nil { - g.Log().Warningf(ctx, "[执行任务][更新数据库失败] taskId=%s err=%v", t.TaskID, err) - } - queue.ReleaseQueueSlot(ctx, t.ModelName, t.TaskID) - go gateway.TriggerCallback(context.WithoutCancel(ctx), t) -}