feat: 新增业务字段路径读写工具
新增 TakeBusinessFields、WriteBusinessFields、SetByPath 与 GetByPath 等工具,支持按映射路径写入请求体与解析响应,并更新相关依赖。
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 // 异步调用
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -2,8 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service/task"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/service"
|
||||
"model-gateway/service/utils"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -14,55 +15,56 @@ import (
|
||||
"gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/jaeger"
|
||||
_ "gitea.redpowerfuture.com/red-future/common/swagger"
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
|
||||
_ "github.com/gogf/gf/contrib/nosql/redis/v2"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtimer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ctx := context.Background()
|
||||
defer jaeger.ShutDown(ctx)
|
||||
|
||||
// 初始化全局协程池(最大 goroutine 数,从配置文件读取,默认 100)
|
||||
workerNum := g.Cfg().MustGet(ctx, "pool.workerNum", utils.DefaultWorkerNum).Int()
|
||||
utils.Init(workerNum)
|
||||
g.Log().Infof(ctx, "[main] 全局协程池已初始化, workerNum=%d", workerNum)
|
||||
|
||||
// 注册路由
|
||||
http.RouteRegister([]interface{}{
|
||||
controller.ModelCall,
|
||||
controller.ModelManage,
|
||||
controller.ModelGatewayModels,
|
||||
controller.ModelGatewayTask,
|
||||
controller.ModelGatewayLogsStat,
|
||||
})
|
||||
|
||||
// 本地调试:可选自动触发 worker/cleaner(由配置文件控制)
|
||||
startAutoRunner(ctx)
|
||||
gmq.GmqRegister(public.GmqMsgPluginsName, &mq.NatsConn{
|
||||
NatsConfig: mq.NatsConfig{
|
||||
Addr: g.Config().MustGet(ctx, "nats.addr").String(),
|
||||
Port: g.Config().MustGet(ctx, "nats.port").String(),
|
||||
Username: g.Config().MustGet(ctx, "nats.username").String(),
|
||||
Password: g.Config().MustGet(ctx, "nats.password").String(),
|
||||
},
|
||||
})
|
||||
|
||||
gtimer.AddSingleton(ctx, 10*time.Second, func(ctx context.Context) {
|
||||
err := service.ModelTaskEndService.GetTaskStartList(ctx)
|
||||
if err != nil {
|
||||
g.Log().Error(ctx, "模型视频任务处理失败 err: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听退出信号,确保 Ctrl+C 能完整退出(停止 worker/cleaner 并关闭 gateway server)
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
g.Log().Infof(ctx, "[main] 收到退出信号,开始优雅退出...")
|
||||
cancel()
|
||||
// 关闭 gateway server(RouteRegister 内部是 go Httpserver.Run() 启动的)
|
||||
_ = http.Httpserver.Shutdown()
|
||||
}
|
||||
utils.Shutdown()
|
||||
g.Log().Infof(ctx, "[main] 全局协程池已关闭")
|
||||
|
||||
func startAutoRunner(ctx context.Context) {
|
||||
// queryPending
|
||||
if g.Cfg().MustGet(ctx, "asynch.queryPending.enabled").Bool() {
|
||||
interval := g.Cfg().MustGet(ctx, "asynch.queryPending.intervalSeconds", 10).Int()
|
||||
limit := g.Cfg().MustGet(ctx, "asynch.queryPending.limit", 10).Int()
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := task.ModelGatewayTask.QueryPendingTasks(ctx, &dto.QueryPendingTasksReq{Limit: limit}); err != nil {
|
||||
g.Log().Warningf(ctx, "[auto-queryPending] run once failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// 收到退出信号后,关闭全局协程池,等待所有已提交的任务执行完成
|
||||
g.Log().Info(ctx, "服务正在关闭...")
|
||||
}
|
||||
|
||||
@@ -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 的遵从程度"`
|
||||
}
|
||||
@@ -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:"枚举值列表"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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:"错误消息"`
|
||||
}
|
||||
@@ -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:"错误消息"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,缺省长期有效"`
|
||||
}
|
||||
@@ -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:"错误消息"`
|
||||
}
|
||||
@@ -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:"错误消息"`
|
||||
}
|
||||
@@ -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:"错误消息"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"model-gateway/model/dto"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/gclient"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// DeductBalanceReq 扣减余额请求
|
||||
type DeductBalanceReq struct {
|
||||
Id uint64 `json:"id"`
|
||||
Surplus float64 `json:"surplus"`
|
||||
}
|
||||
|
||||
// DeductBalance 扣减租户余额。走 admin-go 内部接口 /pub/tenant/deduct(无 gftoken/Auth,供 model-gateway 内部调用)。
|
||||
// admin-go 的 tenant/edit 对 surplus 走 gdb.Counter 增量(正加负减),故扣减须传负值;调用方在本次未产生费用(cost<=0)时应跳过。
|
||||
func DeductBalance(ctx context.Context, tenantId uint64, amount float64) error {
|
||||
apiURL := "admin-go/api/v1/pub/tenant/deduct"
|
||||
headers := setCtxHeader(ctx)
|
||||
|
||||
body := DeductBalanceReq{
|
||||
Id: tenantId,
|
||||
Surplus: -amount,
|
||||
}
|
||||
jsonData, _ := json.Marshal(body)
|
||||
|
||||
var resp struct{}
|
||||
err := commonHttp.Post(ctx, apiURL, headers, &resp, jsonData)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[扣减余额] 失败 tenantId=%d amount=%.6f err=%v", tenantId, amount, err)
|
||||
return err
|
||||
}
|
||||
g.Log().Infof(ctx, "[扣减余额] 成功 tenantId=%d amount=%.6f", tenantId, amount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TenantSurplusResp 租户余额返回
|
||||
type TenantSurplusResp struct {
|
||||
Tenant struct {
|
||||
Surplus float64 `json:"surplus"`
|
||||
} `json:"tenant"`
|
||||
}
|
||||
|
||||
// GetTenantSurplus 获取租户余额(走 admin-go 内部接口 /pub/tenant/balance,无 gftoken/Auth)
|
||||
func GetTenantSurplus(ctx context.Context, tenantId uint64) (float64, error) {
|
||||
apiURL := fmt.Sprintf("admin-go/api/v1/pub/tenant/balance?tenantId=%d", tenantId)
|
||||
headers := setCtxHeader(ctx)
|
||||
|
||||
var resp TenantSurplusResp
|
||||
err := commonHttp.Get(ctx, apiURL, headers, &resp, nil)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[获取余额] 失败 tenantId=%d err=%v", tenantId, err)
|
||||
return 0, err
|
||||
}
|
||||
return resp.Tenant.Surplus, nil
|
||||
}
|
||||
|
||||
// IsSuperAdmin 调用admin-go服务检查是否是管理员
|
||||
func IsSuperAdmin(ctx context.Context) (res bool, err error) {
|
||||
headers := setCtxHeader(ctx)
|
||||
var r = make(map[string]bool)
|
||||
if err = commonHttp.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headers, &r); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return r["isSuperAdmin"], err
|
||||
}
|
||||
|
||||
func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
part, err := writer.CreateFormFile("file", req.FileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = part.Write(req.FileBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := setCtxHeader(ctx)
|
||||
headers["Content-Type"] = writer.FormDataContentType()
|
||||
// 发起上传请求
|
||||
res := &dto.UploadFileBytesRes{}
|
||||
httpUrl := "oss/file/uploadFile"
|
||||
if err = commonHttp.Post(ctx, httpUrl, headers, res, body.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func setCtxHeader(ctx context.Context) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
// 后台异步任务(临时路径转存 OSS)ctx 无 HTTP 请求、无 token:
|
||||
// 用任务体注入的 user(Creator/TenantId)生成 X-User-Info,供 OSS GetUserInfo 识别用户与桶名
|
||||
if headers["X-User-Info"] == "" {
|
||||
if user := ctx.Value("user"); !g.IsNil(user) {
|
||||
headers["X-User-Info"] = gconv.String(user)
|
||||
}
|
||||
}
|
||||
// 直连场景(请求头无 X-User-Info、ctx 未注入 user):解析调用方 token 得到用户,
|
||||
// 生成 X-User-Info,供 admin-go 内部租户接口做归属校验(调用方只能操作自己所属租户)
|
||||
if headers["X-User-Info"] == "" {
|
||||
if user, err := utils.GetUserInfo(ctx); err == nil && user != nil {
|
||||
headers["X-User-Info"] = gconv.String(user)
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// modelCallHeaderTimeout 模型响应头等待超时。
|
||||
// commonHttp 底层 gclient 默认 ResponseHeaderTimeout 只有 30s,模型生成首字节
|
||||
// (尤其非流式、大 max_tokens)经常超过 30s,导致 http2: timeout awaiting response
|
||||
// headers。模型调用必须用独立 client 并把该超时调大,与模型配置的超时保持一致。
|
||||
const modelCallHeaderTimeout = 30 * time.Minute
|
||||
|
||||
// modelHTTPClient 构建模型调用专用 HTTP client:
|
||||
// 克隆 commonHttp 客户端(保留 ContentJson、header 注入等行为),但把
|
||||
// ResponseHeaderTimeout 从默认 30s 调大到 modelCallHeaderTimeout。
|
||||
func modelHTTPClient() *gclient.Client {
|
||||
client := commonHttp.Httpclient.Clone()
|
||||
if tr, ok := client.Transport.(*http.Transport); ok {
|
||||
tr = tr.Clone() // 独立拷贝,避免改动全局共享 transport
|
||||
tr.ResponseHeaderTimeout = modelCallHeaderTimeout
|
||||
client.Transport = tr
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// modelDoRaw 模型 HTTP 请求(等价 commonHttp.doRequestRaw,但使用调大超时的 client)
|
||||
func modelDoRaw(ctx context.Context, method string, url string, headers map[string]string, data ...any) (*gclient.Response, error) {
|
||||
client := modelHTTPClient()
|
||||
|
||||
if (method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete) && len(data) > 0 {
|
||||
client = client.ContentJson()
|
||||
}
|
||||
|
||||
if len(headers) > 0 {
|
||||
client.SetHeaderMap(headers)
|
||||
} else if r := g.RequestFromCtx(ctx); r != nil {
|
||||
client.SetHeader("Authorization", r.Request.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
var response *gclient.Response
|
||||
var err error
|
||||
if method == http.MethodGet && len(data) > 0 && len(data)%2 == 0 {
|
||||
queryParams := make(map[string]string)
|
||||
for i := 0; i < len(data); i += 2 {
|
||||
if key, ok := data[i].(string); ok && i+1 < len(data) {
|
||||
queryParams[key] = gconv.String(data[i+1])
|
||||
}
|
||||
}
|
||||
response, err = client.DoRequest(ctx, method, url, queryParams)
|
||||
} else if len(data) == 1 {
|
||||
response, err = client.DoRequest(ctx, method, url, data[0])
|
||||
} else {
|
||||
response, err = client.DoRequest(ctx, method, url, data...)
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
// ModelHttpNormalRequest 同步/异步 普通HTTP全量请求
|
||||
func ModelHttpNormalRequest(ctx context.Context, url string, headers map[string]string, httpMethod string, body map[string]any) (res []byte, err error) {
|
||||
response, err := modelDoRaw(ctx, httpMethod, url, headers, body)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[HttpModel] 模型请求失败 [Error]: %v", err)
|
||||
return nil, fmt.Errorf("模型请求失败: %w", err)
|
||||
}
|
||||
defer response.Close()
|
||||
return response.ReadAll(), nil
|
||||
}
|
||||
|
||||
// ModelHttpStreamRequest 通用流式请求
|
||||
// stream=true 时设置 SSE 头并验证 Flusher;stream=false 时只返回 Reader,不设置响应头
|
||||
func ModelHttpStreamRequest(ctx context.Context, w http.ResponseWriter, url string, headers map[string]string, httpMethod string, body map[string]any) (io.Reader, error) {
|
||||
// 1) 先发起上游请求(此时还没写任何 SSE 头,失败可以正常返回 error)
|
||||
response, err := modelDoRaw(ctx, httpMethod, url, headers, body)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[HttpModel] 模型流式请求失败 [Error]: %v", err)
|
||||
return nil, fmt.Errorf("模型流式请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查 HTTP 状态码
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(response.Body)
|
||||
response.Close()
|
||||
return nil, fmt.Errorf("[HTTP][Stream] 状态码异常: %d, body=%s", response.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
if w != nil {
|
||||
// 2) 上游连接成功,再设置 SSE 头
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
|
||||
if _, ok := w.(http.Flusher); !ok {
|
||||
response.Close()
|
||||
return nil, errors.New("response writer not support flush")
|
||||
}
|
||||
}
|
||||
|
||||
// 下层统一托管关闭:用包装器保证流最终关闭
|
||||
return &autoCloseReader{r: response.Body}, nil
|
||||
}
|
||||
|
||||
// autoCloseReader 包装 io.ReadCloser,读取结束/销毁时自动 Close
|
||||
type autoCloseReader struct {
|
||||
r io.ReadCloser
|
||||
}
|
||||
|
||||
func (a *autoCloseReader) Read(p []byte) (int, error) {
|
||||
n, err := a.r.Read(p)
|
||||
// 读取完毕 / 读出错,主动关闭流
|
||||
if err != nil {
|
||||
_ = a.r.Close()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// SSE 常量
|
||||
const (
|
||||
ssePrefixData = "data:"
|
||||
ssePrefixEvent = "event:"
|
||||
ssePrefixComment = ":"
|
||||
sseStreamDone = "[DONE]"
|
||||
|
||||
scanBufInitSize = 64 * 1024 // 64KB
|
||||
scanMaxLineSize = 1024 * 1024 // 单行最大 1MB
|
||||
)
|
||||
|
||||
// ParseSSEStream 标准 SSE 流式解析,逐分片回调,支持多行data、上下文取消
|
||||
func ParseSSEStream(ctx context.Context, respBody io.Reader, onChunk func(ctx context.Context, chunk map[string]any) error) {
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
scanner.Buffer(make([]byte, 0, scanBufInitSize), scanMaxLineSize)
|
||||
|
||||
var dataBuilder strings.Builder
|
||||
|
||||
for scanner.Scan() {
|
||||
// 监听上下文取消,及时终止
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
g.Log().Infof(ctx, "[SSE] 上下文取消,终止流读取: %v", ctx.Err())
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
|
||||
// 跳过注释、事件行
|
||||
if strings.HasPrefix(line, ssePrefixComment) || strings.HasPrefix(line, ssePrefixEvent) {
|
||||
continue
|
||||
}
|
||||
|
||||
lineTrim := strings.TrimSpace(line)
|
||||
// 空行 = 一个SSE事件结束
|
||||
if lineTrim == "" {
|
||||
if dataBuilder.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
dataStr := dataBuilder.String()
|
||||
dataBuilder.Reset()
|
||||
|
||||
if dataStr == sseStreamDone {
|
||||
continue
|
||||
}
|
||||
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
|
||||
g.Log().Debugf(ctx, "[SSE] JSON解析失败: %s, err: %v", dataStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if onChunk != nil {
|
||||
onChunk(ctx, chunk)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 拼接多行 data 数据
|
||||
if strings.HasPrefix(line, ssePrefixData) {
|
||||
raw := strings.TrimPrefix(line, ssePrefixData)
|
||||
dataBuilder.WriteString(strings.TrimSpace(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// 捕获读取异常
|
||||
if err := scanner.Err(); err != nil {
|
||||
g.Log().Errorf(ctx, "[SSE] 流读取异常: %v", err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[SSE] 流式读取正常结束")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
// 获取上游流式 reader(stream=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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var (
|
||||
// 匹配 [数字]
|
||||
regNumIndex = regexp.MustCompile(`\[\d+\]`)
|
||||
// 匹配 .attrs
|
||||
regAttrs = regexp.MustCompile(`\.attrs`)
|
||||
)
|
||||
|
||||
// NormalizeFieldPath 归一化字段路径到统一语法([*] 数组段):
|
||||
// - 移除模板残留 .attrs
|
||||
// - [数字] 下标 → [*](choices[0] → choices[*])
|
||||
// - 兼容 gjson 风格 .# / .数字 下标 → [*](choices.#、choices.0 → choices[*])
|
||||
//
|
||||
// 统一语法见 business_fields.go 的 SetByPath / GetByPath:
|
||||
//
|
||||
// a.b.c 普通点号路径
|
||||
// a[*].b [*] 表示数组段
|
||||
// a[*].b[*]?k=v&t=# 选择器:数组元素按 k==v 定位,值/读取目标为 t
|
||||
// a[*]?k=v&b[*]?k2=v2&t=# 多级选择器:级数不限,中间级定位容器元素,叶子写值
|
||||
//
|
||||
// 正则归一(.attrs / [数字] / .#)作用于整个路径(含多级选择器中的数组段);
|
||||
// 纯数字段(gjson 下标)归一只作用于首个 ? 之前的 base 路径。
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// usage.attrs.total_tokens → usage.total_tokens
|
||||
// choices.attrs[0].attrs.message.attrs.content → choices[*].message.content
|
||||
// choices.#.message.content → choices[*].message.content
|
||||
// choices.0.message.content → choices[*].message.content
|
||||
func NormalizeFieldPath(path string) string {
|
||||
s := regAttrs.ReplaceAllString(path, "")
|
||||
s = regNumIndex.ReplaceAllString(s, "[*]")
|
||||
s = strings.ReplaceAll(s, ".#", "[*]")
|
||||
base, suffix := s, ""
|
||||
if i := strings.Index(s, "?"); i >= 0 {
|
||||
base, suffix = s[:i], s[i:]
|
||||
}
|
||||
// 逐段把纯数字段(gjson 下标)归一为 [*]:附着到前一段字段(choices.0 → choices[*]),
|
||||
// 避免误伤数字开头的字段名;选择器体用 # 作目标、不用数字段下标,故只归一 base
|
||||
segs := strings.Split(base, ".")
|
||||
var out []string
|
||||
for _, seg := range segs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if isAllDigits(seg) {
|
||||
if len(out) > 0 {
|
||||
out[len(out)-1] += "[*]"
|
||||
} else {
|
||||
out = append(out, "[*]")
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, seg)
|
||||
}
|
||||
return strings.Join(out, ".") + suffix
|
||||
}
|
||||
|
||||
// isAllDigits 判断字符串是否全部为数字字符
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CleanFieldPath 清理字段路径(等价于 NormalizeFieldPath,保留旧名兼容)
|
||||
func CleanFieldPath(path string) string {
|
||||
return NormalizeFieldPath(path)
|
||||
}
|
||||
|
||||
// CleanMapFieldPath 清理字段路径(Map)
|
||||
func CleanMapFieldPath(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
newMap := make(map[string]string, len(m))
|
||||
for k, _ := range m {
|
||||
newMap[k] = CleanFieldPath(k)
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
|
||||
// ParseConfigTemplate 解析配置模板生成简化请求结构
|
||||
//
|
||||
// 输入: config 模板(含 type/value/defaultValue/attrs/enumValues 等元数据字段)
|
||||
// 输出: 简化后的请求结构体
|
||||
//
|
||||
// 规则:
|
||||
// - 标量字段(string/number/boolean): value 非零则用 value,为空则跳过(不再取 defaultValue)
|
||||
// - 对象字段(object): 递归处理 attrs
|
||||
// - 数组字段(array): 遍历 enumValues,每个 enumValue 独立判断是否产出元素
|
||||
// - 数组展开: enumValue 内某叶子字段 value 为数组时,按数组元素展开为多个项
|
||||
func ParseConfigTemplate(cfg map[string]interface{}) map[string]interface{} {
|
||||
var flattenJSON map[string]interface{}
|
||||
flatMap := IsFlatMap(cfg)
|
||||
if flatMap {
|
||||
var err error
|
||||
flattenJSON, err = UnFlatBySjson(cfg)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
flattenJSON = cfg
|
||||
}
|
||||
result := make(map[string]interface{})
|
||||
for key, val := range flattenJSON {
|
||||
field, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v := resolveField(field); v != nil {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveField 按 type 分发解析
|
||||
func resolveField(field map[string]interface{}) interface{} {
|
||||
fieldType, _ := field["type"].(string)
|
||||
switch fieldType {
|
||||
case TypeString, TypeBool, TypeNumber:
|
||||
return resolveScalar(field)
|
||||
case TypeObject:
|
||||
return resolveObject(field)
|
||||
case TypeArray:
|
||||
return resolveArray(field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveScalar 解析标量字段: value 非空则用 value,否则回落 defaultValue
|
||||
// (模板只声明结构、值由业务字段给出时,defaultValue 生效)
|
||||
func resolveScalar(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if vv != "" {
|
||||
return vv
|
||||
}
|
||||
case float64:
|
||||
if vv != 0 {
|
||||
return vv
|
||||
}
|
||||
case bool:
|
||||
return vv
|
||||
default:
|
||||
return vv
|
||||
}
|
||||
}
|
||||
if d, has := field["defaultValue"]; has && d != nil {
|
||||
switch dv := d.(type) {
|
||||
case string:
|
||||
if dv != "" {
|
||||
return dv
|
||||
}
|
||||
case float64:
|
||||
if dv != 0 {
|
||||
return dv
|
||||
}
|
||||
case bool:
|
||||
return dv
|
||||
default:
|
||||
return dv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveObject 解析对象字段,递归处理 attrs
|
||||
//
|
||||
// 特殊处理「参数定义」结构:当 attrs 含 default 字段时,说明该对象是一个
|
||||
// 参数定义(含 default/description/min/max/type/enum/required 等元数据),
|
||||
// 此时只提取 default 的值作为该参数的值,其余元数据字段忽略。
|
||||
func resolveObject(field map[string]interface{}) interface{} {
|
||||
attrs, ok := field["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 参数定义:只提取 default 值,跳过元数据
|
||||
if defaultField, hasDefault := attrs["default"]; hasDefault {
|
||||
if df, ok := defaultField.(map[string]interface{}); ok {
|
||||
return extractRawValueKeepZero(df)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通对象:递归处理所有 attrs
|
||||
result := make(map[string]interface{})
|
||||
for key, val := range attrs {
|
||||
subField, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
result[key] = val // 纯值字段原样保留
|
||||
continue
|
||||
}
|
||||
if subType, _ := subField["type"].(string); subType == "" {
|
||||
result[key] = val // 无 type 键的纯对象原样保留
|
||||
continue
|
||||
}
|
||||
if v := resolveField(subField); v != nil {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveArray 解析数组字段,遍历 enumValues 或 attrs 生成元素列表
|
||||
func resolveArray(field map[string]interface{}) []interface{} {
|
||||
// 实际数据在 value(schema-editor 数据存放处),直接返回
|
||||
if v, has := field["value"]; has {
|
||||
if arr, ok := v.([]interface{}); ok && len(arr) > 0 {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
enumValues, ok := field["enumValues"].([]interface{})
|
||||
if ok {
|
||||
var result []interface{}
|
||||
for _, ev := range enumValues {
|
||||
evMap, ok := ev.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items := resolveEnumObject(evMap)
|
||||
result = append(result, items...)
|
||||
}
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// enumValues 取不到或为空时,尝试从 attrs(数组)中取
|
||||
attrs, ok := field["attrs"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []interface{}
|
||||
for _, item := range attrs {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v := resolveField(itemMap); v != nil {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveEnumObject 解析 enumValue 对象,支持数组展开
|
||||
func resolveEnumObject(ev map[string]interface{}) []interface{} {
|
||||
attrs, ok := ev["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// 将 enumValue 级别的 value 注入 attrs.type.value(如果 type.value 为空)
|
||||
if evVal, has := ev["value"]; has && evVal != nil {
|
||||
if s, ok := evVal.(string); ok && s != "" {
|
||||
if typeField, has := attrs["type"]; has {
|
||||
if typeMap, ok := typeField.(map[string]interface{}); ok {
|
||||
if existing, has := typeMap["value"]; !has || existing == nil || existing == "" {
|
||||
typeMap["value"] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveAttrs(attrs)
|
||||
}
|
||||
|
||||
// resolveAttrs 递归解析 attrs map,支持字段级数组展开
|
||||
func resolveAttrs(attrs map[string]interface{}) []interface{} {
|
||||
currentItems := []map[string]interface{}{{}}
|
||||
hasValue := false
|
||||
|
||||
for key, val := range attrs {
|
||||
subField, isMap := val.(map[string]interface{})
|
||||
var subType string
|
||||
if isMap {
|
||||
subType, _ = subField["type"].(string)
|
||||
}
|
||||
|
||||
var nextItems []map[string]interface{}
|
||||
|
||||
// 非包裹字段(纯值/纯对象,无 type 键):原样保留,数组值仍参与展开
|
||||
if !isMap || subType == "" {
|
||||
raw := val
|
||||
if raw == nil {
|
||||
nextItems = currentItems
|
||||
currentItems = nextItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
if arr, ok := raw.([]interface{}); ok && len(arr) > 0 {
|
||||
for _, item := range currentItems {
|
||||
for _, elem := range arr {
|
||||
cp := copyMap(item)
|
||||
cp[key] = elem
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = raw
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
currentItems = nextItems
|
||||
continue
|
||||
}
|
||||
|
||||
switch subType {
|
||||
case TypeString, TypeBool, TypeNumber:
|
||||
raw := extractRawValue(subField)
|
||||
if raw == nil {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
if arr, ok := raw.([]interface{}); ok && len(arr) > 0 {
|
||||
for _, item := range currentItems {
|
||||
for _, elem := range arr {
|
||||
cp := copyMap(item)
|
||||
cp[key] = elem
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = raw
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
|
||||
case TypeObject:
|
||||
subAttrs, ok := subField["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
subItems := resolveAttrs(subAttrs)
|
||||
if len(subItems) == 0 {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
for _, item := range currentItems {
|
||||
for _, subI := range subItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = subI
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
|
||||
case TypeArray:
|
||||
items := resolveArray(subField)
|
||||
if len(items) == 0 {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = items
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
|
||||
default:
|
||||
nextItems = currentItems
|
||||
}
|
||||
|
||||
currentItems = nextItems
|
||||
}
|
||||
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
result := make([]interface{}, len(currentItems))
|
||||
for i, item := range currentItems {
|
||||
result[i] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractRawValue 提取原始值(保留数组值供上层展开)
|
||||
func extractRawValue(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if vv != "" {
|
||||
return vv
|
||||
}
|
||||
case float64:
|
||||
if vv != 0 {
|
||||
return vv
|
||||
}
|
||||
case bool:
|
||||
return vv
|
||||
case []interface{}:
|
||||
if len(vv) > 0 {
|
||||
return vv
|
||||
}
|
||||
default:
|
||||
return vv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractRawValueKeepZero 同 extractRawValue,但不过滤零值
|
||||
// 在参数定义场景下,default 可能是 false/0/"",需要保留
|
||||
func extractRawValueKeepZero(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case float64:
|
||||
return vv
|
||||
case bool:
|
||||
return vv
|
||||
case []interface{}:
|
||||
if len(vv) > 0 {
|
||||
return vv
|
||||
}
|
||||
return vv
|
||||
default:
|
||||
return vv
|
||||
}
|
||||
}
|
||||
if dv, has := field["defaultValue"]; has && dv != nil {
|
||||
switch dvv := dv.(type) {
|
||||
case string:
|
||||
if field["type"] == TypeBool {
|
||||
if dvv == "true" {
|
||||
return true
|
||||
}
|
||||
if dvv == "false" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return dvv
|
||||
case float64:
|
||||
return dvv
|
||||
case bool:
|
||||
return dvv
|
||||
case []interface{}:
|
||||
if len(dvv) > 0 {
|
||||
return dvv
|
||||
}
|
||||
return dvv
|
||||
default:
|
||||
return dvv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyMap 浅拷贝 map
|
||||
func copyMap(src map[string]interface{}) map[string]interface{} {
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// CoerceBodyTypes 按模板声明的 type 递归归一请求体字段值类型:
|
||||
// - string → gconv.String;number → gconv.Float64;boolean → gconv.Bool
|
||||
// - object → 按模板 attrs 递归子字段;array → 按元素模板逐个递归
|
||||
// - 模板未声明的字段(业务字段写入且超出模板的部分)保持原样
|
||||
//
|
||||
// 用于构建请求体后统一修正:模板字段 value 与业务字段写入的值都可能携带与声明
|
||||
// 类型不一致的 Go 类型(如 number 字段 value 为字符串 "0.7"),在此统一转成模型
|
||||
// API 期望的 JSON 类型。仅做类型归一,不增删字段。
|
||||
func CoerceBodyTypes(out map[string]interface{}, templateParams map[string]interface{}) map[string]interface{} {
|
||||
if len(templateParams) == 0 {
|
||||
return out
|
||||
}
|
||||
for key, raw := range out {
|
||||
if tmplNode, has := templateParams[key]; has {
|
||||
out[key] = coerceNode(raw, tmplNode)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// coerceNode 按单个模板节点归一值类型
|
||||
func coerceNode(value interface{}, tmplNode interface{}) interface{} {
|
||||
tmplMap, ok := tmplNode.(map[string]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
fieldType, _ := tmplMap["type"].(string)
|
||||
switch fieldType {
|
||||
case TypeString:
|
||||
return gconv.String(value)
|
||||
case TypeNumber, TypeNumberInt, TypeNumberFloat:
|
||||
return gconv.Float64(value)
|
||||
case TypeBool:
|
||||
return gconv.Bool(value)
|
||||
case TypeObject:
|
||||
sub, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
if attrs, ok := tmplMap["attrs"].(map[string]interface{}); ok {
|
||||
return coerceObject(sub, attrs)
|
||||
}
|
||||
return value
|
||||
case TypeArray:
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
proto := arrayElementTemplate(tmplMap)
|
||||
if proto == nil {
|
||||
return value
|
||||
}
|
||||
out := make([]interface{}, len(arr))
|
||||
for i, elem := range arr {
|
||||
out[i] = coerceNode(elem, proto)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// coerceObject 按对象模板 attrs 归一对象子字段类型
|
||||
func coerceObject(sub, attrs map[string]interface{}) map[string]interface{} {
|
||||
for key, raw := range sub {
|
||||
if tmplNode, has := attrs[key]; has {
|
||||
sub[key] = coerceNode(raw, tmplNode)
|
||||
}
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// arrayElementTemplate 从数组模板节点提取元素模板(attrs 优先,其次 enumValues)。
|
||||
// 与 arrayElementPrototype 语义一致,但直接工作在原始模板 map 上,供类型归一使用。
|
||||
func arrayElementTemplate(field map[string]interface{}) map[string]interface{} {
|
||||
if attrs, ok := field["attrs"].([]interface{}); ok && len(attrs) > 0 {
|
||||
if m, ok := attrs[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
if evs, ok := field["enumValues"].([]interface{}); ok && len(evs) > 0 {
|
||||
if m, ok := evs[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
@@ -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_config(JSONB),model_session 新增 total_cost(NUMERIC)
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_manage
|
||||
ADD COLUMN IF NOT EXISTS price_config JSONB DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_manage.price_config
|
||||
IS '计费规则:{currency,unit,dimensions,rules,discount},未配置为NULL(费用按0处理)';
|
||||
|
||||
ALTER TABLE model_gateway_session
|
||||
ADD COLUMN IF NOT EXISTS total_cost NUMERIC DEFAULT 0;
|
||||
COMMENT ON COLUMN model_gateway_session.total_cost
|
||||
IS '本次调用总费用(元),未配置计费规则为0';
|
||||
|
||||
-- =========================
|
||||
-- 异步任务计费:task_start 快照 media_type,task_end 记录 total_cost
|
||||
-- 模型计费配置(price_config)任务完成时按 modelId 从 model_manage 现查,不在 task_start 快照
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_task_start
|
||||
ADD COLUMN IF NOT EXISTS media_type VARCHAR(32) DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_task_start.media_type
|
||||
IS '输入媒体类型快照(audio/no_video/has_video,创建任务时按请求体参考媒体字段推导)';
|
||||
|
||||
ALTER TABLE model_gateway_model_task_end
|
||||
ADD COLUMN IF NOT EXISTS total_cost NUMERIC DEFAULT 0;
|
||||
COMMENT ON COLUMN model_gateway_model_task_end.total_cost
|
||||
IS '本次调用总费用(元),未配置计费规则为0';
|
||||
Reference in New Issue
Block a user