feat: 新增业务字段路径读写工具

新增 TakeBusinessFields、WriteBusinessFields、SetByPath 与 GetByPath 等工具,支持按映射路径写入请求体与解析响应,并更新相关依赖。
This commit is contained in:
2026-08-18 10:11:09 +08:00
parent 55c797dd96
commit 76c55fbb73
41 changed files with 6011 additions and 56 deletions
+113
View File
@@ -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
}
+224
View File
@@ -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
}
+27
View File
@@ -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}
}
+13
View File
@@ -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 // 异步调用
+5
View File
@@ -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"
)
+29
View File
@@ -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}
}
+40
View File
@@ -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
}
+65
View File
@@ -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)
}
+208
View File
@@ -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
}
+42
View File
@@ -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()
}
+29
View File
@@ -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()
}
+97
View File
@@ -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
}
+23 -5
View File
@@ -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
+55 -18
View File
@@ -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=
+33 -31
View File
@@ -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 serverRouteRegister 内部是 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, "服务正在关闭...")
}
+45
View File
@@ -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 的遵从程度"`
}
+155
View File
@@ -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:"枚举值列表"`
}
+156
View File
@@ -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"`
}
+32
View File
@@ -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:"错误消息"`
}
+16
View File
@@ -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:"错误消息"`
}
+46
View File
@@ -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"`
}
+164
View File
@@ -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,缺省长期有效"`
}
+60
View File
@@ -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:"错误消息"`
}
+57
View File
@@ -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:"错误消息"`
}
+54
View File
@@ -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:"错误消息"`
}
+424
View File
@@ -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
}
+318
View File
@@ -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
// 用任务体注入的 userCreator/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 头并验证 Flusherstream=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] 流式读取正常结束")
}
+279
View File
@@ -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
}
+405
View File
@@ -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<<attempt) * time.Second
g.Log().Warningf(ctx, "模型上游调用异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, errMsg.Error.Code, errMsg.Error.Message)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
goto LOOP
}
docMsg.ErrorMsg = errMsg.Error.Message
updateModelSessionReq.ErrorMsg = docMsg.ErrorMsg
} else {
if *model.ResponseTypeSync.Code() == *modelInfo.ResponseType {
respBodyMap := make(map[string]string, len(modelInfo.ResponseBodyMapping))
for k, _ := range modelInfo.ResponseBodyMapping {
respBodyMap[modelUtils.CleanFieldPath(k)] = modelUtils.CleanFieldPath(k)
}
// 基于统一字段路径(GetByPath)按映射取值组装结果
var respObj map[string]any
if err = json.Unmarshal(modelRespBody, &respObj); err != nil {
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
}
content := make(map[string]any, len(respBodyMap))
for bizKey, jsonPath := range respBodyMap {
content[bizKey] = modelUtils.GetByPathValue(respObj, jsonPath)
}
businessField := make(map[string]any, len(modelInfo.ResponseBusinessFieldMapping))
for key, value := range modelInfo.ResponseBusinessFieldMapping {
businessField[key] = modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(value))
}
err = gconv.Struct(businessField, docMsg)
if err != nil {
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
}
docMsg.Content = content
docMsg.TotalTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)))
docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)))
docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)))
updateModelSessionReq.PromptTokens = docMsg.PromptTokens
updateModelSessionReq.CompletionTokens = docMsg.CompletionTokens
updateModelSessionReq.TotalTokens = docMsg.TotalTokens
} else {
docMsg.Content = map[string]any{
"respBody": modelRespBody,
}
}
}
if !g.IsEmpty(docMsg.Content) {
// 9) 上传模型返回参数文件
uploadNewResp, err := Upload(ctx, &dto.UploadFileBytesReq{
FileBytes: gconv.Bytes(gconv.String(docMsg.Content)),
FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()),
})
if err != nil {
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", err)
}
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
}
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
// 9.5) 按模型计费规则换算本次调用费用(未配置返回 0)
docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0)
updateModelSessionReq.TotalCost = docMsg.Cost
// 10) 更新模型会话信息
_, err = dao.ModelSession.Update(ctx, &updateModelSessionReq)
if err != nil {
return nil, fmt.Errorf("更新模型会话信息失败: %v", err)
}
return docMsg, nil
}
// CreateSessionStreamOnce 流式调用上游模型 → 缓冲全量后一次返回(走 gf 框架正常返回)
func (s *modelSessionService) CreateSessionStreamOnce(ctx context.Context, req *dto.CallModelSessionReq) (docMsg *dto.ModelCallRes, err error) {
startTime := time.Now()
id := req.Id
modelInfo := req.ModelInfo
newRequestParams := req.RequestParams
// 获取上游流式 readerstream=false → w 不会被使用,传 nil)
streamReader, err := ModelHttpStreamRequest(ctx, nil, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
if err != nil {
return nil, err
}
docMsg = new(dto.ModelCallRes)
docMsg.TaskId = id
var contentBuf strings.Builder
// 路径预处理
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)
ParseSSEStream(ctx, streamReader, func(ctx context.Context, chunk map[string]any) error {
// 基于统一字段路径(GetByPath)在分片对象上取值,取首个数组元素文本累加
for _, 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
}
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
}
+354
View File
@@ -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
}
+73
View File
@@ -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
}
+177
View File
@@ -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
}
+259
View File
@@ -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
}
+101
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+605
View File
@@ -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
}
+43
View File
@@ -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
}
+58
View File
@@ -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()
}
+573
View File
@@ -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{} {
// 实际数据在 valueschema-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.Stringnumber → gconv.Float64boolean → 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
}
+462
View File
@@ -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
}
+91 -1
View File
@@ -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';
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_configJSONB),model_session 新增 total_costNUMERIC
-- =========================
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_typetask_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';