From 76c55fbb73a6cf3422af7afdc67350f4fa3746cc Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Tue, 18 Aug 2026 10:11:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E8=B7=AF=E5=BE=84=E8=AF=BB=E5=86=99=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 TakeBusinessFields、WriteBusinessFields、SetByPath 与 GetByPath 等工具,支持按映射路径写入请求体与解析响应,并更新相关依赖。 --- consts/model/model_supplier.go | 113 +++++ consts/model/model_type.go | 224 ++++++++++ consts/model/response_type.go | 27 ++ consts/public/public.go | 13 + consts/public/table_name.go | 5 + consts/task/status.go | 29 ++ controller/model_call_controller.go | 40 ++ controller/model_manage_controller.go | 65 +++ dao/model_manage_dao.go | 208 +++++++++ dao/model_session_dao.go | 42 ++ dao/model_task_end_dao.go | 29 ++ dao/model_task_start_dao.go | 97 +++++ go.mod | 28 +- go.sum | 73 +++- main.go | 64 +-- model/domain/schema_mapping.go | 45 ++ model/dto/model_call_dto.go | 155 +++++++ model/dto/model_manage_dto.go | 156 +++++++ model/dto/model_session_dto.go | 32 ++ model/dto/model_task_end_dto.go | 16 + model/dto/model_task_start_dto.go | 46 ++ model/entity/model_manage.go | 164 +++++++ model/entity/model_session.go | 60 +++ model/entity/model_task_end.go | 57 +++ model/entity/model_task_start.go | 54 +++ service/model_call_service.go | 424 ++++++++++++++++++ service/model_http_service.go | 318 ++++++++++++++ service/model_manage_service.go | 279 ++++++++++++ service/model_session_service.go | 405 +++++++++++++++++ service/model_task_end_service.go | 354 +++++++++++++++ service/model_task_start_service.go | 73 ++++ service/price.go | 177 ++++++++ service/schema_mapping_service.go | 259 +++++++++++ service/stream_tool_calls.go | 101 +++++ service/task/task_service.go | 2 +- service/utils/business_fields.go | 605 ++++++++++++++++++++++++++ service/utils/json_flatten.go | 43 ++ service/utils/pool.go | 58 +++ service/utils/template_parse.go | 573 ++++++++++++++++++++++++ service/utils/template_validate.go | 462 ++++++++++++++++++++ update.sql | 92 +++- 41 files changed, 6011 insertions(+), 56 deletions(-) create mode 100644 consts/model/model_supplier.go create mode 100644 consts/model/model_type.go create mode 100644 consts/model/response_type.go create mode 100644 consts/task/status.go create mode 100644 controller/model_call_controller.go create mode 100644 controller/model_manage_controller.go create mode 100644 dao/model_manage_dao.go create mode 100644 dao/model_session_dao.go create mode 100644 dao/model_task_end_dao.go create mode 100644 dao/model_task_start_dao.go create mode 100644 model/domain/schema_mapping.go create mode 100644 model/dto/model_call_dto.go create mode 100644 model/dto/model_manage_dto.go create mode 100644 model/dto/model_session_dto.go create mode 100644 model/dto/model_task_end_dto.go create mode 100644 model/dto/model_task_start_dto.go create mode 100644 model/entity/model_manage.go create mode 100644 model/entity/model_session.go create mode 100644 model/entity/model_task_end.go create mode 100644 model/entity/model_task_start.go create mode 100644 service/model_call_service.go create mode 100644 service/model_http_service.go create mode 100644 service/model_manage_service.go create mode 100644 service/model_session_service.go create mode 100644 service/model_task_end_service.go create mode 100644 service/model_task_start_service.go create mode 100644 service/price.go create mode 100644 service/schema_mapping_service.go create mode 100644 service/stream_tool_calls.go create mode 100644 service/utils/business_fields.go create mode 100644 service/utils/json_flatten.go create mode 100644 service/utils/pool.go create mode 100644 service/utils/template_parse.go create mode 100644 service/utils/template_validate.go diff --git a/consts/model/model_supplier.go b/consts/model/model_supplier.go new file mode 100644 index 0000000..cf74d31 --- /dev/null +++ b/consts/model/model_supplier.go @@ -0,0 +1,113 @@ +package model + +import ( + "model-gateway/consts/public" + + "github.com/gogf/gf/v2/util/gconv" +) + +// 供应商编码常量 +const ( + SupplierAliyun = 1 // 阿里云百炼 + SupplierVolcengine = 2 // 火山引擎 + SupplierTencent = 3 // 腾讯云 + SupplierHuawei = 4 // 华为云 + SupplierBaidu = 5 // 百度智能云 + SupplierOpenAI = 6 // OpenAI + SupplierAzure = 7 // 微软 Azure + SupplierAWS = 8 // 亚马逊 AWS + SupplierGoogle = 9 // Google + SupplierDeepSeek = 10 // DeepSeek + SupplierMoonshot = 11 // Moonshot(月之暗面) + SupplierZhipu = 12 // 智谱AI + SupplierBaichuan = 13 // 百川智能 + SupplierMinimax = 14 // MiniMax + SupplierXunfei = 15 // 科大讯飞 + SupplierOthers = 16 // 其他 +) + +// SupplierType 供应商编码类型 +type SupplierType *int8 + +// SupplierItem 供应商项 +type SupplierItem struct { + Code SupplierType `json:"code"` + Desc string `json:"desc"` +} + +// 名称映射【唯一文案维护】 +var supplierNameMap = map[int]string{ + SupplierAliyun: "阿里云百炼", + SupplierVolcengine: "火山引擎", + SupplierTencent: "腾讯云", + SupplierHuawei: "华为云", + SupplierBaidu: "百度智能云", + SupplierOpenAI: "OpenAI", + SupplierAzure: "微软 Azure", + SupplierAWS: "亚马逊 AWS", + SupplierGoogle: "Google", + SupplierDeepSeek: "DeepSeek", + SupplierMoonshot: "Moonshot(月之暗面)", + SupplierZhipu: "智谱AI", + SupplierBaichuan: "百川智能", + SupplierMinimax: "MiniMax", + SupplierXunfei: "科大讯飞", + SupplierOthers: "其他", +} + +// 供应商展示顺序 +var supplierOrder = []int{ + // 国内云厂商 + SupplierAliyun, SupplierVolcengine, SupplierTencent, SupplierHuawei, SupplierBaidu, + // 海外头部 + SupplierOpenAI, SupplierGoogle, SupplierAWS, SupplierAzure, + // 国内AI厂商 + SupplierDeepSeek, SupplierMoonshot, SupplierZhipu, SupplierBaichuan, SupplierMinimax, SupplierXunfei, + // 兜底 + SupplierOthers, +} + +// 全局供应商实例 +var ( + SupplierItemAliyun = newSupplierItem(gconv.PtrInt8(SupplierAliyun)) + SupplierItemVolcengine = newSupplierItem(gconv.PtrInt8(SupplierVolcengine)) + SupplierItemTencent = newSupplierItem(gconv.PtrInt8(SupplierTencent)) + SupplierItemHuawei = newSupplierItem(gconv.PtrInt8(SupplierHuawei)) + SupplierItemBaidu = newSupplierItem(gconv.PtrInt8(SupplierBaidu)) + SupplierItemOpenAI = newSupplierItem(gconv.PtrInt8(SupplierOpenAI)) + SupplierItemAzure = newSupplierItem(gconv.PtrInt8(SupplierAzure)) + SupplierItemAWS = newSupplierItem(gconv.PtrInt8(SupplierAWS)) + SupplierItemGoogle = newSupplierItem(gconv.PtrInt8(SupplierGoogle)) + SupplierItemDeepSeek = newSupplierItem(gconv.PtrInt8(SupplierDeepSeek)) + SupplierItemMoonshot = newSupplierItem(gconv.PtrInt8(SupplierMoonshot)) + SupplierItemZhipu = newSupplierItem(gconv.PtrInt8(SupplierZhipu)) + SupplierItemBaichuan = newSupplierItem(gconv.PtrInt8(SupplierBaichuan)) + SupplierItemMinimax = newSupplierItem(gconv.PtrInt8(SupplierMinimax)) + SupplierItemXunfei = newSupplierItem(gconv.PtrInt8(SupplierXunfei)) + SupplierItemOthers = newSupplierItem(gconv.PtrInt8(SupplierOthers)) +) + +func newSupplierItem(code SupplierType) SupplierItem { + val := int(*code) + return SupplierItem{ + Code: code, + Desc: supplierNameMap[val], + } +} + +// GetSupplierDescByCode 根据编码获取供应商名称 +func GetSupplierDescByCode(code int) string { + return supplierNameMap[code] +} + +// GetSupplierOptionList 获取供应商下拉列表 +func GetSupplierOptionList() []*public.Option { + var list []*public.Option + for _, code := range supplierOrder { + list = append(list, &public.Option{ + Value: code, + Label: supplierNameMap[code], + }) + } + return list +} diff --git a/consts/model/model_type.go b/consts/model/model_type.go new file mode 100644 index 0000000..cf22f2b --- /dev/null +++ b/consts/model/model_type.go @@ -0,0 +1,224 @@ +package model + +import ( + "model-gateway/consts/public" + + "github.com/gogf/gf/v2/util/gconv" +) + +// 模型类型编码常量 +const ( + TypeInference = 100 // 推理模型 + TypeImage = 200 // 图片模型 + TypeAudio = 300 // 音频模型 + TypeVector = 400 // 向量模型 + TypeOmni = 500 // 多模态模型 + TypeVideo = 600 // 视频模型 + TypeCode = 700 // 代码模型 + + //// 推理子类型 + //InferenceSubChat = 101 // 对话/补全 + //InferenceSubReason = 102 // 思维链/深度推理 + //InferenceSubFunction = 103 // 函数调用 + // + //// 图片子类型 + //ImageSubTextToImage = 201 // 文生图 + //ImageSubImageToImage = 202 // 图生图 + //ImageSubImageEdit = 203 // 图片编辑 + //ImageSubImageVariation = 204 // 图片变体 + //ImageSubImageTextToImage = 205 // 图文生图 + // + //// 音频子类型 + //AudioSubTextToSpeech = 301 // 文生音 + //AudioSubSpeechToText = 302 // 音生文 + //AudioSubSpeechToSpeech = 303 // 音生音 + //AudioSubVoiceClone = 304 // 声音克隆 + // + //// 向量子类型 + //VectorSubEmbedding = 401 // 文本嵌入 + //VectorSubRerank = 402 // 重排序 + // + //// 多模态子类型 + //OmniSubTextImageAudio = 501 // 文图音理解 + //OmniSubVision = 502 // 视觉理解 + //OmniSubVideoUnderstand = 503 // 视频理解 + // + //// 视频子类型 + //VideoSubTextToVideo = 601 // 文生视频 + //VideoSubImageToVideo = 602 // 图生视频 + //VideoSubImageTextToVideo = 603 // 图文生视频 + //VideoSubVideoToVideo = 604 // 视频生视频 + // + //// 代码子类型 + //CodeSubGeneration = 701 // 代码生成 + //CodeSubCompletion = 702 // 代码补全 + //CodeSubReview = 703 // 代码审查 +) + +// ModelType 编码类型 +type ModelType *int + +// ModelTypeItem 模型类型项 +type ModelTypeItem struct { + Code ModelType `json:"code"` + Desc string `json:"desc"` +} + +// TypeTree 树形结构 +type TypeTree struct { + Value int `json:"value"` + Label string `json:"label"` + Children []*public.Option `json:"children"` +} + +// 名称映射表【唯一文案维护入口】 +var typeNameMap = map[int]string{ + TypeInference: "推理模型", + TypeImage: "图片模型", + TypeAudio: "音频模型", + TypeVector: "向量模型", + TypeOmni: "多模态模型", + TypeVideo: "视频模型", + TypeCode: "代码模型", + + //InferenceSubChat: "对话/补全", + //InferenceSubReason: "思维链/深度推理", + //InferenceSubFunction: "函数调用", + // + //ImageSubTextToImage: "文生图", + //ImageSubImageToImage: "图生图", + //ImageSubImageEdit: "图片编辑", + //ImageSubImageVariation: "图片变体", + //ImageSubImageTextToImage: "图文生图", + // + //AudioSubTextToSpeech: "文生音", + //AudioSubSpeechToText: "音生文", + //AudioSubSpeechToSpeech: "音生音", + //AudioSubVoiceClone: "声音克隆", + // + //VectorSubEmbedding: "文本嵌入", + //VectorSubRerank: "重排序", + // + //OmniSubTextImageAudio: "文图音理解", + //OmniSubVision: "视觉理解", + //OmniSubVideoUnderstand: "视频理解", + // + //VideoSubTextToVideo: "文生视频", + //VideoSubImageToVideo: "图生视频", + //VideoSubImageTextToVideo: "图文生视频", + //VideoSubVideoToVideo: "视频生视频", + // + //CodeSubGeneration: "代码生成", + //CodeSubCompletion: "代码补全", + //CodeSubReview: "代码审查", +} + +// 父子级映射(仅存有子项的分类) +var parentChildMap = map[int][]int{ + //TypeInference: {InferenceSubChat, InferenceSubReason, InferenceSubFunction}, + //TypeImage: {ImageSubTextToImage, ImageSubImageToImage, ImageSubImageEdit, ImageSubImageVariation, ImageSubImageTextToImage}, + //TypeAudio: {AudioSubTextToSpeech, AudioSubSpeechToText, AudioSubSpeechToSpeech, AudioSubVoiceClone}, + //TypeVector: {VectorSubEmbedding, VectorSubRerank}, + //TypeOmni: {OmniSubTextImageAudio, OmniSubVision, OmniSubVideoUnderstand}, + //TypeVideo: {VideoSubTextToVideo, VideoSubImageToVideo, VideoSubImageTextToVideo, VideoSubVideoToVideo}, + //TypeCode: {CodeSubGeneration, CodeSubCompletion, CodeSubReview}, +} + +// 一级分类展示顺序 +var parentTypeOrder = []int{ + TypeInference, TypeImage, TypeAudio, TypeVector, TypeOmni, TypeVideo, TypeCode, +} + +// 全局实例:一级 + 全部二级子类型,统一通过 newItem 构造,文案仅维护在 typeNameMap +var ( + // 一级类型 + ModelTypeInference = newItem(gconv.PtrInt(TypeInference)) + ModelTypeImage = newItem(gconv.PtrInt(TypeImage)) + ModelTypeAudio = newItem(gconv.PtrInt(TypeAudio)) + ModelTypeVector = newItem(gconv.PtrInt(TypeVector)) + ModelTypeOmni = newItem(gconv.PtrInt(TypeOmni)) + ModelTypeVideo = newItem(gconv.PtrInt(TypeVideo)) + ModelTypeCode = newItem(gconv.PtrInt(TypeCode)) + + //// 推理二级子类型 + //ModelInferenceSubChat = newItem(gconv.PtrInt(InferenceSubChat)) + //ModelInferenceSubReason = newItem(gconv.PtrInt(InferenceSubReason)) + //ModelInferenceSubFunction = newItem(gconv.PtrInt(InferenceSubFunction)) + // + //// 图片二级子类型 + //ModelImageSubTextToImage = newItem(gconv.PtrInt(ImageSubTextToImage)) + //ModelImageSubImageToImage = newItem(gconv.PtrInt(ImageSubImageToImage)) + //ModelImageSubImageEdit = newItem(gconv.PtrInt(ImageSubImageEdit)) + //ModelImageSubImageVariation = newItem(gconv.PtrInt(ImageSubImageVariation)) + //ModelImageSubImageTextToImage = newItem(gconv.PtrInt(ImageSubImageTextToImage)) + // + //// 音频二级子类型 + //ModelAudioSubTextToSpeech = newItem(gconv.PtrInt(AudioSubTextToSpeech)) + //ModelAudioSubSpeechToText = newItem(gconv.PtrInt(AudioSubSpeechToText)) + //ModelAudioSubSpeechToSpeech = newItem(gconv.PtrInt(AudioSubSpeechToSpeech)) + //ModelAudioSubVoiceClone = newItem(gconv.PtrInt(AudioSubVoiceClone)) + // + //// 向量二级子类型 + //ModelVectorSubEmbedding = newItem(gconv.PtrInt(VectorSubEmbedding)) + //ModelVectorSubRerank = newItem(gconv.PtrInt(VectorSubRerank)) + // + //// 多模态二级子类型 + //ModelOmniSubTextImageAudio = newItem(gconv.PtrInt(OmniSubTextImageAudio)) + //ModelOmniSubVision = newItem(gconv.PtrInt(OmniSubVision)) + //ModelOmniSubVideoUnderstand = newItem(gconv.PtrInt(OmniSubVideoUnderstand)) + // + //// 视频二级子类型 + //ModelVideoSubTextToVideo = newItem(gconv.PtrInt(VideoSubTextToVideo)) + //ModelVideoSubImageToVideo = newItem(gconv.PtrInt(VideoSubImageToVideo)) + //ModelVideoSubImageTextToVideo = newItem(gconv.PtrInt(VideoSubImageTextToVideo)) + //ModelVideoSubVideoToVideo = newItem(gconv.PtrInt(VideoSubVideoToVideo)) + // + //// 代码二级子类型 + //ModelCodeSubGeneration = newItem(gconv.PtrInt(CodeSubGeneration)) + //ModelCodeSubCompletion = newItem(gconv.PtrInt(CodeSubCompletion)) + //ModelCodeSubReview = newItem(gconv.PtrInt(CodeSubReview)) +) + +// newItem 构造方法:自动从 typeNameMap 读取描述 +func newItem(code ModelType) ModelTypeItem { + return ModelTypeItem{ + Code: code, + Desc: typeNameMap[*code], + } +} + +// GetDescByCode 根据编码获取名称 +func GetDescByCode(code int) string { + return typeNameMap[code] +} + +// GetTypeTreeList 生成树形数据 +func GetTypeTreeList() []*TypeTree { + var list []*TypeTree + for _, parentCode := range parentTypeOrder { + tree := &TypeTree{ + Value: parentCode, + Label: typeNameMap[parentCode], + Children: make([]*public.Option, 0), + } + if childCodes, ok := parentChildMap[parentCode]; ok { + for _, c := range childCodes { + tree.Children = append(tree.Children, &public.Option{ + Value: c, + Label: typeNameMap[c], + }) + } + } + list = append(list, tree) + } + return list +} + +// GetAllTypeOption 全量平铺选项 +func GetAllTypeOption() []*public.Option { + var list []*public.Option + for code, label := range typeNameMap { + list = append(list, &public.Option{Value: code, Label: label}) + } + return list +} diff --git a/consts/model/response_type.go b/consts/model/response_type.go new file mode 100644 index 0000000..3e14c82 --- /dev/null +++ b/consts/model/response_type.go @@ -0,0 +1,27 @@ +package model + +import "github.com/gogf/gf/v2/util/gconv" + +var ( + ResponseTypeSync = newResponseType(gconv.PtrInt8(1), "sync") // 同步 + ResponseTypeAsync = newResponseType(gconv.PtrInt8(2), "async") // 异步 + ResponseTypeStream = newResponseType(gconv.PtrInt8(3), "stream") // 流 +) + +type ResponseType *int8 + +type responseType struct { + code ResponseType + desc string +} + +func (s responseType) Code() ResponseType { + return s.code +} +func (s responseType) Desc() string { + return s.desc +} + +func newResponseType(code ResponseType, desc string) responseType { + return responseType{code: code, desc: desc} +} diff --git a/consts/public/public.go b/consts/public/public.go index f20e1dc..2db8a63 100644 --- a/consts/public/public.go +++ b/consts/public/public.go @@ -1,5 +1,18 @@ package public +const GmqMsgPluginsName = "gmq_model_msg" + +const KnowledgeLockEsKey = "knowledge:lock:knowledgeIdEs-%v" +const KnowledgeLockSqlKey = "knowledge:lock:knowledgeIdSql-%v" +const KnowledgeContentHashEsKey = "knowledge:knowledgeId:contentHashEs-%v" +const KnowledgeContentHashSqlKey = "knowledge:knowledgeId:contentHashSql-%v" + +// Option 通用下拉选项 +type Option struct { + Value int `json:"value"` + Label string `json:"label"` +} + const ( CallModeSync = 0 // 同步调用 CallModeAsync = 1 // 异步调用 diff --git a/consts/public/table_name.go b/consts/public/table_name.go index a7f89cb..ae9774f 100644 --- a/consts/public/table_name.go +++ b/consts/public/table_name.go @@ -9,4 +9,9 @@ const ( TableNameTask = "model_gateway_task" // 任务表 TableNameOpLog = "model_gateway_logs_op" // 操作日志表 TableNameStat = "model_gateway_logs_stat" // 按天统计表 + + TableNameModelManage = "model_manage" + TableNameModelSession = "model_session" + TableNameModelTaskStart = "model_task_start" + TableNameModelTaskEnd = "model_task_end" ) diff --git a/consts/task/status.go b/consts/task/status.go new file mode 100644 index 0000000..b715d80 --- /dev/null +++ b/consts/task/status.go @@ -0,0 +1,29 @@ +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_call_controller.go b/controller/model_call_controller.go new file mode 100644 index 0000000..42404f4 --- /dev/null +++ b/controller/model_call_controller.go @@ -0,0 +1,40 @@ +package controller + +import ( + "context" + "encoding/json" + "model-gateway/model/dto" + "model-gateway/service" + "net/http" + + "gitea.redpowerfuture.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" +) + +// ModelCall 模型调用控制器 +var ModelCall = new(modelCall) + +type modelCall struct{} + +// ModelCall 模型调用 +func (c *modelCall) ModelCall(ctx context.Context, req *dto.ModelCallReq) (res *dto.ModelCallRes, err error) { + return service.ModelCall.ModelCall(ctx, req) +} + +// CreateSessionStream 创建模型会话(流式) +func (c *modelCall) CreateSessionStream(ctx context.Context, req *dto.ModelCallStreamReq) (res *beans.ResponseEmpty, err error) { + r := g.RequestFromCtx(ctx) + w := r.Response.RawWriter() + err = service.ModelCall.ModelCallStream(ctx, w, req) + if err != nil { + h := w.Header() + h.Set("Content-Type", "application/json; charset=utf-8") + errResp, _ := json.Marshal(map[string]interface{}{ + "code": http.StatusInternalServerError, + "message": err.Error(), + }) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write(errResp) + } + return +} diff --git a/controller/model_manage_controller.go b/controller/model_manage_controller.go new file mode 100644 index 0000000..dc9f75e --- /dev/null +++ b/controller/model_manage_controller.go @@ -0,0 +1,65 @@ +package controller + +import ( + "context" + "model-gateway/model/dto" + "model-gateway/service" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +// ModelManage 模型配置控制器 +var ModelManage = new(modelManage) + +type modelManage struct{} + +// CreateModel 添加配置 +func (c *modelManage) CreateModel(ctx context.Context, req *dto.CreateModelManageReq) (res *dto.CreateModelManageRes, err error) { + return service.ModelManage.Create(ctx, req) +} + +// UpdateModel 更改配置 +func (c *modelManage) UpdateModel(ctx context.Context, req *dto.UpdateModelManageReq) (res *dto.GetModelManageRes, err error) { + return service.ModelManage.Update(ctx, req) +} + +// DeleteModel 删除配置 +func (c *modelManage) DeleteModel(ctx context.Context, req *dto.DeleteModelManageReq) (res *beans.ResponseEmpty, err error) { + err = service.ModelManage.Delete(ctx, req) + return +} + +// GetModel 获取配置 +func (c *modelManage) GetModel(ctx context.Context, req *dto.GetModelManageReq) (res *dto.GetModelManageRes, err error) { + return service.ModelManage.Get(ctx, req) +} + +// GetChatModel 获取聊天模型 +func (c *modelManage) GetChatModel(ctx context.Context, req *dto.GetChatModelReq) (res *dto.GetChatModelRes, err error) { + return service.ModelManage.GetChatModel(ctx, req) +} + +// ListModel 配置列表 +func (c *modelManage) ListModel(ctx context.Context, req *dto.ListModelManageReq) (res *dto.ListModelManageRes, err error) { + return service.ModelManage.List(ctx, req) +} + +// CheckChatModel 检查是否为聊天模型 +func (c *modelManage) CheckChatModel(ctx context.Context, req *dto.CheckChatModelReq) (res *dto.CheckChatModelRes, err error) { + return service.ModelManage.CheckChatModel(ctx, req) +} + +// ListType 模型类型列表 +func (c *modelManage) ListType(ctx context.Context, req *dto.ModelTypeReq) (res *dto.ModelTypeRes, err error) { + return service.ModelManage.GetModelType(ctx, req) +} + +// ListOperator 运营商列表 +func (c *modelManage) ListOperator(ctx context.Context, req *dto.ModelSupplierReq) (res *dto.ModelSupplierRes, err error) { + return service.ModelManage.GetModelSupplier(ctx, req) +} + +// BuildSchemaMapping 自动构建 Schema 映射 +func (c *modelManage) BuildSchemaMapping(ctx context.Context, req *dto.BuildSchemaMappingReq) (res *dto.BuildSchemaMappingRes, err error) { + return service.SchemaMapping.BuildSchemaMapping(ctx, req) +} diff --git a/dao/model_manage_dao.go b/dao/model_manage_dao.go new file mode 100644 index 0000000..c817d86 --- /dev/null +++ b/dao/model_manage_dao.go @@ -0,0 +1,208 @@ +package dao + +import ( + "context" + "fmt" + "model-gateway/consts/public" + "model-gateway/model/dto" + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelManage = &modelManageDao{} + +type modelManageDao struct{} + +// Insert 插入 +func (d *modelManageDao) Insert(ctx context.Context, req *dto.CreateModelManageReq) (id int64, err error) { + var e = new(entity.ModelManage) + err = gconv.Struct(req, &e) + if err != nil { + return + } + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Insert(e) + if err != nil { + return + } + return r.LastInsertId() +} + +// Update 更新 +func (d *modelManageDao) Update(ctx context.Context, req *dto.UpdateModelManageReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).OmitEmpty().Data(req).Where(entity.ModelManageCol.Id, req.Id).Update() + if err != nil { + return + } + return r.RowsAffected() +} + +// Delete 删除 +func (d *modelManageDao) Delete(ctx context.Context, req *dto.DeleteModelManageReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Where(entity.ModelManageCol.Id, req.Id).Delete() + if err != nil { + return + } + return r.RowsAffected() +} + +func (d *modelManageDao) Get(ctx context.Context, req *dto.GetModelManage, fields ...string) (res *entity.ModelManage, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Cache(ctx). + OmitEmpty(). + Where(entity.ModelManageCol.ModelName, req.ModelName). + Where(entity.ModelManageCol.ChatModel, req.ChatModel). + Where(entity.ModelManageCol.Creator, req.Creator). + Fields(fields).One() + if err != nil { + return + } + err = r.Struct(&res) + return +} + +// GetByCreatorAndName 按创建人+模型名精确查询(无缓存),用于同一用户下的同名唯一性校验。 +// 走 Model 链(自动过滤软删除),与 Get 一致但不带 Cache,避免缓存过期导致重复放行。 +func (d *modelManageDao) GetByCreatorAndName(ctx context.Context, creator, modelName string) (res *entity.ModelManage, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage). + OmitEmpty(). + Where(entity.ModelManageCol.ModelName, modelName). + Where(entity.ModelManageCol.Creator, creator). + One() + if err != nil { + return + } + err = r.Struct(&res) + return +} + +func (d *modelManageDao) GetNotTenantId(ctx context.Context, req *dto.GetModelManageReq, fields ...string) (res *entity.ModelManage, err error) { + // 获取表前缀 + prefix := g.Cfg().MustGet(ctx, fmt.Sprintf("database.%s.0.prefix", public.DbNameModelGateway)).String() + table := prefix + public.TableNameModelManage + // 动态拼接 SELECT 列 + var field string + if !g.IsEmpty(fields) { + for k, v := range fields { + if k == len(fields)-1 { + field = field + v + } else { + field = field + v + "," + } + } + } else { + field = "*" + } + // 动态拼接 WHERE 条件 + var whereCondition string + var queryParams []interface{} + if !g.IsEmpty(req.Id) { + whereCondition = fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.Id) + queryParams = append(queryParams, req.Id) + } + whereCondition = whereCondition + " AND " + entity.ModelManageCol.DeletedAt + " IS NULL " + + sql := `SELECT ` + field + ` FROM ` + table + ` WHERE 1=1 ` + whereCondition + `` + // 执行查询 + result, err := gfdb.DB(ctx, public.DbNameModelGateway).GetOne(ctx, sql, queryParams...) + if err != nil { + return nil, err + } + err = result.Struct(&res) + return +} +func (d *modelManageDao) ListNotTenantId(ctx context.Context, req *dto.ListModelManageReq, fields ...string) (res []*entity.ModelManage, total int, err error) { + // 获取表前缀 + prefix := g.Cfg().MustGet(ctx, fmt.Sprintf("database.%s.0.prefix", public.DbNameModelGateway)).String() + table := prefix + public.TableNameModelManage + + // 动态拼接 SELECT 列 + var field string + if !g.IsEmpty(fields) { + for k, v := range fields { + if k == len(fields)-1 { + field = field + v + } else { + field = field + v + "," + } + } + } else { + field = "*" + } + + // 动态拼接 WHERE 条件 + var whereCondition string + var queryParams []interface{} + + if !g.IsEmpty(req.ModelName) { + whereCondition += fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.ModelName) + queryParams = append(queryParams, req.ModelName) + } + if !g.IsEmpty(req.ModelType) { + whereCondition += fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.ModelType) + queryParams = append(queryParams, req.ModelType) + } + if !g.IsEmpty(req.Creator) { + whereCondition += fmt.Sprintf(" AND (%s=(?) OR %s=true) ", entity.ModelManageCol.Creator, entity.ModelManageCol.SystemModel) + queryParams = append(queryParams, req.Creator) + } + whereCondition = whereCondition + " AND " + entity.ModelManageCol.DeletedAt + " IS NULL " + + // 1. 统计去重后总条数 + countSql := fmt.Sprintf( + `SELECT COUNT(DISTINCT %s) FROM %s WHERE 1=1 %s`, + entity.ModelManageCol.ModelName, + table, + whereCondition, + ) + countResult, err := gfdb.DB(ctx, public.DbNameModelGateway).GetOne(ctx, countSql, queryParams...) + if err != nil { + return nil, 0, err + } + type crr struct { + Count int64 `db:"count"` + } + var cr crr + if err = countResult.Struct(&cr); err != nil { + return nil, 0, err + } + total = int(cr.Count) + // 2. 分页处理 + limitSql := "" + if req.Page != nil { + pageNum := int(req.Page.PageNum) + pageSize := int(req.Page.PageSize) + offset := (pageNum - 1) * pageSize + limitSql = fmt.Sprintf(" LIMIT ? OFFSET ? ") + // PG 语法 LIMIT 条数 OFFSET 偏移量 + queryParams = append(queryParams, pageSize, offset) + } + + // 排序优先级:1.分组字段ModelName 2.SystemModel升序(false在前,保留用户数据) 3.创建时间倒序 + orderSql := fmt.Sprintf( + " ORDER BY %s, %s ASC, %s DESC ", + entity.ModelManageCol.ModelName, + entity.ModelManageCol.SystemModel, + entity.ModelManageCol.CreatedAt, + ) + + // PG DISTINCT ON 按模型名去重,同名只取第一条(用户数据) + sql := fmt.Sprintf( + `SELECT DISTINCT ON (%s) %s FROM %s WHERE 1=1 %s %s %s`, + entity.ModelManageCol.ModelName, + field, + table, + whereCondition, + orderSql, + limitSql, + ) + + // 执行查询 + result, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql, queryParams...) + if err != nil { + return nil, 0, err + } + err = result.Structs(&res) + return +} diff --git a/dao/model_session_dao.go b/dao/model_session_dao.go new file mode 100644 index 0000000..da4780d --- /dev/null +++ b/dao/model_session_dao.go @@ -0,0 +1,42 @@ +package dao + +import ( + "context" + "model-gateway/consts/public" + "model-gateway/model/dto" + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelSession = &modelSessionDao{} + +type modelSessionDao struct{} + +// Insert 插入 +func (d *modelSessionDao) Insert(ctx context.Context, req *dto.CreateModelSessionReq) (id int64, err error) { + m := new(entity.ModelSession) + err = gconv.Struct(req, &m) + if err != nil { + return + } + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelSession).Insert(m) + if err != nil { + return + } + return r.LastInsertId() +} + +// Update 更新(按ID) +func (d *modelSessionDao) Update(ctx context.Context, req *dto.UpdateModelSessionReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelSession). + OmitEmpty(). + Data(req). + Where(entity.ModelSessionCol.Id, req.Id). + Update() + if err != nil { + return + } + return r.RowsAffected() +} diff --git a/dao/model_task_end_dao.go b/dao/model_task_end_dao.go new file mode 100644 index 0000000..87a0c2b --- /dev/null +++ b/dao/model_task_end_dao.go @@ -0,0 +1,29 @@ +package dao + +import ( + "context" + "model-gateway/consts/public" + "model-gateway/model/dto" + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelTaskEnd = &modelTaskEndDao{} + +type modelTaskEndDao struct{} + +// Insert 插入 +func (d *modelTaskEndDao) Insert(ctx context.Context, req *dto.CreateModelTaskEndReq) (id int64, err error) { + m := new(entity.ModelTaskEnd) + err = gconv.Struct(req, &m) + if err != nil { + return + } + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskEnd).Insert(m) + if err != nil { + return + } + return r.LastInsertId() +} diff --git a/dao/model_task_start_dao.go b/dao/model_task_start_dao.go new file mode 100644 index 0000000..15d3813 --- /dev/null +++ b/dao/model_task_start_dao.go @@ -0,0 +1,97 @@ +package dao + +import ( + "context" + "fmt" + "model-gateway/consts/public" + "model-gateway/model/dto" + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelTaskStart = &modelTaskStartDao{} + +type modelTaskStartDao struct{} + +// Insert 插入 +func (d *modelTaskStartDao) Insert(ctx context.Context, req *dto.CreateModelTaskStartReq) (id int64, err error) { + m := new(entity.ModelTaskStart) + err = gconv.Struct(req, &m) + if err != nil { + return + } + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskStart).Insert(m) + if err != nil { + return + } + return r.LastInsertId() +} + +// Update 更新(按ID) +func (d *modelTaskStartDao) Update(ctx context.Context, req *dto.UpdateModelTaskStartReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskStart). + OmitEmpty(). + Data(req). + Where(entity.ModelTaskStartCol.Id, req.Id). + Update() + if err != nil { + return + } + return r.RowsAffected() +} + +func (d *modelTaskStartDao) Delete(ctx context.Context, req *dto.DeleteModelTaskStartReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskStart). + Where(entity.ModelTaskStartCol.Id, req.Id). + Delete() + if err != nil { + return + } + return r.RowsAffected() +} + +func (d *modelTaskStartDao) ListByLimitNotTenantId(ctx context.Context, req *dto.GetModelTaskStartListReq, fields ...string) (res []entity.ModelTaskStart, err error) { + // 获取表前缀 + prefix := g.Cfg().MustGet(ctx, fmt.Sprintf("database.%s.0.prefix", public.DbNameModelGateway)).String() + table := prefix + public.TableNameModelTaskStart + // 动态拼接 SELECT 列 + var field string + if !g.IsEmpty(fields) { + for k, v := range fields { + if k == len(fields)-1 { + field = field + v + } else { + field = field + v + "," + } + } + } else { + field = "*" + } + // 动态拼接 WHERE 条件 + var whereCondition string + whereCondition = whereCondition + fmt.Sprintf(" AND %s != '' ", entity.ModelTaskStartCol.TaskId) + whereCondition = whereCondition + fmt.Sprintf(" AND %s IS NULL ", entity.ModelTaskStartCol.DeletedAt) + // 排序 + orderSql := fmt.Sprintf(" ORDER BY %s ASC ", entity.ModelTaskStartCol.CreatedAt) + // 分页 + limitSql := "" + if req.Page != nil { + pageNum := int(req.Page.PageNum) + pageSize := int(req.Page.PageSize) + offset := (pageNum - 1) * pageSize + limitSql = fmt.Sprintf(" LIMIT %d OFFSET %d ", pageSize, offset) + } + // 查询 + sql := `SELECT ` + field + ` FROM ` + table + ` WHERE 1=1 ` + whereCondition + orderSql + limitSql + `` + // 执行查询 + result, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql) + if err != nil { + return nil, err + } + err = result.Structs(&res) + + return +} diff --git a/go.mod b/go.mod index cc44967..622871d 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,19 @@ go 1.26.1 require ( gitea.redpowerfuture.com/red-future/common v0.0.23 + github.com/bjang03/gmq v0.0.1 github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 github.com/gogf/gf/v2 v2.10.2 github.com/google/uuid v1.6.0 github.com/tidwall/gjson v1.19.0 + github.com/tidwall/sjson v1.2.5 ) +replace gitea.redpowerfuture.com/red-future/common v0.0.23 => ../common + +replace github.com/bjang03/gmq v0.0.1 => ../gmq + require ( github.com/BurntSushi/toml v1.5.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect @@ -25,9 +31,13 @@ require ( github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-ego/gse v1.0.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -49,7 +59,8 @@ require ( github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/serf v0.10.1 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -57,13 +68,18 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/nats-io/nats.go v1.49.0 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.0.9 // indirect github.com/olekukonko/tablewriter v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/r3labs/diff/v2 v2.15.1 // indirect - github.com/redis/go-redis/v9 v9.12.1 // indirect + github.com/rabbitmq/amqp091-go v1.10.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tiger1103/gfast-token v1.0.10 // indirect @@ -79,10 +95,12 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect diff --git a/go.sum b/go.sum index 670f2b0..0433054 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,4 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -gitea.redpowerfuture.com/red-future/common v0.0.23 h1:xieoA00iKOCDm5SO9iXn+cSyMKBAlZwI0fuEVPWrHLg= -gitea.redpowerfuture.com/red-future/common v0.0.23/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -19,6 +17,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= +github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -63,8 +63,12 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4= github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -76,7 +80,17 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 h1:u8EpP24GkprogROnJ7htMov9Fc66pTP1eVYrWxiCYOs= github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2/go.mod h1:GmvM3r8GVByVMi4RD2+MCs5+CfxVXPMeT8mVDkAaAXE= github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 h1:iTQegT+lEg/wDKvj2mi3W1wrdrwFarjokf88EXVVgu4= @@ -185,8 +199,10 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -196,6 +212,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -232,6 +250,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= @@ -264,8 +288,10 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/r3labs/diff/v2 v2.15.1 h1:EOrVqPUzi+njlumoqJwiS/TgGgmZo83619FNDB9xQUg= github.com/r3labs/diff/v2 v2.15.1/go.mod h1:I8noH9Fc2fjSaMxqF3G2lhDdC0b+JXCfyx85tWFM9kc= -github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= -github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -276,6 +302,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUt github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -288,12 +316,15 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s= github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= @@ -305,6 +336,8 @@ github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaU github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.mongodb.org/mongo-driver/v2 v2.4.0 h1:Oq6BmUAAFTzMeh6AonuDlgZMuAuEiUxoAD1koK5MuFo= go.mongodb.org/mongo-driver/v2 v2.4.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI= go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= @@ -327,6 +360,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -334,6 +369,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= @@ -342,8 +379,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -359,8 +396,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -369,8 +406,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -395,15 +432,15 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -413,8 +450,8 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index d00e8fe..bece4fa 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,9 @@ package main import ( "context" - "model-gateway/model/dto" - "model-gateway/service/task" + "model-gateway/consts/public" + "model-gateway/service" + "model-gateway/service/utils" "os" "os/signal" "syscall" @@ -14,55 +15,56 @@ import ( "gitea.redpowerfuture.com/red-future/common/http" "gitea.redpowerfuture.com/red-future/common/jaeger" _ "gitea.redpowerfuture.com/red-future/common/swagger" + gmq "github.com/bjang03/gmq/core/gmq" + "github.com/bjang03/gmq/mq" _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" _ "github.com/gogf/gf/contrib/nosql/redis/v2" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtimer" ) func main() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := context.Background() defer jaeger.ShutDown(ctx) + // 初始化全局协程池(最大 goroutine 数,从配置文件读取,默认 100) + workerNum := g.Cfg().MustGet(ctx, "pool.workerNum", utils.DefaultWorkerNum).Int() + utils.Init(workerNum) + g.Log().Infof(ctx, "[main] 全局协程池已初始化, workerNum=%d", workerNum) + // 注册路由 http.RouteRegister([]interface{}{ + controller.ModelCall, + controller.ModelManage, controller.ModelGatewayModels, controller.ModelGatewayTask, controller.ModelGatewayLogsStat, }) - // 本地调试:可选自动触发 worker/cleaner(由配置文件控制) - startAutoRunner(ctx) + gmq.GmqRegister(public.GmqMsgPluginsName, &mq.NatsConn{ + NatsConfig: mq.NatsConfig{ + Addr: g.Config().MustGet(ctx, "nats.addr").String(), + Port: g.Config().MustGet(ctx, "nats.port").String(), + Username: g.Config().MustGet(ctx, "nats.username").String(), + Password: g.Config().MustGet(ctx, "nats.password").String(), + }, + }) + + gtimer.AddSingleton(ctx, 10*time.Second, func(ctx context.Context) { + err := service.ModelTaskEndService.GetTaskStartList(ctx) + if err != nil { + g.Log().Error(ctx, "模型视频任务处理失败 err: %v", err) + } + }) // 监听退出信号,确保 Ctrl+C 能完整退出(停止 worker/cleaner 并关闭 gateway server) quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit - g.Log().Infof(ctx, "[main] 收到退出信号,开始优雅退出...") - cancel() - // 关闭 gateway server(RouteRegister 内部是 go Httpserver.Run() 启动的) - _ = http.Httpserver.Shutdown() -} + utils.Shutdown() + g.Log().Infof(ctx, "[main] 全局协程池已关闭") -func startAutoRunner(ctx context.Context) { - // queryPending - if g.Cfg().MustGet(ctx, "asynch.queryPending.enabled").Bool() { - interval := g.Cfg().MustGet(ctx, "asynch.queryPending.intervalSeconds", 10).Int() - limit := g.Cfg().MustGet(ctx, "asynch.queryPending.limit", 10).Int() - ticker := time.NewTicker(time.Duration(interval) * time.Second) - go func() { - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if _, err := task.ModelGatewayTask.QueryPendingTasks(ctx, &dto.QueryPendingTasksReq{Limit: limit}); err != nil { - g.Log().Warningf(ctx, "[auto-queryPending] run once failed: %v", err) - } - } - } - }() - } + // 收到退出信号后,关闭全局协程池,等待所有已提交的任务执行完成 + g.Log().Info(ctx, "服务正在关闭...") } diff --git a/model/domain/schema_mapping.go b/model/domain/schema_mapping.go new file mode 100644 index 0000000..2274808 --- /dev/null +++ b/model/domain/schema_mapping.go @@ -0,0 +1,45 @@ +package domain + +// ChatFieldsReq 对话/推理模型业务字段映射 +// 适用于 推理模型(100) 和 多模态模型(500) 的子类型 +type ChatFieldsReq struct { + MaxTokens string `json:"max_tokens" dc:"模型支持的最大输出 token 数"` + Stream string `json:"stream" dc:"是否流式输出"` + Tools string `json:"tools" dc:"工具"` + ToolId string `json:"tool_id" dc:"工具 ID"` + ToolPrompt string `json:"tool_prompt" dc:"工具提示词,用于描述工具的需求"` + UserPrompt string `json:"user_prompt" dc:"用户提示词,用于描述用户的需求"` + SystemPrompt string `json:"system_prompt" dc:"系统提示词,用于描述系统的需求"` + AssistantPrompt string `json:"assistant_prompt" dc:"助手提示词,用于描述助手的需求"` + ReferenceImage string `json:"reference_image" dc:"参考图片,用于生成角色形象和风格一致性参考"` + ReferenceVideo string `json:"reference_video" dc:"参考视频,用于生成动作和场景一致性参考"` + ReferenceAudio string `json:"reference_audio" dc:"参考音频,用于生成声音和风格一致性参考"` + ImgReferenceTemplate string `json:"img_reference_template" dc:"prompt 中引用参考图片的标签格式,用 %d 作为编号占位符"` + VideoReferenceTemplate string `json:"video_reference_template" dc:"prompt 中引用参考视频的标签格式,用 %d 作为编号占位符"` + AudioReferenceTemplate string `json:"audio_reference_template" dc:"prompt 中引用参考音频的标签格式,用 %d 作为编号占位符"` +} + +// ChatFieldsRes 对话/推理模型业务字段映射 +// 适用于 推理模型(100) 和 多模态模型(500) 的子类型 +type ChatFieldsRes struct { + Tools string `json:"tools" dc:"工具"` + ReasoningContent string `json:"reasoning_content" dc:"推理内容"` +} + +// VideoFields 视频模型业务字段映射 +// 适用于 视频模型(600) 及其子类型 +type VideoFields struct { + MinDuration string `json:"min_duration" dc:"模型支持的最小视频时长(秒)"` + MaxDuration string `json:"max_duration" dc:"模型支持的最大视频时长(秒)"` + FirstFrame string `json:"first_frame" dc:"视频的首帧/初始画面,传入一张图片作为视频第一帧画面"` + ReferenceImage string `json:"reference_image" dc:"参考图片,用于生成角色形象和风格一致性参考"` + ReferenceVideo string `json:"reference_video" dc:"参考视频,用于生成动作和场景一致性参考"` + MaxMediaItems string `json:"max_media_items" dc:"模型允许传入的最大参考媒体数量"` + ImgReferenceTemplate string `json:"img_reference_template" dc:"prompt 中引用参考图片的标签格式,用 %d 作为编号占位符"` + VideoReferenceTemplate string `json:"video_reference_template" dc:"prompt 中引用参考视频的标签格式,用 %d 作为编号占位符"` + AudioReferenceTemplate string `json:"audio_reference_template" dc:"prompt 中引用参考音频的标签格式,用 %d 作为编号占位符"` + Fps string `json:"fps" dc:"视频帧率"` + Resolution string `json:"resolution" dc:"视频分辨率,如 1920x1080"` + NegativePrompt string `json:"negative_prompt" dc:"反向提示词,描述不希望出现的内容"` + CfgScale string `json:"cfg_scale" dc:"CFG 引导比例,控制对 prompt 的遵从程度"` +} diff --git a/model/dto/model_call_dto.go b/model/dto/model_call_dto.go new file mode 100644 index 0000000..27ca8b1 --- /dev/null +++ b/model/dto/model_call_dto.go @@ -0,0 +1,155 @@ +package dto + +import ( + "model-gateway/consts/task" + + "github.com/gogf/gf/v2/frame/g" +) + +// ModelCallReq 模型调用请求 +type ModelCallReq struct { + g.Meta `path:"/modelCall" method:"post" tags:"模型管理" summary:"模型调用" dc:"模型调用"` + ModelId int64 `json:"modelId" v:"required#modelId不能为空" dc:"模型ID"` + BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"` + SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"` + RequestParams map[string]any `json:"requestParams" dc:"请求参数(模板字段)"` + BusinessParams map[string]any `json:"businessParams" dc:"业务参数(按业务字段名传,按 RequestBusinessFieldMapping 写入请求体)"` + MsgTopic string `json:"msgTopic" dc:"消息主题(异步必要参数)"` +} + +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"` + Tools []ModelTool `json:"tools" dc:"工具"` + ReasoningContent string `json:"reasoningContent" dc:"思考内容"` + Content map[string]any `json:"content" dc:"内容"` + Cost float64 `json:"cost" dc:"费用(元)"` + ErrorMsg string `json:"errorMsg" dc:"错误消息"` +} + +type ModelTool struct { + Id string `json:"id" dc:"工具ID"` + Type string `json:"type" dc:"工具类型"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +// ModelCallStreamEvent 流式增量事件(SSE data 行)。文本增量事件省略 Type, +// 流末 done 事件带 Type="done" 与 Tools。字段名由本结构体 json tag 统一管理。 +type ModelCallStreamEvent struct { + Type string `json:"type,omitempty"` + Content map[string]any `json:"content,omitempty"` + ReasoningContent string `json:"reasoningContent,omitempty"` + TotalTokens int64 `json:"totalTokens,omitempty"` + PromptTokens int64 `json:"promptTokens,omitempty"` + CompletionTokens int64 `json:"completionTokens,omitempty"` + Tools []ModelTool `json:"tools,omitempty"` + Cost float64 `json:"cost,omitempty" dc:"费用(元),done事件携带最终费用,增量事件不带"` +} + +// ModelCallStreamReq 模型调用流式请求 +type ModelCallStreamReq struct { + g.Meta `path:"/modelCallStream" method:"post" tags:"模型管理" summary:"模型调用流式" dc:"模型调用流式"` + ModelId int64 `json:"modelId" v:"required#modelId不能为空" dc:"模型ID"` + BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"` + SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"` + RequestParams map[string]any `json:"requestParams" dc:"请求参数(模板字段)"` + BusinessParams map[string]any `json:"businessParams" dc:"业务参数(按业务字段名传,按 RequestBusinessFieldMapping 写入请求体)"` +} + +var ModelErrorResp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +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"` + Content map[string]any `json:"content" dc:"内容"` + Cost float64 `json:"cost" dc:"费用(元)"` + ErrorMsg string `json:"errorMsg" dc:"错误消息"` +} + +//======================== +// Upload 文件上传定义 +//======================== + +// UploadFileBytesReq 上传文件请求(字节流) +type UploadFileBytesReq struct { + FileName string `json:"fileName" dc:"文件名"` + FileBytes []byte `json:"fileBytes" dc:"文件字节流"` + FileStoreURL string `json:"fileStoreURL" dc:"文件存储URL"` +} + +type UploadFileBytesRes struct { + FileURL string `json:"fileURL" dc:"上传地址"` + FileSize int `json:"fileSize" dc:"文件大小"` + FileName string `json:"fileName" dc:"文件名称"` + FileFormat string `json:"fileFormat" dc:"文件格式"` + FileAddressPrefix string `json:"fileAddressPrefix"` +} + +// =========================== +// Template 元数据模板定义 +// =========================== + +// UploadRule 上传文件规则 +type UploadRule struct { + Format string `json:"format" dc:"格式"` + MaxSize int `json:"maxSize" dc:"最大大小"` + MaxCount int `json:"maxCount" dc:"最大数量"` +} + +// Constraint 字段约束 +type Constraint struct { + // 数字类型:int、float、double + NumberType string `json:"numberType" dc:"数字类型"` + Min any `json:"min" dc:"最小值"` + Max any `json:"max" dc:"最大值"` + + // 字符串类型:string、text + MinLength int `json:"minLength" dc:"最小长度"` + MaxLength int `json:"maxLength" dc:"最大长度"` + Pattern string `json:"pattern" dc:"正则"` + + // 上传文件类型 + UploadTotalMaxCount int `json:"uploadTotalMaxCount" dc:"上传文件最大数量"` + UploadTotalMaxSize int `json:"uploadTotalMaxSize" dc:"上传文件最大大小"` + UploadRules []UploadRule `json:"uploadRules" dc:"上传文件规则"` +} + +// SelectOptionT 选择框选项 +type SelectOptionT struct { + Label string `json:"label" dc:"展示标签"` + Value any `json:"value" dc:"选项值"` +} + +// Template 元数据模板结构体 +// 用于描述单个字段的元数据,包括类型、值、默认值、校验规则等 +type Template struct { + Type string `json:"type" dc:"类型:string/boolean/number/object/array/null"` + Value any `json:"value" dc:"值"` + DefaultValue any `json:"defaultValue" dc:"默认值"` + Required bool `json:"required" dc:"是否必填"` + FieldType string `json:"fieldType" dc:"字段类型(输入框/选择框/多行文本/数字输入等)"` + Attrs any `json:"attrs" dc:"子字段(object/array时使用)"` + IsContainer bool `json:"isContainer" dc:"是否为容器"` + LinkRules []map[string]string `json:"linkRules" dc:"链接规则"` + Label string `json:"label" dc:"展示标签"` + Loop bool `json:"loop" dc:"是否为循环"` + IsForm bool `json:"isForm" dc:"是否为表单"` + Constraint Constraint `json:"constraint" dc:"字段约束"` + Options []SelectOptionT `json:"options" dc:"选项列表(字段类型为选择框时使用)"` + FieldConstraint string `json:"fieldConstraint" dc:"字段额外约束"` + EnumValues []any `json:"enumValues" dc:"枚举值列表"` +} diff --git a/model/dto/model_manage_dto.go b/model/dto/model_manage_dto.go new file mode 100644 index 0000000..0a3cd17 --- /dev/null +++ b/model/dto/model_manage_dto.go @@ -0,0 +1,156 @@ +package dto + +import ( + "model-gateway/consts/model" + "model-gateway/consts/public" + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" +) + +// CreateModelManageReq 添加模型配置 +type CreateModelManageReq struct { + g.Meta `path:"/createModelManage" method:"post" tags:"模型管理" summary:"创建模型配置" dc:"添加新的模型配置"` + ModelSupplier model.SupplierType `json:"modelSupplier" v:"required#模型供应商不能为空" dc:"模型供应商"` + ModelName string `json:"modelName" v:"required#模型名称不能为空" dc:"模型名称"` + ModelType model.ModelType `json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"` + BaseURL string `json:"baseUrl" v:"required#模型服务地址不能为空" dc:"模型服务地址"` + SystemModel *bool `json:"systemModel" dc:"系统模型"` + HttpMethod string `json:"httpMethod" dc:"请求方式:GET/POST" d:"POST"` + ChatModel *bool `json:"chatModel" dc:"对话模型"` + ResponseType model.ResponseType `json:"responseType" v:"required#调用模式不能为空" dc:"调用模式:0-同步 1-异步 2-流式"` + ApiKey string `json:"apiKey" dc:"调用凭证/密钥"` + Enabled *bool `json:"enabled" dc:"启用"` + RequestHeadMapping map[string]string `json:"requestHeadMapping" dc:"请求头映射"` + RequestBodyMapping map[string]any `json:"requestBodyMapping" dc:"请求体映射"` + RequestBusinessFieldMapping map[string]string `json:"requestBusinessFieldMapping" dc:"业务字段映射"` + ResponseMapping map[string]any `json:"responseMapping" dc:"返回映射"` + ResponseBodyMapping map[string]string `json:"responseBodyMapping" dc:"返回体映射"` + ResponseBusinessFieldMapping map[string]string `json:"responseBusinessFieldMapping" dc:"业务字段映射"` + MaxConcurrency int `json:"maxConcurrency" dc:"最大并发数(默认10)"` + TokenMapping *entity.TokenMapping `json:"tokenMapping" dc:"token映射"` + AsyncTaskMapping *entity.AsyncTaskMapping `json:"asyncTaskMapping" dc:"异步任务映射"` + TokenPredictPrice float64 `json:"tokenPredictPrice" dc:"模型Token预估价格"` + TokenPredictPriceUnit string `json:"tokenPredictPriceUnit" dc:"模型Token预估价格单位"` + PriceConfig *entity.PriceConfig `json:"priceConfig" dc:"计费规则"` + MaxTokens int `json:"maxTokens" dc:"最大token数"` + MinDuration int `json:"minDuration" dc:"最小时长"` + MaxDuration int `json:"maxDuration" dc:"最大时长"` + LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"` +} + +type CreateModelManageRes struct { + Id int64 `json:"id,string" dc:"配置ID"` +} + +type UpdateModelManageReq struct { + g.Meta `path:"/updateModelManage" method:"put" tags:"模型管理" summary:"更新模型配置" dc:"更新指定ID的模型配置"` + Id int64 `json:"id" v:"required#id不能为空" dc:"配置ID"` + ModelSupplier model.SupplierType `json:"modelSupplier" dc:"模型供应商"` + ModelName string `json:"modelName" dc:"模型名称"` + ModelType model.ModelType `json:"modelType" dc:"模型类型"` + BaseURL string `json:"baseUrl" dc:"模型服务地址"` + SystemModel *bool `json:"systemModel" dc:"系统模型"` + HttpMethod string `json:"httpMethod" dc:"请求方式:GET/POST"` + ChatModel *bool `json:"chatModel" dc:"对话模型"` + ResponseType model.ResponseType `json:"responseType" dc:"调用模式:0-同步 1-异步 2-流式"` + ApiKey string `json:"apiKey" dc:"调用凭证/密钥"` + Enabled *bool `json:"enabled" dc:"启用"` + RequestHeadMapping map[string]string `json:"requestHeadMapping" dc:"请求头映射"` + RequestBodyMapping map[string]any `json:"requestBodyMapping" dc:"请求体映射"` + RequestBusinessFieldMapping map[string]string `json:"requestBusinessFieldMapping" dc:"业务字段映射"` + ResponseMapping map[string]any `json:"responseMapping" dc:"返回映射"` + ResponseBodyMapping map[string]string `json:"responseBodyMapping" dc:"返回主体映射"` + ResponseBusinessFieldMapping map[string]string `json:"responseBusinessFieldMapping" dc:"业务字段映射"` + MaxConcurrency int `json:"maxConcurrency" dc:"最大并发数(默认10)"` + TokenMapping *entity.TokenMapping `json:"tokenMapping" dc:"token映射"` + AsyncTaskMapping *entity.AsyncTaskMapping `json:"asyncTaskMapping" dc:"异步任务映射"` + TokenPredictPrice float64 `json:"tokenPredictPrice" dc:"模型Token预估价格"` + TokenPredictPriceUnit string `json:"tokenPredictPriceUnit" dc:"模型Token预估价格单位"` + PriceConfig *entity.PriceConfig `json:"priceConfig" dc:"计费规则"` + MaxTokens int `json:"maxTokens" dc:"最大token数"` + MinDuration int `json:"minDuration" dc:"最小时长"` + MaxDuration int `json:"maxDuration" dc:"最大时长"` + LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"` +} + +type DeleteModelManageReq struct { + g.Meta `path:"/deleteModelManage" method:"delete" tags:"模型管理" summary:"删除模型配置" dc:"删除指定ID的模型配置"` + Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"` +} + +type GetModelManage struct { + ChatModel *bool `json:"chatModel" dc:"对话模型"` + Creator string `json:"creator" dc:"创建人"` + ModelName string `json:"modelName" dc:"模型名称"` +} + +type GetModelManageReq struct { + g.Meta `path:"/getModelManage" method:"get" tags:"模型管理" summary:"获取模型配置" dc:"获取指定ID的模型配置"` + Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"` +} + +type GetModelManageRes struct { + ModelManage *entity.ModelManage `json:"modelManage"` +} + +type GetChatModelReq struct { + g.Meta `path:"/getChatModel" method:"get" tags:"模型管理" summary:"获取聊天模型" dc:"获取聊天模型"` +} + +type GetChatModelRes struct { + ModelManage *entity.ModelManage `json:"modelManage"` +} + +// ListModelManageReq 配置列表 +type ListModelManageReq struct { + g.Meta `path:"/listModelManage" method:"get" tags:"模型管理" summary:"模型配置列表" dc:"分页获取模型配置列表"` + *beans.Page `json:"page"` + Id int64 `p:"id" json:"id,string" dc:"配置ID"` + ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"` + ModelType model.ModelType `p:"modelType" json:"modelType" dc:"模型类型"` + IsSameType bool `p:"isSameType" json:"isSameType" dc:"是否相同类型"` + Creator string `json:"creator" dc:"创建人"` +} + +type ListModelManageRes struct { + List []*entity.ModelManage `json:"list" dc:"列表数据"` + Total int `json:"total" dc:"总数"` +} + +type CheckChatModelReq struct { + g.Meta `path:"/checkChatModel" method:"get" tags:"模型管理" summary:"检查是否为聊天模型" dc:"检查是否为聊天模型"` +} + +type CheckChatModelRes struct { + IsChatModel bool `json:"isChatModel" dc:"是否为聊天模型"` +} + +// ModelTypeReq 模型类型列表(分页) +type ModelTypeReq struct { + g.Meta `path:"/modelType" method:"get" tags:"模型管理" summary:"模型类型列表" dc:"分页获取模型类型列表"` +} + +type ModelTypeRes struct { + List []*model.TypeTree `json:"list" dc:"模型类型ID到名称的映射"` +} + +type ModelSupplierReq struct { + g.Meta `path:"/modelSupplier" method:"get" tags:"模型管理" summary:"获取运营商列表" dc:"获取运营商列表"` +} + +type ModelSupplierRes struct { + List []*public.Option `json:"list" dc:"运营商名称到ID的映射"` +} + +// BuildSchemaMappingReq 构建 Schema 映射请求 +type BuildSchemaMappingReq struct { + g.Meta `path:"/buildSchemaMapping" method:"post" tags:"模型管理" summary:"自动构建 Schema 映射" dc:"根据模型类型和 Schema JSON,自动生成业务字段映射"` + ModelType int `json:"modelType" v:"required#模型类型不能为空" dc:"模型类型编码"` + Schema map[string]any `json:"schema" v:"required#Schema不能为空" dc:"模型的完整 Schema JSON"` +} + +type BuildSchemaMappingRes struct { + SchemaMapping map[string]any `json:"schemaMapping" dc:"生成的 Schema 映射 JSON"` +} diff --git a/model/dto/model_session_dto.go b/model/dto/model_session_dto.go new file mode 100644 index 0000000..a6d940b --- /dev/null +++ b/model/dto/model_session_dto.go @@ -0,0 +1,32 @@ +package dto + +import "model-gateway/model/entity" + +type CallModelSessionReq struct { + Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"` + ModelInfo *entity.ModelManage `json:"modelInfo" dc:"模型信息"` + RequestParams map[string]any `json:"requestParams" dc:"新请求参数"` +} + +// CreateModelSessionReq 创建会话 +type CreateModelSessionReq struct { + ModelId int64 `json:"modelId" dc:"模型ID"` + BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"` + SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"` + RequestPath string `json:"requestPath" dc:"请求参数保存路径"` + OriginalRequestPath string `json:"originalRequestPath" dc:"原始请求参数保存路径"` +} + +// UpdateModelSessionReq 修改会话 +type UpdateModelSessionReq struct { + Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"` + RetryCount int `json:"retryCount" dc:"重试"` + ResponsePath string `json:"responsePath" dc:"响应结果保存路径"` + OriginalResponsePath string `json:"originalResponsePath" dc:"原始响应结果保存路径"` + DurationSeconds int64 `json:"durationSeconds" dc:"耗时(秒)"` + PromptTokens int64 `json:"promptTokens" dc:"输入token"` + CompletionTokens int64 `json:"completionTokens" dc:"输出token"` + TotalTokens int64 `json:"totalTokens" dc:"总token"` + TotalCost float64 `json:"totalCost" dc:"总费用(元)"` + ErrorMsg string `json:"errorMsg" dc:"错误消息"` +} diff --git a/model/dto/model_task_end_dto.go b/model/dto/model_task_end_dto.go new file mode 100644 index 0000000..820c10a --- /dev/null +++ b/model/dto/model_task_end_dto.go @@ -0,0 +1,16 @@ +package dto + +type CreateModelTaskEndReq struct { + ModelId int64 `json:"modelId" dc:"模型ID"` + BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"` + MsgTopic string `json:"msgTopic" dc:"消息主题(可选,用于后续业务通知)"` + TaskId string `json:"taskId" dc:"任务ID"` + ResponseParams map[string]any `json:"responseParams" dc:"响应结果"` + OriginalResponseParams map[string]any `json:"originalResponseParams" dc:"原始响应结果"` + DurationSeconds int64 `json:"durationSeconds" dc:"耗时(秒)"` + PromptTokens int64 `json:"promptTokens" dc:"输入token"` + CompletionTokens int64 `json:"completionTokens" dc:"输出token"` + TotalTokens int64 `json:"totalTokens" dc:"总token"` + TotalCost float64 `json:"totalCost" dc:"本次调用总费用(元),未配置计费规则为0"` + ErrorMsg string `json:"errorMsg" dc:"错误消息"` +} diff --git a/model/dto/model_task_start_dto.go b/model/dto/model_task_start_dto.go new file mode 100644 index 0000000..528987a --- /dev/null +++ b/model/dto/model_task_start_dto.go @@ -0,0 +1,46 @@ +package dto + +import ( + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +type CallModelTaskStartReq struct { + Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"` + ModelInfo *entity.ModelManage `json:"modelInfo" dc:"模型信息"` + RequestParams map[string]any `json:"requestParams" dc:"新请求参数"` +} + +// CreateModelTaskStartReq 创建任务 +type CreateModelTaskStartReq struct { + ModelId int64 `json:"modelId" dc:"模型ID"` + BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"` + MsgTopic string `json:"msgTopic" dc:"消息主题(可选,用于后续业务通知)"` + RequestPath string `json:"requestPath" dc:"请求参数保存路径"` + OriginalRequestPath string `json:"originalRequestPath" dc:"原始请求参数保存路径"` + MediaType string `json:"mediaType" dc:"输入媒体类型快照(audio/no_video/has_video,创建任务时按请求体推导)"` +} + +type CreateModelTaskStartRes struct { + Id int64 `json:"id" dc:"任务ID"` +} + +// UpdateModelTaskStartReq 修改任务 +type UpdateModelTaskStartReq struct { + Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"` + RetryCount int `json:"retryCount" dc:"重试"` + ResponseParams map[string]any `json:"responseParams" dc:"响应结果"` + OriginalResponseParams map[string]any `json:"originalResponseParams" dc:"原始响应结果"` + TaskId string `json:"taskId" dc:"任务ID"` + DurationSeconds int64 `json:"durationSeconds" dc:"耗时(秒)"` + ErrorMsg string `json:"errorMsg" dc:"错误消息"` +} + +type DeleteModelTaskStartReq struct { + Id int64 `json:"id" v:"required#ids不能为空" dc:"任务ID"` +} + +type GetModelTaskStartListReq struct { + Page *beans.Page `json:"page"` +} diff --git a/model/entity/model_manage.go b/model/entity/model_manage.go new file mode 100644 index 0000000..9b34d09 --- /dev/null +++ b/model/entity/model_manage.go @@ -0,0 +1,164 @@ +package entity + +import ( + "model-gateway/consts/model" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +type modelManageCol struct { + beans.SQLBaseCol + ModelSupplier string + ModelName string + ModelType string + BaseURL string + SystemModel string + HttpMethod string + ChatModel string + ResponseType string + ApiKey string + Enabled string + RequestHeadMapping string + RequestBodyMapping string + RequestBusinessFieldMapping string + ResponseMapping string + ResponseBodyMapping string + ResponseBusinessFieldMapping string + MaxConcurrency string + TokenPredictPrice string + TokenPredictPriceUnit string + PriceConfig string + MaxTokens string + MinDuration string + MaxDuration string + LastFrame string +} + +var ModelManageCol = modelManageCol{ + SQLBaseCol: beans.DefSQLBaseCol, + ModelSupplier: "model_supplier", + ModelName: "model_name", + ModelType: "model_type", + BaseURL: "base_url", + SystemModel: "system_model", + HttpMethod: "http_method", + ChatModel: "chat_model", + ResponseType: "response_type", + ApiKey: "api_key", + Enabled: "enabled", + RequestHeadMapping: "request_head_mapping", + RequestBodyMapping: "request_body_mapping", + RequestBusinessFieldMapping: "request_business_field_mapping", + ResponseMapping: "response_mapping", + ResponseBodyMapping: "response_body_mapping", + ResponseBusinessFieldMapping: "response_business_field_mapping", + MaxConcurrency: "max_concurrency", + TokenPredictPrice: "token_predict_price", + TokenPredictPriceUnit: "token_predict_price_unit", + PriceConfig: "price_config", + MaxTokens: "max_tokens", + MinDuration: "min_duration", + MaxDuration: "max_duration", + LastFrame: "last_frame", +} + +type ModelManage struct { + beans.SQLBaseDO `orm:",inline"` + ModelSupplier model.SupplierType `orm:"model_supplier" json:"modelSupplier" description:"模型供应商"` + ModelName string `orm:"model_name" json:"modelName" description:"模型名称"` + ModelType model.ModelType `orm:"model_type" json:"modelType" description:"模型类型"` + BaseURL string `orm:"base_url" json:"baseUrl" description:"模型地址"` + SystemModel *bool `orm:"system_model" json:"systemModel" description:"系统模型"` + HttpMethod string `orm:"http_method" json:"httpMethod" description:"http方法"` + ChatModel *bool `orm:"chat_model" json:"chatModel" description:"是否聊天模型"` + ResponseType model.ResponseType `orm:"response_type" json:"responseType" description:"返回类型:1同步,2异步,3流"` + ApiKey string `orm:"api_key" json:"apiKey" description:"api key"` + Enabled *bool `orm:"enabled" json:"enabled" description:"是否启用"` + RequestHeadMapping map[string]string `orm:"request_head_mapping" json:"requestHeadMapping" description:"请求头映射"` + RequestBodyMapping map[string]any `orm:"request_body_mapping" json:"requestBodyMapping" description:"请求体映射"` + RequestBusinessFieldMapping map[string]string `orm:"request_business_field_mapping" json:"requestBusinessFieldMapping" description:"请求业务字段映射"` + ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping" description:"响应映射"` + ResponseBodyMapping map[string]string `orm:"response_body_mapping" json:"responseBodyMapping" description:"响应主体映射"` + ResponseBusinessFieldMapping map[string]string `orm:"response_business_field_mapping" json:"responseBusinessFieldMapping" description:"响应业务字段映射"` + MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency" description:"最大并发数"` + TokenMapping *TokenMapping `orm:"token_mapping" json:"tokenMapping" description:"token映射"` + AsyncTaskMapping *AsyncTaskMapping `orm:"async_task_mapping" json:"asyncTaskMapping" description:"异步任务映射"` + TokenPredictPrice float64 `orm:"token_predict_price" json:"tokenPredictPrice" description:"模型Token预估价格"` + TokenPredictPriceUnit string `orm:"token_predict_price_unit" json:"tokenPredictPriceUnit" description:"模型token预估价格单位(秒,百万Token,千Token,字数)"` + PriceConfig *PriceConfig `orm:"price_config" json:"priceConfig" description:"计费规则"` + MaxTokens int `orm:"max_tokens" json:"maxTokens" description:"最大token数"` + MinDuration int `orm:"min_duration" json:"minDuration" description:"最小时长(秒)"` + MaxDuration int `orm:"max_duration" json:"maxDuration" description:"最大时长(秒)"` + LastFrame string `orm:"last_frame" json:"lastFrame" description:"视频的尾帧图像"` +} + +type TokenMapping struct { + PromptTokens string `json:"promptTokens" dc:"输入token"` + CompletionTokens string `json:"completionTokens" dc:"输出token"` + TotalTokens string `json:"totalTokens" dc:"总token"` +} + +type AsyncTaskMapping struct { + Url string `json:"url" dc:"url"` + HttpMethod string `json:"httpMethod" dc:"http方法" d:"POST"` + RequestHeadMapping map[string]string `json:"requestHeadMapping" description:"请求头映射"` + ResponseMapping map[string]any `json:"responseMapping" description:"响应映射"` + TaskId string `json:"taskId" dc:"任务id"` + TaskStatus string `json:"taskStatus" dc:"任务状态"` + TaskStatusPending string `json:"taskStatusPending" dc:"任务状态-待处理"` + TaskStatusRunning string `json:"taskStatusRunning" dc:"任务状态-运行中"` + TaskStatusSuccess string `json:"taskStatusSuccess" dc:"任务状态-成功"` + TaskStatusFailed string `json:"taskStatusFailed" dc:"任务状态-失败"` + TaskStatusCancel string `json:"taskStatusCancel" dc:"任务状态-取消"` + TaskStatusUnknown string `json:"taskStatusUnknown" dc:"任务状态-未知"` +} + +// PriceConfig 模型计费规则(price_config 列,JSONB)。 +// 命中条件为固定字段结构(PriceMatch):token 档位从调用用量读取,媒体类型由请求体参考媒体字段推导, +// 全部字段缺省表示无条件命中。 +type PriceConfig struct { + Currency string `json:"currency" dc:"币种,默认CNY"` + Unit string `json:"unit" dc:"单价基准:per_1K-千token/per_1M-百万token/per_1-单个"` + Rules []PriceRule `json:"rules" dc:"定价规则数组,按序首条命中生效"` + Discount *PriceDiscount `json:"discount" dc:"模型级限时折扣,规则级可覆盖"` + + // 单规则便捷字段:Rules 为空时的兜底价(等价于一条空 match 规则),全部为 0 时不参与计费 + InputPrice float64 `json:"inputPrice,omitempty" dc:"输入单价"` + OutputPrice float64 `json:"outputPrice,omitempty" dc:"输出单价"` + CacheHitPrice float64 `json:"cacheHitPrice,omitempty" dc:"缓存命中单价"` + CacheStorageHourPrice float64 `json:"cacheStorageHourPrice,omitempty" dc:"缓存存储单价(元/小时)"` +} + +// PriceRule 定价规则:match 条件命中后按价格项计价,缺省项视为 0 +type PriceRule struct { + Name string `json:"name"` + Match *PriceMatch `json:"match,omitempty" dc:"命中条件(固定字段,见 PriceMatch);空表示任意调用"` + Input float64 `json:"input" dc:"输入单价(非音频)"` + InputAudio float64 `json:"inputAudio,omitempty" dc:"输入单价(音频),请求体 reference_audio 参考媒体字段命中时生效(见 DetectMediaType)"` + Output float64 `json:"output" dc:"输出单价"` + CacheHit float64 `json:"cacheHit" dc:"缓存命中单价(非音频)"` + CacheHitAudio float64 `json:"cacheHitAudio,omitempty" dc:"缓存命中单价(音频),请求体 reference_audio 参考媒体字段命中时生效(见 DetectMediaType)"` + CacheStorageHour float64 `json:"cacheStorageHour" dc:"缓存存储单价(元/小时)"` + Discount *PriceDiscount `json:"discount" dc:"规则级折扣,覆盖模型级折扣"` +} + +// PriceMatch 命中条件:字段对应调用上下文固定路径,全部缺省(空值)表示无条件命中任意调用。 +// token 档位(InputLength/OutputLength/TotalLength/CachedTokens)从调用用量读取, +// MediaType 由请求体参考媒体字段推导(见 DetectMediaType)。 +type PriceMatch struct { + MediaType string `json:"mediaType,omitempty" dc:"输入媒体类型精确值(audio/no_video/has_video,由请求体参考媒体字段推导)"` + InputLengthMax int64 `json:"inputLengthMax,omitempty" dc:"输入token上限(<=,usage.prompt_tokens)"` + InputLengthMin int64 `json:"inputLengthMin,omitempty" dc:"输入token下限(>=,usage.prompt_tokens)"` + OutputLengthMax int64 `json:"outputLengthMax,omitempty" dc:"输出token上限(<=,usage.completion_tokens)"` + OutputLengthMin int64 `json:"outputLengthMin,omitempty" dc:"输出token下限(>=,usage.completion_tokens)"` + TotalLengthMax int64 `json:"totalLengthMax,omitempty" dc:"总token上限(<=,usage.total_tokens)"` + TotalLengthMin int64 `json:"totalLengthMin,omitempty" dc:"总token下限(>=,usage.total_tokens)"` + CachedTokensMax int64 `json:"cachedTokensMax,omitempty" dc:"缓存命中token上限(<=,usage.cached_tokens)"` + CachedTokensMin int64 `json:"cachedTokensMin,omitempty" dc:"缓存命中token下限(>=,usage.cached_tokens)"` +} + +// PriceDiscount 限时折扣:rate 为折扣率(0.4=4折),effective 为空数组表示长期有效 +type PriceDiscount struct { + Rate float64 `json:"rate" dc:"折扣率"` + Effective [2]string `json:"effective" dc:"有效期[起始,截止],格式YYYY-MM-DD,缺省长期有效"` +} diff --git a/model/entity/model_session.go b/model/entity/model_session.go new file mode 100644 index 0000000..7703632 --- /dev/null +++ b/model/entity/model_session.go @@ -0,0 +1,60 @@ +package entity + +import ( + "gitea.redpowerfuture.com/red-future/common/beans" +) + +type modelSessionCol struct { + beans.SQLBaseCol + ModelId string + BizName string + SessionId string + RetryCount string + RequestPath string + ResponsePath string + OriginalRequestPath string + OriginalResponsePath string + DurationSeconds string + PromptTokens string + CompletionTokens string + TotalTokens string + TotalCost string + ErrorMsg string +} + +var ModelSessionCol = modelSessionCol{ + SQLBaseCol: beans.DefSQLBaseCol, + ModelId: "model_id", + BizName: "biz_name", + SessionId: "session_id", + RetryCount: "retry_count", + RequestPath: "request_path", + ResponsePath: "response_path", + OriginalRequestPath: "original_request_path", + OriginalResponsePath: "original_response_path", + DurationSeconds: "duration_seconds", + PromptTokens: "prompt_tokens", + CompletionTokens: "completion_tokens", + TotalTokens: "total_tokens", + TotalCost: "total_cost", + ErrorMsg: "error_msg", +} + +// ModelSession 模型网关任务 +type ModelSession struct { + beans.SQLBaseDO `orm:",inline"` + ModelId int64 `orm:"model_id" json:"modelId" dc:"模型ID"` + BizName string `orm:"biz_name" json:"bizName" dc:"业务名称"` + SessionId string `orm:"session_id" json:"sessionId" dc:"会话ID"` + RetryCount int `orm:"retry_count" json:"retryCount" dc:"重试"` + RequestPath string `orm:"request_path" json:"requestPath" dc:"请求参数保存路径"` + ResponsePath string `orm:"response_path" json:"responsePath" dc:"响应结果保存路径"` + OriginalRequestPath string `orm:"original_request_path" json:"originalRequestPath" dc:"原始请求参数保存路径"` + OriginalResponsePath string `orm:"original_response_path" json:"originalResponsePath" dc:"原始响应结果保存路径"` + DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"` + PromptTokens int64 `orm:"prompt_tokens" json:"promptTokens" dc:"输入token"` + CompletionTokens int64 `orm:"completion_tokens" json:"completionTokens" dc:"输出token"` + TotalTokens int64 `orm:"total_tokens" json:"totalTokens" dc:"总token"` + TotalCost float64 `orm:"total_cost" json:"totalCost" dc:"总费用(元)"` + ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"` +} diff --git a/model/entity/model_task_end.go b/model/entity/model_task_end.go new file mode 100644 index 0000000..39bbe0a --- /dev/null +++ b/model/entity/model_task_end.go @@ -0,0 +1,57 @@ +package entity + +import ( + "gitea.redpowerfuture.com/red-future/common/beans" +) + +type modelTaskEndCol struct { + beans.SQLBaseCol + ModelId string + BizName string + MsgTopic string + RetryCount string + ResponseParams string + OriginalResponseParams string + DurationSeconds string + TaskId string + PromptTokens string + CompletionTokens string + TotalTokens string + TotalCost string + ErrorMsg string +} + +var ModelTaskEndCol = modelTaskEndCol{ + SQLBaseCol: beans.DefSQLBaseCol, + ModelId: "model_id", + BizName: "biz_name", + MsgTopic: "msg_topic", + RetryCount: "retry_count", + ResponseParams: "response_params", + OriginalResponseParams: "original_response_params", + DurationSeconds: "duration_seconds", + TaskId: "task_id", + PromptTokens: "prompt_tokens", + CompletionTokens: "completion_tokens", + TotalTokens: "total_tokens", + TotalCost: "total_cost", + ErrorMsg: "error_msg", +} + +// ModelTaskEnd 模型网关任务 +type ModelTaskEnd struct { + beans.SQLBaseDO `orm:",inline"` + ModelId int64 `orm:"model_id" json:"modelId" dc:"模型ID"` + BizName string `orm:"biz_name" json:"bizName" dc:"业务名称"` + MsgTopic string `orm:"msg_topic" json:"msgTopic" dc:"消息主题"` + RetryCount int `orm:"retry_count" json:"retryCount" dc:"重试"` + ResponseParams string `orm:"response_params" json:"responseParams" dc:"响应结果"` + OriginalResponseParams string `orm:"original_response_params" json:"originalResponseParams" dc:"原始响应结果"` + DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"` + TaskId string `orm:"task_id" json:"taskId" dc:"任务ID"` + PromptTokens int64 `orm:"prompt_tokens" json:"promptTokens" dc:"输入token"` + CompletionTokens int64 `orm:"completion_tokens" json:"completionTokens" dc:"输出token"` + TotalTokens int64 `orm:"total_tokens" json:"totalTokens" dc:"总token"` + TotalCost float64 `orm:"total_cost" json:"totalCost" dc:"本次调用总费用(元),未配置计费规则为0"` + ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"` +} diff --git a/model/entity/model_task_start.go b/model/entity/model_task_start.go new file mode 100644 index 0000000..ad60f28 --- /dev/null +++ b/model/entity/model_task_start.go @@ -0,0 +1,54 @@ +package entity + +import ( + "gitea.redpowerfuture.com/red-future/common/beans" +) + +type modelTaskStartCol struct { + beans.SQLBaseCol + ModelId string + BizName string + MsgTopic string + RetryCount string + RequestPath string + OriginalRequestPath string + ResponseParams string + OriginalResponseParams string + DurationSeconds string + TaskId string + MediaType string + ErrorMsg string +} + +var ModelTaskStartCol = modelTaskStartCol{ + SQLBaseCol: beans.DefSQLBaseCol, + ModelId: "model_id", + BizName: "biz_name", + MsgTopic: "msg_topic", + RetryCount: "retry_count", + RequestPath: "request_path", + OriginalRequestPath: "original_request_path", + ResponseParams: "response_params", + OriginalResponseParams: "original_response_params", + DurationSeconds: "duration_seconds", + TaskId: "task_id", + MediaType: "media_type", + ErrorMsg: "error_msg", +} + +// ModelTaskStart 模型网关任务 +type ModelTaskStart struct { + beans.SQLBaseDO `orm:",inline"` + ModelId int64 `orm:"model_id" json:"modelId" dc:"模型ID"` + BizName string `orm:"biz_name" json:"bizName" dc:"业务名称"` + MsgTopic string `orm:"msg_topic" json:"msgTopic" dc:"消息主题"` + RetryCount int `orm:"retry_count" json:"retryCount" dc:"重试"` + RequestPath string `orm:"request_path" json:"requestPath" dc:"请求参数保存路径"` + OriginalRequestPath string `orm:"original_request_path" json:"originalRequestPath" dc:"原始请求参数保存路径"` + ResponseParams map[string]any `orm:"response_params" json:"responseParams" dc:"响应结果"` + OriginalResponseParams map[string]any `orm:"original_response_params" json:"originalResponseParams" dc:"原始响应结果"` + DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"` + TaskId string `orm:"task_id" json:"taskId" dc:"任务ID"` + MediaType string `orm:"media_type" json:"mediaType" dc:"输入媒体类型快照(audio/no_video/has_video,创建任务时按请求体推导)"` + ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"` +} diff --git a/service/model_call_service.go b/service/model_call_service.go new file mode 100644 index 0000000..971e954 --- /dev/null +++ b/service/model_call_service.go @@ -0,0 +1,424 @@ +package service + +import ( + "context" + "errors" + "fmt" + "model-gateway/consts/model" + "model-gateway/dao" + "model-gateway/model/dto" + "model-gateway/model/entity" + modelUtils "model-gateway/service/utils" + "net/http" + "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" +) + +var ModelCall = &modelCallService{} + +type modelCallService struct{} + +// minTenantSurplusForUse 调用模型前租户余额最低门槛(元),余额须大于该值才允许调用 +const minTenantSurplusForUse = 200.0 + +// CheckTenantBalance 调用模型前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用。 +// 返回当前余额;余额不足或获取失败返回错误。 +func CheckTenantBalance(ctx context.Context, tenantId uint64) (surplus float64, err error) { + surplus, err = GetTenantSurplus(ctx, tenantId) + if err != nil { + return 0, fmt.Errorf("获取租户余额失败: %w", err) + } + if surplus <= minTenantSurplusForUse { + return surplus, fmt.Errorf("租户余额不足,无法调用模型(需余额大于%.0f元,当前余额%.2f元)", minTenantSurplusForUse, surplus) + } + return surplus, nil +} + +func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq) (res *dto.ModelCallRes, err error) { + // 1) 检查模型配置 + var modelInfo *entity.ModelManage + modelInfo, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{ + Id: req.ModelId, + }) + if err != nil { + return nil, fmt.Errorf("获取模型配置失败: %v", err) + } + if modelInfo == nil || (modelInfo.Enabled != nil && !*modelInfo.Enabled) { + return nil, fmt.Errorf("模型不存在或未启用") + } + + now := time.Now() + userInfo, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + // 调用前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用 + if _, err = CheckTenantBalance(ctx, userInfo.TenantId); err != nil { + return nil, err + } + err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) { + if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() || *modelInfo.ResponseType == *model.ResponseTypeStream.Code() { + var newRequestParams map[string]any + var id int64 + id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, req) + if err != nil { + return err + } + if g.IsEmpty(id) || g.IsEmpty(newRequestParams) { + return fmt.Errorf("保存模型请求参数失败") + } + if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() { + res, err = ModelSession.CreateSession(ctx, &dto.CallModelSessionReq{ + Id: id, + ModelInfo: modelInfo, + RequestParams: newRequestParams, + }) + } else { + res, err = ModelSession.CreateSessionStreamOnce(ctx, &dto.CallModelSessionReq{ + Id: id, + ModelInfo: modelInfo, + RequestParams: newRequestParams, + }) + } + } + if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() { + if g.IsEmpty(req.MsgTopic) { + return fmt.Errorf("请指定消息主题") + } + var newRequestParams map[string]any + var id int64 + id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, req) + if err != nil { + return err + } + if g.IsEmpty(id) || g.IsEmpty(newRequestParams) { + return fmt.Errorf("保存模型请求参数失败") + } + res, err = ModelTaskStart.CreateTask(ctx, &dto.CallModelTaskStartReq{ + Id: id, + ModelInfo: modelInfo, + RequestParams: newRequestParams, + }) + } + // 扣减本次调用费用:同步/一次性流式返回实际费用;异步提交费用为 0 跳过,由任务完成时(handleSingleTask)扣减 + if err == nil && res != nil && res.Cost > 0 { + if dedErr := DeductBalance(ctx, userInfo.TenantId, res.Cost); dedErr != nil { + g.Log().Errorf(ctx, "[扣减余额] 模型调用扣费失败 modelId=%d cost=%.6f err=%v", modelInfo.Id, res.Cost, dedErr) + } + } + return + }) + return +} + +func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseWriter, req *dto.ModelCallStreamReq) (err error) { + // 1) 检查模型配置 + var modelInfo *entity.ModelManage + modelInfo, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{ + Id: req.ModelId, + }) + if err != nil { + return fmt.Errorf("获取模型配置失败: %v", err) + } + if modelInfo == nil || (modelInfo.Enabled != nil && !*modelInfo.Enabled) { + return fmt.Errorf("模型不存在或未启用") + } + if *modelInfo.ResponseType == *model.ResponseTypeStream.Code() { + now := time.Now() + userInfo, err := utils.GetUserInfo(ctx) + if err != nil { + return err + } + // 调用前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用 + if _, err = CheckTenantBalance(ctx, userInfo.TenantId); err != nil { + return err + } + err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) { + var newRequestParams map[string]any + var id int64 + id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, &dto.ModelCallReq{ + ModelId: req.ModelId, + RequestParams: req.RequestParams, + BusinessParams: req.BusinessParams, + SessionId: req.SessionId, + BizName: req.BizName, + }) + if err != nil { + return err + } + if g.IsEmpty(id) || g.IsEmpty(newRequestParams) { + return fmt.Errorf("保存模型请求参数失败") + } + var streamRes *dto.ModelCallRes + streamRes, err = ModelSession.CreateSessionStream(ctx, w, &dto.CallModelSessionReq{ + Id: id, + ModelInfo: modelInfo, + RequestParams: newRequestParams, + }) + // 扣减本次流式调用费用(流结束返回实际费用) + if err == nil && streamRes != nil && streamRes.Cost > 0 { + if dedErr := DeductBalance(ctx, userInfo.TenantId, streamRes.Cost); dedErr != nil { + g.Log().Errorf(ctx, "[扣减余额] 流式调用扣费失败 modelId=%d cost=%.6f err=%v", modelInfo.Id, streamRes.Cost, dedErr) + } + } + return + }) + } else { + return fmt.Errorf("模型响应类型错误") + } + return err +} + +// saveModelRequestParams 保存模型请求参数 +func (s *modelCallService) saveModelRequestParams(ctx context.Context, now time.Time, modelInfo *entity.ModelManage, req *dto.ModelCallReq) (id int64, newRequestParams map[string]any, err error) { + + // 统一走模板校验+构建:requestParams 只装模板字段,businessParams 只装业务字段 + out, err := buildChatRequestParams(modelInfo, req.RequestParams, req.BusinessParams) + if err != nil { + return 0, nil, err + } + + // 1) 上传模型原始请求参数文件(requestParams + businessParams 合并,保证审计完整) + originalParams := make(map[string]any, len(req.RequestParams)+len(req.BusinessParams)) + for k, v := range req.RequestParams { + originalParams[k] = v + } + for k, v := range req.BusinessParams { + originalParams[k] = v + } + uploadOriginalReq, err := Upload(ctx, &dto.UploadFileBytesReq{ + FileBytes: gconv.Bytes(gconv.String(originalParams)), + FileName: fmt.Sprintf("modelRequestParams:%v.json", now.UnixMilli()), + }) + if err != nil { + return 0, nil, fmt.Errorf("上传模型原始请求参数文件失败: %v", err) + } + + // 2) 上传模型解析成功的请求参数文件 + uploadNewReq, err := Upload(ctx, &dto.UploadFileBytesReq{ + FileBytes: gconv.Bytes(gconv.String(out)), + FileName: fmt.Sprintf("modelNewRequestParams:%v.json", now.UnixMilli()), + }) + if err != nil { + return 0, nil, fmt.Errorf("上传模型解析请求参数文件失败:%v", err) + } + + // 3) 保存模型请求信息(快照媒体类型供任务完成时换算费用;模型计费配置任务完成时按 modelId 现查) + if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() { + id, err = dao.ModelTaskStart.Insert(ctx, &dto.CreateModelTaskStartReq{ + ModelId: req.ModelId, + BizName: req.BizName, + MsgTopic: req.MsgTopic, + RequestPath: uploadNewReq.FileURL, + OriginalRequestPath: uploadOriginalReq.FileURL, + MediaType: DetectMediaType(modelInfo.RequestBusinessFieldMapping, out), + }) + if err != nil { + return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err) + } + } else { + id, err = dao.ModelSession.Insert(ctx, &dto.CreateModelSessionReq{ + ModelId: req.ModelId, + BizName: req.BizName, + SessionId: req.SessionId, + RequestPath: uploadNewReq.FileURL, + OriginalRequestPath: uploadOriginalReq.FileURL, + }) + if err != nil { + return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err) + } + } + return id, out, nil +} + +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) + + // 循环尝试获取并发名额,超限则等待重试 + 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) + } + + // 未超限:跳出循环,执行业务 + 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 +} + +// 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 + } + + // 过期时间合法校验(单位:秒) + 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) + } + } + + return true, runErr + } + + // 抢锁失败,休眠重试 + time.Sleep(time.Second) + goto LOOP +} + +// buildChatRequestParams 按模型配置的请求模板 + 业务字段映射构建请求体(ModelCall 请求路径共用): +// 1. requestParams 只装模板字段,按 requestBodyMapping 模板校验(CheckParams)+ 构建(ParseConfigTemplate); +// 未配置映射的字段(如未配置映射的 messages/tools)会被模板拒绝,明确报错 +// 2. requestParams 为空时按配置模板构建请求结构(模板 defaultValue 生效), +// 避免"结构由模板声明、值全走业务字段"的场景因请求体为空而构建失败 +// 3. businessParams 只装业务字段,按业务字段名(RequestBusinessFieldMapping 的 key)传值, +// TakeBusinessFields 解析为写入路径,构建完成后由 WriteBusinessFields 按路径写入最终请求体 +func buildChatRequestParams(modelInfo *entity.ModelManage, requestParams, businessParams map[string]any) (map[string]any, error) { + rest := make(map[string]any, len(requestParams)) + for k, v := range requestParams { + rest[k] = v + } + // 请求结构源:模板字段兜底(模板 value/defaultValue 生效)+ requestParams 覆盖同名; + // 保证模板声明的结构字段(如 stream_options 对象)即使 requestParams 未传也进请求体 + src := rest + for k, v := range modelInfo.RequestBodyMapping { + mapV := gconv.Map(v) + if mapV["type"] == "array" { + continue + } + if _, has := src[k]; !has { + src[k] = v + } + } + if len(requestParams) > 0 { + // requestParams 非空才按模板严格校验模板字段(空值回填 default); + // 为空时跳过,避免业务字段未写入就误报必填缺失 + if err := modelUtils.CheckParams(src, modelInfo.RequestBodyMapping); err != nil { + return nil, err + } + } + + out := modelUtils.ParseConfigTemplate(src) + if !g.IsEmpty(businessParams) { + // 业务字段:businessParams 按业务字段名传值,解析为映射路径后写入 + bizValues, err := modelUtils.TakeBusinessFields(businessParams, modelInfo.RequestBusinessFieldMapping) + if err != nil { + return nil, err + } + // 业务字段按映射路径写入最终请求体(写 out 而非 rest:rest 只是模板字段容器,out 才是下发模型的请求体) + if err = modelUtils.WriteBusinessFields(out, bizValues); err != nil { + return nil, err + } + // 合并后按模板约束整体校验:必填/长度/范围 + if err = modelUtils.CheckBody(out, modelInfo.RequestBodyMapping); err != nil { + return nil, err + } + } + // 按模板声明的 type 归一字段值类型(模板字段 value / 业务字段写入值都可能与声明类型不符) + out = modelUtils.CoerceBodyTypes(out, modelInfo.RequestBodyMapping) + return out, nil +} diff --git a/service/model_http_service.go b/service/model_http_service.go new file mode 100644 index 0000000..9225755 --- /dev/null +++ b/service/model_http_service.go @@ -0,0 +1,318 @@ +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/utils" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" + "github.com/gogf/gf/v2/util/gconv" +) + +// DeductBalanceReq 扣减余额请求 +type DeductBalanceReq struct { + Id uint64 `json:"id"` + Surplus float64 `json:"surplus"` +} + +// DeductBalance 扣减租户余额。走 admin-go 内部接口 /pub/tenant/deduct(无 gftoken/Auth,供 model-gateway 内部调用)。 +// admin-go 的 tenant/edit 对 surplus 走 gdb.Counter 增量(正加负减),故扣减须传负值;调用方在本次未产生费用(cost<=0)时应跳过。 +func DeductBalance(ctx context.Context, tenantId uint64, amount float64) error { + apiURL := "admin-go/api/v1/pub/tenant/deduct" + headers := setCtxHeader(ctx) + + body := DeductBalanceReq{ + Id: tenantId, + Surplus: -amount, + } + jsonData, _ := json.Marshal(body) + + var resp struct{} + err := commonHttp.Post(ctx, apiURL, headers, &resp, jsonData) + if err != nil { + g.Log().Warningf(ctx, "[扣减余额] 失败 tenantId=%d amount=%.6f err=%v", tenantId, amount, err) + return err + } + g.Log().Infof(ctx, "[扣减余额] 成功 tenantId=%d amount=%.6f", tenantId, amount) + return nil +} + +// TenantSurplusResp 租户余额返回 +type TenantSurplusResp struct { + Tenant struct { + Surplus float64 `json:"surplus"` + } `json:"tenant"` +} + +// GetTenantSurplus 获取租户余额(走 admin-go 内部接口 /pub/tenant/balance,无 gftoken/Auth) +func GetTenantSurplus(ctx context.Context, tenantId uint64) (float64, error) { + apiURL := fmt.Sprintf("admin-go/api/v1/pub/tenant/balance?tenantId=%d", tenantId) + headers := setCtxHeader(ctx) + + var resp TenantSurplusResp + err := commonHttp.Get(ctx, apiURL, headers, &resp, nil) + if err != nil { + g.Log().Warningf(ctx, "[获取余额] 失败 tenantId=%d err=%v", tenantId, err) + return 0, err + } + return resp.Tenant.Surplus, nil +} + +// IsSuperAdmin 调用admin-go服务检查是否是管理员 +func IsSuperAdmin(ctx context.Context) (res bool, err error) { + headers := setCtxHeader(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 +} + +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) + 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 +} + +func setCtxHeader(ctx context.Context) map[string]string { + headers := make(map[string]string) + if r := g.RequestFromCtx(ctx); r != nil { + for k, v := range r.Request.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + } + // 后台异步任务(临时路径转存 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) + } + } + 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_manage_service.go b/service/model_manage_service.go new file mode 100644 index 0000000..9cb66e5 --- /dev/null +++ b/service/model_manage_service.go @@ -0,0 +1,279 @@ +package service + +import ( + "context" + "fmt" + "model-gateway/consts/model" + "model-gateway/consts/public" + "model-gateway/dao" + "model-gateway/model/dto" + "model-gateway/model/entity" + + "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 ModelManage = &modelManageService{} + +type modelManageService struct{} + +// Create 创建模型 +func (s *modelManageService) Create(ctx context.Context, req *dto.CreateModelManageReq) (res *dto.CreateModelManageRes, err error) { + err = gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + // 1)检查是否是超管 + var isSuperAdmin bool + isSuperAdmin, err = IsSuperAdmin(ctx) + if err != nil { + return + } + req.SystemModel = &isSuperAdmin + // 1)如果设为会话模型,先把该用户旧会话模型取消 + err = s.CancelChatModel(ctx, req.ModelType, req.ChatModel, isSuperAdmin) + if err != nil { + return + } + // 2)模型名称唯一性:同一用户下不允许同名模型 + var user *beans.User + user, err = utils.GetUserInfo(ctx) + if err != nil { + return + } + if !g.IsEmpty(req.ModelName) { + var exist *entity.ModelManage + exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, req.ModelName) + if err != nil { + return + } + if exist != nil { + return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", req.ModelName) + } + } + // 3)插入数据 + id, err := dao.ModelManage.Insert(ctx, req) + if err != nil { + return + } + res = &dto.CreateModelManageRes{Id: id} + return + }) + return +} + +// Update 更新模型配置 +func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelManageReq) (res *dto.GetModelManageRes, err error) { + err = gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + var get *entity.ModelManage + get, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{ + Id: req.Id, + }) + if err != nil { + return + } + var user *beans.User + user, err = utils.GetUserInfo(ctx) + if err != nil { + return + } + // 1)如果不是创建者,且是系统模型,则需要拷贝 + if get.Creator != user.UserName { + if get.SystemModel != nil && *get.SystemModel { + // 拷贝前先查同名:用户已存在同名模型则直接返回用户自己的模型,避免重复拷贝 + copyName := req.ModelName + if g.IsEmpty(copyName) { + copyName = get.ModelName + } + if !g.IsEmpty(copyName) { + var exist *entity.ModelManage + exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, copyName) + if err != nil { + return + } + if exist != nil { + res, err = s.Get(ctx, &dto.GetModelManageReq{Id: exist.Id}) + return + } + } + if g.IsEmpty(req.ApiKey) { + return fmt.Errorf("模型apiKey不能为空") + } + d := new(dto.CreateModelManageReq) + err = gconv.Struct(req, d) + if err != nil { + return + } + var r *dto.CreateModelManageRes + r, err = s.Create(ctx, d) + if err != nil { + return err + } + res, err = s.Get(ctx, &dto.GetModelManageReq{ + Id: r.Id, + }) + return + } + return fmt.Errorf("无权限操作") + } + + // 1)检查是否是超管 + var isSuperAdmin bool + isSuperAdmin, err = IsSuperAdmin(ctx) + if err != nil { + return + } + modelType := req.ModelType + if g.IsEmpty(modelType) { + modelType = get.ModelType + } + // 1)如果设为会话模型,先把该用户旧会话模型取消 + err = s.CancelChatModel(ctx, modelType, req.ChatModel, isSuperAdmin) + if err != nil { + return + } + // 2)模型名称唯一性:同一用户下不允许同名模型(排除自身) + if !g.IsEmpty(req.ModelName) { + var exist *entity.ModelManage + exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, req.ModelName) + if err != nil { + return + } + if exist != nil && exist.Id != req.Id { + return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", req.ModelName) + } + } + // 3)更新数据 + _, err = dao.ModelManage.Update(ctx, req) + return + }) + return +} + +func (s *modelManageService) CancelChatModel(ctx context.Context, modelType model.ModelType, chatModel *bool, isSuperAdmin bool) (err error) { + if !g.IsEmpty(chatModel) && *chatModel { + if !g.IsEmpty(modelType) && *modelType == *model.ModelTypeInference.Code { + if isSuperAdmin { + return fmt.Errorf("超级管理员不能设置会话模型") + } + // 2)获取该用户信息 + var user *beans.User + user, err = utils.GetUserInfo(ctx) + if err != nil { + return + } + // 3)取消该用户之前的会话模型 + var get *entity.ModelManage + get, err = dao.ModelManage.Get(ctx, &dto.GetModelManage{ + Creator: user.UserName, + ChatModel: chatModel, + }) + if err != nil { + return + } + if g.IsEmpty(get) { + return + } + _, err = dao.ModelManage.Update(ctx, &dto.UpdateModelManageReq{ + Id: get.Id, + ChatModel: gconv.PtrBool(false), + }) + if err != nil { + return + } + } else { + return fmt.Errorf("只有推理模型可以设置成会话模型") + } + } + return +} + +// Delete 删除模型 +func (s *modelManageService) Delete(ctx context.Context, req *dto.DeleteModelManageReq) error { + _, err := dao.ModelManage.Delete(ctx, req) + return err +} + +func (s *modelManageService) Get(ctx context.Context, req *dto.GetModelManageReq) (res *dto.GetModelManageRes, err error) { + get, err := dao.ModelManage.GetNotTenantId(ctx, req) + if err != nil { + return + } + res = new(dto.GetModelManageRes) + err = gconv.Struct(get, &res.ModelManage) + return +} + +func (s *modelManageService) GetChatModel(ctx context.Context, req *dto.GetChatModelReq) (res *dto.GetChatModelRes, err error) { + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + get, err := dao.ModelManage.Get(ctx, &dto.GetModelManage{ + Creator: user.UserName, + ChatModel: gconv.PtrBool(true), + }) + if err != nil { + return + } + res = &dto.GetChatModelRes{ + ModelManage: get, + } + return +} + +// List 获取模型列表 +func (s *modelManageService) List(ctx context.Context, req *dto.ListModelManageReq) (res *dto.ListModelManageRes, err error) { + if req.IsSameType && !g.IsEmpty(req.Id) { + var get *entity.ModelManage + get, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{ + Id: req.Id, + }) + if err != nil { + return + } + req.ModelType = get.ModelType + } + var user *beans.User + user, err = utils.GetUserInfo(ctx) + if err != nil { + return + } + req.Creator = user.UserName + list, total, err := dao.ModelManage.ListNotTenantId(ctx, req) + if err != nil { + return + } + res = &dto.ListModelManageRes{ + Total: total, + } + err = gconv.Struct(list, &res.List) + return +} + +func (s *modelManageService) CheckChatModel(ctx context.Context, req *dto.CheckChatModelReq) (res *dto.CheckChatModelRes, err error) { + get, err := s.GetChatModel(ctx, &dto.GetChatModelReq{}) + if err != nil { + return + } + res = &dto.CheckChatModelRes{ + IsChatModel: !g.IsEmpty(get), + } + return +} + +// GetModelType 获取模型类型 +func (s *modelManageService) GetModelType(ctx context.Context, req *dto.ModelTypeReq) (res *dto.ModelTypeRes, err error) { + res = &dto.ModelTypeRes{ + List: model.GetTypeTreeList(), + } + return res, nil +} + +// GetModelSupplier 获取运营商列表 +func (s *modelManageService) GetModelSupplier(ctx context.Context, req *dto.ModelSupplierReq) (res *dto.ModelSupplierRes, err error) { + return &dto.ModelSupplierRes{ + List: model.GetSupplierOptionList(), + }, nil +} diff --git a/service/model_session_service.go b/service/model_session_service.go new file mode 100644 index 0000000..aeb1e96 --- /dev/null +++ b/service/model_session_service.go @@ -0,0 +1,405 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "model-gateway/consts/model" + "model-gateway/dao" + "model-gateway/model/domain" + "model-gateway/model/dto" + modelUtils "model-gateway/service/utils" + "net/http" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelSession = &modelSessionService{} + +type modelSessionService struct{} + +// modelCallMaxRetries 上游调用最大重试次数 +const modelCallMaxRetries = 3 + +// 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 累加 + docMsg.TotalTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, totalTokenPath)) + docMsg.PromptTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, promptTokenPath)) + docMsg.CompletionTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, completionTokenPath)) + return nil + }) + + // 流结束后组装 + docMsg.Content = map[string]any{"respBody": contentBuf.String()} + + // 补充更新会话记录 + updateModelSessionReq := dto.UpdateModelSessionReq{ + Id: id, + DurationSeconds: int64(time.Since(startTime).Seconds()), + TotalTokens: docMsg.TotalTokens, + PromptTokens: docMsg.PromptTokens, + CompletionTokens: docMsg.CompletionTokens, + } + if !g.IsEmpty(docMsg.Content) { + uploadNewResp, uploadErr := Upload(ctx, &dto.UploadFileBytesReq{ + FileBytes: gconv.Bytes(gconv.String(docMsg.Content)), + FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()), + }) + if uploadErr != nil { + return nil, fmt.Errorf("上传模型返回参数文件失败:%v", uploadErr) + } + updateModelSessionReq.ResponsePath = uploadNewResp.FileURL + } + updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds()) + // 按模型计费规则换算本次调用费用(未配置返回 0) + docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0) + updateModelSessionReq.TotalCost = docMsg.Cost + if _, updateErr := dao.ModelSession.Update(ctx, &updateModelSessionReq); updateErr != nil { + g.Log().Errorf(ctx, "更新流式会话信息失败: %v", updateErr) + } + + return docMsg, nil +} + +// CreateSessionStream 流式调用上游模型 → SSE 逐分片推送给前端;流结束返回本次调用的 token/费用(docMsg.Cost)供调用方扣减。 +func (s *modelSessionService) CreateSessionStream(ctx context.Context, w http.ResponseWriter, req *dto.CallModelSessionReq) (*dto.ModelCallRes, error) { + startTime := time.Now() + + id := req.Id + modelInfo := req.ModelInfo + newRequestParams := req.RequestParams + + // 获取上游流式 reader 并设置 SSE 响应头 + streamReader, err := ModelHttpStreamRequest(ctx, w, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) + if err != nil { + return nil, err + } + + flusher := w.(http.Flusher) + + docMsg := new(dto.ModelCallRes) + var contentBuf strings.Builder + + // tool_calls 按 index 累加(OpenAI 兼容 delta),流末随 done 事件一次性返回 + toolAcc := make(map[int]*streamToolCallAcc) + + // 路径预处理 + respMapping := make(map[string]string, len(modelInfo.ResponseBodyMapping)) + for k, _ := range modelInfo.ResponseBodyMapping { + respMapping[modelUtils.CleanFieldPath(k)] = modelUtils.CleanFieldPath(k) + } + totalTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens) + promptTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens) + completionTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens) + + // 解析 ResponseBusinessFieldMapping 字段 + businessFieldRes := new(domain.ChatFieldsRes) + err = gconv.Struct(modelInfo.ResponseBusinessFieldMapping, businessFieldRes) + if err != nil { + g.Log().Errorf(ctx, "解析 ResponseBusinessFieldMapping 失败: %v", err) + } + // tool_calls 读取路径:优先走 ResponseBusinessFieldMapping 的 tools 配置,缺省兜底 OpenAI 兼容路径 + //toolsPath := "choices[0].delta.tool_calls" + toolsPath := businessFieldRes.Tools + // reasoning_content 读取路径:走 ResponseBusinessFieldMapping 的 reasoning_content 配置,未配置则不返回思考内容 + reasoningPath := modelUtils.CleanFieldPath(businessFieldRes.ReasoningContent) + + ParseSSEStream(ctx, streamReader, func(ctx context.Context, chunk map[string]any) error { + // 基于统一字段路径(GetByPath)在分片对象上取值,取首个数组元素文本 + content := make(map[string]any, len(respMapping)) + for bizKey, jsonPath := range respMapping { + v := modelUtils.GetByPathValue(chunk, jsonPath) + if v == nil || g.IsEmpty(v) { + continue + } + var realText string + if arr, ok := v.([]any); ok && len(arr) > 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 常在无文本/思考的末分片出现,需据此放行推送) + prevTotal, prevPrompt, prevCompletion := docMsg.TotalTokens, docMsg.PromptTokens, docMsg.CompletionTokens + docMsg.TotalTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, totalTokenPath)) + docMsg.PromptTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, promptTokenPath)) + docMsg.CompletionTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, completionTokenPath)) + tokenDelta := docMsg.TotalTokens != prevTotal || docMsg.PromptTokens != prevPrompt || docMsg.CompletionTokens != prevCompletion + + accumulateStreamToolCallsByPath(chunk, toolsPath, toolAcc) + + // 思考内容提取(独立业务字段,不进回答全文) + 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) + } + } + } + + // 纯 token 分片(无文本/思考)也放行:否则末分片 usage 被过滤,调用方拿不到 token 值 + if len(content) == 0 && reasoningContent == "" && !tokenDelta { + return nil + } + + // 逐 chunk SSE 推送给前端(字段名由 ModelCallStreamEvent 统一管理) + event := &dto.ModelCallStreamEvent{ + Content: content, + ReasoningContent: reasoningContent, + TotalTokens: docMsg.TotalTokens, + PromptTokens: docMsg.PromptTokens, + CompletionTokens: docMsg.CompletionTokens, + } + outBytes, err := json.Marshal(event) + if err != nil { + g.Log().Errorf(ctx, "[SSE] marshal response failed: %v", err) + return nil + } + _, err = fmt.Fprintf(w, "data: %s\n\n", outBytes) + if err != nil { + g.Log().Errorf(ctx, "[SSE] write client failed: %v", err) + return err + } + flusher.Flush() + return nil + }) + + // 流结束:按模型计费规则换算本次调用费用(未配置返回 0) + docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0) + + // 流末 done 事件:携带该步最终 token 与费用。工具调用时附带完整 tool_calls, + // 纯文本流同样补发,使调用方拿到最终费用与 token;不识别 type=done 的消费方忽略该事件。 + event := &dto.ModelCallStreamEvent{ + Type: "done", + TotalTokens: docMsg.TotalTokens, + PromptTokens: docMsg.PromptTokens, + CompletionTokens: docMsg.CompletionTokens, + Cost: docMsg.Cost, + } + if tools := finalizeStreamToolCalls(toolAcc); len(tools) > 0 { + var toolModels []dto.ModelTool + if err := gconv.Structs(tools, &toolModels); err != nil { + g.Log().Errorf(ctx, "[SSE] convert tools failed: %v", err) + } else { + event.Tools = toolModels + } + } + outBytes, err := json.Marshal(event) + if err == nil { + _, _ = fmt.Fprintf(w, "data: %s\n\n", outBytes) + flusher.Flush() + } + + // 流结束后补充更新会话记录 + updateModelSessionReq := dto.UpdateModelSessionReq{ + Id: id, + DurationSeconds: int64(time.Since(startTime).Seconds()), + TotalTokens: docMsg.TotalTokens, + PromptTokens: docMsg.PromptTokens, + CompletionTokens: docMsg.CompletionTokens, + TotalCost: docMsg.Cost, + } + if !g.IsEmpty(contentBuf.String()) { + uploadNewResp, uploadErr := Upload(ctx, &dto.UploadFileBytesReq{ + FileBytes: gconv.Bytes(gconv.String(map[string]any{"respBody": contentBuf.String()})), + FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()), + }) + if uploadErr != nil { + return nil, fmt.Errorf("上传模型返回参数文件失败:%v", uploadErr) + } + updateModelSessionReq.ResponsePath = uploadNewResp.FileURL + } + updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds()) + if _, updateErr := dao.ModelSession.Update(ctx, &updateModelSessionReq); updateErr != nil { + return nil, fmt.Errorf("更新会话信息失败: %v", updateErr) + } + + return docMsg, nil +} + +// 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", "limit_requests", "limit_tokens", "rate_limit_exceeded": + return true + } + return false +} diff --git a/service/model_task_end_service.go b/service/model_task_end_service.go new file mode 100644 index 0000000..6d6b7ae --- /dev/null +++ b/service/model_task_end_service.go @@ -0,0 +1,354 @@ +package service + +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" + 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" + gmq "github.com/bjang03/gmq/core/gmq" + "github.com/bjang03/gmq/mq" + "github.com/bjang03/gmq/types" + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelTaskEndService = &modelTaskEndService{} + +type modelTaskEndService struct{} + +// GetTaskStartList 获取待执行任务 +func (s *modelTaskEndService) GetTaskStartList(ctx context.Context) (err error) { + workerNum := g.Cfg().MustGet(ctx, "pool.workerNum", modelUtils.DefaultWorkerNum).Int64() + + redisKey := "model_video_task:" + var ( + pageNum = gconv.Int64(1) + remain = workerNum + ) + // 字段列表 + cols := []string{ + entity.ModelTaskStartCol.Id, + entity.ModelTaskStartCol.TaskId, + entity.ModelTaskStartCol.ModelId, + entity.ModelTaskStartCol.BizName, + entity.ModelTaskStartCol.Creator, + entity.ModelTaskStartCol.TenantId, + entity.ModelTaskStartCol.MsgTopic, + entity.ModelTaskStartCol.MediaType, + } + + for remain > 0 { + req := &dto.GetModelTaskStartListReq{ + Page: &beans.Page{ + PageNum: pageNum, + PageSize: remain, // 每页只查当前需要的数量 + }, + } + + var list []entity.ModelTaskStart + list, err = dao.ModelTaskStart.ListByLimitNotTenantId(ctx, req, cols...) + if err != nil { + return fmt.Errorf("查询任务失败: %w", err) + } + if len(list) == 0 { + break + } + + // 3. 组装锁key,批量查询Redis(性能最优) + taskMap := make(map[string]*entity.ModelTaskStart, len(list)) + lockKeys := make([]string, 0, len(list)) + for _, item := range list { + key := redisKey + gconv.String(item.Id) + taskMap[key] = &item + lockKeys = append(lockKeys, key) + } + + var mGetRes map[string]*gvar.Var + mGetRes, err = g.Redis().MGet(ctx, lockKeys...) + if err != nil { + return fmt.Errorf("批量查询锁状态失败: %w", err) + } + + // 4. 逐个原子抢锁,筛选可执行任务 + for _, key := range lockKeys { + val := gconv.String(mGetRes[key]) + // 已被其他实例抢占,跳过 + if val != "" { + continue + } + tid := key[len(redisKey):] + // SET NX EX 原子抢锁,防止并发竞争 + err = g.Redis().SetEX(ctx, key, tid, 1200) + 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) + } + remain-- // 占用一个槽位 + if remain <= 0 { + break // 槽位已满,终止遍历 + } + } + pageNum++ // 页码动态累加,不再写死2 + } + + return nil +} + +var urlParamReg = regexp.MustCompile(`\{.+?\}`) + +// handleSingleTask 处理单个视频任务(解耦原循环逻辑) +func (s *modelTaskEndService) handleSingleTask(ctx context.Context, item *entity.ModelTaskStart) error { + // 提交异步执行 + return modelUtils.Submit(ctx, func(ctx context.Context) { + startTime := time.Now() + asyncCtx := context.WithoutCancel(ctx) + // OSS 桶名依赖 ctx 中的用户(GetBucketName → tenantid-{tenantId}), + // 响应临时路径转存 OSS 需要用户信息,故在任务体最前面注入 + asyncCtx = context.WithValue(asyncCtx, "user", &beans.User{ + UserName: item.Creator, + TenantId: item.TenantId, + }) + // 按 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) + // 发起HTTP请求 + modelRespBody, err := ModelHttpNormalRequest( + asyncCtx, + url, + modelInfo.AsyncTaskMapping.RequestHeadMapping, + modelInfo.AsyncTaskMapping.HttpMethod, nil, + ) + if err != nil { + g.Log().Errorf(asyncCtx, "模型请求失败: %v", err) + return + } + if modelRespBody == nil { + g.Log().Errorf(asyncCtx, "模型返回参数为空") + return + } + + // 解析错误响应 + 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 + } + + 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 + } + } + // 按本次实际费用扣减租户余额(未产生费用不扣;异步任务无请求头,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 = dao.ModelTaskStart.Delete(asyncCtx, &dto.DeleteModelTaskStartReq{ + Id: item.Id, + }) + if err != nil { + return err + } + + // 保存视频任务结果 + _, err = dao.ModelTaskEnd.Insert(asyncCtx, &dto.CreateModelTaskEndReq{ + ModelId: item.ModelId, + BizName: item.BizName, + MsgTopic: item.MsgTopic, + TaskId: item.TaskId, + ResponseParams: docMsg.Content, + OriginalResponseParams: respObj, + DurationSeconds: int64(time.Since(startTime).Seconds()), + PromptTokens: docMsg.PromptTokens, + CompletionTokens: docMsg.CompletionTokens, + TotalTokens: docMsg.TotalTokens, + TotalCost: docMsg.Cost, + ErrorMsg: docMsg.ErrorMsg, + }) + if err != nil { + return err + } + return + }) + if err != nil { + g.Log().Errorf(asyncCtx, "保存视频任务结果失败: %v", err) + } + // 删除redis视频任务 + _, err = g.Redis().Del(asyncCtx, "model_video_task:"+gconv.String(item.Id)) + if err != nil { + return + } + // 发布消息 + if err = TaskMsgPublish(asyncCtx, item.MsgTopic, docMsg); err != nil { + g.Log().Errorf(asyncCtx, "模型消息发布失败: %v", err) + } + }) +} + +// 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 + } + return ossRes.FileAddressPrefix + 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{ + Topic: topic, + Data: data, + }, + Durable: true, + }) + if err != nil { + g.Log().Errorf(ctx, "[TaskMsgPublish] 发布消息失败 [Error]: %v", err) + return fmt.Errorf("发布消息失败") + } + return +} diff --git a/service/model_task_start_service.go b/service/model_task_start_service.go new file mode 100644 index 0000000..579a786 --- /dev/null +++ b/service/model_task_start_service.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "model-gateway/dao" + "model-gateway/model/dto" + modelUtils "model-gateway/service/utils" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelTaskStart = &modelTaskStartService{} + +type modelTaskStartService struct{} + +// CreateTask 创建任务 +func (s *modelTaskStartService) CreateTask(ctx context.Context, req *dto.CallModelTaskStartReq) (res *dto.ModelCallRes, err error) { + startTime := time.Now() + + id := req.Id + modelInfo := req.ModelInfo + newRequestParams := req.RequestParams + + // 6) 模型请求 + modelRespBody, err := ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) + if err != nil { + return nil, fmt.Errorf("模型请求失败: %v", err) + } + if modelRespBody == nil { + return nil, fmt.Errorf("模型返回参数是空") + } + // 7) 更新视频任务信息(统一字段路径 GetByPath 基于该对象读取) + var respObj map[string]any + if err = json.Unmarshal(modelRespBody, &respObj); err != nil { + return nil, fmt.Errorf("模型返回参数解析失败:%v", err) + } + updateModelReq := dto.UpdateModelTaskStartReq{ + Id: id, + OriginalResponseParams: respObj, + } + docMsg := new(dto.ModelCallRes) + docMsg.TaskId = id + errMsg := new(dto.ModelErrorResp) + err = gconv.Struct(modelRespBody, errMsg) + if err != nil { + return nil, fmt.Errorf("模型返回参数解析失败:%v", err) + } + if errMsg.Error.Code != "" { + docMsg.ErrorMsg = errMsg.Error.Message + updateModelReq.ErrorMsg = docMsg.ErrorMsg + } else { + taskIDPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskId) + docMsg.Content = map[string]any{ + "respBody": modelUtils.GetByPathValue(respObj, taskIDPath), + } + } + if !g.IsEmpty(docMsg.Content) { + updateModelReq.ResponseParams = docMsg.Content + updateModelReq.TaskId = gconv.String(docMsg.Content["respBody"]) + } + updateModelReq.DurationSeconds = int64(time.Since(startTime).Seconds()) + // 8) 更新模型视频任务信息 + _, err = dao.ModelTaskStart.Update(ctx, &updateModelReq) + if err != nil { + return nil, fmt.Errorf("更新模型视频任务信息失败: %v", err) + } + + return docMsg, nil +} diff --git a/service/price.go b/service/price.go new file mode 100644 index 0000000..1b5f4a8 --- /dev/null +++ b/service/price.go @@ -0,0 +1,177 @@ +package service + +import ( + "math" + "time" + + "model-gateway/model/entity" + modelUtils "model-gateway/service/utils" +) + +// DetectMediaType 按模型业务字段映射从请求体推导输入媒体类型(替代硬编码的 media.type 路径): +// - reference_audio 映射路径在请求体中有值 → "audio" +// - reference_video 映射路径在请求体中有值 → "has_video" +// - 否则 → "no_video" +// +// 判定完全由模型配置(RequestBusinessFieldMapping,业务字段名见 ChatFieldsReq/VideoFields)驱动, +// 无请求结构硬编码;映射路径值即 GetByPathAll 路径(如 input.media?type=audio&url=#)。 +func DetectMediaType(reqBizMapping map[string]string, reqParams map[string]any) string { + if hasMediaValue(reqBizMapping, reqParams, "reference_audio") { + return "audio" + } + if hasMediaValue(reqBizMapping, reqParams, "reference_video") { + return "has_video" + } + return "no_video" +} + +// hasMediaValue 业务字段映射路径在请求体中是否命中值 +func hasMediaValue(reqBizMapping map[string]string, reqParams map[string]any, bizField string) bool { + path := reqBizMapping[bizField] + if path == "" { + return false + } + return len(modelUtils.GetByPathAll(reqParams, path)) > 0 +} + +// unitBase 单价基准换算基数:per_1K=1000、per_1M=1000000、其余(per_1/空)按 1 +func unitBase(unit string) float64 { + switch unit { + case "per_1K": + return 1000 + case "per_1M": + return 1000000 + default: + return 1 + } +} + +// sumPrices 各价格项按 unit 基准换算求和(cost = tokens/unit * 单价) +func sumPrices(promptTokens, completionTokens, cachedTokens int64, base, inputPrice, outputPrice, cacheHitPrice float64) float64 { + return float64(promptTokens)/base*inputPrice + + float64(completionTokens)/base*outputPrice + + float64(cachedTokens)/base*cacheHitPrice +} + +// priceFor 按输入是否音频选择价格:音频变体存在时优先,否则回退默认价 +func priceFor(basePrice, audioPrice float64, isAudio bool) float64 { + if isAudio && audioPrice > 0 { + return audioPrice + } + return basePrice +} + +// CalcModelCallCost 按模型计费规则换算本次调用费用;未配置计费规则返回 0。 +// 供三处调用路径(同步/流式缓冲/流式逐推)在 token 累加后使用。 +func CalcModelCallCost(c *entity.PriceConfig, reqBizMapping map[string]string, reqParams map[string]any, promptTokens, completionTokens, cachedTokens int64) float64 { + return CalcCost(promptTokens, completionTokens, cachedTokens, reqBizMapping, reqParams, c) +} + +// CalcCost 计算本次调用费用(纯函数)。返回 0 表示未配置计费规则或无任何价格项。 +// +// 流程:媒体类型由请求体参考媒体字段推导(DetectMediaType)→ matchRule 按 match 条件首条命中定价规则 → +// 各价格项按 unit 换算到基准求和 → 折扣(规则级覆盖模型级,命中有效期才生效)→ 收敛 6 位小数。 +// match 条件:token 档位直接取本次调用用量(promptTokens/completionTokens/totalTokens/cachedTokens)+ +// 媒体类型(mediaType=audio/no_video/has_video)。 +func CalcCost(promptTokens, completionTokens, cachedTokens int64, reqBizMapping map[string]string, reqParams map[string]any, c *entity.PriceConfig) float64 { + return calcCostWithMediaType(promptTokens, completionTokens, cachedTokens, DetectMediaType(reqBizMapping, reqParams), c) +} + +// calcCostWithMediaType 按已推导的媒体类型计算本次调用费用。 +// 供无法在落库时拿到请求体的调用方使用(如异步任务,媒体类型在任务创建时快照)。 +func calcCostWithMediaType(promptTokens, completionTokens, cachedTokens int64, mediaType string, c *entity.PriceConfig) float64 { + if c == nil { + return 0 + } + audio := mediaType == "audio" + + rule := matchRule(mediaType, promptTokens, completionTokens, cachedTokens, c.Rules) + base := unitBase(c.Unit) + var cost float64 + if rule != nil { + cost = sumPrices(promptTokens, completionTokens, cachedTokens, base, + priceFor(rule.Input, rule.InputAudio, audio), rule.Output, + priceFor(rule.CacheHit, rule.CacheHitAudio, audio)) + } else if hasFallbackPrices(c) { + // 兜底:Rules 为空时的便捷单规则字段(无音频变体,按默认价计) + cost = sumPrices(promptTokens, completionTokens, cachedTokens, base, c.InputPrice, c.OutputPrice, c.CacheHitPrice) + } else { + return 0 + } + + if d := effectiveDiscount(rule, c); d != nil { + cost *= d.Rate + } + return math.Round(cost*1e6) / 1e6 +} + +// hasFallbackPrices 便捷兜底字段是否配置了任一价格项 +func hasFallbackPrices(c *entity.PriceConfig) bool { + return c.InputPrice > 0 || c.OutputPrice > 0 || c.CacheHitPrice > 0 || c.CacheStorageHourPrice > 0 +} + +// matchRule 按序取第一条所有 match 条件命中的规则;无命中返回 nil +func matchRule(mediaType string, promptTokens, completionTokens, cachedTokens int64, rules []entity.PriceRule) *entity.PriceRule { + totalTokens := promptTokens + completionTokens + for i := range rules { + if matchConditions(mediaType, promptTokens, completionTokens, totalTokens, cachedTokens, rules[i].Match) { + return &rules[i] + } + } + return nil +} + +// matchConditions 判定 rule.Match 的全部非零字段条件是否满足;Match 为空表示无条件命中。 +func matchConditions(mediaType string, promptTokens, completionTokens, totalTokens, cachedTokens int64, m *entity.PriceMatch) bool { + if m == nil { + return true + } + if !within(promptTokens, m.InputLengthMin, m.InputLengthMax) { + return false + } + if !within(completionTokens, m.OutputLengthMin, m.OutputLengthMax) { + return false + } + if !within(totalTokens, m.TotalLengthMin, m.TotalLengthMax) { + return false + } + if !within(cachedTokens, m.CachedTokensMin, m.CachedTokensMax) { + return false + } + if m.MediaType != "" && mediaType != m.MediaType { + return false + } + return true +} + +// within 数值是否落在 [min,max];min/max 为 0 表示该侧不设限 +func within(v int64, min, max int64) bool { + if min > 0 && v < min { + return false + } + if max > 0 && v > max { + return false + } + return true +} + +// effectiveDiscount 判定当前时间是否在折扣有效期内,返回应生效的折扣。 +// 规则级 Discount 覆盖模型级;effective 为空数组视为长期有效;不在有效期返回 nil。 +func effectiveDiscount(rule *entity.PriceRule, c *entity.PriceConfig) *entity.PriceDiscount { + var d *entity.PriceDiscount + if rule != nil && rule.Discount != nil { + d = rule.Discount + } else { + d = c.Discount + } + if d == nil { + return nil + } + if d.Effective[0] != "" && d.Effective[1] != "" { + now := time.Now().Format(time.DateOnly) + if now < d.Effective[0] || now > d.Effective[1] { + return nil + } + } + return d +} diff --git a/service/schema_mapping_service.go b/service/schema_mapping_service.go new file mode 100644 index 0000000..a12c21f --- /dev/null +++ b/service/schema_mapping_service.go @@ -0,0 +1,259 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "regexp" + "strings" + "time" + + "model-gateway/model/domain" + "model-gateway/model/dto" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var SchemaMapping = &schemaMappingService{} + +type schemaMappingService struct{} + +// buildFieldDescriptions 从结构体中反射读取字段定义,构建提示词中的目标字段说明 +func buildFieldDescriptions(t reflect.Type) string { + var b strings.Builder + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + jsonName := f.Tag.Get("json") + desc := f.Tag.Get("dc") + typeName := f.Type.String() + if jsonName == "" || jsonName == "-" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString("- **" + jsonName + "** (" + typeName + "): " + desc) + } + return b.String() +} + +// getDomainTypeByModelType 根据模型类型返回对应的业务字段结构体反射类型 +// 如果找不到匹配,返回 nil +func getDomainTypeByModelType(modelType int) reflect.Type { + switch modelType { + case 100, 101, 102, 103, 500, 501, 502, 503: + return reflect.TypeOf((*domain.ChatFieldsReq)(nil)).Elem() + case 600, 601, 602, 603, 604: + return reflect.TypeOf((*domain.VideoFields)(nil)).Elem() + default: + return nil + } +} + +// BuildSchemaMapping 根据模型类型和 schema JSON,自动构建 schema_mapping(补充已有 mapping 的缺失字段) +func (s *schemaMappingService) BuildSchemaMapping(ctx context.Context, req *dto.BuildSchemaMappingReq) (res *dto.BuildSchemaMappingRes, err error) { + if g.IsEmpty(req.Schema) { + return nil, fmt.Errorf("schema 不能为空") + } + + // 1. 根据模型类型获取对应的业务字段结构体 + domainType := getDomainTypeByModelType(req.ModelType) + if domainType == nil { + return nil, fmt.Errorf("不支持的模型类型: %d", req.ModelType) + } + + // 3. 构建 LLM 提示词 输出的 JSON 对象键是 json 字段名。每个字段的值是定位到该位置的完整点号路径。 + fieldDescs := buildFieldDescriptions(domainType) + systemPrompt := fmt.Sprintf(`你是一个 JSON Schema 分析助手。我提供了一个 AI API 的完整 Schema JSON 和待填充的目标结构体。 +请你仔细阅读 Schema 中所有字段的名称、类型、description 描述、枚举值、约束范围等完整信息, +结合对 API 功能的理解,将目标结构体的每个字段映射到 Schema 中恰当的位置。 + +## 输出格式 + +输出的 JSON 对象键是 json 字段名。每个字段的值有两类: + +第一类(Schema 路径):若该概念在 Schema 中有直接定义位置(约束值或字段定义),输出定位到该位置的完整点号路径。若定位的是对象数组的特定元素及其属性,在路径后追加 ?实际筛选字段名=筛选值&实际值字段名=# 格式,其中 =# 标记的目标值字段名替换为 schema 中的实际字段名。 + +第二类(推导字符串):若该概念在 Schema 中没有直接对应的定义位置,输出根据 Schema 信息推导出的内容字符串。 + +## 重要规则 + +1. 输出的每个字段都必须出现在 JSON 中,一个都不能少 +2. 若无法从 Schema 推理出某个字段的值,就输出空字符串 "" + +## 目标字段说明 + +%s`, fieldDescs) + + userPrompt := fmt.Sprintf("请分析以下 Schema JSON,生成对应的 schema_mapping:\n\n%s", req.Schema) + + // 4. 调用 LLM + llmResp, err := callLLM(ctx, systemPrompt, userPrompt) + if err != nil { + return nil, err + } + + // 5. 归一化所有路径为固定点号语法(无论模型输出哪种写法) + rawMap := gconv.Map(llmResp) + for k, v := range rawMap { + if s, ok := v.(string); ok { + rawMap[k] = normalizeSchemaPath(s) + } + } + + return &dto.BuildSchemaMappingRes{ + SchemaMapping: rawMap, + }, nil +} + +// regNumIndexStar 匹配数字下标 [0]、[1] 等 +var regNumIndexStar = regexp.MustCompile(`\[\d+]`) + +// normalizeSchemaPath 将 LLM 生成的 Schema 路径统一为固定点号语法: +// - 移除模板包装字段 attrs / properties / items / defaultValue / required +// - enumValues、items 及 attrs[数字] 标记上一字段为数组,补 [*] +// - [数字] 下标统一转为 [*] +// +// 示例: +// +// messages.attrs.enumValues.attrs.content.enumValues?type=image_url&image_url.url=# +// → messages[*].content[*]?type=image_url&image_url.url=# +// choices.attrs[0].attrs.message.attrs.content → choices[*].message.content +func normalizeSchemaPath(p string) string { + path, suffix := p, "" + if i := strings.Index(p, "?"); i >= 0 { + path, suffix = p[:i], p[i:] + } + segs := strings.Split(path, ".") + var out []string + for _, seg := range segs { + seg = strings.TrimSpace(seg) + switch { + case seg == "": + continue + case seg == "attrs" || seg == "properties" || seg == "defaultValue" || seg == "required": + continue + case seg == "enumValues" || seg == "items" || (strings.HasPrefix(seg, "attrs[") && regNumIndexStar.MatchString(seg)): + markPrevAsArray(&out) + continue + } + seg = regNumIndexStar.ReplaceAllString(seg, "[*]") + out = append(out, seg) + } + return strings.Join(out, ".") + suffix +} + +// markPrevAsArray 将输出序列最后一个字段标记为数组(补 [*]) +func markPrevAsArray(out *[]string) { + if len(*out) == 0 { + return + } + last := (*out)[len(*out)-1] + if !strings.HasSuffix(last, "[]") && !strings.HasSuffix(last, "[*]") { + (*out)[len(*out)-1] = last + "[*]" + } +} + +// callLLM 调用大模型聊天接口(OpenAI 兼容格式) +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" + + body := map[string]any{ + "model": modelName, + "messages": []map[string]string{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": userPrompt}, + }, + "max_tokens": 2048, + "temperature": 0.1, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("marshal request body failed: %w", err) + } + + url := strings.TrimRight(baseURL, "/") + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + return "", fmt.Errorf("create request failed: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response failed (status=%d): %w", resp.StatusCode, err) + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("API error status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var apiResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` + } + + if err = json.Unmarshal(respBody, &apiResp); err != nil { + return "", fmt.Errorf("parse response failed: %s", string(respBody)) + } + + if apiResp.Error != nil { + return "", fmt.Errorf("API error: %s", apiResp.Error.Message) + } + + if len(apiResp.Choices) == 0 { + return "", fmt.Errorf("empty response") + } + + return apiResp.Choices[0].Message.Content, nil +} + +// extractJSONObject 从字符串中提取第一个完整的 JSON 对象({...}) +func extractJSONObject(s string) string { + start := strings.Index(s, "{") + if start < 0 { + return s + } + for start > 0 { + ch := s[start-1] + if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' { + start-- + } else { + break + } + } + end := strings.LastIndex(s, "}") + if end <= start { + return s + } + + snippet := s[start : end+1] + snippet = strings.TrimPrefix(snippet, "```json") + snippet = strings.TrimPrefix(snippet, "```") + snippet = strings.TrimSuffix(snippet, "```") + snippet = strings.TrimSpace(snippet) + return snippet +} diff --git a/service/stream_tool_calls.go b/service/stream_tool_calls.go new file mode 100644 index 0000000..4941ae5 --- /dev/null +++ b/service/stream_tool_calls.go @@ -0,0 +1,101 @@ +package service + +import ( + "sort" + "strings" + + modelUtils "model-gateway/service/utils" + + "github.com/gogf/gf/v2/util/gconv" +) + +// streamToolCallAcc 流式 tool_call 按 index 累加的碎片(OpenAI 兼容 delta 格式) +type streamToolCallAcc struct { + id string + typ string + fnName string + fnArgs strings.Builder +} + +// streamToolCallDelta OpenAI 兼容流式 tool_call 增量片段,字段名集中于此 +type streamToolCallDelta struct { + Index int `json:"index"` + Id string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +// toStreamToolCallDeltas 把 JSON 反序列化的 any 数组在边界转成强类型片段 +func toStreamToolCallDeltas(rawCalls []any) []streamToolCallDelta { + var deltas []streamToolCallDelta + if err := gconv.Structs(rawCalls, &deltas); err != nil { + return nil + } + return deltas +} + +// accumulateStreamToolCallsByPath 按配置路径从 chunk 读取 tool_calls 数组并累加。 +// 路径未命中或值非数组时无副作用(不创建任何槽位)。 +func accumulateStreamToolCallsByPath(chunk map[string]any, toolsPath string, acc map[int]*streamToolCallAcc) { + raw := modelUtils.GetByPathValue(chunk, modelUtils.CleanFieldPath(toolsPath)) + rawCalls, _ := raw.([]any) + accumulateToolCallFragments(toStreamToolCallDeltas(rawCalls), acc) +} + +// accumulateToolCallFragments 按 index 累加 tool_calls 增量片段: +// id/type/function.name 首片段补齐,function.arguments 为字符串片段需按 index 拼接。 +func accumulateToolCallFragments(rawCalls []streamToolCallDelta, acc map[int]*streamToolCallAcc) { + for _, d := range rawCalls { + slot, ok := acc[d.Index] + if !ok { + slot = &streamToolCallAcc{} + acc[d.Index] = slot + } + if d.Id != "" { + slot.id = d.Id + } + if d.Type != "" { + slot.typ = d.Type + } + if d.Function.Name != "" { + slot.fnName = d.Function.Name + } + slot.fnArgs.WriteString(d.Function.Arguments) + } +} + +// finalizeStreamToolCalls 把累加结果按 index 升序转为 []map[string]any,形状对齐 dto.ModelTool。 +// 无有效工具返回 nil。 +func finalizeStreamToolCalls(acc map[int]*streamToolCallAcc) []map[string]any { + if len(acc) == 0 { + return nil + } + idx := make([]int, 0, len(acc)) + for i := range acc { + idx = append(idx, i) + } + sort.Ints(idx) + tools := make([]map[string]any, 0, len(idx)) + for _, i := range idx { + s := acc[i] + fn := map[string]any{} + if s.fnName != "" { + fn["name"] = s.fnName + } + if s.fnArgs.Len() > 0 { + fn["arguments"] = s.fnArgs.String() + } + tool := map[string]any{"function": fn} + if s.id != "" { + tool["id"] = s.id + } + if s.typ != "" { + tool["type"] = s.typ + } + tools = append(tools, tool) + } + return tools +} diff --git a/service/task/task_service.go b/service/task/task_service.go index fe1eba7..1f383d6 100644 --- a/service/task/task_service.go +++ b/service/task/task_service.go @@ -227,7 +227,7 @@ func (s *taskService) ModelTaskCallback(ctx context.Context, req *dto.ModelTaskC // 3. 失败/过期 if req.Status == "failed" || req.Status == "expired" { - NotifyAsyncResult(req.TaskID, nil, fmt.Errorf(req.Status)) + NotifyAsyncResult(req.TaskID, nil, fmt.Errorf("%s", req.Status)) return &dto.ModelTaskCallbackRes{Success: true}, nil } diff --git a/service/utils/business_fields.go b/service/utils/business_fields.go new file mode 100644 index 0000000..2e13ea8 --- /dev/null +++ b/service/utils/business_fields.go @@ -0,0 +1,605 @@ +package utils + +import ( + "fmt" + "sort" + "strings" + + "github.com/gogf/gf/v2/util/gconv" +) + +// ============================================================ +// 业务字段读写:TakeBusinessFields 把 businessParams 按映射解析为写入路径, +// WriteBusinessFields 按路径写入最终请求体(路径语法见 SetByPath)。 +// ============================================================ + +// TakeBusinessFields 把业务参数(businessParams)按映射解析为写入路径: +// - 调用方按业务字段名(RequestBusinessFieldMapping 的 key)传值,这里是独立的 businessParams map, +// 不再与模板字段混在 requestParams 中 +// - 业务字段名未配置映射 → 返回错误(不静默忽略) +// - 解包 {type,value} 包裹格式为原始值 +// - 跳过空值(空串/空数组),避免写入请求体污染 +// +// 返回 map[映射路径]原始值,构建完成后由 WriteBusinessFields 按路径写入请求体。 +func TakeBusinessFields(businessParams map[string]any, bizMapping map[string]string) (map[string]any, error) { + if len(businessParams) == 0 { + return nil, nil + } + keyToPath := make(map[string]string, len(bizMapping)) + for key, path := range bizMapping { + if key != "" && path != "" { + keyToPath[key] = path + } + } + bizValues := make(map[string]any) + for key, raw := range businessParams { + path, isBiz := keyToPath[key] + if !isBiz { + return nil, fmt.Errorf("业务字段 [%s] 未配置映射(RequestBusinessFieldMapping 中不存在该业务字段名)", key) + } + v := unwrapBizValue(raw) + if isEmptyBizValue(v) { + continue + } + bizValues[path] = v + } + return bizValues, nil +} + +// unwrapBizValue 解包模板包裹格式 {type, value},返回原始值;非包裹格式原样返回 +func unwrapBizValue(v any) any { + if m, ok := v.(map[string]any); ok { + if _, hasType := m["type"]; hasType { + if val, hasVal := m["value"]; hasVal { + return val + } + } + } + return v +} + +// isEmptyBizValue 判断业务字段值是否为空(空值不写入请求体) +func isEmptyBizValue(v any) bool { + switch t := v.(type) { + case nil: + return true + case string: + return t == "" + case []any: + return len(t) == 0 + case map[string]any: + return len(t) == 0 + } + return false +} + +// ============================================================ +// 统一字段路径语法(读/写共用,见 NormalizeFieldPath): +// +// a.b.c 普通点号路径 +// a[*].b [*] 表示数组段 +// a[*].b[*]?k=v&t=# 单层选择器:在数组中按 k==v 匹配元素,值/读取目标为 t +// a[*]?k=v&b[*]?k2=v2&t=# 多级选择器:选择器体内可再嵌 [*]?选择器,级数不限。 +// 每级 k=v 既是匹配条件(命中已存在元素时), +// 也是新建元素时写入该元素的字段(如 role=user 直接落为 role 字段); +// 只有带 t=# 的那级是叶子目标(写值/读值的位置)。 +// +// SetByPath(写,构建请求体)与 GetByPath(读,解析响应)共用 parsePath; +// 读方向语义:数组段非末段取第 0 个元素继续下钻,[*] 为末段返回整个数组,选择器定位匹配元素; +// 写方向语义:数组段非末段作用于最后一个元素,末段追加,选择器 upsert(命中更新/未命中新建), +// 多值([]any)仅在叶子选择器展开为多个独立元素(多个参考图/视频等)。 +// ============================================================ + +// SetByPath 按业务字段映射路径把值写入请求结构(请求侧构建)。 +// 路径语法与 BuildSchemaMapping 输出一致(干净形态,无需 attrs 剔除)。 +// +// 写入语义: +// - 目标字段已存在且是数组 → 追加 +// - 目标字段已存在且非数组 → 覆盖(普通叶子路径) +// - 目标字段不存在 → 新建 +// - 数组段/选择器段目标字段不是数组(如字符串 content)→ 返回错误,不覆盖已有值 +// - 中间路径遇到非对象字段 → 返回错误 +// - 数组段无选择器且非末尾 → 作用于最后一个元素(追加语义),数组为空则补一个空元素 +// - 数组段带选择器 → 命中则更新目标字段,未命中则按选择器字段构造新元素追加; +// 选择器段即使未标 [*] 也按数组处理(如 input.media?type=first_frame&url=#) +// - 多级选择器 → 递归:中间级选择器定位/新建容器元素并继续下钻,叶子选择器写值 +// - 值本身是数组 → 叶子选择器逐值追加;普通数组段/点号路径按 appendValues 追加 +func SetByPath(root map[string]any, path string, value any) error { + steps := parsePath(NormalizeFieldPath(path)) + if len(steps) == 0 { + return nil + } + return setBySteps(root, steps, value) +} + +// setBySteps 按步骤序列写入;选择器步骤(可能带嵌套)递归处理,非选择器步骤逐层下钻 +func setBySteps(cur map[string]any, steps []step, value any) error { + first := steps[0] + last := len(steps) == 1 + if first.sel != nil { + // 选择器段:目标字段按数组处理(upsert),路径段未标 [*] 也按数组匹配 + arr, err := existingArray(cur, first.key) + if err != nil { + return err + } + newArr, err := upsertStep(arr, first, value) + if err != nil { + return err + } + cur[first.key] = newArr + return nil + } + if !first.isArray { + if last { + setLeaf(cur, first.key, value) + return nil + } + next, err := ensureMap(cur, first.key) + if err != nil { + return err + } + return setBySteps(next, steps[1:], value) + } + // 数组段(无选择器) + arr, err := existingArray(cur, first.key) + if err != nil { + return err + } + if last { + cur[first.key] = appendValues(arr, value) + return nil + } + // 无选择器数组段:作用于最后一个元素(追加语义) + if len(arr) == 0 { + arr = append(arr, map[string]any{}) + cur[first.key] = arr + } + lastElem, ok := arr[len(arr)-1].(map[string]any) + if !ok { + return fmt.Errorf("字段 [%s] 数组元素不是对象,无法继续下钻(当前类型 %T)", first.key, arr[len(arr)-1]) + } + return setBySteps(lastElem, steps[1:], value) +} + +// WriteBusinessFields 把业务字段值写入最终请求体。 +// bizValues 的键为映射路径(如 input.media?type=reference_video&url=#),值由调用方按路径传入。 +// 按字典序升序写入:父路径是子路径的前缀(短者靠前),保证容器先写、子路径再 upsert, +// 避免子路径先建出的结构被父路径整体覆盖(如 messages 容器与 messages[*].content[*] 内嵌目标并存)。 +// 任一路径写入失败(如数组段目标不是数组)→ 返回错误,由调用方拒绝本次请求。 +func WriteBusinessFields(out map[string]any, bizValues map[string]any) error { + paths := make([]string, 0, len(bizValues)) + for path := range bizValues { + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + if err := SetByPath(out, path, bizValues[path]); err != nil { + return fmt.Errorf("业务字段写入失败 [%s]: %w", path, err) + } + } + return nil +} + +// GetByPath 按字段路径读取响应值(与 SetByPath 同一套路径语法,读方向语义): +// - 普通段:逐层进入对象取字段 +// - 数组段 [*]:非末段取数组第 0 个元素继续下钻;[*] 为末段返回整个数组 +// - 选择器段 ?k=v&t=#:定位 k==v 的元素,返回该元素 t 字段的值;多级选择器递归下钻 +// +// 未命中(路径缺失 / 中间类型不符)返回 (nil, nil),不视为错误;语法错误返回 error。 +func GetByPath(root map[string]any, path string) (any, error) { + steps := parsePath(NormalizeFieldPath(path)) + if len(steps) == 0 { + return nil, nil + } + return getBySteps(root, steps) +} + +// getBySteps 按步骤序列读取;选择器步骤(可能带嵌套)递归处理 +func getBySteps(cur any, steps []step) (any, error) { + if len(steps) == 0 { + return nil, nil + } + first := steps[0] + rest := steps[1:] + m, ok := cur.(map[string]any) + if !ok { + return nil, nil + } + // 选择器段:定位匹配元素,返回叶子目标或递归嵌套下钻 + if first.sel != nil { + arr, ok := m[first.key].([]any) + if !ok { + return nil, nil + } + for _, e := range arr { + em, ok := e.(map[string]any) + if !ok { + continue + } + if matchFilters(em, first.sel) { + return getSelValue(em, first.sel, rest) + } + } + return nil, nil + } + v, ok := m[first.key] + if !ok { + return nil, nil + } + if first.isArray { + arr, ok := v.([]any) + if !ok { + return nil, nil + } + if len(rest) == 0 { + return arr, nil + } + if len(arr) == 0 { + return nil, nil + } + return getBySteps(arr[0], rest) + } + if len(rest) == 0 { + return v, nil + } + return getBySteps(v, rest) +} + +// getSelValue 选择器命中元素后取值:有嵌套路径则递归下钻,否则取叶子目标字段 +// (target 可为点号路径,如 image_url.url=#,按 parseSteps 拆级下钻) +func getSelValue(em map[string]any, sel *selNode, rest []step) (any, error) { + if len(sel.nested) > 0 { + return getBySteps(em, append(sel.nested, rest...)) + } + if sel.target != "" { + return getBySteps(em, append(parseSteps(sel.target), rest...)) + } + return getBySteps(em, rest) +} + +// GetByPathValue 读取路径值,未命中或出错返回 nil(免去调用方处理双返回值) +func GetByPathValue(root map[string]any, path string) any { + v, err := GetByPath(root, path) + if err != nil { + return nil + } + return v +} + +// GetByPathAll 按字段路径读取响应值(与 GetByPath 同一套语法),返回路径下**所有**命中值。 +// 与 GetByPath 的区别:GetByPath 命中即返回第一个匹配;GetByPathAll 遍历数组段/选择器段的全部 +// 元素并展开收集。适用于通配路径(messages[*]...[*]...)取全部匹配值(如收集所有图片 url)。 +// 无命中返回 nil。 +func GetByPathAll(root map[string]any, path string) []any { + steps := parsePath(NormalizeFieldPath(path)) + if len(steps) == 0 { + return nil + } + return getAllBySteps(root, steps) +} + +// getAllBySteps 按步骤序列收集全部匹配值;数组段/选择器段遍历所有元素展开,普通段单值包裹返回 +func getAllBySteps(cur any, steps []step) []any { + if len(steps) == 0 { + return nil + } + first := steps[0] + rest := steps[1:] + m, ok := cur.(map[string]any) + if !ok { + return nil + } + // 选择器段:遍历命中元素收集 + if first.sel != nil { + arr, ok := m[first.key].([]any) + if !ok { + return nil + } + var out []any + for _, e := range arr { + em, ok := e.(map[string]any) + if !ok { + continue + } + if matchFilters(em, first.sel) { + out = append(out, getSelValueAll(em, first.sel, rest)...) + } + } + return out + } + v, ok := m[first.key] + if !ok { + return nil + } + if first.isArray { + arr, ok := v.([]any) + if !ok { + return nil + } + if len(rest) == 0 { + return arr + } + var out []any + for _, e := range arr { + out = append(out, getAllBySteps(e, rest)...) + } + return out + } + if len(rest) == 0 { + return []any{v} + } + return getAllBySteps(v, rest) +} + +// getSelValueAll 选择器命中元素后收集:有嵌套路径递归下钻,否则取叶子目标字段(全部) +func getSelValueAll(em map[string]any, sel *selNode, rest []step) []any { + if len(sel.nested) > 0 { + return getAllBySteps(em, append(sel.nested, rest...)) + } + if sel.target != "" { + return getAllBySteps(em, append(parseSteps(sel.target), rest...)) + } + return getAllBySteps(em, rest) +} + +// step 路径段;sel 非空表示该段带选择器(按数组处理) +type step struct { + key string + isArray bool + sel *selNode +} + +// selNode 选择器: +// - filters:k=v 匹配条件,新建元素时也作为字段写入 +// - target:叶子目标字段(k=#),值/读取目标;target 为空且 nested 非空时为中间级选择器 +// - nested:下钻子路径(多级嵌套选择器,级数不限) +type selNode struct { + filters [][2]string + target string + nested []step +} + +// parsePath 解析路径为步骤序列。选择器体挂到最后一个步骤上;选择器体中的嵌套 [*]?选择器 +// 递归解析为 nested(级数不限)。 +func parsePath(p string) []step { + base, suffix := p, "" + if i := strings.Index(p, "?"); i >= 0 { + base, suffix = p[:i], p[i+1:] + } + steps := parseSteps(base) + if suffix != "" { + parseSelector(suffix, &steps) + } + return steps +} + +// parseSteps 解析点号分隔的普通步骤(含 [*] 数组段) +func parseSteps(s string) []step { + var steps []step + for _, raw := range strings.Split(s, ".") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + st := step{} + if strings.HasSuffix(raw, "[*]") { + st.key = strings.TrimSuffix(raw, "[*]") + st.isArray = true + } else { + st.key = raw + } + steps = append(steps, st) + } + return steps +} + +// parseSelector 解析选择器体(? 之后的内容)并挂到最后一个步骤上。 +// 元素用顶层 & 分隔(? 之后的 & 属于嵌套选择器);k=v 为过滤/写入对,k=# 为叶子目标, +// 含 [*] 或路径的块为嵌套下钻子路径(递归 parsePath)。 +func parseSelector(selStr string, steps *[]step) { + if len(*steps) == 0 { + return + } + sel := &selNode{} + var nested []step + for _, el := range splitTopLevel(selStr) { + if isPair(el) { + k, v, _ := strings.Cut(el, "=") + if v == "#" { + sel.target = k + } else { + sel.filters = append(sel.filters, [2]string{k, v}) + } + continue + } + // 嵌套路径(含自己的选择器):级数不限,递归解析 + nested = append(nested, parsePath(el)...) + } + if len(sel.filters) == 0 && sel.target == "" && len(nested) == 0 { + return + } + last := &(*steps)[len(*steps)-1] + if last.sel == nil { + last.sel = sel + } + if len(nested) > 0 { + last.sel.nested = nested + } +} + +// splitTopLevel 按顶层 & 拆分选择器体;? 之后的 & 属于嵌套选择器,不在此层拆分 +func splitTopLevel(s string) []string { + var elems []string + var cur strings.Builder + inNested := false + for _, ch := range s { + if ch == '?' { + inNested = true + } + if ch == '&' && !inNested { + elems = append(elems, cur.String()) + cur.Reset() + continue + } + cur.WriteRune(ch) + } + if cur.Len() > 0 { + elems = append(elems, cur.String()) + } + return elems +} + +// isPair 判断元素是否为 k=v 对:= 出现在任何 [ ? 之前则是 pair,否则为嵌套路径。 +// 目标字段 k 本身可以是点号路径(image_url.url=#),故 . 不参与判别。 +func isPair(el string) bool { + for i := 0; i < len(el); i++ { + switch el[i] { + case '=': + return true + case '[', '?': + return false + } + } + return false +} + +// existingArray 返回数组字段的当前数组: +// - 字段不存在 → 空数组(允许按追加语义新建) +// - 字段是数组 → 原样 +// - 字段是其他类型(如字符串 content)→ 返回错误,调用方拒绝写入,不覆盖已有值 +func existingArray(cur map[string]any, key string) ([]any, error) { + v, ok := cur[key] + if !ok { + return []any{}, nil + } + if arr, ok := v.([]any); ok { + return arr, nil + } + return nil, fmt.Errorf("字段 [%s] 不是数组,无法按数组路径写入(当前类型 %T)", key, v) +} + +// upsertStep 选择器 upsert,返回追加后的数组: +// - 叶子选择器(有 target)且值为数组 → 每个值追加一个独立元素 +// - 命中(所有 filters 匹配)→ 把值写入现有元素(叶子写 target,中间级递归 nested) +// - 未命中 → 按选择器字段构造新元素并追加 +// +// 返回新切片(append 可能重新分配底层数组),调用方需用返回值覆盖写回。 +func upsertStep(arr []any, st step, value any) ([]any, error) { + sel := st.sel + // 叶子选择器:多值逐个展开为独立元素(多个参考图/视频等) + if sel.target != "" { + if vals, ok := value.([]any); ok && len(vals) > 0 { + for _, v := range vals { + elem, err := buildStepElement(sel, v) + if err != nil { + return nil, err + } + arr = append(arr, elem) + } + return arr, nil + } + } + for _, e := range arr { + m, ok := e.(map[string]any) + if !ok { + continue + } + if matchFilters(m, sel) { + if err := writeStepValue(m, sel, value); err != nil { + return nil, err + } + return arr, nil + } + } + elem, err := buildStepElement(sel, value) + if err != nil { + return nil, err + } + return append(arr, elem), nil +} + +// matchFilters 判断元素是否匹配选择器全部过滤条件;无过滤条件时命中第一个元素 +func matchFilters(m map[string]any, sel *selNode) bool { + if len(sel.filters) == 0 { + return true + } + for _, f := range sel.filters { + if gconv.String(m[f[0]]) != f[1] { + return false + } + } + return true +} + +// writeStepValue 把值写入已定位元素:中间级递归 nested 下钻,叶子写 target 字段 +func writeStepValue(m map[string]any, sel *selNode, value any) error { + if len(sel.nested) > 0 { + return setBySteps(m, sel.nested, value) + } + if sel.target != "" { + return setLeafPath(m, sel.target, value) + } + return nil +} + +// buildStepElement 按选择器构造新元素:{filterKey: filterVal, ...} + 叶子写 target / 中间级递归 nested +func buildStepElement(sel *selNode, value any) (map[string]any, error) { + elem := make(map[string]any, len(sel.filters)+1) + for _, f := range sel.filters { + elem[f[0]] = f[1] + } + if err := writeStepValue(elem, sel, value); err != nil { + return nil, err + } + return elem, nil +} + +// setLeafPath 在对象内按点号路径写入值(叶子用 setLeaf 语义) +func setLeafPath(m map[string]any, path string, value any) error { + cur := m + segs := strings.Split(path, ".") + for i, k := range segs { + if i == len(segs)-1 { + setLeaf(cur, k, value) + return nil + } + next, err := ensureMap(cur, k) + if err != nil { + return err + } + cur = next + } + return nil +} + +// setLeaf 叶子写入:目标已是数组 → 追加;非数组/不存在 → 覆盖/新建 +func setLeaf(parent map[string]any, key string, value any) { + if existing, ok := parent[key]; ok { + if arr, isArr := existing.([]any); isArr { + parent[key] = appendValues(arr, value) + return + } + } + parent[key] = value +} + +// appendValues 追加值到数组;value 为数组时逐个追加 +func appendValues(arr []any, value any) []any { + if vals, ok := value.([]any); ok { + return append(arr, vals...) + } + return append(arr, value) +} + +// ensureMap 确保键对应 map,不存在则新建;已存在但非对象 → 返回错误 +func ensureMap(parent map[string]any, key string) (map[string]any, error) { + if v, ok := parent[key]; ok { + if m, isMap := v.(map[string]any); isMap { + return m, nil + } + return nil, fmt.Errorf("字段 [%s] 不是对象,无法按路径写入(当前类型 %T)", key, v) + } + m := map[string]any{} + parent[key] = m + return m, nil +} diff --git a/service/utils/json_flatten.go b/service/utils/json_flatten.go new file mode 100644 index 0000000..29af6c5 --- /dev/null +++ b/service/utils/json_flatten.go @@ -0,0 +1,43 @@ +package utils + +import ( + "encoding/json" + "fmt" + + "github.com/tidwall/sjson" +) + +// IsFlatMap 递归判断 map 是否扁平化 +func IsFlatMap(m map[string]interface{}) bool { + for _, v := range m { + switch val := v.(type) { + case map[string]interface{}: + return false + case []interface{}: + for _, item := range val { + if _, ok := item.(map[string]interface{}); ok { + return false + } + } + } + } + return true +} + +// UnFlatBySjson 将扁平路径映射还原为嵌套 JSON +func UnFlatBySjson(flatMap map[string]interface{}) (map[string]interface{}, error) { + raw := "{}" + for path, val := range flatMap { + var err error + raw, err = sjson.Set(raw, path, val) + if err != nil { + return nil, fmt.Errorf("sjson set path %s failed: %w", path, err) + } + } + + var result map[string]interface{} + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return nil, fmt.Errorf("parse final json failed: %w", err) + } + return result, nil +} diff --git a/service/utils/pool.go b/service/utils/pool.go new file mode 100644 index 0000000..40906ed --- /dev/null +++ b/service/utils/pool.go @@ -0,0 +1,58 @@ +package utils + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/gogf/gf/v2/os/grpool" +) + +var ( + defaultPool atomic.Pointer[grpool.Pool] + once sync.Once + wg sync.WaitGroup +) + +const DefaultWorkerNum = 100 + +// Init 初始化全局协程池,首次调用生效,后续调用忽略。 +func Init(workerNum int) { + once.Do(func() { + if workerNum <= 0 { + workerNum = DefaultWorkerNum + } + defaultPool.Store(grpool.New(workerNum)) + }) +} + +// Submit 提交异步任务,上下文透传至 grpool。 +// Submit 也可在 Init 前调用(自动 Init),但 Shutdown 后返回 ErrPoolClosed。 +func Submit(ctx context.Context, task func(ctx context.Context)) error { + p := defaultPool.Load() + if p == nil { + Init(DefaultWorkerNum) + p = defaultPool.Load() + } + + wg.Add(1) + err := p.AddWithRecover(ctx, func(ctx context.Context) { + defer wg.Done() + task(ctx) + }, nil) + if err != nil { + wg.Done() + return err + } + return nil +} + +// Shutdown 优雅关闭:停止新任务,等待全部已完成/排队任务完成。 +func Shutdown() { + p := defaultPool.Swap(nil) + if p == nil { + return + } + wg.Wait() + p.Close() +} diff --git a/service/utils/template_parse.go b/service/utils/template_parse.go new file mode 100644 index 0000000..9391066 --- /dev/null +++ b/service/utils/template_parse.go @@ -0,0 +1,573 @@ +package utils + +import ( + "regexp" + "strings" + + "github.com/gogf/gf/v2/util/gconv" +) + +var ( + // 匹配 [数字] + regNumIndex = regexp.MustCompile(`\[\d+\]`) + // 匹配 .attrs + regAttrs = regexp.MustCompile(`\.attrs`) +) + +// NormalizeFieldPath 归一化字段路径到统一语法([*] 数组段): +// - 移除模板残留 .attrs +// - [数字] 下标 → [*](choices[0] → choices[*]) +// - 兼容 gjson 风格 .# / .数字 下标 → [*](choices.#、choices.0 → choices[*]) +// +// 统一语法见 business_fields.go 的 SetByPath / GetByPath: +// +// a.b.c 普通点号路径 +// a[*].b [*] 表示数组段 +// a[*].b[*]?k=v&t=# 选择器:数组元素按 k==v 定位,值/读取目标为 t +// a[*]?k=v&b[*]?k2=v2&t=# 多级选择器:级数不限,中间级定位容器元素,叶子写值 +// +// 正则归一(.attrs / [数字] / .#)作用于整个路径(含多级选择器中的数组段); +// 纯数字段(gjson 下标)归一只作用于首个 ? 之前的 base 路径。 +// +// 示例: +// +// usage.attrs.total_tokens → usage.total_tokens +// choices.attrs[0].attrs.message.attrs.content → choices[*].message.content +// choices.#.message.content → choices[*].message.content +// choices.0.message.content → choices[*].message.content +func NormalizeFieldPath(path string) string { + s := regAttrs.ReplaceAllString(path, "") + s = regNumIndex.ReplaceAllString(s, "[*]") + s = strings.ReplaceAll(s, ".#", "[*]") + base, suffix := s, "" + if i := strings.Index(s, "?"); i >= 0 { + base, suffix = s[:i], s[i:] + } + // 逐段把纯数字段(gjson 下标)归一为 [*]:附着到前一段字段(choices.0 → choices[*]), + // 避免误伤数字开头的字段名;选择器体用 # 作目标、不用数字段下标,故只归一 base + segs := strings.Split(base, ".") + var out []string + for _, seg := range segs { + if seg == "" { + continue + } + if isAllDigits(seg) { + if len(out) > 0 { + out[len(out)-1] += "[*]" + } else { + out = append(out, "[*]") + } + continue + } + out = append(out, seg) + } + return strings.Join(out, ".") + suffix +} + +// isAllDigits 判断字符串是否全部为数字字符 +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// CleanFieldPath 清理字段路径(等价于 NormalizeFieldPath,保留旧名兼容) +func CleanFieldPath(path string) string { + return NormalizeFieldPath(path) +} + +// CleanMapFieldPath 清理字段路径(Map) +func CleanMapFieldPath(m map[string]string) map[string]string { + if m == nil { + return nil + } + newMap := make(map[string]string, len(m)) + for k, _ := range m { + newMap[k] = CleanFieldPath(k) + } + return newMap +} + +// ParseConfigTemplate 解析配置模板生成简化请求结构 +// +// 输入: config 模板(含 type/value/defaultValue/attrs/enumValues 等元数据字段) +// 输出: 简化后的请求结构体 +// +// 规则: +// - 标量字段(string/number/boolean): value 非零则用 value,为空则跳过(不再取 defaultValue) +// - 对象字段(object): 递归处理 attrs +// - 数组字段(array): 遍历 enumValues,每个 enumValue 独立判断是否产出元素 +// - 数组展开: enumValue 内某叶子字段 value 为数组时,按数组元素展开为多个项 +func ParseConfigTemplate(cfg map[string]interface{}) map[string]interface{} { + var flattenJSON map[string]interface{} + flatMap := IsFlatMap(cfg) + if flatMap { + var err error + flattenJSON, err = UnFlatBySjson(cfg) + if err != nil { + return nil + } + } else { + flattenJSON = cfg + } + result := make(map[string]interface{}) + for key, val := range flattenJSON { + field, ok := val.(map[string]interface{}) + if !ok { + continue + } + if v := resolveField(field); v != nil { + result[key] = v + } + } + return result +} + +// resolveField 按 type 分发解析 +func resolveField(field map[string]interface{}) interface{} { + fieldType, _ := field["type"].(string) + switch fieldType { + case TypeString, TypeBool, TypeNumber: + return resolveScalar(field) + case TypeObject: + return resolveObject(field) + case TypeArray: + return resolveArray(field) + } + return nil +} + +// resolveScalar 解析标量字段: value 非空则用 value,否则回落 defaultValue +// (模板只声明结构、值由业务字段给出时,defaultValue 生效) +func resolveScalar(field map[string]interface{}) interface{} { + if v, has := field["value"]; has && v != nil { + switch vv := v.(type) { + case string: + if vv != "" { + return vv + } + case float64: + if vv != 0 { + return vv + } + case bool: + return vv + default: + return vv + } + } + if d, has := field["defaultValue"]; has && d != nil { + switch dv := d.(type) { + case string: + if dv != "" { + return dv + } + case float64: + if dv != 0 { + return dv + } + case bool: + return dv + default: + return dv + } + } + return nil +} + +// resolveObject 解析对象字段,递归处理 attrs +// +// 特殊处理「参数定义」结构:当 attrs 含 default 字段时,说明该对象是一个 +// 参数定义(含 default/description/min/max/type/enum/required 等元数据), +// 此时只提取 default 的值作为该参数的值,其余元数据字段忽略。 +func resolveObject(field map[string]interface{}) interface{} { + attrs, ok := field["attrs"].(map[string]interface{}) + if !ok { + return nil + } + + // 参数定义:只提取 default 值,跳过元数据 + if defaultField, hasDefault := attrs["default"]; hasDefault { + if df, ok := defaultField.(map[string]interface{}); ok { + return extractRawValueKeepZero(df) + } + return nil + } + + // 普通对象:递归处理所有 attrs + result := make(map[string]interface{}) + for key, val := range attrs { + subField, ok := val.(map[string]interface{}) + if !ok { + result[key] = val // 纯值字段原样保留 + continue + } + if subType, _ := subField["type"].(string); subType == "" { + result[key] = val // 无 type 键的纯对象原样保留 + continue + } + if v := resolveField(subField); v != nil { + result[key] = v + } + } + if len(result) == 0 { + return nil + } + return result +} + +// resolveArray 解析数组字段,遍历 enumValues 或 attrs 生成元素列表 +func resolveArray(field map[string]interface{}) []interface{} { + // 实际数据在 value(schema-editor 数据存放处),直接返回 + if v, has := field["value"]; has { + if arr, ok := v.([]interface{}); ok && len(arr) > 0 { + return arr + } + } + + enumValues, ok := field["enumValues"].([]interface{}) + if ok { + var result []interface{} + for _, ev := range enumValues { + evMap, ok := ev.(map[string]interface{}) + if !ok { + continue + } + items := resolveEnumObject(evMap) + result = append(result, items...) + } + if len(result) > 0 { + return result + } + } + + // enumValues 取不到或为空时,尝试从 attrs(数组)中取 + attrs, ok := field["attrs"].([]interface{}) + if !ok { + return nil + } + var result []interface{} + for _, item := range attrs { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if v := resolveField(itemMap); v != nil { + result = append(result, v) + } + } + return result +} + +// resolveEnumObject 解析 enumValue 对象,支持数组展开 +func resolveEnumObject(ev map[string]interface{}) []interface{} { + attrs, ok := ev["attrs"].(map[string]interface{}) + if !ok { + return nil + } + // 将 enumValue 级别的 value 注入 attrs.type.value(如果 type.value 为空) + if evVal, has := ev["value"]; has && evVal != nil { + if s, ok := evVal.(string); ok && s != "" { + if typeField, has := attrs["type"]; has { + if typeMap, ok := typeField.(map[string]interface{}); ok { + if existing, has := typeMap["value"]; !has || existing == nil || existing == "" { + typeMap["value"] = s + } + } + } + } + } + return resolveAttrs(attrs) +} + +// resolveAttrs 递归解析 attrs map,支持字段级数组展开 +func resolveAttrs(attrs map[string]interface{}) []interface{} { + currentItems := []map[string]interface{}{{}} + hasValue := false + + for key, val := range attrs { + subField, isMap := val.(map[string]interface{}) + var subType string + if isMap { + subType, _ = subField["type"].(string) + } + + var nextItems []map[string]interface{} + + // 非包裹字段(纯值/纯对象,无 type 键):原样保留,数组值仍参与展开 + if !isMap || subType == "" { + raw := val + if raw == nil { + nextItems = currentItems + currentItems = nextItems + continue + } + hasValue = true + if arr, ok := raw.([]interface{}); ok && len(arr) > 0 { + for _, item := range currentItems { + for _, elem := range arr { + cp := copyMap(item) + cp[key] = elem + nextItems = append(nextItems, cp) + } + } + } else { + for _, item := range currentItems { + cp := copyMap(item) + cp[key] = raw + nextItems = append(nextItems, cp) + } + } + currentItems = nextItems + continue + } + + switch subType { + case TypeString, TypeBool, TypeNumber: + raw := extractRawValue(subField) + if raw == nil { + nextItems = currentItems + continue + } + hasValue = true + if arr, ok := raw.([]interface{}); ok && len(arr) > 0 { + for _, item := range currentItems { + for _, elem := range arr { + cp := copyMap(item) + cp[key] = elem + nextItems = append(nextItems, cp) + } + } + } else { + for _, item := range currentItems { + cp := copyMap(item) + cp[key] = raw + nextItems = append(nextItems, cp) + } + } + + case TypeObject: + subAttrs, ok := subField["attrs"].(map[string]interface{}) + if !ok { + nextItems = currentItems + continue + } + subItems := resolveAttrs(subAttrs) + if len(subItems) == 0 { + nextItems = currentItems + continue + } + hasValue = true + for _, item := range currentItems { + for _, subI := range subItems { + cp := copyMap(item) + cp[key] = subI + nextItems = append(nextItems, cp) + } + } + + case TypeArray: + items := resolveArray(subField) + if len(items) == 0 { + nextItems = currentItems + continue + } + hasValue = true + for _, item := range currentItems { + cp := copyMap(item) + cp[key] = items + nextItems = append(nextItems, cp) + } + + default: + nextItems = currentItems + } + + currentItems = nextItems + } + + if !hasValue { + return nil + } + result := make([]interface{}, len(currentItems)) + for i, item := range currentItems { + result[i] = item + } + return result +} + +// extractRawValue 提取原始值(保留数组值供上层展开) +func extractRawValue(field map[string]interface{}) interface{} { + if v, has := field["value"]; has && v != nil { + switch vv := v.(type) { + case string: + if vv != "" { + return vv + } + case float64: + if vv != 0 { + return vv + } + case bool: + return vv + case []interface{}: + if len(vv) > 0 { + return vv + } + default: + return vv + } + } + return nil +} + +// extractRawValueKeepZero 同 extractRawValue,但不过滤零值 +// 在参数定义场景下,default 可能是 false/0/"",需要保留 +func extractRawValueKeepZero(field map[string]interface{}) interface{} { + if v, has := field["value"]; has && v != nil { + switch vv := v.(type) { + case string: + return vv + case float64: + return vv + case bool: + return vv + case []interface{}: + if len(vv) > 0 { + return vv + } + return vv + default: + return vv + } + } + if dv, has := field["defaultValue"]; has && dv != nil { + switch dvv := dv.(type) { + case string: + if field["type"] == TypeBool { + if dvv == "true" { + return true + } + if dvv == "false" { + return false + } + } + return dvv + case float64: + return dvv + case bool: + return dvv + case []interface{}: + if len(dvv) > 0 { + return dvv + } + return dvv + default: + return dvv + } + } + return nil +} + +// copyMap 浅拷贝 map +func copyMap(src map[string]interface{}) map[string]interface{} { + dst := make(map[string]interface{}, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +// CoerceBodyTypes 按模板声明的 type 递归归一请求体字段值类型: +// - string → gconv.String;number → gconv.Float64;boolean → gconv.Bool +// - object → 按模板 attrs 递归子字段;array → 按元素模板逐个递归 +// - 模板未声明的字段(业务字段写入且超出模板的部分)保持原样 +// +// 用于构建请求体后统一修正:模板字段 value 与业务字段写入的值都可能携带与声明 +// 类型不一致的 Go 类型(如 number 字段 value 为字符串 "0.7"),在此统一转成模型 +// API 期望的 JSON 类型。仅做类型归一,不增删字段。 +func CoerceBodyTypes(out map[string]interface{}, templateParams map[string]interface{}) map[string]interface{} { + if len(templateParams) == 0 { + return out + } + for key, raw := range out { + if tmplNode, has := templateParams[key]; has { + out[key] = coerceNode(raw, tmplNode) + } + } + return out +} + +// coerceNode 按单个模板节点归一值类型 +func coerceNode(value interface{}, tmplNode interface{}) interface{} { + tmplMap, ok := tmplNode.(map[string]interface{}) + if !ok { + return value + } + fieldType, _ := tmplMap["type"].(string) + switch fieldType { + case TypeString: + return gconv.String(value) + case TypeNumber, TypeNumberInt, TypeNumberFloat: + return gconv.Float64(value) + case TypeBool: + return gconv.Bool(value) + case TypeObject: + sub, ok := value.(map[string]interface{}) + if !ok { + return value + } + if attrs, ok := tmplMap["attrs"].(map[string]interface{}); ok { + return coerceObject(sub, attrs) + } + return value + case TypeArray: + arr, ok := value.([]interface{}) + if !ok { + return value + } + proto := arrayElementTemplate(tmplMap) + if proto == nil { + return value + } + out := make([]interface{}, len(arr)) + for i, elem := range arr { + out[i] = coerceNode(elem, proto) + } + return out + default: + return value + } +} + +// coerceObject 按对象模板 attrs 归一对象子字段类型 +func coerceObject(sub, attrs map[string]interface{}) map[string]interface{} { + for key, raw := range sub { + if tmplNode, has := attrs[key]; has { + sub[key] = coerceNode(raw, tmplNode) + } + } + return sub +} + +// arrayElementTemplate 从数组模板节点提取元素模板(attrs 优先,其次 enumValues)。 +// 与 arrayElementPrototype 语义一致,但直接工作在原始模板 map 上,供类型归一使用。 +func arrayElementTemplate(field map[string]interface{}) map[string]interface{} { + if attrs, ok := field["attrs"].([]interface{}); ok && len(attrs) > 0 { + if m, ok := attrs[0].(map[string]interface{}); ok { + return m + } + } + if evs, ok := field["enumValues"].([]interface{}); ok && len(evs) > 0 { + if m, ok := evs[0].(map[string]interface{}); ok { + return m + } + } + return nil +} diff --git a/service/utils/template_validate.go b/service/utils/template_validate.go new file mode 100644 index 0000000..2709484 --- /dev/null +++ b/service/utils/template_validate.go @@ -0,0 +1,462 @@ +package utils + +import ( + "fmt" + "model-gateway/model/dto" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gogf/gf/v2/util/gutil" +) + +// 数据类型常量 +const ( + TypeString = "string" + TypeBool = "boolean" + TypeNumber = "number" + TypeNumberInt = "integer" + TypeNumberFloat = "float" + TypeNull = "null" + TypeObject = "object" + TypeArray = "array" +) + +// CheckParams 校验用户入参并回填默认值: +// 用户只传 key/value,约束参数(type/required/constraint)全部取模板定义。 +// 模板定义必填的字段,用户未传或传空值都报错;用户值为空时用模板 defaultValue 回填。 +// 严格模式:未知字段报错。 +func CheckParams(userParams map[string]interface{}, templateParams map[string]interface{}) error { + return checkParams(userParams, templateParams, true, true) +} + +// CheckBody 校验构建完成的请求体(ParseConfigTemplate + WriteBusinessFields 之后): +// 业务字段按映射写入的路径可能超出模板声明,未知字段不报错;默认值已在构建期处理,不做回填。 +// 仍按模板约束校验必填/长度/范围。 +func CheckBody(body map[string]interface{}, templateParams map[string]interface{}) error { + return checkParams(body, templateParams, false, true) +} + +// checkParams 按模板校验请求结构。strictUnknown:未知字段是否报错;backfill:空值是否回填 defaultValue。 +func checkParams(userParams map[string]interface{}, templateParams map[string]interface{}, strictUnknown, backfill bool) error { + // 兼容扁平路径入参:还原为嵌套结构 + orig := userParams + if IsFlatMap(userParams) { + nested, err := UnFlatBySjson(userParams) + if err != nil { + return fmt.Errorf("无法解析用户参数: %w", err) + } + orig = nested + } + // 顶层未知字段检查 + if strictUnknown { + for key := range orig { + if _, has := templateParams[key]; !has { + return fmt.Errorf("非法字段: %s 模板中不存在该字段", key) + } + } + } + for key, tmplNode := range templateParams { + if err := validateNode(orig, key, tmplNode, key, strictUnknown, backfill); err != nil { + return err + } + } + return nil +} + +// validateNode 按模板节点校验用户值,空值回填 defaultValue。 +// parent 为用户原始结构(模板格式 {type,value/attrs} 或纯值),key 为字段名;回填写回 parent[key]。 +func validateNode(parent map[string]interface{}, key string, tmplNode interface{}, path string, strictUnknown, backfill bool) error { + raw, hasRaw := parent[key] + + tmplMap, ok := tmplNode.(map[string]interface{}) + if !ok { + return nil // 模板节点不是对象,无约束可校验 + } + var tmpl dto.Template + if err := gconv.Struct(tmplMap, &tmpl); err != nil { + return fmt.Errorf("字段 [%s] 模板解析错误: %w", path, err) + } + + label := tmpl.Label + if label == "" { + label = path + } + + switch tmpl.Type { + case TypeObject: + userMap, hasUser := userObjectValue(raw, hasRaw) + if !hasUser { + if tmpl.Required { + return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label) + } + return nil // 未传对象且非必填:跳过 + } + attrs, ok := tmpl.Attrs.(map[string]interface{}) + if !ok { + return nil + } + // 未知子字段检查 + if strictUnknown { + for k := range userMap { + if _, has := attrs[k]; !has { + return fmt.Errorf("非法字段: %s 模板中不存在该字段", path+"."+k) + } + } + } + // 递归子字段(即使对象未传,子字段必填校验仍生效) + for subKey, subTmpl := range attrs { + if err := validateNode(userMap, subKey, subTmpl, path+"."+subKey, strictUnknown, backfill); err != nil { + return err + } + } + return nil + + case TypeArray: + // 枚举项:逐项校验请求 enumValue.attrs 子字段并回填默认值 + if err := validateEnumValues(raw, hasRaw, path, strictUnknown, backfill); err != nil { + return err + } + userArr, hasUser := userArrayValue(raw, hasRaw) + if !hasUser || len(userArr) == 0 { + // 先回填 defaultValue(必填字段也可由默认值兜底),回填后重新判空 + if backfill { + backfillDefault(parent, key, raw, hasRaw, &tmpl) + } + if tmpl.Required && isValueEmptyByType(&tmpl) { + return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label) + } + return nil + } + // 数组数量约束:上限取 Constraint.UploadTotalMaxCount 或各 UploadRule.MaxCount 之和(schema 构建时配置) + if limit := maxArrayCount(&tmpl); limit > 0 && len(userArr) > limit { + return fmt.Errorf("字段 [%s] 数量 %d 超过限制 %d", label, len(userArr), limit) + } + proto := arrayElementPrototype(&tmpl) + if proto == nil { + return nil + } + var protoTmpl dto.Template + if err := gconv.Struct(proto, &protoTmpl); err != nil { + return nil + } + switch protoTmpl.Type { + case TypeObject: + // 对象元素:以元素 attrs 为容器递归校验子字段(模板对象节点 {type:object,attrs:{...}} 的 + // 子字段藏在 attrs 下;纯对象 map 直接以自身为容器) + if attrs, ok := protoTmpl.Attrs.(map[string]interface{}); ok { + for i, elem := range userArr { + elemMap, isMap := elem.(map[string]interface{}) + if !isMap { + continue + } + container, has := userObjectValue(elemMap, true) + if !has { + continue + } + for subKey, subTmpl := range attrs { + subPath := fmt.Sprintf("%s[%d].%s", path, i, subKey) + if err := validateNode(container, subKey, subTmpl, subPath, strictUnknown, backfill); err != nil { + return err + } + } + } + } + default: + // 标量元素:逐元素校验(数组内元素不参与整体必填) + for i, elem := range userArr { + pt := protoTmpl + pt.Value = elem + pt.Required = false + if err := checkScalar(&pt, fmt.Sprintf("%s[%d]", path, i)); err != nil { + return err + } + } + } + return nil + + default: + // 标量类型:先回填 defaultValue(必填字段也可由默认值兜底),回填后重新判空,再校验必填/约束 + tmpl.Value = userScalarValue(raw, hasRaw) + if isValueEmptyByType(&tmpl) { + if backfill { + backfillDefault(parent, key, raw, hasRaw, &tmpl) + tmpl.Value = tmpl.DefaultValue + } + if isValueEmptyByType(&tmpl) { + if tmpl.Required { + return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label) + } + return nil + } + } + return checkScalar(&tmpl, label) + } +} + +// checkScalar 校验标量值:必填 + 约束 +func checkScalar(tmpl *dto.Template, label string) error { + if isValueEmptyByType(tmpl) { + if tmpl.Required { + return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label) + } + return nil + } + switch tmpl.Type { + case TypeString: + return checkStringTmpl(tmpl) + case TypeNumber: + return checkNumberTmpl(tmpl) + case TypeBool: + return checkBoolTmpl(tmpl) + case TypeNull: + return nil + default: + return fmt.Errorf("字段 [%s] 不支持的模板类型: %s", label, tmpl.Type) + } +} + +// userObjectValue 从原始请求节点提取对象值(模板格式取 attrs/value,纯值直接返回 map) +func userObjectValue(raw interface{}, hasRaw bool) (map[string]interface{}, bool) { + if !hasRaw || raw == nil { + return nil, false + } + if m, ok := raw.(map[string]interface{}); ok { + if _, isTpl := m["type"]; isTpl { + if v, has := m["attrs"]; has { + if sub, ok := v.(map[string]interface{}); ok { + return sub, true + } + } + if v, has := m["value"]; has { + if sub, ok := v.(map[string]interface{}); ok { + return sub, true + } + } + return nil, false + } + return m, true + } + return nil, false +} + +// userArrayValue 从原始请求节点提取数组值 +func userArrayValue(raw interface{}, hasRaw bool) ([]interface{}, bool) { + if !hasRaw || raw == nil { + return nil, false + } + if arr, ok := raw.([]interface{}); ok { + return arr, true + } + if m, ok := raw.(map[string]interface{}); ok { + if _, isTpl := m["type"]; isTpl { + if v, has := m["value"]; has { + if sub, ok := v.([]interface{}); ok { + return sub, true + } + } + v1, has1 := m["attrs"] + v2, has2 := m["enumValues"] + if has1 || has2 { + sub1, ok1 := v1.([]interface{}) + sub2, ok2 := v2.([]interface{}) + if ok1 { + if ok2 { + return sub2, true + } + return sub1, true + } + if ok2 { + return sub2, true + } + } + } + } + return nil, false +} + +// userScalarValue 从原始请求节点提取标量值 +func userScalarValue(raw interface{}, hasRaw bool) interface{} { + if !hasRaw { + return nil + } + if m, ok := raw.(map[string]interface{}); ok { + if _, isTpl := m["type"]; isTpl { + return m["value"] + } + } + return raw +} + +// backfillDefault 空值回填 defaultValue: +// 模板格式节点写 value 键;纯值直接覆盖;字段缺失则补一个模板格式节点供下游产出默认值。 +func backfillDefault(parent map[string]interface{}, key string, raw interface{}, hasRaw bool, tmpl *dto.Template) { + if tmpl.DefaultValue == nil { + return + } + if m, ok := raw.(map[string]interface{}); ok { + if _, isTpl := m["type"]; isTpl { + m["value"] = tmpl.DefaultValue + return + } + } + if hasRaw { + parent[key] = tmpl.DefaultValue + return + } + parent[key] = map[string]interface{}{ + "type": tmpl.Type, + "value": tmpl.DefaultValue, + } +} + +// validateEnumValues 校验数组枚举项:逐项取请求 enumValue.attrs 作为字段容器, +// 递归校验每个子字段(必填/约束)并回填空值的 defaultValue。与旧 checkArrayTmpl 行为对齐。 +func validateEnumValues(raw interface{}, hasRaw bool, path string, strictUnknown, backfill bool) error { + if !hasRaw { + return nil + } + rawMap, ok := raw.(map[string]interface{}) + if !ok { + return nil + } + evs, ok := rawMap["enumValues"].([]interface{}) + if !ok { + return nil + } + for i, ev := range evs { + evMap, ok := ev.(map[string]interface{}) + if !ok { + continue + } + attrs, ok := evMap["attrs"].(map[string]interface{}) + if !ok { + continue + } + for subKey, subTmpl := range attrs { + subPath := fmt.Sprintf("%s.enumValues[%d].%s", path, i, subKey) + if err := validateNode(attrs, subKey, subTmpl, subPath, strictUnknown, backfill); err != nil { + return err + } + } + } + return nil +} + +// maxArrayCount 取数组字段的数量上限:UploadTotalMaxCount 优先,其次各 UploadRule.MaxCount 之和;未配置返回 0 +func maxArrayCount(tmpl *dto.Template) int { + if tmpl.Constraint.UploadTotalMaxCount > 0 { + return tmpl.Constraint.UploadTotalMaxCount + } + total := 0 + for _, rule := range tmpl.Constraint.UploadRules { + total += rule.MaxCount + } + return total +} + +// arrayElementPrototype 取数组元素模板原型(attrs 优先,其次 enumValues) +func arrayElementPrototype(tmpl *dto.Template) map[string]interface{} { + if attrs, ok := tmpl.Attrs.([]interface{}); ok && len(attrs) > 0 { + if m, ok := attrs[0].(map[string]interface{}); ok { + return m + } + } + if len(tmpl.EnumValues) > 0 { + if m, ok := tmpl.EnumValues[0].(map[string]interface{}); ok { + return m + } + } + return nil +} + +// isValueEmptyByType 按 tmpl.Type 判断是否为"业务空值" +func isValueEmptyByType(tmpl *dto.Template) bool { + switch tmpl.Type { + case TypeString: + return g.IsEmpty(gconv.String(tmpl.Value)) + case TypeNumber: + return g.IsEmpty(gconv.Float64(tmpl.Value)) + case TypeBool: + return tmpl.Value == nil + case TypeObject: + return g.IsEmpty(gconv.Map(tmpl.Value)) + case TypeArray: + return g.IsEmpty(gconv.SliceAny(tmpl.Value)) + case TypeNull: + return true + default: + return true + } +} + +// checkStringTmpl 字符串类型校验 +func checkStringTmpl(tmpl *dto.Template) error { + val := gconv.String(tmpl.Value) + if tmpl.Required && gutil.IsEmpty(val) { + return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label) + } + ct := tmpl.Constraint + if gutil.IsEmpty(ct) { + return nil + } + if tmpl.FieldType == "string" || tmpl.FieldType == "textarea" { + if ct.MinLength > 0 && len(val) < ct.MinLength { + return fmt.Errorf("字段 [%s] 长度应大于等于 %d,当前长度 %d", tmpl.Label, ct.MinLength, len(val)) + } + if ct.MaxLength > 0 && len(val) > ct.MaxLength { + return fmt.Errorf("字段 [%s] 长度应小于等于 %d,当前长度 %d", tmpl.Label, ct.MaxLength, len(val)) + } + } + return nil +} + +// checkNumberTmpl 数字类型校验 +func checkNumberTmpl(tmpl *dto.Template) error { + ct := tmpl.Constraint + if gutil.IsEmpty(ct) { + return nil + } + + switch ct.NumberType { + case TypeNumberInt: + val := gconv.Int(tmpl.Value) + if tmpl.Required && gutil.IsEmpty(val) { + return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label) + } + minVal := gconv.Int(ct.Min) + maxVal := gconv.Int(ct.Max) + if !g.IsEmpty(minVal) && val < minVal { + return fmt.Errorf("字段 [%s] 值 %d 不应小于 最小值 %d", tmpl.Label, val, minVal) + } + if !g.IsEmpty(maxVal) && val > maxVal { + return fmt.Errorf("字段 [%s] 值 %d 不应大于 最大值 %d", tmpl.Label, val, maxVal) + } + + case TypeNumberFloat: + val := gconv.Float64(tmpl.Value) + if tmpl.Required && gutil.IsEmpty(val) { + return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label) + } + minVal := gconv.Float64(ct.Min) + maxVal := gconv.Float64(ct.Max) + if !g.IsEmpty(minVal) && val < minVal { + return fmt.Errorf("字段 [%s] 值 %.2f 不应小于 最小值 %.2f", tmpl.Label, val, minVal) + } + if !g.IsEmpty(maxVal) && val > maxVal { + return fmt.Errorf("字段 [%s] 值 %.2f 不应大于 最大值 %.2f", tmpl.Label, val, maxVal) + } + + default: + return fmt.Errorf("字段 [%s] 数字类型 [%s] 错误,仅支持 int/float", tmpl.Label, ct.NumberType) + } + return nil +} + +// checkBoolTmpl 布尔类型校验 +func checkBoolTmpl(tmpl *dto.Template) error { + val := gconv.Bool(tmpl.Value) + if tmpl.Required && gutil.IsEmpty(val) { + return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label) + } + return nil +} diff --git a/update.sql b/update.sql index bde1dc0..ad5c018 100644 --- a/update.sql +++ b/update.sql @@ -227,4 +227,94 @@ COMMENT ON COLUMN model_gateway_logs_op.success IS '是否成功:1成功/0失 COMMENT ON COLUMN model_gateway_logs_op.error_msg IS '错误信息(失败时)'; COMMENT ON COLUMN model_gateway_logs_op.cost_ms IS '耗时(毫秒)'; COMMENT ON COLUMN model_gateway_logs_op.request_payload IS '请求 JSON'; -COMMENT ON COLUMN model_gateway_logs_op.response_payload IS '响应 JSON'; \ No newline at end of file +COMMENT ON COLUMN model_gateway_logs_op.response_payload IS '响应 JSON'; + + +-- ===================================================================================== + +--------------------pgsql创建model_gateway_model_manage表语句--------------------------- +-- 模型管理表 +CREATE TABLE IF NOT EXISTS model_gateway_model_manage ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at timestamp(6), + + model_supplier VARCHAR(64) NOT NULL DEFAULT '', + model_name VARCHAR(128) NOT NULL DEFAULT '', + model_type INT NOT NULL DEFAULT 0, + base_url VARCHAR(512) NOT NULL DEFAULT '', + http_method VARCHAR(32) NOT NULL DEFAULT '', + system_model BOOLEAN NOT NULL DEFAULT false, + private_model BOOLEAN NOT NULL DEFAULT false, + chat_model BOOLEAN NOT NULL DEFAULT false, + invoke_type VARCHAR(32) NOT NULL DEFAULT '', + api_key VARCHAR(255) NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT false, + request_mapping JSONB DEFAULT '{}', + response_mapping JSONB DEFAULT '{}', + max_concurrency INT NOT NULL DEFAULT 0 + ); + +-- 索引 +CREATE INDEX idx_model_manage_tenant_id ON model_gateway_model_manage(tenant_id); +CREATE INDEX idx_model_manage_supplier ON model_gateway_model_manage(model_supplier); +CREATE INDEX idx_model_manage_type ON model_gateway_model_manage(model_type); +CREATE INDEX idx_model_manage_enabled ON model_gateway_model_manage(enabled); +CREATE INDEX idx_model_manage_deleted_at ON model_gateway_model_manage(deleted_at); + +-- 字段与表注释 +COMMENT ON TABLE model_gateway_model_manage IS '模型管理表'; +COMMENT ON COLUMN model_gateway_model_manage.id IS '主键ID'; +COMMENT ON COLUMN model_gateway_model_manage.tenant_id IS '租户ID'; +COMMENT ON COLUMN model_gateway_model_manage.creator IS '创建人'; +COMMENT ON COLUMN model_gateway_model_manage.created_at IS '创建时间'; +COMMENT ON COLUMN model_gateway_model_manage.updater IS '更新人'; +COMMENT ON COLUMN model_gateway_model_manage.updated_at IS '更新时间'; +COMMENT ON COLUMN model_gateway_model_manage.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN model_gateway_model_manage.model_supplier IS '模型供应商'; +COMMENT ON COLUMN model_gateway_model_manage.model_name IS '模型名称'; +COMMENT ON COLUMN model_gateway_model_manage.model_type IS '模型类型'; +COMMENT ON COLUMN model_gateway_model_manage.base_url IS '模型地址'; +COMMENT ON COLUMN model_gateway_model_manage.http_method IS 'http方法'; +COMMENT ON COLUMN model_gateway_model_manage.system_model IS '是否系统模型'; +COMMENT ON COLUMN model_gateway_model_manage.private_model IS '是否私有模型'; +COMMENT ON COLUMN model_gateway_model_manage.chat_model IS '是否聊天模型'; +COMMENT ON COLUMN model_gateway_model_manage.invoke_type IS '调用类型'; +COMMENT ON COLUMN model_gateway_model_manage.api_key IS 'api key'; +COMMENT ON COLUMN model_gateway_model_manage.enabled IS '是否启用'; +COMMENT ON COLUMN model_gateway_model_manage.request_mapping IS '请求映射'; +COMMENT ON COLUMN model_gateway_model_manage.response_mapping IS '响应映射'; +COMMENT ON COLUMN model_gateway_model_manage.max_concurrency IS '最大并发数'; +--------------------pgsql创建model_gateway_model_manage表语句--------------------------- + + +-- ========================= +-- 计费规则:model_manage 新增 price_config(JSONB),model_session 新增 total_cost(NUMERIC) +-- ========================= +ALTER TABLE model_gateway_model_manage + ADD COLUMN IF NOT EXISTS price_config JSONB DEFAULT NULL; +COMMENT ON COLUMN model_gateway_model_manage.price_config + IS '计费规则:{currency,unit,dimensions,rules,discount},未配置为NULL(费用按0处理)'; + +ALTER TABLE model_gateway_session + ADD COLUMN IF NOT EXISTS total_cost NUMERIC DEFAULT 0; +COMMENT ON COLUMN model_gateway_session.total_cost + IS '本次调用总费用(元),未配置计费规则为0'; + +-- ========================= +-- 异步任务计费:task_start 快照 media_type,task_end 记录 total_cost +-- 模型计费配置(price_config)任务完成时按 modelId 从 model_manage 现查,不在 task_start 快照 +-- ========================= +ALTER TABLE model_gateway_model_task_start + ADD COLUMN IF NOT EXISTS media_type VARCHAR(32) DEFAULT NULL; +COMMENT ON COLUMN model_gateway_model_task_start.media_type + IS '输入媒体类型快照(audio/no_video/has_video,创建任务时按请求体参考媒体字段推导)'; + +ALTER TABLE model_gateway_model_task_end + ADD COLUMN IF NOT EXISTS total_cost NUMERIC DEFAULT 0; +COMMENT ON COLUMN model_gateway_model_task_end.total_cost + IS '本次调用总费用(元),未配置计费规则为0'; \ No newline at end of file