Compare commits
46
Commits
master
..
4a9ae2d412
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a9ae2d412 | ||
|
|
e079f4ea28 | ||
|
|
657c53142c | ||
|
|
174eb7cf27 | ||
|
|
cb9d04648c | ||
|
|
9d9bd71468 | ||
|
|
76c55fbb73 | ||
|
|
55c797dd96 | ||
|
|
445ee02c5a | ||
|
|
b3b111995e | ||
|
|
1c6c9bae14 | ||
|
|
afd60caf56 | ||
|
|
196d2069ac | ||
|
|
7596cbde09 | ||
|
|
7ec18926e3 | ||
|
|
a6b32bfeb3 | ||
|
|
2dc88ae587 | ||
|
|
e906248b0a | ||
|
|
e5781aca06 | ||
|
|
0cf8948cd2 | ||
|
|
96e8bdfe62 | ||
|
|
26de41d04e | ||
|
|
0bee3685fb | ||
|
|
9049e0d2e8 | ||
|
|
aae46a4f29 | ||
|
|
bcfcc7ed47 | ||
|
|
2c7838807b | ||
|
|
52124385a1 | ||
|
|
c7e9eb889b | ||
|
|
558fd49ec1 | ||
|
|
d409b84b58 | ||
|
|
e487b4bb5e | ||
|
|
a28fcbaee9 | ||
|
|
5416e7a983 | ||
|
|
0e2ac286e9 | ||
|
|
a88dc84d99 | ||
|
|
4d2d4fd93d | ||
|
|
7129bd2de7 | ||
|
|
09474eb997 | ||
|
|
4946220185 | ||
|
|
b6cdb8ff1d | ||
|
|
4626d819b5 | ||
|
|
170568e03e | ||
|
|
a080a5536d | ||
|
|
142fea1e91 | ||
|
|
a585233c4d |
+5
-25
@@ -1,43 +1,23 @@
|
||||
# 多阶段构建 - 第一阶段:编译(使用已安装的镜像)
|
||||
FROM golang:1.26-alpine3.23 AS builder
|
||||
# 阶段1: 构建
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
ENV GO111MODULE=on
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOTOOLCHAIN=auto
|
||||
ENV GOPRIVATE=gitea.com/red-future/common
|
||||
|
||||
# 配置git使用私有Gitea仓库(带Token认证)
|
||||
RUN git config --global url."http://x-token-auth:619679cd366aefea3a50f0622d842a41f2209e08595767bba49c3836ef57d415@116.204.74.41:3000/red-future/common.git".insteadOf "https://gitea.com/red-future/common.git" && \
|
||||
git config --global credential.helper store
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# 复制父目录的 common 模块(因为 go.mod 中使用了本地 replace)
|
||||
#COPY ../common /build/common
|
||||
COPY . .
|
||||
|
||||
RUN go mod download && go mod tidy
|
||||
|
||||
RUN go build -ldflags="-s -w" -o main ./main.go
|
||||
|
||||
# 第二阶段:运行
|
||||
FROM alpine:3.23
|
||||
|
||||
ENV TIME_ZONE=Asia/Shanghai
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
ln -sf /usr/share/zoneinfo/$TIME_ZONE /etc/localtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制编译好的二进制文件
|
||||
COPY --from=builder /build/main .
|
||||
COPY --from=builder /build/config.yml ./
|
||||
|
||||
# 创建日志目录
|
||||
RUN mkdir -p /logs /app/resource/log/run /app/resource/log/server
|
||||
|
||||
EXPOSE 3004
|
||||
|
||||
|
||||
+33
-12
@@ -7,7 +7,7 @@ server:
|
||||
database:
|
||||
default:
|
||||
- type: "pgsql"
|
||||
host: "116.204.74.41"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
@@ -26,20 +26,49 @@ database:
|
||||
updatedAt: "updated_at" # (可选)自动更新时间字段名称
|
||||
deletedAt: "deleted_at" # (可选)软删除时间字段名称
|
||||
timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效
|
||||
model_gateway:
|
||||
- type: "pgsql"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
name: "model-gateway"
|
||||
prefix: "model_gateway_"
|
||||
role: "master"
|
||||
debug: true
|
||||
dryRun: false
|
||||
charset: "utf8"
|
||||
timezone: "Asia/Shanghai"
|
||||
maxIdle: 15
|
||||
maxOpen: 60
|
||||
maxLifetime: "30s"
|
||||
maxIdleConnTime: "30s"
|
||||
createdAt: "created_at"
|
||||
updatedAt: "updated_at"
|
||||
deletedAt: "deleted_at"
|
||||
timeMaintainDisabled: false
|
||||
|
||||
redis:
|
||||
default:
|
||||
address: 116.204.74.41:6379
|
||||
address: 192.168.0.83:6379
|
||||
db: 0
|
||||
|
||||
consul:
|
||||
address: 116.204.74.41:8500
|
||||
address: 192.168.0.83:8500
|
||||
|
||||
jaeger:
|
||||
addr: 116.204.74.41:4318
|
||||
addr: 192.168.0.83:4318
|
||||
|
||||
nats:
|
||||
addr: 192.168.0.83
|
||||
port: 4222
|
||||
|
||||
# 本地调试用:可选自动执行 worker/cleaner(默认关闭)
|
||||
asynch:
|
||||
queryPending:
|
||||
enabled: false
|
||||
intervalSeconds: 10 # 每10秒轮询一次
|
||||
limit: 10 # 每次查10条
|
||||
worker:
|
||||
enabled: false
|
||||
intervalSeconds: 5
|
||||
@@ -48,11 +77,3 @@ asynch:
|
||||
cleaner:
|
||||
enabled: false
|
||||
intervalSeconds: 30
|
||||
|
||||
modelType:
|
||||
types:
|
||||
1: "推理模型"
|
||||
2: "图片模型"
|
||||
3: "音频模型"
|
||||
4: "向量化模型"
|
||||
5: "全模态模型"
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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 // 异步调用
|
||||
CallModeStream = 2 // 流式调用
|
||||
)
|
||||
|
||||
const (
|
||||
TaskStatusPending = 0 // 排队中
|
||||
TaskStatusRunning = 1 // 执行中
|
||||
TaskStatusSuccess = 2 // 成功
|
||||
TaskStatusFailed = 3 // 失败
|
||||
TaskStatusDownloaded = 4 // 已下载
|
||||
)
|
||||
|
||||
const (
|
||||
BuildTypePrompt = 1 //提示词构建
|
||||
BuildTypeNode = 2 //节点构建
|
||||
BuildTypeStruct = 3 //结构构建
|
||||
)
|
||||
|
||||
// ModelType 模型类型常量
|
||||
const (
|
||||
ModelTypeInference = 100 // 推理模型
|
||||
|
||||
ModelTypeImage = 200 // 图片模型
|
||||
ImageSubTypeTextToImage = 201 // 图片模型-文生图
|
||||
ImageSubTypeImageToImage = 202 // 图片模型-图生图
|
||||
ImageSubTypeImageEdit = 203 // 图片模型-图片编辑
|
||||
ImageSubTypeImageVariation = 204 // 图片模型-图片变体
|
||||
ImageSubTypeImageTextToImage = 205 // 图片模型-图文生图
|
||||
|
||||
ModelTypeAudio = 300 // 音频模型
|
||||
AudioSubTypeTextToSpeech = 301 // 音频模型-文生音
|
||||
AudioSubTypeSpeechToText = 302 // 音频模型-音生文
|
||||
AudioSubTypeSpeechToSpeech = 303 // 音频模型-音生音
|
||||
|
||||
ModelTypeVector = 400 // 向量化模型
|
||||
VectorSubTypeEmbedding = 401 // 向量化模型-文本嵌入
|
||||
VectorSubTypeRerank = 402 // 向量化模型-重排序
|
||||
|
||||
ModelTypeOmni = 500 // 全模态模型
|
||||
OmniSubTypeTextImageAudio = 501 // 全模态模型-文图音
|
||||
OmniSubTypeVision = 502 // 全模态模型-视觉理解
|
||||
|
||||
ModelTypeVideo = 600 // 视频模型
|
||||
VideoSubTypeTextToVideo = 601 // 视频模型-文生视频
|
||||
VideoSubTypeImageToVideo = 602 // 视频模型-图生视频
|
||||
VideoSubTypeImageTextToVideo = 603 // 视频模型-图文生视频
|
||||
VideoSubTypeVideoToVideo = 604 // 视频模型-视频生视频
|
||||
)
|
||||
|
||||
// ModelTypeName 模型类型名称映射
|
||||
var ModelTypeName = map[int]string{
|
||||
ModelTypeInference: "推理模型",
|
||||
|
||||
ModelTypeImage: "图片模型",
|
||||
ImageSubTypeTextToImage: "图片模型-文生图",
|
||||
ImageSubTypeImageToImage: "图片模型-图生图",
|
||||
ImageSubTypeImageEdit: "图片模型-图片编辑",
|
||||
ImageSubTypeImageVariation: "图片模型-图片变体",
|
||||
ImageSubTypeImageTextToImage: "图片模型-图文生图",
|
||||
|
||||
ModelTypeAudio: "音频模型",
|
||||
AudioSubTypeTextToSpeech: "音频模型-文生音",
|
||||
AudioSubTypeSpeechToText: "音频模型-音生文",
|
||||
AudioSubTypeSpeechToSpeech: "音频模型-音生音",
|
||||
|
||||
ModelTypeVector: "向量化模型",
|
||||
VectorSubTypeEmbedding: "向量化模型-文本嵌入",
|
||||
VectorSubTypeRerank: "向量化模型-重排序",
|
||||
|
||||
ModelTypeOmni: "全模态模型",
|
||||
OmniSubTypeTextImageAudio: "全模态模型-文图音",
|
||||
OmniSubTypeVision: "全模态模型-视觉理解",
|
||||
|
||||
ModelTypeVideo: "视频模型",
|
||||
VideoSubTypeTextToVideo: "视频模型-文生视频",
|
||||
VideoSubTypeImageToVideo: "视频模型-图生视频",
|
||||
VideoSubTypeImageTextToVideo: "视频模型-图文生视频",
|
||||
VideoSubTypeVideoToVideo: "视频模型-视频生视频",
|
||||
}
|
||||
|
||||
// 运营商常量
|
||||
const (
|
||||
OperatorAliyun = "阿里云百炼"
|
||||
OperatorVolcengine = "火山引擎"
|
||||
OperatorTencent = "腾讯云"
|
||||
OperatorHuawei = "华为云"
|
||||
OperatorBaidu = "百度智能云"
|
||||
OperatorOpenAI = "OpenAI"
|
||||
OperatorAzure = "Azure OpenAI"
|
||||
OperatorAWS = "AWS Bedrock"
|
||||
OperatorGoogle = "Google Cloud"
|
||||
OperatorDeepSeek = "DeepSeek"
|
||||
OperatorMoonshot = "Moonshot"
|
||||
OperatorZhipu = "智谱AI"
|
||||
OperatorBaichuan = "百川智能"
|
||||
OperatorMinimax = "MiniMax"
|
||||
OperatorXunfei = "科大讯飞"
|
||||
OperatorOthers = "其他"
|
||||
)
|
||||
|
||||
// OperatorList 运营商列表(供前端下拉框使用)
|
||||
var OperatorList = []string{
|
||||
OperatorAliyun,
|
||||
OperatorVolcengine,
|
||||
OperatorTencent,
|
||||
OperatorHuawei,
|
||||
OperatorBaidu,
|
||||
OperatorOpenAI,
|
||||
OperatorAzure,
|
||||
OperatorAWS,
|
||||
OperatorGoogle,
|
||||
OperatorDeepSeek,
|
||||
OperatorMoonshot,
|
||||
OperatorZhipu,
|
||||
OperatorBaichuan,
|
||||
OperatorMinimax,
|
||||
OperatorXunfei,
|
||||
OperatorOthers,
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package public
|
||||
|
||||
const (
|
||||
TableNameModel = "asynch_models" // 模型表
|
||||
TableNameTask = "asynch_task" // 任务表
|
||||
TableNameOpLog = "logs_model_op" // 操作日志表
|
||||
TableNameStat = "logs_model_stat" // 按天统计表(请求次数)
|
||||
DbNameModelGateway = "model_gateway" //数据库名称
|
||||
)
|
||||
|
||||
const (
|
||||
TableNameModelManage = "model_manage"
|
||||
TableNameModelSession = "model_session"
|
||||
TableNameModelTaskStart = "model_task_start"
|
||||
TableNameModelTaskEnd = "model_task_end"
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package controller
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
"model-gateway/service"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type model struct{}
|
||||
|
||||
// Model 模型配置控制器
|
||||
var Model = new(model)
|
||||
|
||||
// CreateModel 添加配置
|
||||
func (c *model) CreateModel(ctx context.Context, req *dto.CreateModelReq) (res *dto.CreateModelRes, err error) {
|
||||
return service.Model.Create(ctx, req)
|
||||
}
|
||||
|
||||
// UpdateModel 更改配置
|
||||
func (c *model) UpdateModel(ctx context.Context, req *dto.UpdateModelReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.Model.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteModel 删除配置
|
||||
func (c *model) DeleteModel(ctx context.Context, req *dto.DeleteModelReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.Model.Delete(ctx, req.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// GetModel 获取配置详情(按 modelName)
|
||||
func (c *model) GetModel(ctx context.Context, req *dto.GetModelReq) (res *dto.GetModelRes, err error) {
|
||||
model, err := service.Model.Get(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if model == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &dto.GetModelRes{Model: model}, nil
|
||||
}
|
||||
|
||||
// ListModel 配置列表
|
||||
func (c *model) ListModel(ctx context.Context, req *dto.ListModelReq) (res *dto.ListModelRes, err error) {
|
||||
list, total, err := service.Model.List(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ListModelRes{
|
||||
List: list,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AutoTune 动态调参(由上层定时任务每小时触发一次)
|
||||
func (c *model) AutoTune(ctx context.Context, req *dto.AutoTuneReq) (res *dto.AutoTuneRes, err error) {
|
||||
windowSeconds := 3600
|
||||
if req != nil && req.WindowSeconds > 0 {
|
||||
windowSeconds = req.WindowSeconds
|
||||
}
|
||||
list, err := service.AutoTune(ctx, windowSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AutoTuneRes{List: list}, nil
|
||||
}
|
||||
|
||||
func (c *model) ListType(ctx context.Context, req *dto.ListTypeReq) (res dto.TypeItem, err error) {
|
||||
modelType := service.GetModelTypesFromConfig(ctx)
|
||||
res.Type = modelType
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UpdateChatModel 更新是否为聊天模型
|
||||
func (c *model) UpdateChatModel(ctx context.Context, req *dto.UpdateChatModelReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.Model.UpdateChatModel(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// GetIsChatModel 获取是否为聊天模型
|
||||
func (c *model) GetIsChatModel(ctx context.Context, req *dto.GetIsChatModelReq) (res *entity.AsynchModel, err error) {
|
||||
return service.Model.GetIsChatModel(ctx)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service"
|
||||
)
|
||||
|
||||
type stat struct{}
|
||||
|
||||
// Stat 统计控制器
|
||||
var Stat = new(stat)
|
||||
|
||||
// ListModelStat 统计列表
|
||||
func (c *stat) ListModelStat(ctx context.Context, req *dto.ListModelStatReq) (res *dto.ListModelStatRes, err error) {
|
||||
return service.Stat.List(ctx, req)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service"
|
||||
)
|
||||
|
||||
type task struct{}
|
||||
|
||||
// Task 任务控制器
|
||||
var Task = new(task)
|
||||
|
||||
// CreateTask 根据 modelName 创建异步任务,返回 taskId
|
||||
func (c *task) CreateTask(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) {
|
||||
return service.Task.Create(ctx, req)
|
||||
}
|
||||
|
||||
// GetTaskResult 获取任务结果(只返回 oss 地址 + state)
|
||||
func (c *task) GetTaskResult(ctx context.Context, req *dto.GetTaskResultReq) (res *dto.GetTaskResultRes, err error) {
|
||||
return service.Task.GetResult(ctx, req.TaskID)
|
||||
}
|
||||
|
||||
// GetTaskBatch 批量查询任务(成功任务标记为已下载)
|
||||
func (c *task) GetTaskBatch(ctx context.Context, req *dto.GetTaskBatchReq) (res *dto.GetTaskBatchRes, err error) {
|
||||
return service.Task.GetBatch(ctx, req)
|
||||
}
|
||||
|
||||
// ListTask 任务列表分页查询
|
||||
func (c *task) ListTask(ctx context.Context, req *dto.ListTaskReq) (res *dto.ListTaskRes, err error) {
|
||||
return service.Task.List(ctx, req)
|
||||
}
|
||||
|
||||
// RunWork 手动触发一次 worker(由上层定时任务调用)
|
||||
func (c *task) RunWork(ctx context.Context, req *dto.RunWorkReq) (res *dto.RunWorkRes, err error) {
|
||||
batchSize, goroutines := 10, 1
|
||||
if req != nil {
|
||||
if req.BatchSize > 0 {
|
||||
batchSize = req.BatchSize
|
||||
}
|
||||
if req.Goroutines > 0 {
|
||||
goroutines = req.Goroutines
|
||||
}
|
||||
}
|
||||
n, err := service.AsyncWorker.RunOnce(ctx, batchSize, goroutines)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.RunWorkRes{Claimed: n}, nil
|
||||
}
|
||||
|
||||
// CleanWork 手动触发一次 cleaner(由上层定时任务调用)
|
||||
func (c *task) CleanWork(ctx context.Context, req *dto.CleanWorkReq) (res *dto.CleanWorkRes, err error) {
|
||||
service.Cleaner.RunOnce(ctx)
|
||||
return &dto.CleanWorkRes{Ok: true}, nil
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Model = &modelDao{}
|
||||
|
||||
type modelDao struct{}
|
||||
|
||||
func (d *modelDao) Insert(ctx context.Context, req *dto.CreateModelReq) (id int64, err error) {
|
||||
asyncModel := new(entity.AsynchModel)
|
||||
err = gconv.Struct(req, &asyncModel)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).Data(asyncModel).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *modelDao) Update(ctx context.Context, m *dto.UpdateModelReq) (rows int64, err error) {
|
||||
// 触发 gfdb 的 updateHook 自动填充 updater,需要显式带 updater 字段
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
OmitEmpty().
|
||||
Where(entity.AsynchModelCol.Id, m.ID).
|
||||
Data(m).
|
||||
Update()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *modelDao) DeleteByID(ctx context.Context, id string) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
Where(entity.AsynchModelCol.Id, id).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *modelDao) GetByModelName(ctx context.Context, modelName string) (m *entity.AsynchModel, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
Where(entity.AsynchModelCol.ModelName, modelName).
|
||||
One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&m)
|
||||
return
|
||||
}
|
||||
|
||||
func (d *modelDao) Get(ctx context.Context, id int64) (m *entity.AsynchModel, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
NoTenantId(ctx).
|
||||
Where(entity.AsynchModelCol.Id, id).
|
||||
One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&m)
|
||||
return
|
||||
}
|
||||
|
||||
func (d *modelDao) Count(ctx context.Context, req *dto.GetModelReq) (count int, err error) {
|
||||
count, err = gfdb.DB(ctx).Model(ctx, public.TableNameModel).OmitEmpty().
|
||||
Where(entity.AsynchModelCol.Creator, req.Creator).
|
||||
Where(entity.AsynchModelCol.Id, req.ID).Count()
|
||||
return
|
||||
}
|
||||
|
||||
func (d *modelDao) List(ctx context.Context, pageNum, pageSize int, modelNameLike string, modelType int, isPrivate int) (list []*entity.AsynchModel, total int64, err error) {
|
||||
model := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
OrderDesc(entity.AsynchModelCol.CreatedAt)
|
||||
if modelNameLike != "" {
|
||||
model = model.WhereLike(entity.AsynchModelCol.ModelName, "%"+modelNameLike+"%")
|
||||
}
|
||||
if modelType != 0 {
|
||||
model = model.Where(entity.AsynchModelCol.ModelType, modelType)
|
||||
}
|
||||
if isPrivate != 0 {
|
||||
model = model.Where(entity.AsynchModelCol.IsPrivate, isPrivate)
|
||||
}
|
||||
if pageNum > 0 && pageSize > 0 {
|
||||
model = model.Page(pageNum, pageSize)
|
||||
}
|
||||
r, totalInt, err := model.AllAndCount(false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total = gconv.Int64(totalInt)
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// ListByCreatorAndPlatform 普通用户:平台公共(tenant_id=0) + 自己创建的(creator=xxx)
|
||||
func (d *modelDao) ListByCreatorAndPlatform(ctx context.Context, creator string, pageNum, pageSize int, modelNameLike string) (list []*entity.AsynchModel, total int64, err error) {
|
||||
// 构建 Where 条件
|
||||
whereSQL := "deleted_at IS NULL AND (tenant_id = 1 OR creator = ?)" //1 代表超级管理员
|
||||
args := []any{creator}
|
||||
|
||||
if modelNameLike != "" {
|
||||
whereSQL += " AND model_name LIKE ?"
|
||||
args = append(args, "%"+modelNameLike+"%")
|
||||
}
|
||||
|
||||
// 查总数
|
||||
countSQL := fmt.Sprintf("SELECT COUNT(1) FROM %s WHERE %s", public.TableNameModel, whereSQL)
|
||||
countResult, err := gfdb.DB(ctx).GetAll(ctx, countSQL, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(countResult) > 0 {
|
||||
total = gconv.Int64(countResult[0]["count"])
|
||||
}
|
||||
|
||||
// 查列表
|
||||
querySQL := fmt.Sprintf("SELECT * FROM %s WHERE %s ORDER BY created_at DESC", public.TableNameModel, whereSQL)
|
||||
if pageNum > 0 && pageSize > 0 {
|
||||
offset := (pageNum - 1) * pageSize
|
||||
querySQL += fmt.Sprintf(" LIMIT %d OFFSET %d", pageSize, offset)
|
||||
}
|
||||
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx, querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
func (d *modelDao) GetByCreatorAndPlatform(ctx context.Context, req *dto.ListModelReq) (list []*entity.AsynchModel, total int, err error) {
|
||||
// 基础 SQL
|
||||
sql := `
|
||||
SELECT DISTINCT ON (model_name) *
|
||||
FROM asynch_models
|
||||
WHERE deleted_at IS NULL
|
||||
AND (? = '' OR model_name LIKE ?)
|
||||
AND (? = 0 OR model_type = ?)
|
||||
`
|
||||
args := []any{
|
||||
req.ModelName, "%" + req.ModelName + "%",
|
||||
req.ModelType, req.ModelType,
|
||||
}
|
||||
if !g.IsEmpty(req.IsPrivate) {
|
||||
sql += ` AND is_private = ? `
|
||||
args = append(args, req.IsPrivate)
|
||||
}
|
||||
if req.IsOwner != nil && *req.IsOwner == 0 {
|
||||
sql += ` AND creator = ? AND is_owner = ? `
|
||||
args = append(args, req.Creator)
|
||||
args = append(args, req.IsOwner)
|
||||
} else if req.IsOwner != nil && *req.IsOwner == 1 {
|
||||
if req.Enabled != nil && *req.Enabled == 1 {
|
||||
sql += ` AND ((creator = ? AND is_owner = ? AND enabled=1) OR (is_owner = 0 AND enabled=1)) `
|
||||
} else if req.Enabled != nil && *req.Enabled == 0 {
|
||||
sql += ` AND ((creator = ? AND is_owner = ? AND enabled=0) OR (is_owner = 0 AND enabled=1)) `
|
||||
} else {
|
||||
sql += ` AND ((creator = ? AND is_owner = ?) OR (is_owner = 0 AND enabled=1)) `
|
||||
}
|
||||
args = append(args, req.Creator)
|
||||
args = append(args, req.IsOwner)
|
||||
}
|
||||
|
||||
// 最后拼接排序
|
||||
sql += ` ORDER BY model_name, is_owner DESC, created_at DESC`
|
||||
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = r.Structs(&list)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
total = len(list)
|
||||
return
|
||||
}
|
||||
|
||||
func (d *modelDao) GetByIsChatModel(ctx context.Context) (m *entity.AsynchModel, err error) {
|
||||
userInfo, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
Where(entity.AsynchModelCol.IsChatModel, 1).
|
||||
Where(entity.AsynchModelCol.Creator, userInfo.UserName).
|
||||
One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&m)
|
||||
return
|
||||
}
|
||||
|
||||
// ListAll 用于分组展示:查询全部模型(不按类型过滤,类型拆分在 service 层处理)
|
||||
func (d *modelDao) ListAll(ctx context.Context) (list []*entity.AsynchModel, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
OrderDesc(entity.AsynchModelCol.CreatedAt).
|
||||
All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
)
|
||||
|
||||
// GetByModelNameForTenant 后台任务使用:按 tenant_id + model_name 查询,不依赖 gfdb Hook/Trace/用户上下文
|
||||
func (d *modelDao) GetByModelNameForTenant(ctx context.Context, tenantId uint64, modelName string) (m *entity.AsynchModel, err error) {
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx,
|
||||
"SELECT * FROM "+public.TableNameModel+" WHERE tenant_id=? AND model_name=? AND deleted_at IS NULL LIMIT 1",
|
||||
tenantId, modelName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
var list []*entity.AsynchModel
|
||||
if err := r.Structs(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
)
|
||||
|
||||
type opLogDao struct{}
|
||||
|
||||
var OpLog = &opLogDao{}
|
||||
|
||||
func (d *opLogDao) Insert(ctx context.Context, log *entity.LogsModelOp) (id int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameOpLog).Data(log).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type statDao struct{}
|
||||
|
||||
var Stat = &statDao{}
|
||||
|
||||
// IncRequestCount 原子累加(支持分布式/多协程):按天+租户+创建人+模型 +1
|
||||
func (d *statDao) IncRequestCount(ctx context.Context, day time.Time, tenantId int64, creator, modelName string) error {
|
||||
sql := fmt.Sprintf(`
|
||||
INSERT INTO %s(day, tenant_id, creator, model_name, request_count, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, 1, NOW(), NOW())
|
||||
ON CONFLICT (day, tenant_id, creator, model_name)
|
||||
DO UPDATE SET request_count = %s.request_count + 1, updated_at = NOW()`,
|
||||
public.TableNameStat, public.TableNameStat,
|
||||
)
|
||||
_, err := gfdb.DB(ctx).Exec(ctx, sql, gtime.New(day).Format("Y-m-d"), tenantId, creator, modelName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *statDao) List(ctx context.Context, pageNum, pageSize int, startDay, endDay string, tenantId *int64, creator, modelName string) (list []*entity.LogsModelStat, total int64, err error) {
|
||||
m := gfdb.DB(ctx).Model(ctx, public.TableNameStat).Where("1=1")
|
||||
if startDay != "" {
|
||||
m = m.Where("day >= ?", startDay)
|
||||
}
|
||||
if endDay != "" {
|
||||
m = m.Where("day <= ?", endDay)
|
||||
}
|
||||
if tenantId != nil {
|
||||
m = m.Where("tenant_id = ?", *tenantId)
|
||||
}
|
||||
if creator != "" {
|
||||
m = m.WhereLike("creator", "%"+creator+"%")
|
||||
}
|
||||
if modelName != "" {
|
||||
m = m.WhereLike("model_name", "%"+modelName+"%")
|
||||
}
|
||||
m = m.OrderDesc("day").OrderDesc("request_count")
|
||||
if pageNum > 0 && pageSize > 0 {
|
||||
m = m.Page(pageNum, pageSize)
|
||||
}
|
||||
r, totalInt, err := m.AllAndCount(false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total = int64(totalInt)
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
-250
@@ -1,250 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Task = &taskDao{}
|
||||
|
||||
type taskDao struct{}
|
||||
|
||||
func (d *taskDao) Insert(ctx context.Context, t *entity.AsynchTask) (id int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).Data(t).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *taskDao) GetByTaskID(ctx context.Context, taskID string) (t *entity.AsynchTask, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
Where(entity.AsynchTaskCol.TaskID, taskID).
|
||||
One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&t)
|
||||
return
|
||||
}
|
||||
|
||||
// ListByTaskIDs 批量查询任务(会受 gfdb 的租户 Hook 影响,只返回当前租户数据)
|
||||
func (d *taskDao) ListByTaskIDs(ctx context.Context, taskIDs []string) (list []*entity.AsynchTask, err error) {
|
||||
if len(taskIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
WhereIn(entity.AsynchTaskCol.TaskID, taskIDs).
|
||||
All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// MarkDownloadedByID 将成功任务标记为已下载(state=4),并写入过期时间
|
||||
func (d *taskDao) MarkDownloadedByID(ctx context.Context, id int64, expireAt *gtime.Time) error {
|
||||
data := gdb.Map{
|
||||
entity.AsynchTaskCol.State: 4,
|
||||
entity.AsynchTaskCol.ExpireAt: expireAt,
|
||||
entity.AsynchTaskCol.Updater: "",
|
||||
}
|
||||
_, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
Where(entity.AsynchTaskCol.Id, id).
|
||||
Where(entity.AsynchTaskCol.State, 2).
|
||||
Data(data).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *taskDao) UpdateRunning(ctx context.Context, id int64) error {
|
||||
now := gtime.Now()
|
||||
data := gdb.Map{
|
||||
entity.AsynchTaskCol.State: 1,
|
||||
entity.AsynchTaskCol.StartedAt: now,
|
||||
entity.AsynchTaskCol.Updater: "",
|
||||
}
|
||||
_, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
Where(entity.AsynchTaskCol.Id, id).
|
||||
Data(data).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *taskDao) UpdateSuccess(ctx context.Context, id int64, ossFile, fileType string, fileSize int64, expireAt *gtime.Time) error {
|
||||
now := gtime.Now()
|
||||
data := gdb.Map{
|
||||
entity.AsynchTaskCol.State: 2,
|
||||
entity.AsynchTaskCol.OssFile: ossFile,
|
||||
entity.AsynchTaskCol.FileType: fileType,
|
||||
entity.AsynchTaskCol.FileSize: fileSize,
|
||||
entity.AsynchTaskCol.ErrorMsg: "",
|
||||
entity.AsynchTaskCol.FinishedAt: now,
|
||||
entity.AsynchTaskCol.ExpireAt: expireAt,
|
||||
entity.AsynchTaskCol.Updater: "",
|
||||
}
|
||||
_, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
Where(entity.AsynchTaskCol.Id, id).
|
||||
Data(data).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *taskDao) UpdateFailed(ctx context.Context, id int64, errorMsg string) error {
|
||||
now := gtime.Now()
|
||||
data := gdb.Map{
|
||||
entity.AsynchTaskCol.State: 3,
|
||||
entity.AsynchTaskCol.ErrorMsg: errorMsg,
|
||||
entity.AsynchTaskCol.FinishedAt: now,
|
||||
entity.AsynchTaskCol.Updater: "",
|
||||
}
|
||||
_, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
Where(entity.AsynchTaskCol.Id, id).
|
||||
Data(data).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *taskDao) SoftDeleteByTaskID(ctx context.Context, taskID string) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
Where(entity.AsynchTaskCol.TaskID, taskID).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
// CountActiveByModel 统计某模型排队中/执行中的任务数,用于 queue_limit 限制(近似值)
|
||||
func (d *taskDao) CountActiveByModel(ctx context.Context, modelName string) (int64, error) {
|
||||
n, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
Where(entity.AsynchTaskCol.ModelName, modelName).
|
||||
WhereIn(entity.AsynchTaskCol.State, []int{0, 1}).
|
||||
Count()
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
// List 任务分页查询(受 gfdb 租户 Hook 影响)
|
||||
func (d *taskDao) List(ctx context.Context, pageNum, pageSize int, modelNameLike, taskIDLike string, state *int) (list []*entity.AsynchTask, total int64, err error) {
|
||||
m := gfdb.DB(ctx).Model(ctx, public.TableNameTask).Where("deleted_at IS NULL")
|
||||
if modelNameLike != "" {
|
||||
m = m.WhereLike(entity.AsynchTaskCol.ModelName, "%"+modelNameLike+"%")
|
||||
}
|
||||
if taskIDLike != "" {
|
||||
m = m.WhereLike(entity.AsynchTaskCol.TaskID, "%"+taskIDLike+"%")
|
||||
}
|
||||
if state != nil {
|
||||
m = m.Where(entity.AsynchTaskCol.State, *state)
|
||||
}
|
||||
m = m.OrderDesc(entity.AsynchTaskCol.CreatedAt)
|
||||
if pageNum > 0 && pageSize > 0 {
|
||||
m = m.Page(pageNum, pageSize)
|
||||
}
|
||||
r, totalInt, err := m.AllAndCount(false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total = gconv.Int64(totalInt)
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// ClaimPending 抢占 pending 任务(state=0),并在同一事务中更新为 running(state=1)
|
||||
// 使用 PostgreSQL: FOR UPDATE SKIP LOCKED 避免多 worker 重复消费
|
||||
func (d *taskDao) ClaimPending(ctx context.Context, batchSize int) (tasks []*entity.AsynchTask, err error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
err = gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
sql := fmt.Sprintf(
|
||||
`SELECT id, tenant_id, model_name, task_id, input_ref, request_payload
|
||||
FROM %s
|
||||
WHERE deleted_at IS NULL AND state = 0
|
||||
ORDER BY created_at ASC
|
||||
LIMIT %d
|
||||
FOR UPDATE SKIP LOCKED`,
|
||||
public.TableNameTask,
|
||||
batchSize,
|
||||
)
|
||||
r, err := tx.GetAll(sql)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
tasks = nil
|
||||
return nil
|
||||
}
|
||||
if err := r.Structs(&tasks); err != nil {
|
||||
return err
|
||||
}
|
||||
// 更新为 running
|
||||
now := time.Now()
|
||||
for _, t := range tasks {
|
||||
// tx.Model 不走 gfdb Hook,这里手动更新必要字段
|
||||
_, err = tx.Exec(
|
||||
fmt.Sprintf(`UPDATE %s SET state=1, started_at=?, updated_at=? WHERE id=?`, public.TableNameTask),
|
||||
now, now, t.Id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ListExpiredSuccess 获取已成功且过期的任务
|
||||
func (d *taskDao) ListExpiredSuccess(ctx context.Context, limit int) (list []*entity.AsynchTask, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
Where(entity.AsynchTaskCol.State, 2).
|
||||
Where(entity.AsynchTaskCol.ExpireAt+" IS NOT NULL").
|
||||
Where(entity.AsynchTaskCol.ExpireAt+" < ?", gtime.Now()).
|
||||
Limit(limit).
|
||||
All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// ListTimeoutTasks 获取超时的排队/执行中任务
|
||||
func (d *taskDao) ListTimeoutTasks(ctx context.Context, timeout time.Duration, limit int) (list []*entity.AsynchTask, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
deadline := gtime.New(time.Now().Add(-timeout))
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameTask).
|
||||
WhereIn(entity.AsynchTaskCol.State, []int{0, 1}).
|
||||
Where(entity.AsynchTaskCol.UpdatedAt+" < ?", deadline).
|
||||
Limit(limit).
|
||||
All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// DebugPing 用于启动时检测数据库连通性(可选)
|
||||
func (d *taskDao) DebugPing(ctx context.Context) error {
|
||||
_, err := gfdb.DB(ctx).GetAll(ctx, "SELECT 1")
|
||||
return err
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// ClaimPendingGlobal 后台任务使用:全局抢占 pending 任务(不加 tenant 过滤)
|
||||
func (d *taskDao) ClaimPendingGlobal(ctx context.Context, batchSize int) (tasks []*entity.AsynchTask, err error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
err = gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
sql := fmt.Sprintf(
|
||||
`SELECT id, tenant_id, creator, model_name, task_id, biz_name, callback_url, model_key, input_ref, request_payload, phase, tmp_file
|
||||
FROM %s
|
||||
WHERE deleted_at IS NULL AND state = 0
|
||||
ORDER BY enqueue_at ASC
|
||||
LIMIT %d
|
||||
FOR UPDATE SKIP LOCKED`,
|
||||
public.TableNameTask,
|
||||
batchSize,
|
||||
)
|
||||
r, err := tx.GetAll(sql)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
tasks = nil
|
||||
return nil
|
||||
}
|
||||
if err := r.Structs(&tasks); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for _, t := range tasks {
|
||||
_, err = tx.Exec(
|
||||
fmt.Sprintf(`UPDATE %s SET state=1, started_at=?, updated_at=? WHERE id=?`, public.TableNameTask),
|
||||
now, now, t.Id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ClaimPendingByTaskIDGlobal 按 task_id 定向抢占单个 pending 任务(不加 tenant 过滤)
|
||||
// 用于 createTask 创建成功后立即异步尝试执行当前任务,避免只依赖后续 runWork 扫描队列。
|
||||
func (d *taskDao) ClaimPendingByTaskIDGlobal(ctx context.Context, taskID string) (task *entity.AsynchTask, err error) {
|
||||
if taskID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
err = gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
sql := fmt.Sprintf(
|
||||
`SELECT id, tenant_id, creator, model_name, task_id, biz_name, callback_url, model_key, input_ref, request_payload, phase, tmp_file
|
||||
FROM %s
|
||||
WHERE deleted_at IS NULL AND state = 0 AND task_id = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED`,
|
||||
public.TableNameTask,
|
||||
)
|
||||
r, err := tx.GetOne(sql, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
task = nil
|
||||
return nil
|
||||
}
|
||||
if err := r.Struct(&task); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = tx.Exec(
|
||||
fmt.Sprintf(`UPDATE %s SET state=1, started_at=?, updated_at=? WHERE id=?`, public.TableNameTask),
|
||||
now, now, task.Id,
|
||||
)
|
||||
return err
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (d *taskDao) UpdateSuccessGlobal(ctx context.Context, id int64, ossFile, fileType, textResult string, fileSize int64, expireAt *gtime.Time, expendTokens int) error {
|
||||
now := gtime.Now()
|
||||
_, err := gfdb.DB(ctx).Exec(ctx,
|
||||
fmt.Sprintf(`UPDATE %s
|
||||
SET state=2,
|
||||
oss_file=?,
|
||||
file_type=?,
|
||||
text_result=?,
|
||||
expend_tokens=?,
|
||||
file_size=?,
|
||||
error_msg='',
|
||||
finished_at=?,
|
||||
duration_seconds=EXTRACT(EPOCH FROM (? - created_at))::BIGINT,
|
||||
expire_at=NULL,
|
||||
phase=0,
|
||||
tmp_file='',
|
||||
updated_at=?
|
||||
WHERE id=?`, public.TableNameTask),
|
||||
ossFile, fileType, textResult, expendTokens, fileSize, now, now, now, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *taskDao) UpdateFailedGlobal(ctx context.Context, id int64, errorMsg string) error {
|
||||
now := gtime.Now()
|
||||
_, err := gfdb.DB(ctx).Exec(ctx,
|
||||
fmt.Sprintf(`UPDATE %s
|
||||
SET state=3,
|
||||
error_msg=?,
|
||||
finished_at=?,
|
||||
duration_seconds=EXTRACT(EPOCH FROM (? - created_at))::BIGINT,
|
||||
phase=0,
|
||||
tmp_file='',
|
||||
updated_at=?
|
||||
WHERE id=?`, public.TableNameTask),
|
||||
errorMsg, now, now, now, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateFailedKeepTmpGlobal OSS 上传失败:保留 phase/tmp_file,下一轮仅重试 OSS 上传
|
||||
func (d *taskDao) UpdateFailedKeepTmpGlobal(ctx context.Context, id int64, errorMsg string) error {
|
||||
now := gtime.Now()
|
||||
_, err := gfdb.DB(ctx).Exec(ctx,
|
||||
fmt.Sprintf(`UPDATE %s SET state=3, error_msg=?, finished_at=?, phase=1, updated_at=? WHERE id=?`, public.TableNameTask),
|
||||
errorMsg, now, now, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTmpAfterModelGlobal 模型调用成功后,写入临时文件路径并标记 phase=1
|
||||
func (d *taskDao) UpdateTmpAfterModelGlobal(ctx context.Context, id int64, tmpFile string) error {
|
||||
_, err := gfdb.DB(ctx).Exec(ctx,
|
||||
fmt.Sprintf(`UPDATE %s SET phase=1, tmp_file=?, updated_at=NOW() WHERE id=?`, public.TableNameTask),
|
||||
tmpFile, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *taskDao) SoftDeleteByTaskIDGlobal(ctx context.Context, taskID string) error {
|
||||
_, err := gfdb.DB(ctx).Exec(ctx,
|
||||
fmt.Sprintf(`UPDATE %s SET deleted_at=NOW(), updated_at=NOW() WHERE task_id=? AND deleted_at IS NULL`, public.TableNameTask),
|
||||
taskID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *taskDao) RollbackToPendingGlobal(ctx context.Context, id int64) error {
|
||||
_, err := gfdb.DB(ctx).Exec(ctx,
|
||||
fmt.Sprintf(`UPDATE %s SET state=0, enqueue_at=NOW(), updated_at=NOW() WHERE id=? AND state=1`, public.TableNameTask),
|
||||
id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListExpiredDownloadedGlobal 获取已下载(state=4)且过期的任务,用于清理
|
||||
func (d *taskDao) ListExpiredDownloadedGlobal(ctx context.Context, limit int) (list []*entity.AsynchTask, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx,
|
||||
fmt.Sprintf(`SELECT * FROM %s WHERE deleted_at IS NULL AND state=4 AND expire_at IS NOT NULL AND expire_at < ? LIMIT ?`, public.TableNameTask),
|
||||
gtime.Now(), limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// ListFailedRetryableGlobal 获取失败(state=3)且仍可重试的任务
|
||||
// retry_count 不含首次执行;retry_times 表示失败后最多再重试 N 次
|
||||
func (d *taskDao) ListFailedRetryableGlobal(ctx context.Context, limit int) (list []*entity.AsynchTask, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx,
|
||||
fmt.Sprintf(`
|
||||
SELECT t.*,
|
||||
m.retry_queue_max_seconds AS retry_queue_max_seconds
|
||||
FROM %s t
|
||||
JOIN %s m
|
||||
ON t.tenant_id = m.tenant_id
|
||||
AND t.model_name = m.model_name
|
||||
WHERE t.deleted_at IS NULL
|
||||
AND t.state = 3
|
||||
AND t.retry_count < m.retry_times
|
||||
ORDER BY t.updated_at ASC
|
||||
LIMIT ?`, public.TableNameTask, public.TableNameModel),
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// RequeueForRetryGlobal 将任务重新入队(state=0),并将 retry_count +1
|
||||
// enqueueAt 用于控制重试任务在队列中的位置:
|
||||
// - enqueueAt 越早,越靠前(ClaimPendingGlobal 按 enqueue_at ASC 抢占)
|
||||
func (d *taskDao) RequeueForRetryGlobal(ctx context.Context, id int64, enqueueAt time.Time) error {
|
||||
_, err := gfdb.DB(ctx).Exec(ctx,
|
||||
fmt.Sprintf(`UPDATE %s SET state=0, retry_count=retry_count+1, enqueue_at=?, updated_at=NOW() WHERE id=? AND state=3 AND deleted_at IS NULL`, public.TableNameTask),
|
||||
enqueueAt, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListFailedExhaustedGlobal 获取失败(state=3)且超过重试次数的任务,用于硬删除
|
||||
func (d *taskDao) ListFailedExhaustedGlobal(ctx context.Context, limit int) (list []*entity.AsynchTask, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx,
|
||||
fmt.Sprintf(`
|
||||
SELECT t.*
|
||||
FROM %s t
|
||||
JOIN %s m
|
||||
ON t.tenant_id = m.tenant_id
|
||||
AND t.model_name = m.model_name
|
||||
WHERE t.deleted_at IS NULL
|
||||
AND t.state = 3
|
||||
AND t.retry_count >= m.retry_times
|
||||
ORDER BY t.updated_at ASC
|
||||
LIMIT ?`, public.TableNameTask, public.TableNameModel),
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// HardDeleteByIDGlobal 硬删除任务记录
|
||||
func (d *taskDao) HardDeleteByIDGlobal(ctx context.Context, id int64) error {
|
||||
_, err := gfdb.DB(ctx).Exec(ctx,
|
||||
fmt.Sprintf(`DELETE FROM %s WHERE id=?`, public.TableNameTask),
|
||||
id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListTimeoutTasksGlobal 根据模型配置 expected_seconds 判定超时任务:
|
||||
// - state in (0,1)
|
||||
// - 模型 expected_seconds > 0
|
||||
// - now - created_at >= expected_seconds
|
||||
func (d *taskDao) ListTimeoutTasksGlobal(ctx context.Context, limit int) (list []*entity.AsynchTask, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx,
|
||||
fmt.Sprintf(`
|
||||
SELECT t.*
|
||||
FROM %s t
|
||||
JOIN %s m
|
||||
ON t.tenant_id = m.tenant_id
|
||||
AND t.model_name = m.model_name
|
||||
WHERE t.deleted_at IS NULL
|
||||
AND t.state IN (0,1)
|
||||
AND m.expected_seconds > 0
|
||||
AND t.created_at < (NOW() - (m.expected_seconds || ' seconds')::interval)
|
||||
LIMIT ?`, public.TableNameTask, public.TableNameModel),
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
@@ -1,22 +1,15 @@
|
||||
module model-gateway
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
gitea.com/red-future/common v0.0.19
|
||||
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0
|
||||
github.com/gogf/gf/v2 v2.10.0
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.32
|
||||
github.com/bjang03/gmq v0.0.3
|
||||
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.14.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/r3labs/diff/v2 v2.15.1 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
github.com/tidwall/gjson v1.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -33,9 +26,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
|
||||
@@ -57,7 +54,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.2 // 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
|
||||
@@ -65,15 +63,24 @@ 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/redis/go-redis/v9 v9.12.1 // indirect
|
||||
github.com/r3labs/diff/v2 v2.15.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/tidwall/sjson v1.2.5
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tiger1103/gfast-token v1.0.10 // indirect
|
||||
github.com/vcaesar/cedar v0.30.0 // indirect
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.4.0 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
@@ -84,10 +91,13 @@ 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.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.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
|
||||
google.golang.org/grpc v1.75.0 // indirect
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
gitea.com/red-future/common v0.0.19 h1:9/WrfCFUCeFUYwuhBYF+JOQi5F5xuOy+gVnf2ZvHZu4=
|
||||
gitea.com/red-future/common v0.0.19/go.mod h1:6/nqIucVzmjOyqDTIq71feYBXXFNBy0rFwzaQ0/Ueoo=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.31 h1:9H8nL5Drazcv7Hs9d4j+cXhaB+7uOllIUqEOyZy1Eao=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.31/go.mod h1:xPU7aaMxn8rtNnWc2LDUXZL+IkaUkpQeLgflqw9FvdU=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.32/go.mod h1:xPU7aaMxn8rtNnWc2LDUXZL+IkaUkpQeLgflqw9FvdU=
|
||||
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 +20,11 @@ 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/bjang03/gmq v0.0.2 h1:3CcVorDXYoRIN65bbzwRuUxzkBCkEpHWmKHOkfXzUo0=
|
||||
github.com/bjang03/gmq v0.0.2/go.mod h1:Y7TwWGuV4Cw97WUDaM7x+NC4kyFx1z44WAvNwJV3HV8=
|
||||
github.com/bjang03/gmq v0.0.3/go.mod h1:Y7TwWGuV4Cw97WUDaM7x+NC4kyFx1z44WAvNwJV3HV8=
|
||||
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 +69,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,17 +86,27 @@ 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/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 h1:39+jbTenm7KBj4hO2C8ANAxVHpX/7OuRDs1VcGC9ylA=
|
||||
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0/go.mod h1:B0s0fVzn0W220E8UTpSGzrrGKsop5KcB90twBeLCiz0=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0 h1:N/F9CuDdUZLoM1nVRqrDE/33pDZuhVxpNY4wYdeIaBs=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0/go.mod h1:x6uoJGfZOtirIRQls8xUlYzC6f7T/eULPUa9er368X0=
|
||||
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=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2/go.mod h1:ZRw3GNz5cq4uYrW4TPSVyrYWaoqzujKdWro/AOcGBaE=
|
||||
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 h1:eUqwJ/qNH8lJ6yssiqskazgp1ACQuNU6zXlLOZVuXTQ=
|
||||
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5/go.mod h1:sjQyMry9+0POYZCA6lHXBxO77WoNKkruJpRB4xKqk5k=
|
||||
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 h1:tHUEZYB5GTqEYYVDYnlGobf1xISARKDE4KHVlgjwTec=
|
||||
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5/go.mod h1:cfzTn2HS9RDX8f5pUVkbGxUWcSosouqfNQ1G6cY0V88=
|
||||
github.com/gogf/gf/v2 v2.10.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs=
|
||||
github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
||||
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
@@ -185,8 +205,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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
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 +218,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 +256,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 +294,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 +308,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 +322,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 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo=
|
||||
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/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/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=
|
||||
@@ -307,6 +344,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=
|
||||
@@ -329,6 +368,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=
|
||||
@@ -336,6 +377,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=
|
||||
@@ -344,8 +387,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.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
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=
|
||||
@@ -361,8 +404,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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
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=
|
||||
@@ -397,15 +440,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
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=
|
||||
@@ -415,8 +458,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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
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=
|
||||
@@ -426,6 +469,8 @@ gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
|
||||
@@ -2,90 +2,66 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/service"
|
||||
"model-gateway/service/utils"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"model-gateway/controller"
|
||||
"model-gateway/service"
|
||||
|
||||
"gitea.com/red-future/common/http"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
_ "gitea.com/red-future/common/swagger"
|
||||
"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.Model,
|
||||
controller.Task,
|
||||
controller.Stat,
|
||||
controller.ModelCall,
|
||||
controller.ModelManage,
|
||||
})
|
||||
|
||||
// 本地调试:可选自动触发 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(),
|
||||
},
|
||||
})
|
||||
|
||||
// 监听退出信号,确保 Ctrl+C 能完整退出(停止 worker/cleaner 并关闭 http server)
|
||||
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 能完整退出(停掉定时器与协程池,等任务执行完成再关闭)
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
g.Log().Infof(ctx, "[main] 收到退出信号,开始优雅退出...")
|
||||
cancel()
|
||||
// 关闭 http server(RouteRegister 内部是 go Httpserver.Run() 启动的)
|
||||
_ = http.Httpserver.Shutdown()
|
||||
}
|
||||
utils.Shutdown()
|
||||
g.Log().Infof(ctx, "[main] 全局协程池已关闭")
|
||||
|
||||
func startAutoRunner(ctx context.Context) {
|
||||
// worker
|
||||
if g.Cfg().MustGet(ctx, "asynch.worker.enabled").Bool() {
|
||||
interval := g.Cfg().MustGet(ctx, "asynch.worker.intervalSeconds").Int()
|
||||
if interval <= 0 {
|
||||
interval = 5
|
||||
}
|
||||
batchSize := g.Cfg().MustGet(ctx, "asynch.worker.batchSize").Int()
|
||||
goroutines := g.Cfg().MustGet(ctx, "asynch.worker.goroutines").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 := service.AsyncWorker.RunOnce(ctx, batchSize, goroutines); err != nil {
|
||||
g.Log().Warningf(ctx, "[auto-worker] run once failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// cleaner
|
||||
if g.Cfg().MustGet(ctx, "asynch.cleaner.enabled").Bool() {
|
||||
interval := g.Cfg().MustGet(ctx, "asynch.cleaner.intervalSeconds").Int()
|
||||
if interval <= 0 {
|
||||
interval = 30
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
service.Cleaner.RunOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// 收到退出信号后,关闭全局协程池,等待所有已提交的任务执行完成
|
||||
g.Log().Info(ctx, "服务正在关闭...")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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 {
|
||||
SystemPrompt string `json:"system_prompt" dc:"系统提示词,用于描述系统的需求"`
|
||||
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,156 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"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"`
|
||||
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 写入请求体)"`
|
||||
}
|
||||
|
||||
type ModelErrorResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type ModelError1Resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ModelMsg struct {
|
||||
TaskID int64 `json:"id" dc:"任务ID"`
|
||||
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:"枚举值列表"`
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// CreateModelReq 添加模型配置
|
||||
type CreateModelReq struct {
|
||||
g.Meta `path:"/createModel" method:"post" tags:"模型管理" summary:"创建模型配置" dc:"添加新的模型配置"`
|
||||
ModelName string `p:"modelName" json:"modelName" v:"required#modelName不能为空" dc:"模型名称(唯一标识)"`
|
||||
ModelType int `p:"modelType" json:"modelType" v:"required#modelType不能为空" dc:"模型类型:1-文本生成 2-图像生成 3-语音 4-视频 5-多模态"`
|
||||
BaseURL string `p:"baseUrl" json:"baseUrl" v:"required#baseUrl不能为空" dc:"模型服务基础地址(如 http(s)://host:port)"`
|
||||
HttpMethod string `p:"httpMethod" json:"httpMethod" dc:"请求方式:GET/POST(默认POST)"`
|
||||
HeadMsg string `p:"headMsg" json:"headMsg" dc:"请求头绑定(支持多个,逗号分隔),示例:Authorization:Bearer xxx,Content-Type:application/json"`
|
||||
IsPrivate *int `p:"isPrivate" json:"isPrivate" v:"in:0,1#私有化参数只能为0或1" dc:"是否私有化:0-私有(默认) 1-公共"`
|
||||
Enabled *int `p:"enabled" json:"enabled" v:"in:0,1#启用参数只能为0或1" dc:"是否启用:0-禁用,1-启用(默认1)"`
|
||||
IsChatModel *int `p:"isChatModel" json:"isChatModel" v:"in:0,1#对话模型参数只能为0或1" dc:"是否为对话模型:0-否,1-是(默认0)"`
|
||||
IsOwner *int `p:"isOwner" json:"isOwner" v:"in:0,1#是否为所有者参数只能为0或1" dc:"是否为所有者:0-否,1-是(默认0)"`
|
||||
ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥,用于模型认证"`
|
||||
Form any `p:"form" json:"form" dc:"动态表单配置(JSON),用于前端渲染配置项"`
|
||||
RequestMapping any `p:"requestMapping" json:"requestMapping" dc:"请求映射"`
|
||||
ResponseMapping any `p:"responseMapping" json:"responseMapping" dc:"返回映射"`
|
||||
ResponseBody any `p:"responseBody" json:"responseBody" dc:"返回主体"`
|
||||
TokenMapping string `p:"tokenMapping" json:"tokenMapping" dc:"token映射"`
|
||||
MaxConcurrency int `p:"maxConcurrency" json:"maxConcurrency" dc:"最大并发数(默认10)"`
|
||||
QueueLimit int `p:"queueLimit" json:"queueLimit" dc:"排队队列上限(默认1000)"`
|
||||
TimeoutSeconds int `p:"timeoutSeconds" json:"timeoutSeconds" dc:"请求超时时间(秒,默认600)"`
|
||||
ExpectedSeconds int `p:"expectedSeconds" json:"expectedSeconds" dc:"模型预计执行时间(秒,默认600)"`
|
||||
RetryTimes int `p:"retryTimes" json:"retryTimes" dc:"失败重试次数(默认3)"`
|
||||
RetryQueueMaxSeconds int `p:"retryQueueMaxSeconds" json:"retryQueueMaxSeconds" dc:"失败重试最大排队时间(秒,默认600)"`
|
||||
AutoCleanSeconds int `p:"autoCleanSeconds" json:"autoCleanSeconds" dc:"任务完成后自动清理时间(秒,默认86400)"`
|
||||
Remark string `p:"remark" json:"remark" dc:"备注说明"`
|
||||
}
|
||||
|
||||
type CreateModelRes struct {
|
||||
ID int64 `json:"id,string" dc:"配置ID"`
|
||||
}
|
||||
|
||||
type UpdateModelReq struct {
|
||||
g.Meta `path:"/updateModel" method:"put" tags:"模型管理" summary:"更新模型配置" dc:"更新指定ID的模型配置"`
|
||||
ID int64 `p:"id" json:"id" v:"required#id不能为空" dc:"配置ID"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(唯一标识)"`
|
||||
ModelType int `p:"modelType" json:"modelType" dc:"模型类型ID列表(逗号分隔)(可选更新)"`
|
||||
BaseURL string `p:"baseUrl" json:"baseUrl" dc:"模型服务基础地址"`
|
||||
HttpMethod string `p:"httpMethod" json:"httpMethod" dc:"请求方式:GET/POST(可选更新)"`
|
||||
HeadMsg string `p:"headMsg" json:"headMsg" dc:"请求头绑定(可选更新)"`
|
||||
ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥,用于模型认证(可选更新)"`
|
||||
Form any `p:"form" json:"form" dc:"动态表单配置(JSON)(可选更新)"`
|
||||
RequestMapping any `p:"requestMapping" json:"requestMapping" dc:"请求参数映射(可选更新)"`
|
||||
ResponseMapping any `p:"responseMapping" json:"responseMapping" dc:"返回参数映射(可选更新)"`
|
||||
ResponseBody any `p:"responseBody" json:"responseBody" dc:"返回主体(可选更新)"`
|
||||
TokenMapping string `p:"tokenMapping" json:"tokenMapping" dc:"token映射(可选更新)"`
|
||||
Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-禁用,1-启用(可选更新)"`
|
||||
IsPrivate *int `p:"isPrivate" json:"isPrivate" v:"in:0,1#私有化参数只能为0或1" dc:"是否私有化:0-私有(默认) 1-公共"`
|
||||
IsChatModel *int `p:"isChatModel" json:"isChatModel" v:"in:0,1#对话模型参数只能为0或1" dc:"是否为对话模型:0-否,1-是(默认0)"`
|
||||
IsOwner *int `p:"isOwner" json:"isOwner" v:"in:0,1#是否为所有者参数只能为0或1" dc:"是否为所有者:0-否,1-是(默认0)"`
|
||||
MaxConcurrency int `p:"maxConcurrency" json:"maxConcurrency" dc:"最大并发数(可选更新)"`
|
||||
QueueLimit int `p:"queueLimit" json:"queueLimit" dc:"排队队列上限(可选更新)"`
|
||||
TimeoutSeconds int `p:"timeoutSeconds" json:"timeoutSeconds" dc:"请求超时时间(秒)(可选更新)"`
|
||||
ExpectedSeconds int `p:"expectedSeconds" json:"expectedSeconds" dc:"模型预计执行时间(秒)(可选更新)"`
|
||||
RetryTimes int `p:"retryTimes" json:"retryTimes" dc:"失败重试次数(可选更新)"`
|
||||
RetryQueueMaxSeconds int `p:"retryQueueMaxSeconds" json:"retryQueueMaxSeconds" dc:"失败重试最大排队时间(秒)(可选更新)"`
|
||||
AutoCleanSeconds int `p:"autoCleanSeconds" json:"autoCleanSeconds" dc:"自动清理间隔(秒)(可选更新)"`
|
||||
Remark string `p:"remark" json:"remark" dc:"备注说明(可选更新)"`
|
||||
}
|
||||
|
||||
// DeleteModelReq 删除模型配置
|
||||
type DeleteModelReq struct {
|
||||
g.Meta `path:"/deleteModel" method:"delete" tags:"模型管理" summary:"删除模型配置" dc:"删除指定ID的模型配置"`
|
||||
ID string `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"`
|
||||
}
|
||||
|
||||
// GetModelReq 获取模型配置详情
|
||||
type GetModelReq struct {
|
||||
g.Meta `path:"/getModel" method:"get" tags:"模型管理" summary:"获取模型配置" dc:"根据模型ID获取配置详情"`
|
||||
ID int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"`
|
||||
Creator string `p:"creator" json:"creator" dc:"创建人"`
|
||||
}
|
||||
|
||||
type GetModelRes struct {
|
||||
Model any `json:"model" dc:"模型配置详情"`
|
||||
}
|
||||
|
||||
// ListModelReq 配置列表
|
||||
type ListModelReq struct {
|
||||
g.Meta `path:"/listModel" method:"get" tags:"模型管理" summary:"模型配置列表" dc:"分页获取模型配置列表"`
|
||||
Page *beans.Page `json:"page"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"`
|
||||
ModelType int `p:"modelType" json:"modelType" dc:"模型类型"`
|
||||
Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-禁用,1-启用"`
|
||||
IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化 0-私有 1-公共"`
|
||||
IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者 0-否 1-是"`
|
||||
Creator string `p:"creator" json:"creator" dc:"创建人"`
|
||||
}
|
||||
|
||||
type ListModelRes struct {
|
||||
List any `json:"list" dc:"列表数据"`
|
||||
Total int `json:"total" dc:"总数"`
|
||||
}
|
||||
|
||||
// AutoTuneReq 动态调参(由上层定时任务每小时触发一次)
|
||||
type AutoTuneReq struct {
|
||||
g.Meta `path:"/autoTune" method:"post" tags:"模型管理" summary:"动态调参" dc:"按 model_name 维度统计指定时间窗口内执行耗时(P90),动态生成运行时 max_concurrency/queue_limit(不超过配置上限),写入 Redis 供 Worker/CreateTask 使用;windowSeconds 不传默认 3600"`
|
||||
WindowSeconds int `p:"windowSeconds" json:"windowSeconds" dc:"统计窗口秒数;不传/<=0 默认 3600(1小时)"`
|
||||
}
|
||||
|
||||
type AutoTuneRes struct {
|
||||
List any `json:"list" dc:"调参结果列表"`
|
||||
}
|
||||
|
||||
type ModelTypeModelItem struct {
|
||||
ID int64 `json:"id" dc:"模型主键ID"`
|
||||
Name string `json:"name" dc:"模型名称"`
|
||||
Form any `json:"form" dc:"动态表单配置(JSON数组),用于前端渲染"`
|
||||
}
|
||||
|
||||
// ListModelTypeReq 模型类型列表(分页)
|
||||
type ListTypeReq struct {
|
||||
g.Meta `path:"/listType" method:"get" tags:"模型类型列表" summary:"模型类型列表" dc:"分页获取模型类型列表"`
|
||||
}
|
||||
|
||||
type TypeItem struct {
|
||||
Type map[int]string `json:"type" dc:"模型类型ID到名称的映射"`
|
||||
}
|
||||
|
||||
type UpdateChatModelReq struct {
|
||||
g.Meta `path:"/updateChatModel" method:"post" tags:"模型管理" summary:"更新聊天模型" dc:"更新指定模型的聊天模型"`
|
||||
Id int64 `p:"id" json:"id" v:"required#model不能为空" dc:"模型id"`
|
||||
}
|
||||
|
||||
type GetIsChatModelReq struct {
|
||||
g.Meta `path:"/getIsChatModel" method:"get" tags:"模型管理" summary:"获取模型是否为聊天模型" dc:"根据模型ID获取是否为聊天模型"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// ListModelStatReq 统计列表
|
||||
type ListModelStatReq struct {
|
||||
g.Meta `path:"/listModelStat" method:"get" tags:"统计" summary:"模型请求统计列表" dc:"按天统计模型请求次数,支持分页与条件筛选"`
|
||||
PageNum int `p:"pageNum" json:"pageNum" dc:"页码(默认1)"`
|
||||
PageSize int `p:"pageSize" json:"pageSize" dc:"每页条数(默认10)"`
|
||||
StartDay string `p:"startDay" json:"startDay" dc:"开始日期(YYYY-MM-DD,可选)"`
|
||||
EndDay string `p:"endDay" json:"endDay" dc:"结束日期(YYYY-MM-DD,可选)"`
|
||||
TenantID *int64 `p:"tenantId" json:"tenantId" dc:"租户ID(可选)"`
|
||||
Creator string `p:"creator" json:"creator" dc:"创建人(可选,模糊匹配)"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(可选,模糊匹配)"`
|
||||
}
|
||||
|
||||
type ListModelStatRes struct {
|
||||
List any `json:"list" dc:"列表数据"`
|
||||
Total int64 `json:"total" dc:"总数"`
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// CreateTaskReq 创建异步任务
|
||||
type CreateTaskReq struct {
|
||||
g.Meta `path:"/createTask" method:"post" tags:"任务管理" summary:"创建异步任务" dc:"创建异步任务并返回任务ID;创建成功后会立即异步尝试执行当前任务,执行成功后按回调配置触发钩子"`
|
||||
ModelName string `p:"modelName" json:"modelName" v:"required#modelName不能为空" dc:"模型名称"`
|
||||
BizName string `p:"bizName" json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
|
||||
CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址(可选,用于后续业务通知)"`
|
||||
InputRef string `p:"inputRef" json:"inputRef" dc:"输入引用(如OSS/文件引用等)"`
|
||||
RequestPayload any `p:"requestPayload" json:"requestPayload" dc:"请求负载(透传给模型服务)"`
|
||||
EpicycleId int64 `json:"epicycleId" dc:"轮次ID"`
|
||||
}
|
||||
|
||||
type CreateTaskRes struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
// GetTaskResultReq 获取结果(只返回 oss 地址)
|
||||
type GetTaskResultReq struct {
|
||||
g.Meta `path:"/getTaskResult" method:"get" tags:"任务管理" summary:"获取任务结果" dc:"根据任务ID获取结果(只返回OSS地址)"`
|
||||
TaskID string `p:"taskId" json:"taskId" v:"required#taskId不能为空" dc:"任务ID"`
|
||||
}
|
||||
|
||||
type GetTaskResultRes struct {
|
||||
OssFile string `json:"ossFile" dc:"结果文件OSS地址"`
|
||||
State int `json:"state" dc:"任务状态"`
|
||||
}
|
||||
|
||||
// GetTaskBatchReq 批量查询任务(并对成功任务标记为已下载)
|
||||
type GetTaskBatchReq struct {
|
||||
g.Meta `path:"/getTaskBatch" method:"post" tags:"任务管理" summary:"批量查询任务" dc:"批量查询任务状态与OSS地址;对成功(state=2)的任务自动标记为已下载(state=4),并写入保留到期时间"`
|
||||
TaskIDs []string `p:"taskIds" json:"taskIds" v:"required#taskIds不能为空" dc:"任务ID列表"`
|
||||
}
|
||||
|
||||
type GetTaskBatchItem struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
State int `json:"state" dc:"任务状态"`
|
||||
OssFile string `json:"ossFile" dc:"结果文件OSS地址"`
|
||||
}
|
||||
|
||||
type GetTaskBatchRes struct {
|
||||
List []GetTaskBatchItem `json:"list" dc:"任务列表"`
|
||||
}
|
||||
|
||||
// ListTaskReq 任务列表分页查询
|
||||
type ListTaskReq struct {
|
||||
g.Meta `path:"/listTask" method:"get" tags:"任务管理" summary:"任务列表" dc:"分页查询任务列表,支持按状态/模型名称/task_id过滤"`
|
||||
PageNum int `p:"pageNum" json:"pageNum" dc:"页码(默认1)"`
|
||||
PageSize int `p:"pageSize" json:"pageSize" dc:"每页条数(默认10)"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊匹配)"`
|
||||
TaskID string `p:"taskId" json:"taskId" dc:"任务ID(模糊匹配)"`
|
||||
State *int `p:"state" json:"state" dc:"任务状态(0/1/2/3/4,可选)"`
|
||||
}
|
||||
|
||||
type ListTaskRes struct {
|
||||
List any `json:"list" dc:"列表数据"`
|
||||
Total int64 `json:"total" dc:"总数"`
|
||||
}
|
||||
|
||||
// RunWorkReq 手动触发 worker 执行一次(由上层定时任务调用)
|
||||
type RunWorkReq struct {
|
||||
g.Meta `path:"/runWork" method:"post" tags:"任务管理" summary:"执行一次Worker" dc:"手动触发一次Worker抢占并处理排队中的任务;适合处理 createTask 立即执行时未处理到的任务以及积压队列"`
|
||||
BatchSize int `p:"batchSize" json:"batchSize" dc:"本次抢占任务数量(默认10)"`
|
||||
Goroutines int `p:"goroutines" json:"goroutines" dc:"本次并发数(默认1)"`
|
||||
}
|
||||
|
||||
type RunWorkRes struct {
|
||||
Claimed int `json:"claimed" dc:"本次抢占并处理的任务数"`
|
||||
}
|
||||
|
||||
// CleanWorkReq 手动触发 cleaner 执行一次(由上层定时任务调用)
|
||||
type CleanWorkReq struct {
|
||||
g.Meta `path:"/cleanWork" method:"post" tags:"任务管理" summary:"执行一次Cleaner" dc:"手动触发一次清理/重试(用于由上层定时任务控制)"`
|
||||
}
|
||||
|
||||
type CleanWorkRes struct {
|
||||
Ok bool `json:"ok" dc:"是否执行成功"`
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package entity
|
||||
|
||||
import "gitea.com/red-future/common/beans"
|
||||
|
||||
type asynchModelCol struct {
|
||||
beans.SQLBaseCol
|
||||
ModelName string
|
||||
ModelType string
|
||||
BaseURL string
|
||||
HttpMethod string
|
||||
HeadMsg string
|
||||
FormJSON string
|
||||
RequestMapping string
|
||||
ResponseMapping string
|
||||
ResponseBody string
|
||||
TokenMapping string
|
||||
Prompt string
|
||||
IsPrivate string
|
||||
IsChatModel string
|
||||
ApiKey string
|
||||
Enabled string
|
||||
MaxConcurrency string
|
||||
QueueLimit string
|
||||
TimeoutSeconds string
|
||||
ExpectedSeconds string
|
||||
RetryTimes string
|
||||
RetryQueueMaxSecs string
|
||||
AutoCleanSeconds string
|
||||
Remark string
|
||||
IsOwner string
|
||||
}
|
||||
|
||||
var AsynchModelCol = asynchModelCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ModelName: "model_name",
|
||||
ModelType: "model_type",
|
||||
BaseURL: "base_url",
|
||||
HttpMethod: "http_method",
|
||||
HeadMsg: "head_msg",
|
||||
FormJSON: "form_json",
|
||||
RequestMapping: "request_mapping",
|
||||
ResponseMapping: "response_mapping",
|
||||
ResponseBody: "response_body",
|
||||
TokenMapping: "token_mapping",
|
||||
Prompt: "prompt",
|
||||
IsPrivate: "is_private",
|
||||
IsChatModel: "is_chat_model",
|
||||
ApiKey: "api_key",
|
||||
Enabled: "enabled",
|
||||
MaxConcurrency: "max_concurrency",
|
||||
QueueLimit: "queue_limit",
|
||||
TimeoutSeconds: "timeout_seconds",
|
||||
ExpectedSeconds: "expected_seconds",
|
||||
RetryTimes: "retry_times",
|
||||
RetryQueueMaxSecs: "retry_queue_max_seconds",
|
||||
AutoCleanSeconds: "auto_clean_seconds",
|
||||
Remark: "remark",
|
||||
IsOwner: "is_owner",
|
||||
}
|
||||
|
||||
// AsynchModel 异步模型配置
|
||||
type AsynchModel struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
ModelName string `orm:"model_name" json:"modelName"`
|
||||
ModelType int `orm:"model_type" json:"modelType"`
|
||||
BaseURL string `orm:"base_url" json:"baseUrl"`
|
||||
HttpMethod string `orm:"http_method" json:"httpMethod"`
|
||||
HeadMsg string `orm:"head_msg" json:"headMsg"`
|
||||
Form any `orm:"form_json" json:"form"`
|
||||
RequestMapping any `orm:"request_mapping" json:"requestMapping"`
|
||||
ResponseMapping any `orm:"response_mapping" json:"responseMapping"`
|
||||
ResponseBody any `orm:"response_body" json:"responseBody"`
|
||||
TokenMapping string `orm:"token_mapping" json:"tokenMapping"`
|
||||
Prompt string `orm:"prompt" json:"prompt"`
|
||||
IsPrivate *int `orm:"is_private" json:"isPrivate"`
|
||||
IsChatModel *int `orm:"is_chat_model" json:"isChatModel"`
|
||||
ApiKey string `orm:"api_key" json:"apiKey"`
|
||||
Enabled *int `orm:"enabled" json:"enabled"`
|
||||
MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency"`
|
||||
QueueLimit int `orm:"queue_limit" json:"queueLimit"`
|
||||
TimeoutSeconds int `orm:"timeout_seconds" json:"timeoutSeconds"`
|
||||
ExpectedSeconds int `orm:"expected_seconds" json:"expectedSeconds"`
|
||||
RetryTimes int `orm:"retry_times" json:"retryTimes"`
|
||||
RetryQueueMaxSeconds int `orm:"retry_queue_max_seconds" json:"retryQueueMaxSeconds"`
|
||||
AutoCleanSeconds int `orm:"auto_clean_seconds" json:"autoCleanSeconds"`
|
||||
Remark string `orm:"remark" json:"remark"`
|
||||
IsOwner *int `json:"isOwner" orm:"is_owner"` // 1=当前用户创建的,0=超级管理员的
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package entity
|
||||
|
||||
import "gitea.com/red-future/common/beans"
|
||||
|
||||
type asynchModelTypeCol struct {
|
||||
beans.SQLBaseCol
|
||||
TypeID string
|
||||
TypeName string
|
||||
Remark string
|
||||
}
|
||||
|
||||
var AsynchModelTypeCol = asynchModelTypeCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
TypeID: "type_id",
|
||||
TypeName: "type_name",
|
||||
Remark: "remark",
|
||||
}
|
||||
|
||||
// AsynchModelType 模型类型(图片/音频/视频等)
|
||||
type AsynchModelType struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
TypeID int `orm:"type_id" json:"typeId"`
|
||||
TypeName string `orm:"type_name" json:"type"`
|
||||
Remark string `orm:"remark" json:"remark"`
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type asynchTaskCol struct {
|
||||
beans.SQLBaseCol
|
||||
ModelName string
|
||||
TaskID string
|
||||
BizName string
|
||||
CallbackURL string
|
||||
ModelKey string
|
||||
State string
|
||||
OssFile string
|
||||
FileType string
|
||||
FileSize string
|
||||
ErrorMsg string
|
||||
StartedAt string
|
||||
FinishedAt string
|
||||
DurationSeconds string
|
||||
ExpireAt string
|
||||
RetryCount string
|
||||
EnqueueAt string
|
||||
Phase string
|
||||
TmpFile string
|
||||
InputRef string
|
||||
RequestPayload string
|
||||
TextResult string
|
||||
EpicycleId string
|
||||
ExpendTokens string
|
||||
}
|
||||
|
||||
var AsynchTaskCol = asynchTaskCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ModelName: "model_name",
|
||||
TaskID: "task_id",
|
||||
BizName: "biz_name",
|
||||
CallbackURL: "callback_url",
|
||||
ModelKey: "model_key",
|
||||
State: "state",
|
||||
OssFile: "oss_file",
|
||||
FileType: "file_type",
|
||||
FileSize: "file_size",
|
||||
ErrorMsg: "error_msg",
|
||||
StartedAt: "started_at",
|
||||
FinishedAt: "finished_at",
|
||||
DurationSeconds: "duration_seconds",
|
||||
ExpireAt: "expire_at",
|
||||
RetryCount: "retry_count",
|
||||
EnqueueAt: "enqueue_at",
|
||||
Phase: "phase",
|
||||
TmpFile: "tmp_file",
|
||||
InputRef: "input_ref",
|
||||
RequestPayload: "request_payload",
|
||||
TextResult: "text_result",
|
||||
EpicycleId: "epicycle_id",
|
||||
ExpendTokens: "expend_tokens",
|
||||
}
|
||||
|
||||
// AsynchTask 异步任务
|
||||
type AsynchTask struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
ModelName string `orm:"model_name" json:"modelName"`
|
||||
TaskID string `orm:"task_id" json:"taskId"`
|
||||
BizName string `orm:"biz_name" json:"bizName"`
|
||||
CallbackURL string `orm:"callback_url" json:"callbackUrl"`
|
||||
ModelKey string `orm:"model_key" json:"modelKey"`
|
||||
State int `orm:"state" json:"state"` // 0排队中/1执行中/2成功/3失败/4已下载
|
||||
OssFile string `orm:"oss_file" json:"ossFile"`
|
||||
FileType string `orm:"file_type" json:"fileType"`
|
||||
FileSize int64 `orm:"file_size" json:"fileSize"`
|
||||
ErrorMsg string `orm:"error_msg" json:"errorMsg"`
|
||||
StartedAt *gtime.Time `orm:"started_at" json:"startedAt"`
|
||||
FinishedAt *gtime.Time `orm:"finished_at" json:"finishedAt"`
|
||||
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds"`
|
||||
ExpireAt *gtime.Time `orm:"expire_at" json:"expireAt"` // 已下载(state=4)后的过期时间
|
||||
RetryCount int `orm:"retry_count" json:"retryCount"`
|
||||
EnqueueAt *gtime.Time `orm:"enqueue_at" json:"enqueueAt"`
|
||||
Phase int `orm:"phase" json:"phase"` // 0模型阶段/1OSS阶段
|
||||
TmpFile string `orm:"tmp_file" json:"tmpFile"` // 临时结果文件路径
|
||||
InputRef string `orm:"input_ref" json:"inputRef"`
|
||||
RequestPayload any `orm:"request_payload" json:"requestPayload"`
|
||||
TextResult string `orm:"text_result" json:"text"`
|
||||
EpicycleId int64 `orm:"epicycle_id" json:"epicycleId"` // 轮次ID(用于标识同一轮次的任务)
|
||||
ExpendTokens int64 `orm:"expend_tokens" json:"expendTokens"` // 消耗 token 数
|
||||
RetryQueueMaxSeconds int `orm:"retry_queue_max_seconds" json:"-"`
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type LogsModelPpCol struct {
|
||||
beans.SQLBaseCol
|
||||
IP string
|
||||
UserAgent string
|
||||
APIPath string
|
||||
HttpMethod string
|
||||
BizName string
|
||||
ModelName string
|
||||
TaskID string
|
||||
OpType string
|
||||
Success string
|
||||
ErrorMsg string
|
||||
CostMs string
|
||||
RequestPayload string
|
||||
ResponsePayload string
|
||||
}
|
||||
|
||||
var LogsModelOpCol = LogsModelPpCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
IP: "ip",
|
||||
UserAgent: "user_agent",
|
||||
APIPath: "api_path",
|
||||
HttpMethod: "http_method",
|
||||
BizName: "biz_name",
|
||||
ModelName: "model_name",
|
||||
TaskID: "task_id",
|
||||
OpType: "op_type",
|
||||
Success: "success",
|
||||
ErrorMsg: "error_msg",
|
||||
CostMs: "cost_ms",
|
||||
RequestPayload: "request_payload",
|
||||
ResponsePayload: "response_payload",
|
||||
}
|
||||
|
||||
// LogsModelOp 操作日志(创建任务等)
|
||||
type LogsModelOp struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
IP string `orm:"ip" json:"ip"`
|
||||
UserAgent string `orm:"user_agent" json:"userAgent"`
|
||||
APIPath string `orm:"api_path" json:"apiPath"`
|
||||
HttpMethod string `orm:"http_method" json:"httpMethod"`
|
||||
BizName string `orm:"biz_name" json:"bizName"`
|
||||
ModelName string `orm:"model_name" json:"modelName"`
|
||||
TaskID string `orm:"task_id" json:"taskId"`
|
||||
OpType string `orm:"op_type" json:"opType"`
|
||||
Success int `orm:"success" json:"success"`
|
||||
ErrorMsg string `orm:"error_msg" json:"errorMsg"`
|
||||
CostMs int64 `orm:"cost_ms" json:"costMs"`
|
||||
RequestPayload any `orm:"request_payload" json:"requestPayload"`
|
||||
ResponsePayload any `orm:"response_payload" json:"responsePayload"`
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// LogsModelStatCol 字段常量
|
||||
type LogsModelStatCol struct {
|
||||
Day string
|
||||
TenantId string
|
||||
Creator string
|
||||
ModelName string
|
||||
RequestCount string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
var LogsModelStatCols = LogsModelStatCol{
|
||||
Day: "day",
|
||||
TenantId: "tenant_id",
|
||||
Creator: "creator",
|
||||
ModelName: "model_name",
|
||||
RequestCount: "request_count",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// LogsModelStat 按天统计:某天/租户/创建人/模型的请求次数
|
||||
// 注:这里不走通用 SQLBaseDO,采用联合唯一键(day,tenant_id,creator,model_name)做 UPSERT 原子累加。
|
||||
type LogsModelStat struct {
|
||||
Day *gtime.Time `orm:"day" json:"day"` // 日期(建议仅使用日期部分)
|
||||
TenantId int64 `orm:"tenant_id" json:"tenantId"` // 租户ID
|
||||
Creator string `orm:"creator" json:"creator"` // 创建人/操作人
|
||||
ModelName string `orm:"model_name" json:"modelName"` // 模型名称
|
||||
RequestCount int64 `orm:"request_count" json:"requestCount"` // 请求次数
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"` // 创建时间
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"` // 更新时间
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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:"视频的尾帧图像"`
|
||||
ErrorMessageMapping map[string]any `orm:"error_message_mapping" json:"errorMessageMapping" 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"`
|
||||
RequestBodyMapping map[string]any `json:"requestBodyMapping" description:"请求体映射"`
|
||||
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:"错误消息"`
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AutoTuneResult 单次调参结果(按 model_name)
|
||||
type AutoTuneResult struct {
|
||||
ModelName string `json:"modelName"` // 模型名称(asynch_models.model_name)
|
||||
Samples int `json:"samples"` // 统计样本数(窗口内 state=2/3 且 started_at/finished_at 非空的任务数量)
|
||||
P90Exec float64 `json:"p90ExecSeconds"` // 执行耗时 P90(秒),口径:finished_at - started_at
|
||||
|
||||
CapMaxConcurrency int `json:"capMaxConcurrency"` // 配置上限:asynch_models.max_concurrency(cap,不会被动态调参覆盖)
|
||||
OldMaxConcurrency int `json:"oldMaxConcurrency"` // 调参前运行时值(Redis),若无则等于 cap
|
||||
NewMaxConcurrency int `json:"newMaxConcurrency"` // 本次计算出的运行时值(将写入 Redis),受 ±50% 约束且不超过 cap
|
||||
|
||||
CapQueueLimit int `json:"capQueueLimit"` // 配置上限:asynch_models.queue_limit(cap,不会被动态调参覆盖)
|
||||
OldQueueLimit int `json:"oldQueueLimit"` // 调参前运行时值(Redis),若无则等于 cap
|
||||
NewQueueLimit int `json:"newQueueLimit"` // 本次计算出的运行时值(将写入 Redis),受 ±50% 约束且不超过 cap
|
||||
|
||||
ExpectedSeconds int `json:"expectedSeconds"` // 模型预计执行时间(秒):asynch_models.expected_seconds(用于 queue_limit 计算绑定)
|
||||
}
|
||||
|
||||
// AutoTune 由上层定时任务通过接口触发:
|
||||
// - 统计指定时间窗口内该模型任务的执行耗时(finished_at - started_at,取 P90)
|
||||
// - 基于吞吐与 P90 执行耗时估算 max_concurrency 的运行时值(不超过 cap)
|
||||
// - queue_limit 与 expected_seconds 绑定(允许排队时间 = expected_seconds * 2),生成运行时值(不超过 cap)
|
||||
// - 单次调整幅度限制 ±50%,写入 Redis(带 TTL)
|
||||
func AutoTune(ctx context.Context, windowSeconds int) ([]AutoTuneResult, error) {
|
||||
if windowSeconds <= 0 {
|
||||
windowSeconds = 3600
|
||||
}
|
||||
// 1) 读取模型配置(cap),按 model_name 聚合去重(如果表里有多租户重复数据,取较大上限)
|
||||
var modelRows []*entity.AsynchModel
|
||||
if err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
Where("deleted_at IS NULL").
|
||||
Where(entity.AsynchModelCol.Enabled, 1).
|
||||
Scan(&modelRows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelMap := make(map[string]*entity.AsynchModel)
|
||||
for _, m := range modelRows {
|
||||
if m == nil || m.ModelName == "" {
|
||||
continue
|
||||
}
|
||||
cur := modelMap[m.ModelName]
|
||||
if cur == nil {
|
||||
modelMap[m.ModelName] = m
|
||||
continue
|
||||
}
|
||||
// 取更大的 cap
|
||||
if m.MaxConcurrency > cur.MaxConcurrency {
|
||||
cur.MaxConcurrency = m.MaxConcurrency
|
||||
}
|
||||
if m.QueueLimit > cur.QueueLimit {
|
||||
cur.QueueLimit = m.QueueLimit
|
||||
}
|
||||
if m.ExpectedSeconds > cur.ExpectedSeconds {
|
||||
cur.ExpectedSeconds = m.ExpectedSeconds
|
||||
}
|
||||
}
|
||||
if len(modelMap) == 0 {
|
||||
return []AutoTuneResult{}, nil
|
||||
}
|
||||
|
||||
// 2) 统计指定窗口:按 model_name 计算 cnt 和 P90 执行耗时
|
||||
type statRow struct {
|
||||
ModelName string
|
||||
Cnt int
|
||||
P90Exec float64
|
||||
}
|
||||
var stats []statRow
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT model_name,
|
||||
COUNT(1) AS cnt,
|
||||
COALESCE(percentile_cont(0.9) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (finished_at - started_at))), 0) AS p90_exec
|
||||
FROM %s
|
||||
WHERE deleted_at IS NULL
|
||||
AND state IN (2,3)
|
||||
AND started_at IS NOT NULL
|
||||
AND finished_at IS NOT NULL
|
||||
AND finished_at >= (NOW() - (? || ' seconds')::interval)
|
||||
GROUP BY model_name`, public.TableNameTask)
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx, sql, windowSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = r.Structs(&stats)
|
||||
statMap := make(map[string]statRow, len(stats))
|
||||
for _, s := range stats {
|
||||
statMap[s.ModelName] = s
|
||||
}
|
||||
|
||||
// 3) 调参计算
|
||||
const utilization = 0.8
|
||||
const maxChangeRatio = 0.5 // ±50%
|
||||
const queueFactor = 2.0 // 与 expected_seconds 绑定:W_target = expected_seconds * 2
|
||||
|
||||
out := make([]AutoTuneResult, 0, len(modelMap))
|
||||
for modelName, m := range modelMap {
|
||||
s := statMap[modelName]
|
||||
capMax := m.MaxConcurrency
|
||||
capQueue := m.QueueLimit
|
||||
oldMax := GetRuntimeMaxConcurrency(ctx, modelName, capMax)
|
||||
oldQueue := GetRuntimeQueueLimit(ctx, modelName, capQueue)
|
||||
|
||||
// 默认:无样本则不调整
|
||||
if s.Cnt <= 0 || s.P90Exec <= 0 {
|
||||
out = append(out, AutoTuneResult{
|
||||
ModelName: modelName,
|
||||
Samples: s.Cnt,
|
||||
P90Exec: s.P90Exec,
|
||||
CapMaxConcurrency: capMax,
|
||||
OldMaxConcurrency: oldMax,
|
||||
NewMaxConcurrency: oldMax,
|
||||
CapQueueLimit: capQueue,
|
||||
OldQueueLimit: oldQueue,
|
||||
NewQueueLimit: oldQueue,
|
||||
ExpectedSeconds: m.ExpectedSeconds,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// arrival_rate ≈ 完成数/3600
|
||||
arrivalRate := float64(s.Cnt) / 3600.0
|
||||
|
||||
// desiredMax = ceil(arrivalRate * p90 / utilization)
|
||||
desiredMax := int(math.Ceil(arrivalRate * s.P90Exec / utilization))
|
||||
if desiredMax < 1 {
|
||||
desiredMax = 1
|
||||
}
|
||||
// 单次变化幅度限制
|
||||
minMax := int(math.Floor(float64(oldMax) * (1 - maxChangeRatio)))
|
||||
maxMax := int(math.Ceil(float64(oldMax) * (1 + maxChangeRatio)))
|
||||
if minMax < 1 {
|
||||
minMax = 1
|
||||
}
|
||||
newMax := clampInt(desiredMax, minMax, maxMax)
|
||||
if capMax > 0 {
|
||||
newMax = clampInt(newMax, 1, capMax)
|
||||
}
|
||||
setRuntimeInt(ctx, runtimeMaxConcurrencyKey(modelName), newMax)
|
||||
|
||||
// queue_limit:W_target = expected_seconds * queueFactor
|
||||
exp := m.ExpectedSeconds
|
||||
if exp <= 0 {
|
||||
exp = 60
|
||||
}
|
||||
wTarget := float64(exp) * queueFactor
|
||||
desiredQueue := int(math.Ceil(arrivalRate*wTarget)) + newMax
|
||||
if desiredQueue < newMax {
|
||||
desiredQueue = newMax
|
||||
}
|
||||
|
||||
newQueue := oldQueue
|
||||
if capQueue > 0 {
|
||||
minQ := int(math.Floor(float64(oldQueue) * (1 - maxChangeRatio)))
|
||||
maxQ := int(math.Ceil(float64(oldQueue) * (1 + maxChangeRatio)))
|
||||
if minQ < newMax {
|
||||
minQ = newMax
|
||||
}
|
||||
if maxQ < minQ {
|
||||
maxQ = minQ
|
||||
}
|
||||
newQueue = clampInt(desiredQueue, minQ, maxQ)
|
||||
newQueue = clampInt(newQueue, newMax, capQueue)
|
||||
setRuntimeInt(ctx, runtimeQueueLimitKey(modelName), newQueue)
|
||||
}
|
||||
|
||||
out = append(out, AutoTuneResult{
|
||||
ModelName: modelName,
|
||||
Samples: s.Cnt,
|
||||
P90Exec: s.P90Exec,
|
||||
CapMaxConcurrency: capMax,
|
||||
OldMaxConcurrency: oldMax,
|
||||
NewMaxConcurrency: newMax,
|
||||
CapQueueLimit: capQueue,
|
||||
OldQueueLimit: oldQueue,
|
||||
NewQueueLimit: newQueue,
|
||||
ExpectedSeconds: m.ExpectedSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[auto_tune] done models=%d windowSeconds=%d", len(out), windowSeconds)
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/http"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// triggerCallback 任务成功后的回调:
|
||||
// - JSON body 参数:task_id/state/oss_file/file_type/text(可选)
|
||||
func triggerCallback(ctx context.Context, t *entity.AsynchTask) {
|
||||
callbackURL := t.BizName + t.CallbackURL
|
||||
headers := forwardHeaders(ctx)
|
||||
var req struct{}
|
||||
payload := map[string]interface{}{
|
||||
"task_id": t.TaskID,
|
||||
"state": t.State,
|
||||
"oss_file": t.OssFile,
|
||||
"file_type": t.FileType,
|
||||
"text": t.TextResult,
|
||||
"error_msg": t.ErrorMsg,
|
||||
}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[回调] JSON序列化失败 taskId=%s 错误=%v", t.TaskID, err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[回调] 开始发送 taskId=%s 回调地址=%s 请求头数量=%d 消息体大小=%d字节",
|
||||
t.TaskID, callbackURL, len(headers), len(jsonData))
|
||||
|
||||
err = http.Post(ctx, callbackURL, headers, &req, jsonData)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[回调] 发送失败 taskId=%s 回调地址=%s 错误=%v", t.TaskID, callbackURL, err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[回调] 发送成功 taskId=%s 回调地址=%s 消息体大小=%d字节", t.TaskID, callbackURL, len(jsonData))
|
||||
}
|
||||
|
||||
// triggerPromptsCallback 任务成功后的提示词回调
|
||||
// - JSON body 参数:epicycleId(轮次id)/textResult(模型回答消息)
|
||||
func triggerPromptsCallback(ctx context.Context, t *entity.AsynchTask, epicycleId int64) {
|
||||
callbackURL := "prompts-core/session/sessionCallback"
|
||||
headers := forwardHeaders(ctx)
|
||||
var req struct{}
|
||||
payload := map[string]interface{}{
|
||||
"epicycleId": epicycleId,
|
||||
"text": t.TextResult,
|
||||
}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[提示词回调] JSON序列化失败 epicycleId=%d 错误=%v", epicycleId, err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[提示词回调] 开始发送 epicycleId=%d 回调地址=%s 请求头数量=%d 消息体大小=%d字节",
|
||||
t.EpicycleId, callbackURL, len(headers), len(jsonData))
|
||||
|
||||
err = http.Post(ctx, callbackURL, headers, &req, jsonData)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[提示词回调] 发送失败 epicycleId=%d 回调地址=%s 错误=%v", t.EpicycleId, callbackURL, err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[提示词回调] 发送成功 epicycleId=%d 回调地址=%s 消息体大小=%d字节", t.EpicycleId, callbackURL, len(jsonData))
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"model-gateway/dao"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var Cleaner = &cleaner{}
|
||||
|
||||
type cleaner struct{}
|
||||
|
||||
// RunOnce 由上层定时任务触发:执行一次清理/重试
|
||||
func (c *cleaner) RunOnce(ctx context.Context) {
|
||||
// 1) 清理已下载(state=4)且过期的任务(硬删除 + OSS)
|
||||
expired, err := dao.Task.ListExpiredDownloadedGlobal(ctx, 200)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[cleaner] list expired(downloaded) error: %v", err)
|
||||
} else {
|
||||
for _, t := range expired {
|
||||
deleteTmpResult(t.TmpFile)
|
||||
_ = dao.Task.HardDeleteByIDGlobal(ctx, t.Id)
|
||||
}
|
||||
g.Log().Infof(ctx, "[cleaner] expired(downloaded) cleaned, count=%d", len(expired))
|
||||
}
|
||||
|
||||
// 2) 超时任务标失败
|
||||
list, err := dao.Task.ListTimeoutTasksGlobal(ctx, 200)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[cleaner] list timeout error: %v", err)
|
||||
} else {
|
||||
for _, t := range list {
|
||||
_ = dao.Task.UpdateFailedGlobal(ctx, t.Id, "任务超时自动失败")
|
||||
ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
|
||||
}
|
||||
g.Log().Infof(ctx, "[cleaner] timeout cleaned, count=%d", len(list))
|
||||
}
|
||||
|
||||
// 3) 失败(state=3)的任务按模型配置 retry_times 重新入队(放到队尾)
|
||||
retryable, err := dao.Task.ListFailedRetryableGlobal(ctx, 200)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[cleaner] list failed retryable error: %v", err)
|
||||
} else {
|
||||
for _, t := range retryable {
|
||||
// 失败任务重新入队(state=3 -> 0)前,先严格占用 queue_limit slot;占用失败则留在失败态,下一轮再尝试
|
||||
// 获取模型配置以得到 queue_limit / expected_seconds
|
||||
m, err := dao.Model.GetByModelNameForTenant(ctx, t.TenantId, t.ModelName)
|
||||
if err != nil || m == nil {
|
||||
continue
|
||||
}
|
||||
limit := GetRuntimeQueueLimit(ctx, t.ModelName, m.QueueLimit)
|
||||
if limit > 0 {
|
||||
ok, _ := AcquireQueueSlot(ctx, t.ModelName, t.TaskID, limit, m.ExpectedSeconds)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// retry_queue_max_seconds 控制失败重试的排队策略:
|
||||
// - =0:失败重试插队到队首
|
||||
// - >0:当任务从创建到现在的排队时长 >= maxSeconds,则插队到队首;否则仍放到队尾
|
||||
now := time.Now()
|
||||
enqueueAt := now
|
||||
maxSeconds := t.RetryQueueMaxSeconds
|
||||
if maxSeconds == 0 {
|
||||
enqueueAt = now.Add(-100 * 365 * 24 * time.Hour)
|
||||
} else if maxSeconds > 0 && t.CreatedAt != nil {
|
||||
if now.Sub(t.CreatedAt.Time) >= time.Duration(maxSeconds)*time.Second {
|
||||
enqueueAt = now.Add(-100 * 365 * 24 * time.Hour)
|
||||
}
|
||||
}
|
||||
_ = dao.Task.RequeueForRetryGlobal(ctx, t.Id, enqueueAt)
|
||||
}
|
||||
g.Log().Infof(ctx, "[cleaner] failed retryable cleaned, count=%d", len(retryable))
|
||||
}
|
||||
|
||||
// 4) 超过重试次数仍失败(state=3)的任务:硬删除
|
||||
exhausted, err := dao.Task.ListFailedExhaustedGlobal(ctx, 200)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[cleaner] list failed exhausted error: %v", err)
|
||||
} else {
|
||||
for _, t := range exhausted {
|
||||
deleteTmpResult(t.TmpFile)
|
||||
// 重试耗尽硬删除:释放闸门占位(兜底,若此前已释放则幂等)
|
||||
ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
|
||||
_ = dao.Task.HardDeleteByIDGlobal(ctx, t.Id)
|
||||
}
|
||||
g.Log().Infof(ctx, "[cleaner] failed exhausted cleaned, count=%d", len(exhausted))
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DetectFileType 根据返回的二进制内容推断 contentType + 扩展名(尽量稳定)
|
||||
func DetectFileType(data []byte) (contentType string, ext string) {
|
||||
if len(data) == 0 {
|
||||
return "application/octet-stream", ""
|
||||
}
|
||||
ct := http.DetectContentType(data)
|
||||
// http.DetectContentType 可能带 charset 等参数:text/plain; charset=utf-8
|
||||
if idx := strings.Index(ct, ";"); idx > 0 {
|
||||
ct = strings.TrimSpace(ct[:idx])
|
||||
}
|
||||
switch ct {
|
||||
case "audio/mpeg":
|
||||
return ct, ".mp3"
|
||||
case "audio/wave", "audio/wav", "audio/x-wav":
|
||||
return ct, ".wav"
|
||||
case "video/mp4":
|
||||
return ct, ".mp4"
|
||||
case "image/png":
|
||||
return ct, ".png"
|
||||
case "image/jpeg":
|
||||
return ct, ".jpg"
|
||||
case "application/pdf":
|
||||
return ct, ".pdf"
|
||||
case "text/plain":
|
||||
return ct, ".txt"
|
||||
case "application/json":
|
||||
return ct, ".json"
|
||||
default:
|
||||
// 兜底:尝试从 ct 截取 subtype 作为后缀(例如 application/json)
|
||||
if parts := strings.Split(ct, "/"); len(parts) == 2 {
|
||||
sub := parts[1]
|
||||
// 避免出现 "plain; charset=utf-8" 之类的后缀
|
||||
if idx := strings.Index(sub, ";"); idx > 0 {
|
||||
sub = strings.TrimSpace(sub[:idx])
|
||||
}
|
||||
return ct, "." + sub
|
||||
}
|
||||
return ct, ""
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// asyncCtx 固化异步执行所需的 token/user,避免请求结束后丢失(仅在“同请求内起 goroutine”有用)。
|
||||
// 本项目当前是“落库 + 后台 worker”模式,因此还会把必要信息持久化到任务表的 request_payload 中。
|
||||
func asyncCtx(ctx context.Context) context.Context {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
if token := r.Header.Get("Authorization"); token != "" {
|
||||
asyncCtx = context.WithValue(asyncCtx, "token", token)
|
||||
}
|
||||
if userInfo := r.Header.Get("X-User-Info"); userInfo != "" {
|
||||
asyncCtx = context.WithValue(asyncCtx, "xUserInfo", userInfo)
|
||||
}
|
||||
}
|
||||
if user, err := utils.GetUserInfo(ctx); err == nil && user != nil {
|
||||
asyncCtx = context.WithValue(asyncCtx, "user", user)
|
||||
}
|
||||
return asyncCtx
|
||||
}
|
||||
|
||||
// forwardHeaders 透传调用链路中必须的头信息(优先使用 ctx 里固化的 token / xUserInfo)。
|
||||
func forwardHeaders(ctx context.Context) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
|
||||
if token, ok := ctx.Value("token").(string); ok && token != "" {
|
||||
headers["Authorization"] = token
|
||||
}
|
||||
if x, ok := ctx.Value("xUserInfo").(string); ok && x != "" {
|
||||
headers["X-User-Info"] = x
|
||||
}
|
||||
|
||||
// 兜底:从请求头拿
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
if headers["Authorization"] == "" {
|
||||
if token := r.Header.Get("Authorization"); token != "" {
|
||||
headers["Authorization"] = token
|
||||
}
|
||||
}
|
||||
if headers["X-User-Info"] == "" {
|
||||
if userInfo := r.Header.Get("X-User-Info"); userInfo != "" {
|
||||
headers["X-User-Info"] = userInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package httpclient 模型网关的传输层:模型 HTTP 请求(含瞬时网络错误重试)与 SSE 流式解析。
|
||||
// 纯基础设施,不依赖 session/task/call 等业务逻辑;业务代码只通过三个导出函数使用。
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/gclient"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// modelCallHeaderTimeout 模型响应头等待超时。
|
||||
// commonHttp 底层 gclient 默认 ResponseHeaderTimeout 只有 30s,模型生成首字节
|
||||
// (尤其非流式、大 max_tokens)经常超过 30s,导致 http2: timeout awaiting response
|
||||
// headers。模型调用必须用独立 client 并把该超时调大,与模型配置的超时保持一致。
|
||||
const modelCallHeaderTimeout = 30 * time.Minute
|
||||
|
||||
// modelHTTPClient 构建模型调用专用 HTTP client:
|
||||
// 克隆 commonHttp 客户端(保留 ContentJson、header 注入等行为),但把
|
||||
// ResponseHeaderTimeout 从默认 30s 调大到 modelCallHeaderTimeout。
|
||||
func modelHTTPClient() *gclient.Client {
|
||||
client := commonHttp.Httpclient.Clone()
|
||||
if tr, ok := client.Transport.(*http.Transport); ok {
|
||||
tr = tr.Clone() // 独立拷贝,避免改动全局共享 transport
|
||||
tr.ResponseHeaderTimeout = modelCallHeaderTimeout
|
||||
client.Transport = tr
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// modelNetRetryTimes 模型请求瞬时网络错误最大重试次数(不含首次);modelNetRetryBackoff 为退避基数。
|
||||
// 模型域名 DNS 解析失败(Docker 内 127.0.0.11 偶发 no such host)是瞬时错误,短退避重试即可恢复。
|
||||
// 重试在 HTTP 层完成,覆盖同步/异步/流式全部调用路径;流式场景发生在写 SSE 响应头之前,重试安全。
|
||||
const (
|
||||
modelNetRetryTimes = 3
|
||||
modelNetRetryBackoff = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// isTransientNetError 判定是否可重试的瞬时网络错误。仅命中 DNS 解析失败(no such host):
|
||||
// 模型域名解析抖动可重试恢复;连接拒绝/超时等其他网络错误可能反映真实配置问题,不纳入,避免掩盖错误。
|
||||
func isTransientNetError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "no such host") || strings.Contains(err.Error(), "timeout")
|
||||
}
|
||||
|
||||
// modelDoRaw 模型 HTTP 请求(等价 commonHttp.doRequestRaw,但使用调大超时的 client)。
|
||||
// DNS 解析失败等瞬时网络错误在请求层短退避重试(modelNetRetryTimes 次);
|
||||
// 其余错误(含上游业务错误码)原样返回,由上层按错误码决定是否重试。
|
||||
func modelDoRaw(ctx context.Context, method string, url string, headers map[string]string, data ...any) (*gclient.Response, error) {
|
||||
client := modelHTTPClient()
|
||||
|
||||
if (method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete) && len(data) > 0 {
|
||||
client = client.ContentJson()
|
||||
}
|
||||
|
||||
if len(headers) > 0 {
|
||||
client.SetHeaderMap(headers)
|
||||
} else if r := g.RequestFromCtx(ctx); r != nil {
|
||||
client.SetHeader("Authorization", r.Request.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
doOnce := func() (*gclient.Response, error) {
|
||||
if method == http.MethodGet && len(data) > 0 && len(data)%2 == 0 {
|
||||
queryParams := make(map[string]string)
|
||||
for i := 0; i < len(data); i += 2 {
|
||||
if key, ok := data[i].(string); ok && i+1 < len(data) {
|
||||
queryParams[key] = gconv.String(data[i+1])
|
||||
}
|
||||
}
|
||||
return client.DoRequest(ctx, method, url, queryParams)
|
||||
}
|
||||
if len(data) == 1 {
|
||||
return client.DoRequest(ctx, method, url, data[0])
|
||||
}
|
||||
return client.DoRequest(ctx, method, url, data...)
|
||||
}
|
||||
|
||||
var response *gclient.Response
|
||||
var err error
|
||||
for attempt := 0; ; attempt++ {
|
||||
response, err = doOnce()
|
||||
if err == nil || !isTransientNetError(err) {
|
||||
return response, err
|
||||
}
|
||||
if attempt >= modelNetRetryTimes {
|
||||
break
|
||||
}
|
||||
wait := time.Duration(1<<attempt) * modelNetRetryBackoff
|
||||
g.Log().Warningf(ctx, "[HttpModel] 模型请求瞬时网络错误,第 %d/%d 次重试(等待 %v): %v", attempt+1, modelNetRetryTimes, wait, err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SSE 常量
|
||||
const (
|
||||
ssePrefixData = "data:"
|
||||
ssePrefixEvent = "event:"
|
||||
ssePrefixComment = ":"
|
||||
sseStreamDone = "[DONE]"
|
||||
|
||||
scanBufInitSize = 64 * 1024 // 64KB
|
||||
scanMaxLineSize = 1024 * 1024 // 单行最大 1MB
|
||||
)
|
||||
|
||||
// ParseSSEStream 标准 SSE 流式解析,逐分片回调,支持多行data、上下文取消
|
||||
func ParseSSEStream(ctx context.Context, respBody io.Reader, onChunk func(ctx context.Context, chunk map[string]any) error) {
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
scanner.Buffer(make([]byte, 0, scanBufInitSize), scanMaxLineSize)
|
||||
|
||||
var dataBuilder strings.Builder
|
||||
|
||||
for scanner.Scan() {
|
||||
// 监听上下文取消,及时终止
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
g.Log().Infof(ctx, "[SSE] 上下文取消,终止流读取: %v", ctx.Err())
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
|
||||
// 跳过注释、事件行
|
||||
if strings.HasPrefix(line, ssePrefixComment) || strings.HasPrefix(line, ssePrefixEvent) {
|
||||
continue
|
||||
}
|
||||
|
||||
lineTrim := strings.TrimSpace(line)
|
||||
// 空行 = 一个SSE事件结束
|
||||
if lineTrim == "" {
|
||||
if dataBuilder.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
dataStr := dataBuilder.String()
|
||||
dataBuilder.Reset()
|
||||
|
||||
if dataStr == sseStreamDone {
|
||||
continue
|
||||
}
|
||||
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
|
||||
g.Log().Debugf(ctx, "[SSE] JSON解析失败: %s, err: %v", dataStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if onChunk != nil {
|
||||
onChunk(ctx, chunk)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 拼接多行 data 数据
|
||||
if strings.HasPrefix(line, ssePrefixData) {
|
||||
raw := strings.TrimPrefix(line, ssePrefixData)
|
||||
dataBuilder.WriteString(strings.TrimSpace(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// 捕获读取异常
|
||||
if err := scanner.Err(); err != nil {
|
||||
g.Log().Errorf(ctx, "[SSE] 流读取异常: %v", err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[SSE] 流式读取正常结束")
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/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) {
|
||||
const (
|
||||
keyExpireSec = 600 // 名额Key兜底过期时间 10min(进程崩溃后自愈)
|
||||
refreshStep = keyExpireSec / 3 // 执行期间续期间隔
|
||||
waitInterval = 10 * time.Second // 超限轮询等待间隔
|
||||
)
|
||||
// Redis 操作统一使用独立上下文,避免外部 ctx canceled
|
||||
redisCtx := context.WithoutCancel(ctx)
|
||||
concurrencyKey := fmt.Sprintf("model:concurrency:%d:%s", tenantId, modelName)
|
||||
|
||||
// 1) 原子占用并发名额:utils.SemaphoreAcquire 在 WATCH 事务内完成 判满→INCR→首设EXPIRE→超限不写,
|
||||
// 不再需要旧 reserveSlot 的「分布式锁 + Incr + 回滚」组合(组合已事务化,外层锁冗余)。
|
||||
// 超限(false)按 waitInterval 轮询重试;max<=0 视为不限制(SemaphoreAcquire 内部直接放行)。
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
ok, e := utils.SemaphoreAcquire(redisCtx, concurrencyKey, int(maxCon), keyExpireSec)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if ok {
|
||||
// 展示当前并发数(占用后 GET,与旧 reserveSlot 的 Incr 后计数值语义一致)
|
||||
if v, e := g.Redis().Get(redisCtx, concurrencyKey); e == nil {
|
||||
glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, v.Int64(), maxCon)
|
||||
}
|
||||
break
|
||||
}
|
||||
glog.Infof(ctx, "并发超限等待: %s max=%d", concurrencyKey, maxCon)
|
||||
time.Sleep(waitInterval)
|
||||
}
|
||||
|
||||
// 2) 执行业务期间周期续期名额Key:SemaphoreAcquire 仅在首次占用(计数从 0 起)时设 TTL,
|
||||
// 长耗时调用靠本循环持续保活——Key 过期后计数归零会突破 max 并发上限造成超发。
|
||||
stop := make(chan struct{})
|
||||
go refreshTTL(redisCtx, concurrencyKey, keyExpireSec, refreshStep, stop)
|
||||
// 3) 无论业务正常返回还是 panic,都停掉续期并释放名额(幂等,计数归零自动删除 key)
|
||||
defer func() {
|
||||
close(stop)
|
||||
_ = utils.SemaphoreRelease(redisCtx, concurrencyKey)
|
||||
}()
|
||||
|
||||
// 4) 执行业务
|
||||
return f(ctx)
|
||||
}
|
||||
|
||||
// refreshTTL 周期给名额 Key 续期,直到 stop 关闭;防止长耗时执行期间 Key 提前过期。
|
||||
func refreshTTL(redisCtx context.Context, concurrencyKey string, keyExpireSec, step int64, stop <-chan struct{}) {
|
||||
interval := step
|
||||
if interval < 1 {
|
||||
interval = 1
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if _, err := g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec); err != nil {
|
||||
glog.Errorf(context.TODO(), "redis refresh concurrency ttl err: %v", err)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
// requestParams 可能混有扁平路径 key(messages.enumValues...)与已是对象/数组的值(stream)。
|
||||
// IsFlatMap 遇 map/slice 值即整体返回 false 会跳过 unflatten;sjson.Set 能处理任意值类型作为子树,
|
||||
// 带点 key 按路径展开、无点 key 直接赋值,故始终 unflatten
|
||||
var err error
|
||||
requestParams, err = utils.UnFlatBySjson(requestParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rest := make(map[string]any, len(requestParams))
|
||||
for k, v := range requestParams {
|
||||
rest[k] = v
|
||||
}
|
||||
// 请求结构源:模板字段兜底(模板 value/defaultValue 生效)+ requestParams 覆盖同名;
|
||||
// 保证模板声明的结构字段(如 stream_options 对象)即使 requestParams 未传也进请求体
|
||||
// 用户已传字段按模板类型元数据递归合并(补 type 包装、补默认字段),数组字段仅在用户提供时才合并
|
||||
src := rest
|
||||
for k, v := range modelInfo.RequestBodyMapping {
|
||||
tmplMap, _ := v.(map[string]any)
|
||||
if tmplMap != nil {
|
||||
if t, _ := tmplMap["type"].(string); t == "array" {
|
||||
if _, has := src[k]; !has {
|
||||
continue
|
||||
}
|
||||
src[k] = modelUtils.MergeNode(src[k], v)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if userVal, has := src[k]; has {
|
||||
src[k] = modelUtils.MergeNode(userVal, v)
|
||||
} else {
|
||||
src[k] = modelUtils.DeepCopyNode(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,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/model/dto"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/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
|
||||
}
|
||||
|
||||
// Upload 上传文件到 OSS。统一走 common/oss(multipart field=file、X-User-Info 三态注入与旧 setCtxHeader 等价)。
|
||||
func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) {
|
||||
res, err := oss.UploadFileBytes(ctx, req.FileName, req.FileBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.UploadFileBytesRes{
|
||||
FileURL: res.FileURL,
|
||||
FileSize: res.FileSize,
|
||||
FileName: res.FileName,
|
||||
FileFormat: res.FileFormat,
|
||||
FileAddressPrefix: res.FileAddressPrefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// setCtxHeader 构造调用方请求头透传(X-User-Info 三态注入):
|
||||
// 1. 透传 HTTP 请求头(含 Authorization/X-User-Info)
|
||||
// 2. ctx 无请求头时,用任务体注入的 user(异步任务 Creator/TenantId)生成 X-User-Info
|
||||
// 3. 仍为空时,解析调用方 token 得到用户生成 X-User-Info(直连场景归属校验)
|
||||
func setCtxHeader(ctx context.Context) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if headers["X-User-Info"] == "" {
|
||||
if user := ctx.Value("user"); !g.IsNil(user) {
|
||||
headers["X-User-Info"] = gconv.String(user)
|
||||
}
|
||||
}
|
||||
if headers["X-User-Info"] == "" {
|
||||
if user, err := utils.GetUserInfo(ctx); err == nil && user != nil {
|
||||
headers["X-User-Info"] = gconv.String(user)
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// parseHeadMsgHeaders 支持多个 header 绑定,逗号分隔:
|
||||
// 示例:
|
||||
// - X-API-Key:qwen3-tts-key,operation:true,count:123
|
||||
// - X-API-Key:"qwen3-tts-key",operation:"true"
|
||||
//
|
||||
// 说明:
|
||||
// - HTTP Header 最终都是字符串,这里做的是“值的字符串化表达”。
|
||||
// - 若 value 用双引号包裹,会去掉外层引号再注入,便于在配置中区分字符串/布尔/数字等表达(以及避免值中包含特殊字符时歧义)。
|
||||
func parseHeadMsgHeaders(headMsg string) map[string]string {
|
||||
headMsg = strings.TrimSpace(headMsg)
|
||||
if headMsg == "" {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
parts := strings.Split(headMsg, ",")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// HeaderName:HeaderValue(推荐) / HeaderName=HeaderValue(兼容)
|
||||
if strings.Contains(p, ":") {
|
||||
kv := strings.SplitN(p, ":", 2)
|
||||
k := strings.TrimSpace(kv[0])
|
||||
v := strings.TrimSpace(kv[1])
|
||||
v = strings.Trim(v, "\"")
|
||||
if k != "" && v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(p, "=") {
|
||||
kv := strings.SplitN(p, "=", 2)
|
||||
k := strings.TrimSpace(kv[0])
|
||||
v := strings.TrimSpace(kv[1])
|
||||
v = strings.Trim(v, "\"")
|
||||
if k != "" && v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func payloadToQuery(payload any) (url.Values, error) {
|
||||
if payload == nil {
|
||||
return url.Values{}, nil
|
||||
}
|
||||
// 统一转成 map[string]any
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]any{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
for k, v := range m {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
// 复杂类型直接 json 字符串化
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
q.Set(k, vv)
|
||||
case float64, bool, int, int64, uint64:
|
||||
q.Set(k, fmt.Sprintf("%v", vv))
|
||||
default:
|
||||
bs, _ := json.Marshal(v)
|
||||
q.Set(k, string(bs))
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// InvokeModel 调用模型服务,返回二进制结果
|
||||
// modelKey 用于覆盖/补充模型配置 head_msg(例如每次请求携带不同的 X-API-Key)。
|
||||
func InvokeModel(ctx context.Context, m *entity.AsynchModel, payload any, modelKey string) ([]byte, error) {
|
||||
if m == nil || m.BaseURL == "" {
|
||||
return nil, fmt.Errorf("模型配置不完整")
|
||||
}
|
||||
|
||||
// ============ 新增:请求参数映射 ============
|
||||
mappedPayload, err := mapRequestPayload(m.RequestMapping, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求参数映射失败: %w", err)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(m.BaseURL, "/")
|
||||
timeout := time.Duration(m.TimeoutSeconds) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
|
||||
method := strings.ToUpper(strings.TrimSpace(m.HttpMethod))
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
|
||||
var (
|
||||
req *http.Request
|
||||
)
|
||||
switch method {
|
||||
case http.MethodGet:
|
||||
q, err := payloadToQuery(mappedPayload) // 使用映射后的payload
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(q) > 0 {
|
||||
if strings.Contains(url, "?") {
|
||||
url = url + "&" + q.Encode()
|
||||
} else {
|
||||
url = url + "?" + q.Encode()
|
||||
}
|
||||
}
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
default:
|
||||
bodyBytes, err := json.Marshal(mappedPayload) // 使用映射后的payload
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 先注入模型配置 head_msg(静态头部,适合公共模型固定 API Key)
|
||||
for hk, hv := range parseHeadMsgHeaders(m.HeadMsg) {
|
||||
req.Header.Set(hk, hv)
|
||||
}
|
||||
|
||||
// 最后注入动态 modelKey(允许覆盖/补充静态 head_msg),适合按请求动态传密钥。
|
||||
for hk, hv := range parseHeadMsgHeaders(modelKey) {
|
||||
req.Header.Set(hk, hv)
|
||||
}
|
||||
|
||||
if method != http.MethodGet {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
msg := string(b)
|
||||
if len(msg) > 2000 {
|
||||
msg = msg[:2000]
|
||||
}
|
||||
return nil, fmt.Errorf("模型服务返回非2xx: %d, body=%s", resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
// ============ 新增:响应参数映射 ============
|
||||
mappedResponse, err := mapResponsePayload(m.ResponseMapping, b)
|
||||
if err != nil {
|
||||
// 响应映射失败不阻塞,返回原始数据
|
||||
g.Log().Warningf(ctx, "响应参数映射失败: %v,返回原始数据", err)
|
||||
return b, nil
|
||||
}
|
||||
// =========================================
|
||||
|
||||
return mappedResponse, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 映射相关函数
|
||||
// ============================================
|
||||
|
||||
// mapRequestPayload 将标准请求映射为模型特定格式
|
||||
func mapRequestPayload(mappingAny any, payload any) (any, error) {
|
||||
// 1. 解析请求映射配置(值是any类型,支持bool、number等)
|
||||
mapping, err := parseRequestMapping(mappingAny)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果没有映射配置,直接返回原始payload
|
||||
if len(mapping) == 0 {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// 2. 将payload转为map
|
||||
var payloadMap map[string]any
|
||||
switch v := payload.(type) {
|
||||
case map[string]any:
|
||||
payloadMap = v
|
||||
case []map[string]any:
|
||||
// 如果传进来的是纯messages数组,包装成标准格式
|
||||
payloadMap = map[string]any{
|
||||
"messages": v,
|
||||
}
|
||||
default:
|
||||
// 通过JSON转换
|
||||
jsonBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化payload失败: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(jsonBytes, &payloadMap); err != nil {
|
||||
return nil, fmt.Errorf("反序列化payload失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 用数据库固定参数覆盖/补充
|
||||
for key, value := range mapping {
|
||||
if existingValue, exists := payloadMap[key]; !exists || isEmptyValue(existingValue) {
|
||||
payloadMap[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return payloadMap, nil
|
||||
}
|
||||
|
||||
// mapResponsePayload 将模型响应映射为标准格式
|
||||
func mapResponsePayload(mappingAny any, responseBytes []byte) ([]byte, error) {
|
||||
mapping, err := parseResponseMapping(mappingAny)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(mapping) == 0 {
|
||||
return responseBytes, nil
|
||||
}
|
||||
|
||||
responseStr := string(responseBytes)
|
||||
resultStr := `{}`
|
||||
|
||||
for standardField, modelPath := range mapping {
|
||||
value := gjson.Get(responseStr, modelPath)
|
||||
if !value.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
resultStr, err = sjson.SetRaw(resultStr, standardField, value.Raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("提取字段 %s <- %s 失败: %w", standardField, modelPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return []byte(resultStr), nil
|
||||
}
|
||||
|
||||
func parseRequestMapping(mappingAny any) (map[string]any, error) {
|
||||
if mappingAny == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
|
||||
switch v := mappingAny.(type) {
|
||||
case *gvar.Var:
|
||||
if v == nil || v.IsNil() || v.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
// 尝试转成 map
|
||||
if m := v.Map(); m != nil {
|
||||
for k, val := range m {
|
||||
result[k] = val
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
// 尝试转成 string
|
||||
if s := v.String(); s != "" && s != "{}" && s != "null" {
|
||||
if err := json.Unmarshal([]byte(s), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请求映射字符串失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
return nil, nil
|
||||
// =======================================================
|
||||
|
||||
case map[string]interface{}:
|
||||
result = v
|
||||
|
||||
case string:
|
||||
if v == "" || v == "{}" || v == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(v), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请求映射字符串失败: %w", err)
|
||||
}
|
||||
|
||||
case []byte:
|
||||
if len(v) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := json.Unmarshal(v, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请求映射字节失败: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
jsonBytes, err := json.Marshal(mappingAny)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化映射配置失败: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析映射配置失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseResponseMapping 解析响应映射配置
|
||||
// 返回值类型为 map[string]string,值都是JSON路径字符串
|
||||
func parseResponseMapping(mappingAny any) (map[string]string, error) {
|
||||
if mappingAny == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mapping := make(map[string]string)
|
||||
|
||||
switch v := mappingAny.(type) {
|
||||
case *gvar.Var:
|
||||
if v == nil || v.IsNil() || v.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
if m := v.Map(); m != nil {
|
||||
for k, val := range m {
|
||||
if strVal, ok := val.(string); ok {
|
||||
mapping[k] = strVal
|
||||
}
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
if s := v.String(); s != "" && s != "{}" && s != "null" {
|
||||
if err := json.Unmarshal([]byte(s), &mapping); err != nil {
|
||||
return nil, fmt.Errorf("解析响应映射字符串失败: %w", err)
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
return nil, nil
|
||||
case string:
|
||||
if v == "" || v == "{}" || v == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(v), &mapping); err != nil {
|
||||
return nil, fmt.Errorf("解析响应映射字符串失败: %w", err)
|
||||
}
|
||||
|
||||
case map[string]interface{}:
|
||||
// 数据库JSONB直接返回的map
|
||||
for k, val := range v {
|
||||
if strVal, ok := val.(string); ok {
|
||||
mapping[k] = strVal
|
||||
}
|
||||
}
|
||||
|
||||
case []byte:
|
||||
if len(v) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := json.Unmarshal(v, &mapping); err != nil {
|
||||
return nil, fmt.Errorf("解析响应映射字节失败: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
jsonBytes, err := json.Marshal(mappingAny)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化响应映射配置失败: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(jsonBytes, &mapping); err != nil {
|
||||
return nil, fmt.Errorf("解析响应映射配置失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
// isEmptyValue 判断值是否为空
|
||||
func isEmptyValue(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val == ""
|
||||
case []any:
|
||||
return len(val) == 0
|
||||
case map[string]any:
|
||||
return len(val) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
"gitea.com/red-future/common/http"
|
||||
"gitea.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 Model = &modelService{}
|
||||
|
||||
type modelService struct{}
|
||||
|
||||
// IsSuperAdmin 调用admin-go服务检查是否是超级管理员
|
||||
func (s *modelService) IsSuperAdmin(ctx context.Context) (res bool, err error) {
|
||||
headers := forwardHeaders(ctx)
|
||||
var r = make(map[string]bool)
|
||||
if err = http.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headers, &r); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return r["isSuperAdmin"], err
|
||||
}
|
||||
|
||||
func (s *modelService) Create(ctx context.Context, req *dto.CreateModelReq) (res *dto.CreateModelRes, err error) {
|
||||
// 获取当前会话模型
|
||||
if !g.IsEmpty(req.IsChatModel) && *req.IsChatModel == 1 {
|
||||
var model *entity.AsynchModel
|
||||
model, err = dao.Model.GetByIsChatModel(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 如果有会话模型,那就改变为 0
|
||||
if model != nil {
|
||||
_, err = dao.Model.Update(ctx, &dto.UpdateModelReq{
|
||||
ID: model.Id,
|
||||
IsChatModel: gconv.PtrInt(0),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req.IsOwner = gconv.PtrInt(1)
|
||||
admin, err := s.IsSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if admin {
|
||||
req.IsOwner = gconv.PtrInt(0)
|
||||
}
|
||||
id, err := dao.Model.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CreateModelRes{ID: id}, nil
|
||||
}
|
||||
|
||||
func (s *modelService) Update(ctx context.Context, req *dto.UpdateModelReq) error {
|
||||
//根据当前 isChatModel 来判断是否更新模型
|
||||
if req.IsChatModel == gconv.PtrInt(1) {
|
||||
//判断当前用户是否有会话模型
|
||||
model, err := dao.Model.GetByIsChatModel(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if model != nil {
|
||||
return errors.New("用户已存在会话模型,不能创建")
|
||||
}
|
||||
}
|
||||
|
||||
req.IsOwner = gconv.PtrInt(1)
|
||||
admin, err := s.IsSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if admin {
|
||||
req.IsOwner = gconv.PtrInt(0)
|
||||
_, err = dao.Model.Update(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var user *beans.User
|
||||
user, err = utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 判断当前传过来的模型id的模型是否是超级管理员的。如果是超管的进行创建,否则更新
|
||||
var count int
|
||||
count, err = dao.Model.Count(ctx, &dto.GetModelReq{
|
||||
ID: req.ID,
|
||||
Creator: user.UserName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
insertDto := new(dto.CreateModelReq)
|
||||
err = gconv.Struct(req, insertDto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dao.Model.Insert(ctx, insertDto)
|
||||
return err
|
||||
}
|
||||
_, err = dao.Model.Update(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *modelService) Delete(ctx context.Context, id string) error {
|
||||
_, err := dao.Model.DeleteByID(ctx, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *modelService) Get(ctx context.Context, id int64) (*entity.AsynchModel, error) {
|
||||
model, err := dao.Model.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model.Form = ParseJSONField(model.Form)
|
||||
model.RequestMapping = ParseJSONField(model.RequestMapping)
|
||||
model.ResponseMapping = ParseJSONField(model.ResponseMapping)
|
||||
model.ResponseBody = ParseJSONField(model.ResponseBody)
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func (s *modelService) List(ctx context.Context, req *dto.ListModelReq) (list []*entity.AsynchModel, total int, err error) {
|
||||
var models []*entity.AsynchModel
|
||||
|
||||
req.IsOwner = gconv.PtrInt(1)
|
||||
admin, err := s.IsSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if admin {
|
||||
req.IsOwner = gconv.PtrInt(0)
|
||||
}
|
||||
|
||||
var user *beans.User
|
||||
user, err = utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Creator = user.UserName
|
||||
|
||||
models, total, err = dao.Model.GetByCreatorAndPlatform(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理列表中每条记录的 JSONB 字段
|
||||
for _, m := range models {
|
||||
m.Form = ParseJSONField(m.Form)
|
||||
m.RequestMapping = ParseJSONField(m.RequestMapping)
|
||||
m.ResponseMapping = ParseJSONField(m.ResponseMapping)
|
||||
m.ResponseBody = ParseJSONField(m.ResponseBody)
|
||||
}
|
||||
return models, total, nil
|
||||
}
|
||||
|
||||
// GetModelTypesFromConfig 从配置文件读取模型类型
|
||||
func GetModelTypesFromConfig(ctx context.Context) map[int]string {
|
||||
typeMap := make(map[int]string)
|
||||
|
||||
// 读取配置
|
||||
configMap := g.Cfg().MustGet(ctx, "modelType.types").Map()
|
||||
for k, v := range configMap {
|
||||
typeID := gconv.Int(k)
|
||||
typeName := gconv.String(v)
|
||||
if typeID > 0 && typeName != "" {
|
||||
typeMap[typeID] = typeName
|
||||
}
|
||||
}
|
||||
// 如果配置为空,使用默认值
|
||||
if len(typeMap) == 0 {
|
||||
typeMap = map[int]string{
|
||||
1: "推理模型",
|
||||
2: "图片模型",
|
||||
3: "音频模型",
|
||||
4: "向量化模型",
|
||||
5: "全模态模型",
|
||||
}
|
||||
}
|
||||
return typeMap
|
||||
}
|
||||
|
||||
func (s *modelService) UpdateChatModel(ctx context.Context, req *dto.UpdateChatModelReq) error {
|
||||
// 校验新会话模型是否存在
|
||||
newModel, err := dao.Model.Get(ctx, req.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newModel == nil {
|
||||
return errors.New("新会话模型不存在")
|
||||
}
|
||||
|
||||
// 获取当前用户会话模型
|
||||
currentModel, err := dao.Model.GetByIsChatModel(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if !g.IsEmpty(currentModel) {
|
||||
if currentModel.ModelType != 1 {
|
||||
return errors.New("当前模型为非推理模型,不能设置为会话模型")
|
||||
}
|
||||
|
||||
// 如果点击的就是当前会话模型(已经是1),取消它(设为0)
|
||||
if currentModel.Id != req.Id {
|
||||
_, err = dao.Model.Update(ctx, &dto.UpdateModelReq{
|
||||
ID: currentModel.Id,
|
||||
IsChatModel: gconv.PtrInt(0),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前为会话模型(设为1)
|
||||
_, err = dao.Model.Update(ctx, &dto.UpdateModelReq{
|
||||
ID: req.Id,
|
||||
IsChatModel: gconv.PtrInt(1),
|
||||
})
|
||||
return err
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *modelService) GetIsChatModel(ctx context.Context) (*entity.AsynchModel, error) {
|
||||
model, err := dao.Model.GetByIsChatModel(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if model == nil {
|
||||
return nil, nil
|
||||
}
|
||||
model.Form = ParseJSONField(model.Form)
|
||||
model.RequestMapping = ParseJSONField(model.RequestMapping)
|
||||
model.ResponseMapping = ParseJSONField(model.ResponseMapping)
|
||||
model.ResponseBody = ParseJSONField(model.ResponseBody)
|
||||
return model, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
"model-gateway/service/httpclient"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
"github.com/bjang03/gmq/types"
|
||||
"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. 提交异步处理:锁在 goroutine 内抢(WithLock 单次尝试),此处 MGet 只做快速预筛
|
||||
for _, key := range lockKeys {
|
||||
val := gconv.String(mGetRes[key])
|
||||
// 已被其他实例抢占,跳过(MGet 只做快速预筛;真正互斥靠 goroutine 内的原子抢锁)
|
||||
if val != "" {
|
||||
continue
|
||||
}
|
||||
err = s.handleSingleTask(ctx, taskMap[key], key)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "提交任务失败: %v", err)
|
||||
}
|
||||
remain-- // 占用一个槽位
|
||||
if remain <= 0 {
|
||||
break // 槽位已满,终止遍历
|
||||
}
|
||||
}
|
||||
pageNum++ // 页码动态累加,不再写死2
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskLockTTL 任务锁 TTL(秒)。utils.WithLock 自动续期锁住整个任务处理,TTL 仅作崩溃兜底:
|
||||
// worker 崩溃后续期停止,TTL 过期后其他 worker 重新抢占。
|
||||
const taskLockTTL = 1200
|
||||
|
||||
var urlParamReg = regexp.MustCompile(`\{.+?\}`)
|
||||
|
||||
// handleSingleTask 提交异步处理:锁在 goroutine 内抢(utils.WithLock 自动续期 + 单次尝试)。
|
||||
// 自动续期:锁持满整个任务处理,任务 >20min 不提前过期,避免其它 worker 重新抢到导致重复处理;
|
||||
// 单次尝试:锁被其它 worker 持有(任务已被别人处理)时立刻跳过——等待会拿着过期 item 在行删除后
|
||||
// 重复扣费/重复回调。Submit 失败(池关闭)goroutine 不运行、从没抢锁,无锁泄漏路径。
|
||||
func (s *modelTaskEndService) handleSingleTask(ctx context.Context, item *entity.ModelTaskStart, lockKey string) error {
|
||||
return modelUtils.Submit(ctx, func(ctx context.Context) {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
// OSS 桶名依赖 ctx 中的用户(GetBucketName → tenantid-{tenantId}),
|
||||
// 响应临时路径转存 OSS 需要用户信息,故在任务体最前面注入
|
||||
asyncCtx = context.WithValue(asyncCtx, "user", &beans.User{
|
||||
UserName: item.Creator,
|
||||
TenantId: item.TenantId,
|
||||
})
|
||||
ok, err := utils.WithLock(asyncCtx, lockKey, taskLockTTL, func(ctx context.Context) error {
|
||||
return s.processClaimedTask(ctx, item)
|
||||
}, 1)
|
||||
if err != nil || !ok {
|
||||
// 锁被其它实例持有或抢锁失败:跳过,任务行保留由持有方处理,下轮扫描不再命中
|
||||
g.Log().Warningf(asyncCtx, "任务锁未抢占,跳过 taskId=%d: %v", item.Id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// processClaimedTask 抢到任务锁后的完整处理:轮询模型结果 → 终态落库 + 发布。
|
||||
// 终态结果统一承载:成功/错误/解析失败任何路径都写进 docMsg.ErrorMsg 后走 finalize 落库+发布,
|
||||
// 避免早期直接 return 把任务丢弃——任务行不删、无结果落库、调用方永远收不到通知,只会被其他 worker 反复重捡。
|
||||
func (s *modelTaskEndService) processClaimedTask(asyncCtx context.Context, item *entity.ModelTaskStart) error {
|
||||
startTime := time.Now()
|
||||
docMsg := new(dto.ModelMsg)
|
||||
docMsg.TaskID = item.Id
|
||||
var respObj map[string]any
|
||||
|
||||
// 终态处理:扣费(若产生)→ 删任务行 → 插结果行(含 ErrorMsg)→ NATS 发布结果给调用方
|
||||
finalize := func() {
|
||||
// 按本次实际费用扣减租户余额(未产生费用不扣;异步任务无请求头,admin-go 租户接口无需鉴权可直接调用)
|
||||
if docMsg.Cost > 0 {
|
||||
if err := DeductBalance(asyncCtx, item.TenantId, docMsg.Cost); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "[扣减余额] 异步任务扣费失败 taskId=%d cost=%.6f err=%v", item.Id, docMsg.Cost, err)
|
||||
}
|
||||
}
|
||||
err := gfdb.DB(asyncCtx, public.DbNameModelGateway).Transaction(asyncCtx, func(asyncCtx context.Context, tx gdb.TX) (err error) {
|
||||
// 删除视频任务
|
||||
_, err = 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)
|
||||
}
|
||||
// 发布消息
|
||||
if err = TaskMsgPublish(asyncCtx, item.MsgTopic, docMsg); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型消息发布失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 modelId 现查模型配置(异步映射/token 映射/计费规则不随任务快照,任务完成时取当前配置)
|
||||
modelInfo, err := dao.ModelManage.GetNotTenantId(asyncCtx, &dto.GetModelManageReq{Id: item.ModelId})
|
||||
if err != nil {
|
||||
g.Log().Errorf(asyncCtx, "查询模型配置失败: modelId=%d err=%v", item.ModelId, err)
|
||||
docMsg.ErrorMsg = fmt.Sprintf("查询模型配置失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
if modelInfo == nil {
|
||||
g.Log().Errorf(asyncCtx, "模型配置不存在: modelId=%d", item.ModelId)
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型配置不存在: modelId=%d", item.ModelId)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 连续轮询失败上限:瞬时抖动(HTTP 错/空响应/解析失败)先有限重试,超限按终态错误落库
|
||||
const maxPollErrRetries = 3
|
||||
pollErrCnt := 0
|
||||
LOOP:
|
||||
// 替换URL占位符
|
||||
url := urlParamReg.ReplaceAllString(modelInfo.AsyncTaskMapping.Url, item.TaskId)
|
||||
// 组装查询请求体:POST 查询接口需要 body(从 RequestBodyMapping 出发,替换 {…} 占位符为任务 ID)
|
||||
reqBody := buildAsyncTaskBody(modelInfo.AsyncTaskMapping.RequestBodyMapping, item.TaskId)
|
||||
// 发起HTTP请求
|
||||
modelRespBody, err := httpclient.ModelHttpNormalRequest(
|
||||
asyncCtx,
|
||||
url,
|
||||
modelInfo.AsyncTaskMapping.RequestHeadMapping,
|
||||
modelInfo.AsyncTaskMapping.HttpMethod, reqBody,
|
||||
)
|
||||
if err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型请求失败: %v", err)
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型请求失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
if modelRespBody == nil {
|
||||
g.Log().Errorf(asyncCtx, "模型返回参数为空")
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = "模型返回参数为空"
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
pollErrCnt = 0 // 请求成功一次即重置连续失败计数
|
||||
|
||||
// 异常响应识别:兼容 OpenAI 嵌套 error / 扁平 code 两种形态(与任务创建端一致),无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err)
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型返回参数解析失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
// 统一字段路径(GetByPath)读取基于该对象
|
||||
if err = json.Unmarshal(modelRespBody, &respObj); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err)
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型返回参数解析失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 无错误时才组装成功内容(错误响应按终态处理,跳过成功解析/轮询)
|
||||
if docMsg.ErrorMsg == "" {
|
||||
// 组装业务返回内容
|
||||
respBodyMap := modelUtils.CleanMapFieldPath(modelInfo.ResponseBodyMapping)
|
||||
content := make(map[string]any, len(respBodyMap))
|
||||
for bizKey, jsonPath := range respBodyMap {
|
||||
content[bizKey] = oss.TempURLToOSS(asyncCtx, modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(jsonPath)))
|
||||
}
|
||||
docMsg.Content = content
|
||||
|
||||
// 解析Token
|
||||
totalTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)
|
||||
promptTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)
|
||||
compTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)
|
||||
|
||||
docMsg.TotalTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, totalTokPath))
|
||||
docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, promptTokPath))
|
||||
docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, compTokPath))
|
||||
|
||||
// 按模型计费规则换算本次调用费用(未配置返回 0);媒体类型取任务创建时的快照
|
||||
docMsg.Cost = calcCostWithMediaType(docMsg.PromptTokens, docMsg.CompletionTokens, 0, item.MediaType, modelInfo.PriceConfig)
|
||||
|
||||
// 判断任务状态,轮询等待
|
||||
statusPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskStatus)
|
||||
status := gconv.String(modelUtils.GetByPathValue(respObj, statusPath))
|
||||
if status == modelInfo.AsyncTaskMapping.TaskStatusPending || status == modelInfo.AsyncTaskMapping.TaskStatusRunning {
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
}
|
||||
// 成功或已识别出错误的终态统一落库+发布(内容组装完成/错误消息已写入 docMsg)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAsyncTaskBody 组装异步任务查询请求体:从 AsyncTaskMapping.RequestBodyMapping 出发,
|
||||
// 把 {…} 占位符(如 {taskId})替换为实际任务 ID,兼容 POST 查询接口需要请求体的场景。
|
||||
// 未配置映射时返回 nil(GET 查询/无需 body 的场景)。
|
||||
func buildAsyncTaskBody(mapping map[string]any, taskID string) map[string]any {
|
||||
if len(mapping) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(mapping))
|
||||
for k, v := range mapping {
|
||||
out[k] = replaceTaskPlaceholder(v, taskID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// replaceTaskPlaceholder 递归替换结构体中的 {…} 占位符为任务 ID
|
||||
func replaceTaskPlaceholder(v any, taskID string) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return urlParamReg.ReplaceAllString(val, taskID)
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(val))
|
||||
for k, x := range val {
|
||||
m[k] = replaceTaskPlaceholder(x, taskID)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
arr := make([]any, len(val))
|
||||
for i, x := range val {
|
||||
arr[i] = replaceTaskPlaceholder(x, taskID)
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
"model-gateway/service/httpclient"
|
||||
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 := httpclient.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
|
||||
// 统一解析模型错误(兼容 OpenAI 嵌套 error / 扁平 code 两种形态),无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
if docMsg.ErrorMsg != "" {
|
||||
updateModelReq.ErrorMsg = docMsg.ErrorMsg
|
||||
}
|
||||
if docMsg.ErrorMsg == "" {
|
||||
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,32 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"model-gateway/model/dto"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// parseModelError 解析模型错误响应,返回错误码与错误消息(无错误均返回空串)。
|
||||
// 兼容两种形态(与任务创建端 model_task_start_service.go 一致):
|
||||
// - OpenAI 嵌套 {"error":{"code","message"}} → 取 error.code / error.message
|
||||
// - 扁平 {"code","message"} → code=20000000 视为成功码,不当作错误
|
||||
//
|
||||
// 解析失败返回 err,由调用方决定重试/终态。
|
||||
func parseModelError(body []byte) (code, msg string, err error) {
|
||||
errMsg := new(dto.ModelErrorResp)
|
||||
if err = gconv.Struct(body, errMsg); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !g.IsEmpty(errMsg.Error.Code) {
|
||||
return errMsg.Error.Code, errMsg.Error.Message, nil
|
||||
}
|
||||
flat := new(dto.ModelError1Resp)
|
||||
if err = gconv.Struct(body, flat); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !g.IsEmpty(flat.Code) && flat.Code != 20000000 {
|
||||
return gconv.String(flat.Code), flat.Message, nil
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package service
|
||||
|
||||
import "github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
// parseStoredPayload 解析入库的 request_payload,拆出模型调用 payload 与透传 headers
|
||||
// 入库格式:{"payload": <any>, "headers": {"Authorization": "...", "X-User-Info":"..."}}
|
||||
func parseStoredPayload(v any) (payload any, headers map[string]string) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
m := gconv.Map(v)
|
||||
if len(m) == 0 {
|
||||
return v, nil
|
||||
}
|
||||
if h, ok := m["headers"]; ok {
|
||||
headers = gconv.MapStrStr(h)
|
||||
}
|
||||
if p, ok := m["payload"]; ok {
|
||||
payload = p
|
||||
} else {
|
||||
payload = v
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ===== 严格 queue_limit:Redis 原子闸门 =====
|
||||
//
|
||||
// 背景:原来的 queue_limit 通过“Count + Insert”做近似控制,分布式并发创建时会短暂超限。
|
||||
// 目标:以 Redis Lua 脚本实现原子校验 + 入队占位,做到严格不超限。
|
||||
//
|
||||
// 计数口径与原逻辑保持一致:只统计 state=0/1(排队中/执行中)。
|
||||
// - CreateTask 成功入库后占用 1 个 slot
|
||||
// - 任务成功/失败(state->2/3)释放 slot
|
||||
// - 失败任务重试(state 3->0)需要再次占用 slot,若占位失败则暂不重试(留在 state=3,下次 cleaner 再尝试)
|
||||
//
|
||||
// 说明:为避免极端情况下“占位泄漏”导致永久占满,采用 ZSET + 过期时间的方式自动回收。
|
||||
// 只要任务实际生命周期远小于 gateTTLSeconds,就可保持严格。
|
||||
|
||||
const (
|
||||
queueGateKeyPrefix = "asynch:qgate:" // asynch:qgate:{modelName}
|
||||
)
|
||||
|
||||
// Lua:清理过期 slot,然后按 limit 做原子判定并占位
|
||||
var queueGateAcquireLua = `
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local limit = tonumber(ARGV[2])
|
||||
local expireAt = tonumber(ARGV[3])
|
||||
local member = ARGV[4]
|
||||
local keyTTL = tonumber(ARGV[5])
|
||||
|
||||
-- 先清理过期的占位
|
||||
redis.call("ZREMRANGEBYSCORE", key, "-inf", now)
|
||||
|
||||
local current = tonumber(redis.call("ZCARD", key) or "0")
|
||||
if current >= limit then
|
||||
return 0
|
||||
end
|
||||
redis.call("ZADD", key, expireAt, member)
|
||||
redis.call("EXPIRE", key, keyTTL)
|
||||
return 1
|
||||
`
|
||||
|
||||
// Lua:释放 slot(幂等)
|
||||
var queueGateReleaseLua = `
|
||||
local key = KEYS[1]
|
||||
local member = ARGV[1]
|
||||
redis.call("ZREM", key, member)
|
||||
return 1
|
||||
`
|
||||
|
||||
func queueGateKey(modelName string) string {
|
||||
return fmt.Sprintf("%s%s", queueGateKeyPrefix, modelName)
|
||||
}
|
||||
|
||||
// calcGateTTLSeconds 计算闸门占位的“自动回收 TTL”
|
||||
// 取 expectedSeconds 的倍数并做上下限,避免任务异常导致永久占位。
|
||||
func calcGateTTLSeconds(expectedSeconds int) int {
|
||||
// 默认至少 1 小时;最多 24 小时
|
||||
minTTL := 3600
|
||||
maxTTL := 24 * 3600
|
||||
if expectedSeconds <= 0 {
|
||||
return minTTL
|
||||
}
|
||||
ttl := int(math.Ceil(float64(expectedSeconds) * 10)) // 预计耗时 * 10 做兜底
|
||||
if ttl < minTTL {
|
||||
ttl = minTTL
|
||||
}
|
||||
if ttl > maxTTL {
|
||||
ttl = maxTTL
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
// AcquireQueueSlot 严格入队:原子占位(成功返回 true)
|
||||
func AcquireQueueSlot(ctx context.Context, modelName, taskId string, limit int, expectedSeconds int) (bool, error) {
|
||||
if limit <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
key := queueGateKey(modelName)
|
||||
now := time.Now().Unix()
|
||||
ttl := calcGateTTLSeconds(expectedSeconds)
|
||||
expireAt := now + int64(ttl)
|
||||
// keyTTL 要略大于 member TTL,避免 key 先过期导致计数丢失
|
||||
keyTTL := ttl + 60
|
||||
r, err := g.Redis().Do(ctx, "EVAL", queueGateAcquireLua, 1, key, now, limit, expireAt, taskId, keyTTL)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("queue gate acquire failed: %w", err)
|
||||
}
|
||||
return gconv.Int(r) == 1, nil
|
||||
}
|
||||
|
||||
// ReleaseQueueSlot 释放占位(幂等)
|
||||
func ReleaseQueueSlot(ctx context.Context, modelName, taskId string) {
|
||||
if taskId == "" || modelName == "" {
|
||||
return
|
||||
}
|
||||
key := queueGateKey(modelName)
|
||||
_, _ = g.Redis().Do(ctx, "EVAL", queueGateReleaseLua, 1, key, taskId)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// modelCallMaxRetries 上游调用最大重试次数
|
||||
const modelCallMaxRetries = 10
|
||||
|
||||
// retryWait 指数退避等待(第 attempt 次重试,等待 1<<attempt 秒)。
|
||||
// 返回 nil 表示可继续重试;ctx 已取消返回 ctx.Err(),调用方应停止。
|
||||
func retryWait(ctx context.Context, attempt int) error {
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// isRetryableErrorCode 判定上游返回的错误码是否可重试:限流(429/limit_requests/limit_tokens/rate_limit_exceeded)与 5xx(500-503)。
|
||||
// httpclient.ModelHttpNormalRequest 不返回 HTTP status,只能按响应体 error.code 字符串判定。
|
||||
func isRetryableErrorCode(code string) bool {
|
||||
switch code {
|
||||
case "429", "500", "501", "502", "503", "InvalidParameter", "limit_requests", "limit_tokens", "rate_limit_exceeded":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// firstText 取任意值首位文本:数组取首个元素,其余原样转字符串
|
||||
func firstText(v any) string {
|
||||
if arr, ok := v.([]any); ok && len(arr) > 0 {
|
||||
return gconv.String(arr[0])
|
||||
}
|
||||
return gconv.String(v)
|
||||
}
|
||||
|
||||
// extractChunkText 从流式分片字段取值并转存 OSS,返回首位文本(数组取首个元素,去掉首尾空白)。
|
||||
// 空值 / 仅空白 / 数组全空 返回空串。
|
||||
func extractChunkText(ctx context.Context, v any) string {
|
||||
if v == nil || g.IsEmpty(v) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(firstText(oss.TempURLToOSS(ctx, v)))
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// 运行时调参存储在 Redis,不修改 asynch_models 中的 cap(最大上限)。
|
||||
// 上层每小时调用 /model/autoTune 写入运行时值;Worker/CreateTask 读取运行时值生效。
|
||||
|
||||
const (
|
||||
runtimeMaxCKeyPrefix = "asynch:runtime:max_concurrency:" // + model_name
|
||||
runtimeQueueKeyPrefix = "asynch:runtime:queue_limit:" // + model_name
|
||||
runtimeTTLSeconds = 2 * 3600 // 2小时,避免一次调参失败导致立即回退
|
||||
)
|
||||
|
||||
func runtimeMaxConcurrencyKey(modelName string) string {
|
||||
return runtimeMaxCKeyPrefix + modelName
|
||||
}
|
||||
func runtimeQueueLimitKey(modelName string) string {
|
||||
return runtimeQueueKeyPrefix + modelName
|
||||
}
|
||||
|
||||
func getRuntimeInt(ctx context.Context, key string) (int, bool) {
|
||||
v, err := g.Redis().Do(ctx, "GET", key)
|
||||
if err != nil || v == nil {
|
||||
return 0, false
|
||||
}
|
||||
iv := gconv.Int(v)
|
||||
if iv <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return iv, true
|
||||
}
|
||||
|
||||
func setRuntimeInt(ctx context.Context, key string, val int) {
|
||||
if val <= 0 {
|
||||
return
|
||||
}
|
||||
// SETEX key ttl val
|
||||
_, _ = g.Redis().Do(ctx, "SETEX", key, runtimeTTLSeconds, val)
|
||||
}
|
||||
|
||||
// GetRuntimeMaxConcurrency 返回运行时并发上限(<= cap)。若不存在运行时值,则返回 cap。
|
||||
func GetRuntimeMaxConcurrency(ctx context.Context, modelName string, cap int) int {
|
||||
if cap <= 0 {
|
||||
return cap
|
||||
}
|
||||
if v, ok := getRuntimeInt(ctx, runtimeMaxConcurrencyKey(modelName)); ok {
|
||||
if v > cap {
|
||||
return cap
|
||||
}
|
||||
return v
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
// GetRuntimeQueueLimit 返回运行时队列上限(<= cap)。若不存在运行时值,则返回 cap。
|
||||
func GetRuntimeQueueLimit(ctx context.Context, modelName string, cap int) int {
|
||||
if cap <= 0 {
|
||||
return cap
|
||||
}
|
||||
if v, ok := getRuntimeInt(ctx, runtimeQueueLimitKey(modelName)); ok {
|
||||
if v > cap {
|
||||
return cap
|
||||
}
|
||||
return v
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
func clampInt(v, minV, maxV int) int {
|
||||
if v < minV {
|
||||
return minV
|
||||
}
|
||||
if v > maxV {
|
||||
return maxV
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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 兼容格式)。
|
||||
// 模型地址/密钥走配置 schemaMapping 段,本地开发无配置时用默认值兜底。
|
||||
func callLLM(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||
modelName := g.Cfg().MustGet(ctx, "schemaMapping.modelName", "doubao-seed-2-0-lite-260428").String()
|
||||
baseURL := g.Cfg().MustGet(ctx, "schemaMapping.baseUrl", "https://ark.cn-beijing.volces.com/api/v3/chat/completions").String()
|
||||
apiKey := g.Cfg().MustGet(ctx, "schemaMapping.apiKey", "ark-9df744e8-a0de-4c54-9db3-18379bccd523-e6733").String()
|
||||
|
||||
body := map[string]any{
|
||||
"model": modelName,
|
||||
"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
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var acquireLua = `
|
||||
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
|
||||
local max = tonumber(ARGV[1])
|
||||
local ttl = tonumber(ARGV[2])
|
||||
if current >= max then
|
||||
return 0
|
||||
end
|
||||
current = redis.call("INCR", KEYS[1])
|
||||
if current == 1 then
|
||||
redis.call("EXPIRE", KEYS[1], ttl)
|
||||
end
|
||||
if current > max then
|
||||
redis.call("DECR", KEYS[1])
|
||||
return 0
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
var releaseLua = `
|
||||
local current = tonumber(redis.call("DECR", KEYS[1]) or "0")
|
||||
if current <= 0 then
|
||||
redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
func acquireSemaphore(ctx context.Context, key string, max int, ttlSeconds int64) (bool, error) {
|
||||
if max <= 0 {
|
||||
// 不限制
|
||||
return true, nil
|
||||
}
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = 3600
|
||||
}
|
||||
r, err := g.Redis().Do(ctx, "EVAL", acquireLua, 1, key, max, ttlSeconds)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("获取并发令牌失败: %w", err)
|
||||
}
|
||||
return gconv.Int(r) == 1, nil
|
||||
}
|
||||
|
||||
func releaseSemaphore(ctx context.Context, key string) error {
|
||||
_, err := g.Redis().Do(ctx, "EVAL", releaseLua, 1, key)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/domain"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service/httpclient"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// CreateSessionStreamOnce 流式调用上游模型 → 缓冲全量后一次返回(走 gf 框架正常返回)。
|
||||
// 与同步请求一致:上游返回可重试错误码(限流/5xx)时按指数退避重试(最多 modelCallMaxRetries 次)。
|
||||
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
|
||||
|
||||
attempt := 0
|
||||
LOOP:
|
||||
// 获取上游流式 reader(stream=false → w 不会被使用,传 nil)。
|
||||
// 非 2xx 状态/网络错误在此返回;错误含可重试错误码(限流/5xx)时按指数退避重试,与同步请求一致。
|
||||
streamReader, err := httpclient.ModelHttpStreamRequest(ctx, nil, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
if retryCode := streamRetryCodeOfError(err); retryCode != "" && attempt < modelCallMaxRetries {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型流式请求异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, retryCode, err)
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
recordSessionError(context.WithoutCancel(ctx), id, startTime, "调用取消: "+waitErr.Error())
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
// 非重试错误/重试耗尽:请求失败即返回,需把失败信息写入模型会话记录,避免留半截无错误信息记录
|
||||
recordSessionError(ctx, id, startTime, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
docMsg = new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
var contentBuf strings.Builder
|
||||
|
||||
// 记录流内 error 事件(OpenAI 兼容 error 分片),供流结束后统一判定重试/报错
|
||||
var streamErrCode, streamErrMsg string
|
||||
|
||||
// 路径预处理
|
||||
respMapping := make(map[string]string, len(modelInfo.ResponseBodyMapping))
|
||||
for k, _ := range modelInfo.ResponseBodyMapping {
|
||||
respMapping[k] = modelUtils.CleanFieldPath(k)
|
||||
}
|
||||
totalTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)
|
||||
promptTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)
|
||||
completionTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)
|
||||
|
||||
httpclient.ParseSSEStream(ctx, streamReader, func(ctx context.Context, chunk map[string]any) error {
|
||||
// 流内错误事件(OpenAI 兼容 error 分片):暂存错误码/消息,不做内容累加,由流结束后统一判定
|
||||
if code, msg := streamErrorOfChunk(chunk); code != "" {
|
||||
streamErrCode, streamErrMsg = code, msg
|
||||
return nil
|
||||
}
|
||||
// 基于统一字段路径(GetByPath)在分片对象上取值,取首个数组元素文本累加
|
||||
for _, jsonPath := range respMapping {
|
||||
if realText := extractChunkText(ctx, modelUtils.GetByPathValue(chunk, jsonPath)); realText != "" {
|
||||
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
|
||||
})
|
||||
|
||||
// 流内返回可重试错误码:丢弃本次部分内容,指数退避后重新请求
|
||||
if streamErrCode != "" {
|
||||
if attempt < modelCallMaxRetries && isRetryableErrorCode(streamErrCode) {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型流式调用异常,第 %d 次重试(等待 %v): code=%s msg=%s", attempt+1, wait, streamErrCode, streamErrMsg)
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
recordSessionError(context.WithoutCancel(ctx), id, startTime, "调用取消: "+waitErr.Error())
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
// 与同步一致:不可重试的错误码记录到 ErrorMsg 后正常走组装返回,不中断流程
|
||||
docMsg.ErrorMsg = streamErrMsg
|
||||
}
|
||||
|
||||
// 流结束后组装(流内出错时内容为空,与同步一致不再组装/上传空内容)
|
||||
if streamErrCode == "" {
|
||||
for k, _ := range modelInfo.ResponseBodyMapping {
|
||||
docMsg.Content = map[string]any{k: contentBuf.String()}
|
||||
}
|
||||
}
|
||||
|
||||
// 补充更新会话记录
|
||||
updateModelSessionReq := dto.UpdateModelSessionReq{
|
||||
Id: id,
|
||||
DurationSeconds: int64(time.Since(startTime).Seconds()),
|
||||
TotalTokens: docMsg.TotalTokens,
|
||||
PromptTokens: docMsg.PromptTokens,
|
||||
CompletionTokens: docMsg.CompletionTokens,
|
||||
ErrorMsg: docMsg.ErrorMsg,
|
||||
}
|
||||
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 := httpclient.ModelHttpStreamRequest(ctx, w, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
// 请求建立前失败:把错误写入模型会话记录,避免留半截无错误信息记录
|
||||
recordSessionError(ctx, id, startTime, err.Error())
|
||||
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)
|
||||
|
||||
httpclient.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 {
|
||||
if realText := extractChunkText(ctx, modelUtils.GetByPathValue(chunk, jsonPath)); realText != "" {
|
||||
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) {
|
||||
reasoningContent = firstText(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
|
||||
}
|
||||
|
||||
// recordSessionError 请求建立前失败(上游不可达/非 2xx 且非重试/重试耗尽/调用取消)时,
|
||||
// 把错误与耗时写入模型会话记录,避免流式调用留半截无错误信息记录。
|
||||
// 仅写 ErrorMsg/DurationSeconds(OmitEmpty 不会影响已落库字段);ctx 已取消时须传 WithoutCancel(ctx)。
|
||||
func recordSessionError(ctx context.Context, id int64, startTime time.Time, errMsg string) {
|
||||
if _, updateErr := dao.ModelSession.Update(ctx, &dto.UpdateModelSessionReq{
|
||||
Id: id,
|
||||
DurationSeconds: int64(time.Since(startTime).Seconds()),
|
||||
ErrorMsg: errMsg,
|
||||
}); updateErr != nil {
|
||||
g.Log().Errorf(ctx, "更新模型会话错误信息失败: %v", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
// streamErrorOfChunk 从流式分片提取错误码与消息:优先 OpenAI 兼容 error 事件,顶层 code 兜底。
|
||||
func streamErrorOfChunk(chunk map[string]any) (code, msg string) {
|
||||
if errObj := gconv.Map(chunk["error"]); errObj != nil {
|
||||
code = gconv.String(errObj["code"])
|
||||
msg = gconv.String(errObj["message"])
|
||||
}
|
||||
if code == "" {
|
||||
code = gconv.String(chunk["code"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// streamRetryCodeOfError 从流式请求错误中提取可重试错误码:优先解析错误体 error.code/顶层 code,
|
||||
// 其次取非 2xx 的 HTTP 状态码;纯网络错误等无错误码场景返回空串(与同步请求一致,不重试)。
|
||||
func streamRetryCodeOfError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
// 非 2xx 时 httpclient.ModelHttpStreamRequest 返回 "[HTTP][Stream] 状态码异常: %d, body={...}"
|
||||
if idx := strings.Index(msg, "body="); idx >= 0 {
|
||||
body := msg[idx+len("body="):]
|
||||
var errResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if json.Unmarshal([]byte(body), &errResp) == nil {
|
||||
if errResp.Error.Code != "" {
|
||||
return errResp.Error.Code
|
||||
}
|
||||
if errResp.Code != "" {
|
||||
return errResp.Code
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(msg, "状态码异常: "); idx >= 0 {
|
||||
codeStr := strings.TrimSpace(msg[idx+len("状态码异常: "):])
|
||||
if comma := strings.IndexByte(codeStr, ','); comma >= 0 {
|
||||
codeStr = codeStr[:comma]
|
||||
}
|
||||
if isRetryableErrorCode(codeStr) {
|
||||
return codeStr
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/consts/model"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service/httpclient"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
"time"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelSession = &modelSessionService{}
|
||||
|
||||
type modelSessionService struct{}
|
||||
|
||||
// CreateSession 创建会话(同步调用,非流式)
|
||||
func (s *modelSessionService) CreateSession(ctx context.Context, req *dto.CallModelSessionReq) (res *dto.ModelCallRes, err error) {
|
||||
startTime := time.Now()
|
||||
attempt := 0
|
||||
id := req.Id
|
||||
modelInfo := req.ModelInfo
|
||||
newRequestParams := req.RequestParams
|
||||
LOOP:
|
||||
// 6) 模型请求
|
||||
modelRespBody, err := httpclient.ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if modelRespBody == nil {
|
||||
return nil, fmt.Errorf("模型返回参数是空")
|
||||
}
|
||||
// 7) 上传模型返回参数文件
|
||||
uploadOriginalResp, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileBytes: modelRespBody,
|
||||
FileName: fmt.Sprintf("modelRespParams:%v.json", time.Now().UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", err)
|
||||
}
|
||||
// 8) 更新模型会话信息
|
||||
updateModelSessionReq := dto.UpdateModelSessionReq{
|
||||
Id: id,
|
||||
OriginalResponsePath: uploadOriginalResp.FileURL,
|
||||
}
|
||||
docMsg := new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
errCode, errMsg, err := parseModelError(modelRespBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
if errCode != "" {
|
||||
|
||||
if attempt < modelCallMaxRetries && isRetryableErrorCode(errCode) {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型上游调用异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, errCode, errMsg)
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
|
||||
docMsg.ErrorMsg = errMsg
|
||||
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[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] = oss.TempURLToOSS(ctx, 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
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
)
|
||||
|
||||
type statService struct{}
|
||||
|
||||
var Stat = &statService{}
|
||||
|
||||
func (s *statService) List(ctx context.Context, req *dto.ListModelStatReq) (res *dto.ListModelStatRes, err error) {
|
||||
pageNum, pageSize := 1, 10
|
||||
if req != nil {
|
||||
if req.PageNum > 0 {
|
||||
pageNum = req.PageNum
|
||||
}
|
||||
if req.PageSize > 0 {
|
||||
pageSize = req.PageSize
|
||||
}
|
||||
}
|
||||
startDay, endDay := "", ""
|
||||
var tenantID *int64
|
||||
creator, modelName := "", ""
|
||||
if req != nil {
|
||||
startDay = req.StartDay
|
||||
endDay = req.EndDay
|
||||
tenantID = req.TenantID
|
||||
creator = req.Creator
|
||||
modelName = req.ModelName
|
||||
}
|
||||
list, total, err := dao.Stat.List(ctx, pageNum, pageSize, startDay, endDay, tenantID, creator, modelName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ListModelStatRes{List: list, Total: total}, nil
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"model-gateway/model/entity"
|
||||
)
|
||||
|
||||
// StorageService 结果存储(OSS/MinIO)抽象
|
||||
type StorageService interface {
|
||||
UploadByTask(ctx context.Context, t *entity.AsynchTask, data []byte, fileExt string, contentType string) (ossURL string, err error)
|
||||
}
|
||||
|
||||
// Storage 默认存储实现(优先对接你们的 oss 文件服务;必要时也可以切到 MinIO)
|
||||
var Storage StorageService = &ossStorage{}
|
||||
|
||||
var ErrStorageNotConfigured = errors.New("存储未配置")
|
||||
@@ -1,81 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"time"
|
||||
|
||||
"model-gateway/model/entity"
|
||||
|
||||
commonHttp "gitea.com/red-future/common/http"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/gogf/gf/v2/util/guid"
|
||||
)
|
||||
|
||||
// 对接你们的 oss 文件服务:POST oss/file/uploadFile (multipart/form-data)
|
||||
type ossStorage struct{}
|
||||
|
||||
type uploadFileResponse struct {
|
||||
FileURL string `json:"fileURL"` // 文件 URL
|
||||
FileSize int `json:"fileSize"` // 文件大小(字节)
|
||||
FileName string `json:"fileName"` // 文件名
|
||||
FileFormat string `json:"fileFormat"` // 文件格式
|
||||
FileAddressPrefix string `json:"fileAddressPrefix"` // 文件地址前缀
|
||||
}
|
||||
|
||||
func (s *ossStorage) UploadByTask(ctx context.Context, _ *entity.AsynchTask, data []byte, fileExt string, _ string) (ossURL string, err error) {
|
||||
// multipart
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
ext := fileExt
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
if ext[0] != '.' {
|
||||
ext = "." + ext
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("asynch_%d_%s%s", time.Now().Unix(), guid.S(), ext)
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
contentType := writer.FormDataContentType()
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
headers := forwardHeaders(ctx)
|
||||
headers["Content-Type"] = contentType
|
||||
|
||||
fullURL := "oss/file/uploadFile"
|
||||
g.Log().Infof(ctx, "[OSS] upload start url=%s filename=%s size=%d", fullURL, filename, len(data))
|
||||
|
||||
var resp uploadFileResponse
|
||||
if err := commonHttp.Post(ctx, fullURL, headers, &resp, body.Bytes()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
g.Log().Infof(ctx, "[OSS] upload success url=%s size=%d format=%s", resp.FileURL, resp.FileSize, resp.FileFormat)
|
||||
return resp.FileURL, nil
|
||||
}
|
||||
|
||||
// setTaskHeadersToCtx 把任务入库时保存的 header 信息注入 ctx,给 worker 调 OSS 用
|
||||
func setTaskHeadersToCtx(ctx context.Context, headers map[string]string) context.Context {
|
||||
if headers == nil {
|
||||
return ctx
|
||||
}
|
||||
if v := gconv.String(headers["Authorization"]); v != "" {
|
||||
ctx = context.WithValue(ctx, "token", v)
|
||||
}
|
||||
if v := gconv.String(headers["X-User-Info"]); v != "" {
|
||||
ctx = context.WithValue(ctx, "xUserInfo", v)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
modelUtils "model-gateway/service/utils"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// streamToolCallAcc 流式 tool_call 按 index 累加的碎片(OpenAI 兼容 delta 格式)
|
||||
type streamToolCallAcc struct {
|
||||
id string
|
||||
typ string
|
||||
fnName string
|
||||
fnArgs strings.Builder
|
||||
}
|
||||
|
||||
// streamToolCallDelta OpenAI 兼容流式 tool_call 增量片段,字段名集中于此
|
||||
type streamToolCallDelta struct {
|
||||
Index int `json:"index"`
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
// toStreamToolCallDeltas 把 JSON 反序列化的 any 数组在边界转成强类型片段
|
||||
func toStreamToolCallDeltas(rawCalls []any) []streamToolCallDelta {
|
||||
var deltas []streamToolCallDelta
|
||||
if err := gconv.Structs(rawCalls, &deltas); err != nil {
|
||||
return nil
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
// accumulateStreamToolCallsByPath 按配置路径从 chunk 读取 tool_calls 数组并累加。
|
||||
// 路径未命中或值非数组时无副作用(不创建任何槽位)。
|
||||
func accumulateStreamToolCallsByPath(chunk map[string]any, toolsPath string, acc map[int]*streamToolCallAcc) {
|
||||
raw := modelUtils.GetByPathValue(chunk, modelUtils.CleanFieldPath(toolsPath))
|
||||
rawCalls, _ := raw.([]any)
|
||||
accumulateToolCallFragments(toStreamToolCallDeltas(rawCalls), acc)
|
||||
}
|
||||
|
||||
// accumulateToolCallFragments 按 index 累加 tool_calls 增量片段:
|
||||
// id/type/function.name 首片段补齐,function.arguments 为字符串片段需按 index 拼接。
|
||||
func accumulateToolCallFragments(rawCalls []streamToolCallDelta, acc map[int]*streamToolCallAcc) {
|
||||
for _, d := range rawCalls {
|
||||
slot, ok := acc[d.Index]
|
||||
if !ok {
|
||||
slot = &streamToolCallAcc{}
|
||||
acc[d.Index] = slot
|
||||
}
|
||||
if d.Id != "" {
|
||||
slot.id = d.Id
|
||||
}
|
||||
if d.Type != "" {
|
||||
slot.typ = d.Type
|
||||
}
|
||||
if d.Function.Name != "" {
|
||||
slot.fnName = d.Function.Name
|
||||
}
|
||||
slot.fnArgs.WriteString(d.Function.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeStreamToolCalls 把累加结果按 index 升序转为 []map[string]any,形状对齐 dto.ModelTool。
|
||||
// 无有效工具返回 nil。
|
||||
func finalizeStreamToolCalls(acc map[int]*streamToolCallAcc) []map[string]any {
|
||||
if len(acc) == 0 {
|
||||
return nil
|
||||
}
|
||||
idx := make([]int, 0, len(acc))
|
||||
for i := range acc {
|
||||
idx = append(idx, i)
|
||||
}
|
||||
sort.Ints(idx)
|
||||
tools := make([]map[string]any, 0, len(idx))
|
||||
for _, i := range idx {
|
||||
s := acc[i]
|
||||
fn := map[string]any{}
|
||||
if s.fnName != "" {
|
||||
fn["name"] = s.fnName
|
||||
}
|
||||
if s.fnArgs.Len() > 0 {
|
||||
fn["arguments"] = s.fnArgs.String()
|
||||
}
|
||||
tool := map[string]any{"function": fn}
|
||||
if s.id != "" {
|
||||
tool["id"] = s.id
|
||||
}
|
||||
if s.typ != "" {
|
||||
tool["type"] = s.typ
|
||||
}
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var Task = &taskService{}
|
||||
|
||||
type taskService struct{}
|
||||
|
||||
func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) {
|
||||
fmt.Printf("打印请求:%+v", req)
|
||||
startAt := time.Now()
|
||||
// 固化 token/user 等信息
|
||||
ctx = asyncCtx(ctx)
|
||||
|
||||
// 1) 检查模型配置
|
||||
m, err := dao.Model.GetByModelName(ctx, req.ModelName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil || (m.Enabled != nil && *m.Enabled != 1) {
|
||||
return nil, errors.New("模型不存在或未启用")
|
||||
}
|
||||
|
||||
taskID := uuid.NewString()
|
||||
// 2) 排队上限(严格控制:Redis 原子闸门)
|
||||
limit := GetRuntimeQueueLimit(ctx, req.ModelName, m.QueueLimit)
|
||||
if limit > 0 {
|
||||
ok, err := AcquireQueueSlot(ctx, req.ModelName, taskID, limit, m.ExpectedSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("任务排队已满,请稍后再试")
|
||||
}
|
||||
}
|
||||
|
||||
// 将调用模型的 payload 与透传头信息一起存入 request_payload,供后台 worker 使用
|
||||
storedPayload := map[string]any{
|
||||
"payload": req.RequestPayload,
|
||||
"headers": forwardHeaders(ctx),
|
||||
}
|
||||
|
||||
t := &entity.AsynchTask{
|
||||
ModelName: req.ModelName,
|
||||
TaskID: taskID,
|
||||
State: 0,
|
||||
BizName: req.BizName,
|
||||
CallbackURL: req.CallbackUrl,
|
||||
ModelKey: m.ApiKey,
|
||||
InputRef: req.InputRef,
|
||||
RequestPayload: storedPayload,
|
||||
EpicycleId: req.EpicycleId,
|
||||
}
|
||||
_, err = dao.Task.Insert(ctx, t)
|
||||
if err != nil {
|
||||
// 入库失败:回滚闸门占位
|
||||
ReleaseQueueSlot(ctx, req.ModelName, taskID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3) 写操作日志(尽量不影响主流程,失败忽略)
|
||||
ip := ""
|
||||
ua := ""
|
||||
apiPath := "/task/createTask"
|
||||
httpMethod := "POST"
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
ip = r.GetClientIp()
|
||||
ua = r.UserAgent()
|
||||
apiPath = r.URL.Path
|
||||
httpMethod = r.Method
|
||||
}
|
||||
_, _ = dao.OpLog.Insert(ctx, &entity.LogsModelOp{
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
APIPath: apiPath,
|
||||
HttpMethod: httpMethod,
|
||||
BizName: req.BizName,
|
||||
ModelName: req.ModelName,
|
||||
TaskID: taskID,
|
||||
OpType: "createTask",
|
||||
Success: 1,
|
||||
ErrorMsg: "",
|
||||
CostMs: time.Since(startAt).Milliseconds(),
|
||||
RequestPayload: storedPayload,
|
||||
ResponsePayload: gdb.Map{
|
||||
"taskId": taskID,
|
||||
},
|
||||
})
|
||||
|
||||
// 4) 创建成功后立即异步尝试执行当前任务,并仅在任务仍处于 pending(state=0) 时做定向轮询。
|
||||
// 一旦任务进入 running/success/failed/downloaded,就停止轮询,避免一直空转。
|
||||
go s.pollAndRunUntilPicked(context.WithoutCancel(ctx), taskID, req.EpicycleId)
|
||||
|
||||
return &dto.CreateTaskRes{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
// pollAndRunUntilPicked 用于 createTask 创建后的“轻量级定向轮询”:
|
||||
// - 目标:尽快把刚创建的任务拉起来执行
|
||||
// - 只在任务仍为 pending(state=0) 时继续尝试抢占
|
||||
// - 一旦任务进入 running(1) / success(2) / failed(3) / downloaded(4),立即停止
|
||||
// - 这样不会无限轮询;runWork 仍负责处理积压队列和未处理到的任务
|
||||
func (s *taskService) pollAndRunUntilPicked(ctx context.Context, taskID string, epicycleId int64) {
|
||||
if taskID == "" {
|
||||
return
|
||||
}
|
||||
interval := g.Cfg().MustGet(ctx, "asynch.worker.intervalSeconds").Int()
|
||||
if interval <= 0 {
|
||||
interval = 5
|
||||
}
|
||||
g.Log().Infof(ctx, "[task-auto-run][start] taskId=%s interval=%ds", taskID, interval)
|
||||
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
tryRun := func() bool {
|
||||
t, err := dao.Task.GetByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[task-auto-run][stop] taskId=%s reason=query_failed err=%v", taskID, err)
|
||||
return true
|
||||
}
|
||||
if t == nil {
|
||||
g.Log().Warningf(ctx, "[task-auto-run][stop] taskId=%s reason=task_not_found", taskID)
|
||||
return true
|
||||
}
|
||||
switch t.State {
|
||||
case 0:
|
||||
if err := AsyncWorker.RunByTaskID(ctx, taskID, epicycleId); err != nil {
|
||||
g.Log().Warningf(ctx, "[task-auto-run][retry] taskId=%s state=0 err=%v", taskID, err)
|
||||
} else {
|
||||
g.Log().Infof(ctx, "[task-auto-run][triggered] taskId=%s state=0", taskID)
|
||||
}
|
||||
return false
|
||||
case 1:
|
||||
g.Log().Infof(ctx, "[task-auto-run][stop] taskId=%s reason=running", taskID)
|
||||
return true
|
||||
case 2, 3, 4:
|
||||
g.Log().Infof(ctx, "[task-auto-run][stop] taskId=%s reason=terminal state=%d", taskID, t.State)
|
||||
return true
|
||||
default:
|
||||
g.Log().Infof(ctx, "[task-auto-run][stop] taskId=%s reason=unknown_state state=%d", taskID, t.State)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 先立即尝试一次
|
||||
if stop := tryRun(); stop {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
g.Log().Infof(ctx, "[task-auto-run][stop] taskId=%s reason=context_done", taskID)
|
||||
return
|
||||
case <-ticker.C:
|
||||
if stop := tryRun(); stop {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *taskService) GetResult(ctx context.Context, taskID string) (res *dto.GetTaskResultRes, err error) {
|
||||
t, err := dao.Task.GetByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t == nil {
|
||||
return nil, errors.New("任务不存在")
|
||||
}
|
||||
return &dto.GetTaskResultRes{
|
||||
OssFile: t.OssFile,
|
||||
State: t.State,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBatch 批量查询任务;将成功(state=2)的任务更新为已下载(state=4),并写入过期时间
|
||||
func (s *taskService) GetBatch(ctx context.Context, req *dto.GetTaskBatchReq) (res *dto.GetTaskBatchRes, err error) {
|
||||
if req == nil || len(req.TaskIDs) == 0 {
|
||||
return &dto.GetTaskBatchRes{List: []dto.GetTaskBatchItem{}}, nil
|
||||
}
|
||||
// 1) 先查当前租户下的任务列表
|
||||
list, err := dao.Task.ListByTaskIDs(ctx, req.TaskIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2) 对成功(state=2)的任务:标记为已下载(state=4)并写入 expire_at
|
||||
now := time.Now()
|
||||
for _, t := range list {
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
if t.State != 2 {
|
||||
continue
|
||||
}
|
||||
// 按模型配置决定保留时间
|
||||
m, err := dao.Model.GetByModelName(ctx, t.ModelName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
retainSeconds := 86400
|
||||
if m != nil && m.AutoCleanSeconds > 0 {
|
||||
retainSeconds = m.AutoCleanSeconds
|
||||
}
|
||||
expireAt := gtime.New(now.Add(time.Duration(retainSeconds) * time.Second))
|
||||
_ = dao.Task.MarkDownloadedByID(ctx, t.Id, expireAt)
|
||||
|
||||
// 为了本次返回一致性,内存里也更新
|
||||
t.State = 4
|
||||
t.ExpireAt = expireAt
|
||||
}
|
||||
|
||||
// 3) 组装返回
|
||||
items := make([]dto.GetTaskBatchItem, 0, len(list))
|
||||
for _, t := range list {
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, dto.GetTaskBatchItem{
|
||||
TaskID: t.TaskID,
|
||||
State: t.State,
|
||||
OssFile: t.OssFile,
|
||||
})
|
||||
}
|
||||
return &dto.GetTaskBatchRes{List: items}, nil
|
||||
}
|
||||
|
||||
func (s *taskService) List(ctx context.Context, req *dto.ListTaskReq) (res *dto.ListTaskRes, err error) {
|
||||
pageNum, pageSize := 1, 10
|
||||
if req != nil {
|
||||
if req.PageNum > 0 {
|
||||
pageNum = req.PageNum
|
||||
}
|
||||
if req.PageSize > 0 {
|
||||
pageSize = req.PageSize
|
||||
}
|
||||
}
|
||||
modelName := ""
|
||||
taskID := ""
|
||||
var state *int
|
||||
if req != nil {
|
||||
modelName = req.ModelName
|
||||
taskID = req.TaskID
|
||||
state = req.State
|
||||
}
|
||||
list, total, err := dao.Task.List(ctx, pageNum, pageSize, modelName, taskID, state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ListTaskRes{List: list, Total: total}, nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// saveTmpResult 将模型输出写入临时文件,用于 OSS 上传失败后的“仅重试 OSS”。
|
||||
func saveTmpResult(taskID string, data []byte, ext string) (string, error) {
|
||||
dir := filepath.Join(os.TempDir(), "model-asynch")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
if ext[0] != '.' {
|
||||
ext = "." + ext
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("%s%s", taskID, ext))
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func loadTmpResult(path string) ([]byte, error) {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func deleteTmpResult(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
)
|
||||
|
||||
func normalizeFormValue(v any) any {
|
||||
// 目标:对外永远返回 JSON 数组/对象,而不是字符串。
|
||||
if v == nil {
|
||||
return []any{}
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return []any{}
|
||||
}
|
||||
return normalizeFormValueFromJSONString(s)
|
||||
case []byte:
|
||||
if len(t) == 0 {
|
||||
return []any{}
|
||||
}
|
||||
return normalizeFormValueFromJSONBytes(t)
|
||||
case *gvar.Var:
|
||||
// goframe 常见的 DB 返回类型
|
||||
if t == nil {
|
||||
return []any{}
|
||||
}
|
||||
b := t.Bytes()
|
||||
if len(b) > 0 {
|
||||
return normalizeFormValueFromJSONBytes(b)
|
||||
}
|
||||
s := strings.TrimSpace(t.String())
|
||||
if s == "" {
|
||||
return []any{}
|
||||
}
|
||||
return normalizeFormValueFromJSONString(s)
|
||||
default:
|
||||
// 尝试兼容其他“像 JSON 的值类型”(例如实现了 Bytes/String 的包装类型)
|
||||
if vb, ok := v.(interface{ Bytes() []byte }); ok {
|
||||
if b := vb.Bytes(); len(b) > 0 {
|
||||
return normalizeFormValueFromJSONBytes(b)
|
||||
}
|
||||
}
|
||||
if vs, ok := v.(interface{ String() string }); ok {
|
||||
if s := strings.TrimSpace(vs.String()); s != "" {
|
||||
return normalizeFormValueFromJSONString(s)
|
||||
}
|
||||
}
|
||||
// 已经是 []any / map[string]any 等结构
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容“JSONB 里存了 JSON 字符串”的历史数据:
|
||||
// 例如 form_json = '"[]"' 或 '"[{...}]"'(外层是字符串,内层才是数组/对象)
|
||||
func normalizeFormValueFromJSONString(s string) any {
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil {
|
||||
return []any{}
|
||||
}
|
||||
// 如果解出来还是 string,且看起来是 JSON,再解一层
|
||||
if inner, ok := out.(string); ok {
|
||||
inner = strings.TrimSpace(inner)
|
||||
if inner == "" {
|
||||
return []any{}
|
||||
}
|
||||
if strings.HasPrefix(inner, "[") || strings.HasPrefix(inner, "{") {
|
||||
var out2 any
|
||||
if err := json.Unmarshal([]byte(inner), &out2); err == nil && out2 != nil {
|
||||
return out2
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeFormValueFromJSONBytes(b []byte) any {
|
||||
var out any
|
||||
if err := json.Unmarshal(b, &out); err != nil || out == nil {
|
||||
return []any{}
|
||||
}
|
||||
// bytes 解出来也可能是 string(同上)
|
||||
if inner, ok := out.(string); ok {
|
||||
return normalizeFormValueFromJSONString(inner)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseJSONField(field any) any {
|
||||
var v *gvar.Var
|
||||
switch val := field.(type) {
|
||||
case *gvar.Var:
|
||||
v = val
|
||||
default:
|
||||
return field
|
||||
}
|
||||
|
||||
if v == nil || v.IsNil() || v.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
str := v.String()
|
||||
var result any
|
||||
if json.Unmarshal([]byte(str), &result) == nil {
|
||||
return result
|
||||
}
|
||||
return str
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
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=#)
|
||||
// - 多级选择器 → 递归:中间级选择器定位/新建容器元素并继续下钻,叶子选择器写值
|
||||
// - 值本身是数组 → 叶子选择器逐值追加;普通数组段/点号路径前置追加(业务值在前,原值保留在后,不覆盖)
|
||||
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] = prependToArray(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) {
|
||||
existing, ok := parent[key]
|
||||
if !ok {
|
||||
parent[key] = value
|
||||
return
|
||||
}
|
||||
parent[key] = prependValue(value, existing)
|
||||
}
|
||||
|
||||
// prependValue 把 value 前置到 existing 前(业务值在前,原值保留在后,不覆盖):
|
||||
// - existing 是数组 → value(或其元素)前插到数组头部
|
||||
// - value 是数组(existing 为标量)→ value 各元素在前,existing 作为末位元素
|
||||
// - 其余标量 → 字符串拼接,业务值在前
|
||||
func prependValue(value, existing any) any {
|
||||
if arr, isArr := existing.([]any); isArr {
|
||||
return prependToArray(arr, value)
|
||||
}
|
||||
if vals, isArr := value.([]any); isArr {
|
||||
out := make([]any, 0, len(vals)+1)
|
||||
out = append(out, vals...)
|
||||
out = append(out, existing)
|
||||
return out
|
||||
}
|
||||
return gconv.String(value) + gconv.String(existing)
|
||||
}
|
||||
|
||||
// prependToArray 把 value(或其元素)插到数组头部,原元素依次后移
|
||||
func prependToArray(arr []any, value any) []any {
|
||||
if vals, isArr := value.([]any); isArr {
|
||||
out := make([]any, 0, len(vals)+len(arr))
|
||||
out = append(out, vals...)
|
||||
out = append(out, arr...)
|
||||
return out
|
||||
}
|
||||
out := make([]any, 0, len(arr)+1)
|
||||
out = append(out, value)
|
||||
out = append(out, arr...)
|
||||
return out
|
||||
}
|
||||
|
||||
// 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,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,260 @@
|
||||
package utils
|
||||
|
||||
import "github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
// MergeNode 按模板节点的元数据把用户数据节点合并为模板可解析的节点:
|
||||
// - 用户节点无 type 键 → 套上模板类型包装(标量取用户值,空则回落模板 value/defaultValue)
|
||||
// - 用户节点有 type 键 → 递归合并子节点(子节点缺 type 时同样补模板类型,避免模板包装泄漏)
|
||||
// - 对象:模板 attrs 缺省字段补进用户容器(保留模板默认值)
|
||||
// - 数组:用户 enumValues/attrs 元素逐个与模板元素原型合并(模板未提供的槽位克隆原型)
|
||||
func MergeNode(user, tmpl any) any {
|
||||
tmplMap, ok := tmpl.(map[string]any)
|
||||
if !ok {
|
||||
return user
|
||||
}
|
||||
fieldType, _ := tmplMap["type"].(string)
|
||||
|
||||
// 用户节点已带 type:按用户类型递归合并子节点;标量叶子已完备,直接返回
|
||||
if userMap, ok := user.(map[string]any); ok {
|
||||
if ut, isTpl := userMap["type"]; isTpl {
|
||||
switch gconv.String(ut) {
|
||||
case TypeObject:
|
||||
return mergeObject(userMap, tmplMap)
|
||||
case TypeArray:
|
||||
return mergeArray(userMap, tmplMap)
|
||||
default:
|
||||
return userMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch fieldType {
|
||||
case TypeString, TypeBool, TypeNumber, TypeNumberInt, TypeNumberFloat:
|
||||
return wrapScalarNode(tmplMap, user)
|
||||
case TypeObject:
|
||||
return mergeObject(user, tmplMap)
|
||||
case TypeArray:
|
||||
return mergeArray(user, tmplMap)
|
||||
default:
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
// wrapScalarNode 把用户标量值包装为 {type, value} 节点;用户值为空时回落模板 value/defaultValue
|
||||
func wrapScalarNode(tmplMap map[string]any, user any) map[string]any {
|
||||
val := user
|
||||
if m, ok := user.(map[string]any); ok {
|
||||
if v, has := m["value"]; has {
|
||||
val = v
|
||||
} else {
|
||||
val = nil
|
||||
}
|
||||
}
|
||||
node := map[string]any{"type": normalizeScalarType(gconv.String(tmplMap["type"]))}
|
||||
if hasUsableValue(val) {
|
||||
node["value"] = val
|
||||
} else if v, has := tmplMap["value"]; has {
|
||||
node["value"] = v
|
||||
} else if d, has := tmplMap["defaultValue"]; has {
|
||||
node["value"] = d
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// normalizeScalarType integer/float 统一为 number(resolveField 只分发 string/boolean/number)
|
||||
func normalizeScalarType(t string) string {
|
||||
if t == TypeNumberInt || t == TypeNumberFloat {
|
||||
return TypeNumber
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// mergeObject 合并对象节点:模板 attrs 缺省字段补进用户容器(模板默认值保留)
|
||||
func mergeObject(user any, tmplMap map[string]any) map[string]any {
|
||||
tmplAttrs, _ := tmplMap["attrs"].(map[string]any)
|
||||
node := map[string]any{"type": TypeObject}
|
||||
if tmplAttrs == nil {
|
||||
node["attrs"] = user
|
||||
return node
|
||||
}
|
||||
var container map[string]any
|
||||
switch u := user.(type) {
|
||||
case map[string]any:
|
||||
if a, has := u["attrs"]; has {
|
||||
if am, ok := a.(map[string]any); ok {
|
||||
container = copyMap(am)
|
||||
} else {
|
||||
container = map[string]any{}
|
||||
}
|
||||
} else if v, has := u["value"]; has {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
container = copyMap(vm)
|
||||
} else {
|
||||
container = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
container = copyMap(u)
|
||||
}
|
||||
default:
|
||||
container = map[string]any{}
|
||||
}
|
||||
for k, subTmpl := range tmplAttrs {
|
||||
if _, has := container[k]; !has {
|
||||
container[k] = DeepCopyNode(subTmpl)
|
||||
} else {
|
||||
container[k] = MergeNode(container[k], subTmpl)
|
||||
}
|
||||
}
|
||||
node["attrs"] = container
|
||||
return node
|
||||
}
|
||||
|
||||
// mergeArray 合并数组节点:用户 enumValues/attrs 元素逐个与模板元素原型合并。
|
||||
// 用户未填的槽位(sjson null 填充)用模板对应槽位原型补位,保留下标不塌缩。
|
||||
func mergeArray(user any, tmplMap map[string]any) map[string]any {
|
||||
proto := arrayElementTemplate(tmplMap)
|
||||
tmplSlots, _ := tmplMap["enumValues"].([]any) // 模板各槽位原型,按下标一一对应
|
||||
node := map[string]any{"type": TypeArray}
|
||||
slotProto := func(i int) any {
|
||||
if i < len(tmplSlots) {
|
||||
return tmplSlots[i]
|
||||
}
|
||||
return proto
|
||||
}
|
||||
switch u := user.(type) {
|
||||
case map[string]any:
|
||||
if evs, ok := u["enumValues"].([]any); ok {
|
||||
out := make([]any, len(evs))
|
||||
for i, ev := range evs {
|
||||
if ev == nil {
|
||||
// 保留 null 槽位以维持下标;解析阶段 resolveArray 会丢弃空元素,
|
||||
// 避免用模板原型填充 null 而物化出"幽灵"元素(用户未填的数组槽位)
|
||||
out[i] = nil
|
||||
continue
|
||||
}
|
||||
out[i] = mergeArrayElement(ev, slotProto(i))
|
||||
}
|
||||
node["enumValues"] = out
|
||||
} else if a, ok := u["attrs"].([]any); ok {
|
||||
out := make([]any, len(a))
|
||||
for i, item := range a {
|
||||
out[i] = mergeArrayElement(item, proto)
|
||||
}
|
||||
node["attrs"] = out
|
||||
} else if v, has := u["value"]; has {
|
||||
node["value"] = v
|
||||
} else if len(u) > 0 {
|
||||
node["attrs"] = []any{mergeArrayElement(u, proto)}
|
||||
}
|
||||
case []any:
|
||||
if isSchemaElementList(u) {
|
||||
out := make([]any, len(u))
|
||||
for i, item := range u {
|
||||
out[i] = mergeArrayElement(item, proto)
|
||||
}
|
||||
node["attrs"] = out
|
||||
} else {
|
||||
node["value"] = u
|
||||
}
|
||||
default:
|
||||
node["value"] = user
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// mergeArrayElement 合并单个数组元素:元素缺 type 时按槽位原型补默认字段。
|
||||
// slotProto 可为 nil(模板未提供槽位原型时)→ 原样返回用户元素
|
||||
func mergeArrayElement(ev any, slotProto any) any {
|
||||
if ev == nil {
|
||||
return ev
|
||||
}
|
||||
proto, _ := slotProto.(map[string]any)
|
||||
if proto == nil {
|
||||
return ev
|
||||
}
|
||||
evMap, ok := ev.(map[string]any)
|
||||
if !ok {
|
||||
return ev
|
||||
}
|
||||
if _, isTpl := evMap["type"]; isTpl {
|
||||
return ev
|
||||
}
|
||||
// 元素原型自身是包装节点(嵌套对象/数组)→ 按模板类型合并
|
||||
if t, has := proto["type"].(string); has && t != "" {
|
||||
return MergeNode(ev, proto)
|
||||
}
|
||||
protoAttrs, _ := proto["attrs"].(map[string]any)
|
||||
var container map[string]any
|
||||
if a, has := evMap["attrs"]; has {
|
||||
if am, ok := a.(map[string]any); ok {
|
||||
container = copyMap(am)
|
||||
} else {
|
||||
container = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
container = copyMap(evMap)
|
||||
}
|
||||
for k, subTmpl := range protoAttrs {
|
||||
if _, has := container[k]; !has {
|
||||
container[k] = DeepCopyNode(subTmpl)
|
||||
} else {
|
||||
container[k] = MergeNode(container[k], subTmpl)
|
||||
}
|
||||
}
|
||||
return map[string]any{"attrs": container}
|
||||
}
|
||||
|
||||
// isSchemaElementList 判断 []any 是否为 schema 元素列表(元素均为 map,且带 type 或 attrs 包装)
|
||||
func isSchemaElementList(list []any) bool {
|
||||
if len(list) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, hasType := m["type"]; !hasType {
|
||||
if _, hasAttrs := m["attrs"]; !hasAttrs {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DeepCopyNode 深拷贝任意嵌套节点(模板节点整体拷贝进 src 时使用,避免共享引用被后续回填修改)
|
||||
func DeepCopyNode(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(val))
|
||||
for k, sub := range val {
|
||||
out[k] = DeepCopyNode(sub)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(val))
|
||||
for i, sub := range val {
|
||||
out[i] = DeepCopyNode(sub)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// hasUsableValue 值是否可写入节点 value;0/false 视为有效值
|
||||
func hasUsableValue(v any) bool {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
return val != ""
|
||||
case []any:
|
||||
return len(val) > 0
|
||||
case map[string]any:
|
||||
return len(val) > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"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 := utils.IsFlatMap(cfg)
|
||||
if flatMap {
|
||||
var err error
|
||||
flattenJSON, err = utils.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 bool:
|
||||
return vv
|
||||
default:
|
||||
// 数值零值(int/float 各类型)视为未提供,跳过;bool/string 已在上方处理
|
||||
if isNumericZero(v) {
|
||||
return nil
|
||||
}
|
||||
return vv
|
||||
}
|
||||
}
|
||||
if d, has := field["defaultValue"]; has && d != nil {
|
||||
switch dv := d.(type) {
|
||||
case string:
|
||||
if dv != "" {
|
||||
return dv
|
||||
}
|
||||
case bool:
|
||||
return dv
|
||||
default:
|
||||
if isNumericZero(d) {
|
||||
return nil
|
||||
}
|
||||
return dv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNumericZero 判断是否为数值零值(模板 value 常为 int 字面量,经 gconv 可能为 float64)
|
||||
func isNumericZero(v interface{}) bool {
|
||||
switch vv := v.(type) {
|
||||
case int:
|
||||
return vv == 0
|
||||
case int8:
|
||||
return vv == 0
|
||||
case int16:
|
||||
return vv == 0
|
||||
case int32:
|
||||
return vv == 0
|
||||
case int64:
|
||||
return vv == 0
|
||||
case uint:
|
||||
return vv == 0
|
||||
case uint8:
|
||||
return vv == 0
|
||||
case uint16:
|
||||
return vv == 0
|
||||
case uint32:
|
||||
return vv == 0
|
||||
case uint64:
|
||||
return vv == 0
|
||||
case float32:
|
||||
return vv == 0
|
||||
case float64:
|
||||
return vv == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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,503 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"model-gateway/model/dto"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"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 utils.IsFlatMap(userParams) {
|
||||
nested, err := utils.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 直接以自身为容器)
|
||||
for i, elem := range userArr {
|
||||
elemMap, isMap := elem.(map[string]interface{})
|
||||
if !isMap {
|
||||
continue
|
||||
}
|
||||
container, has := userObjectValue(elemMap, true)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
// 元素带 type 键(schema 包裹):已由 validateEnumValues 按其自身 attrs 校验,跳过,
|
||||
// 避免用枚举首原型(可能是必填字段模板)误报其他槽位元素缺失
|
||||
if _, isWrapped := elemMap["type"]; isWrapped {
|
||||
continue
|
||||
}
|
||||
// 纯对象元素(解析后数组下标可能塌缩):按 attrs 键集结构匹配槽位原型,避免恒用首原型误报必填
|
||||
subAttrs := matchArraySlotProto(&tmpl, container)
|
||||
if subAttrs == nil {
|
||||
continue
|
||||
}
|
||||
for subKey, subTmpl := range subAttrs {
|
||||
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
|
||||
}
|
||||
|
||||
// matchArraySlotProto 按元素 attrs 键集与各槽位原型 attrs 键集的重合度匹配最合适的槽位原型。
|
||||
// 解析后数组下标可能塌缩(resolveArray 丢弃空元素),不能按 index 对齐,故用结构匹配。
|
||||
// 键集完全无重合时返回 nil(跳过该校验,避免用错误原型误报必填)。
|
||||
func matchArraySlotProto(tmpl *dto.Template, container map[string]interface{}) map[string]interface{} {
|
||||
var best map[string]interface{}
|
||||
bestCount := -1
|
||||
for _, ev := range tmpl.EnumValues {
|
||||
evMap, ok := ev.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attrs, ok := evMap["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
count := 0
|
||||
for k := range container {
|
||||
if _, has := attrs[k]; has {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > bestCount {
|
||||
bestCount = count
|
||||
best = attrs
|
||||
}
|
||||
}
|
||||
if bestCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var AsyncWorker = &asyncWorker{}
|
||||
|
||||
type asyncWorker struct {
|
||||
}
|
||||
|
||||
// RunOnce 由上层定时任务触发:一次性抢占并处理一批任务
|
||||
// - batchSize: 本次抢占数量
|
||||
// - goroutines: 本次并发数(协程池大小)
|
||||
func (w *asyncWorker) RunOnce(ctx context.Context, batchSize, goroutines int) (claimed int, err error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 10
|
||||
}
|
||||
if goroutines <= 0 {
|
||||
goroutines = 1
|
||||
}
|
||||
tasks, err := dao.Task.ClaimPendingGlobal(ctx, batchSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
pool := grpool.New(goroutines)
|
||||
defer pool.Close()
|
||||
|
||||
claimed = len(tasks)
|
||||
done := make(chan struct{}, claimed)
|
||||
for _, t := range tasks {
|
||||
task := t
|
||||
_ = pool.AddWithRecover(ctx, func(ctx context.Context) {
|
||||
w.handleOne(ctx, task, 0)
|
||||
done <- struct{}{}
|
||||
}, func(ctx context.Context, e error) {
|
||||
if e != nil {
|
||||
_ = dao.Task.UpdateFailedGlobal(ctx, task.Id, fmt.Sprintf("worker panic: %v", e))
|
||||
ReleaseQueueSlot(ctx, task.ModelName, task.TaskID)
|
||||
}
|
||||
done <- struct{}{}
|
||||
})
|
||||
}
|
||||
for i := 0; i < claimed; i++ {
|
||||
<-done
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
// RunByTaskID 创建任务后立即异步尝试执行当前任务:
|
||||
// - 只定向抢占当前 taskId 对应的 pending 任务
|
||||
// - 若任务已被其它 worker 抢走/已不在 pending,则直接返回
|
||||
func (w *asyncWorker) RunByTaskID(ctx context.Context, taskID string, epicycleId int64) error {
|
||||
task, err := dao.Task.ClaimPendingByTaskIDGlobal(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
w.handleOne(ctx, task, epicycleId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *asyncWorker) handleOne(ctx context.Context, t *entity.AsynchTask, epicycleId int64) {
|
||||
// 从任务入库的 request_payload 里恢复 payload + headers
|
||||
payload, headers := parseStoredPayload(t.RequestPayload)
|
||||
if len(headers) > 0 {
|
||||
ctx = setTaskHeadersToCtx(ctx, headers)
|
||||
}
|
||||
|
||||
// 1) 拉取模型配置
|
||||
m, err := dao.Model.GetByModelNameForTenant(ctx, t.TenantId, t.ModelName)
|
||||
if err != nil {
|
||||
_ = dao.Task.UpdateFailedGlobal(ctx, t.Id, err.Error())
|
||||
ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
|
||||
// ============ 失败回调 ============
|
||||
t.State = 3
|
||||
t.ErrorMsg = err.Error()
|
||||
go triggerCallback(context.WithoutCancel(ctx), t)
|
||||
// ================================
|
||||
return
|
||||
}
|
||||
if m == nil || (m.Enabled != nil && *m.Enabled != 1) {
|
||||
errMsg := "模型不存在或未启用"
|
||||
_ = dao.Task.UpdateFailedGlobal(ctx, t.Id, errMsg)
|
||||
ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
|
||||
// ============ 失败回调 ============
|
||||
t.State = 3
|
||||
t.ErrorMsg = errMsg
|
||||
go triggerCallback(context.WithoutCancel(ctx), t)
|
||||
// ================================
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 分布式并发限制
|
||||
semKey := fmt.Sprintf("asynch:sem:%s", t.ModelName)
|
||||
leaseSeconds := int64(3600)
|
||||
maxC := GetRuntimeMaxConcurrency(ctx, t.ModelName, m.MaxConcurrency)
|
||||
acquired, err := acquireSemaphore(ctx, semKey, maxC, leaseSeconds)
|
||||
if err != nil {
|
||||
_ = dao.Task.UpdateFailedGlobal(ctx, t.Id, err.Error())
|
||||
ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
|
||||
// ============ 失败回调 ============
|
||||
t.State = 3
|
||||
t.ErrorMsg = err.Error()
|
||||
go triggerCallback(context.WithoutCancel(ctx), t)
|
||||
// ================================
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
// 并发满了:放回排队,不回调(不是失败)
|
||||
_ = w.rollbackToPending(ctx, t.Id)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = releaseSemaphore(ctx, semKey)
|
||||
}()
|
||||
|
||||
// 3) 调用模型服务
|
||||
if payload == nil {
|
||||
payload = map[string]any{
|
||||
"taskId": t.TaskID,
|
||||
"inputRef": t.InputRef,
|
||||
}
|
||||
}
|
||||
var (
|
||||
data []byte
|
||||
contentType string
|
||||
ext string
|
||||
textResult string
|
||||
)
|
||||
|
||||
// phase=1 表示模型已成功但 OSS 上传失败:优先从临时文件加载
|
||||
if t.Phase == 1 && strings.TrimSpace(t.TmpFile) != "" {
|
||||
data, err = loadTmpResult(t.TmpFile)
|
||||
if err == nil && len(data) > 0 {
|
||||
contentType, ext = DetectFileType(data)
|
||||
} else {
|
||||
data = nil
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
// 统计
|
||||
_ = dao.Stat.IncRequestCount(ctx, time.Now(), int64(t.TenantId), t.Creator, t.ModelName)
|
||||
// 核心调用
|
||||
data, err = InvokeModel(ctx, m, payload, t.ModelKey)
|
||||
if err != nil {
|
||||
_ = dao.Task.UpdateFailedGlobal(ctx, t.Id, err.Error())
|
||||
ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
|
||||
// ============ 失败回调 ============
|
||||
t.State = 3
|
||||
t.ErrorMsg = err.Error()
|
||||
go triggerCallback(context.WithoutCancel(ctx), t)
|
||||
// ================================
|
||||
return
|
||||
}
|
||||
contentType, ext = DetectFileType(data)
|
||||
if utf8.Valid(data) && (strings.HasPrefix(contentType, "text/") || contentType == "application/json") {
|
||||
textResult = string(data)
|
||||
}
|
||||
tmpPath, err := saveTmpResult(t.TaskID, data, ext)
|
||||
if err == nil && tmpPath != "" {
|
||||
t.TmpFile = tmpPath
|
||||
t.Phase = 1
|
||||
_ = dao.Task.UpdateTmpAfterModelGlobal(ctx, t.Id, tmpPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 存储 OSS
|
||||
ossURL, err := Storage.UploadByTask(ctx, t, data, ext, contentType)
|
||||
if err != nil {
|
||||
// OSS 阶段失败:保留临时文件,下一轮仅重试 OSS
|
||||
_ = dao.Task.UpdateFailedKeepTmpGlobal(ctx, t.Id, err.Error())
|
||||
ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
|
||||
// ============ OSS失败不回调(还会重试) ============
|
||||
// 注意:OSS失败保留临时文件,下次重试,所以这里不触发最终回调
|
||||
// 如果已经重试多次还没成功,需要在任务超时或超过最大重试次数时才回调失败
|
||||
return
|
||||
}
|
||||
|
||||
// 5) 更新任务状态成功
|
||||
fileType := strings.TrimPrefix(ext, ".")
|
||||
if fileType == "" {
|
||||
fileType = contentType
|
||||
}
|
||||
if err := dao.Task.UpdateSuccessGlobal(
|
||||
ctx,
|
||||
t.Id,
|
||||
ossURL,
|
||||
fileType,
|
||||
textResult,
|
||||
int64(len(data)),
|
||||
nil,
|
||||
GetExpendTokens(m.TokenMapping, textResult),
|
||||
); err != nil {
|
||||
g.Log().Errorf(ctx, "[worker] update success failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 成功/失败均不再占用 queue_limit
|
||||
ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
|
||||
|
||||
// 6) 成功回调
|
||||
t.State = 2
|
||||
t.OssFile = ossURL
|
||||
t.FileType = fileType
|
||||
t.TextResult = textResult
|
||||
g.Log().Infof(ctx, "[CALLBACK][DISPATCH] taskId=%s bizName=%s callbackUrl=%s", t.TaskID, t.BizName, t.CallbackURL)
|
||||
go triggerCallback(context.WithoutCancel(ctx), t)
|
||||
// ============ 如果有 epicycleId,也触发业务回调 ============
|
||||
if epicycleId != 0 {
|
||||
go triggerPromptsCallback(context.WithoutCancel(ctx), t, epicycleId)
|
||||
}
|
||||
|
||||
// 成功后清理临时文件
|
||||
deleteTmpResult(t.TmpFile)
|
||||
}
|
||||
|
||||
func (w *asyncWorker) rollbackToPending(ctx context.Context, id int64) error {
|
||||
return dao.Task.RollbackToPendingGlobal(ctx, id)
|
||||
}
|
||||
|
||||
// GetExpendTokens 根据映射路径从 textResult 中提取消耗 token 值
|
||||
func GetExpendTokens(tokenMapping string, textResult string) int {
|
||||
value := gjson.Get(textResult, tokenMapping)
|
||||
if value.Exists() {
|
||||
return int(value.Int())
|
||||
} else {
|
||||
return len(textResult)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
Asia/Shanghai
|
||||
+291
-235
@@ -1,264 +1,320 @@
|
||||
-- model-asynch 核心表(pgsql)
|
||||
-- 1) asynch_models:模型配置
|
||||
-- 2) asynch_task:异步任务
|
||||
-- 3) logs_model_op:操作日志(统计用)
|
||||
-- 4) logs_model_stat:按天模型请求统计(限流/监控用)
|
||||
|
||||
-- =========================
|
||||
-- 1) asynch_models
|
||||
-- model_gateway_models
|
||||
-- =========================
|
||||
CREATE TABLE IF NOT EXISTS asynch_models (
|
||||
-- 基础字段
|
||||
id BIGINT PRIMARY KEY, -- 主键ID(非自增)
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID
|
||||
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_name VARCHAR(128) NOT NULL, -- 模型名称
|
||||
model_type SMALLINT NOT NULL DEFAULT 0, -- 模型类型
|
||||
base_url VARCHAR(256) NOT NULL, -- 模型地址
|
||||
http_method VARCHAR(8) NOT NULL DEFAULT 'POST', -- 请求方式 GET/POST
|
||||
head_msg VARCHAR(1024) DEFAULT '', -- 请求头绑定(支持多个,逗号分隔)示例 X-API:xxx,operation:true
|
||||
is_private SMALLINT NOT NULL DEFAULT 0, -- 是否私有化 0-私有 1-公共
|
||||
enabled SMALLINT NOT NULL DEFAULT 1, -- 是否启用 0停用 1-启用
|
||||
is_chat_model SMALLINT NOT NULL DEFAULT 0, -- 是否为对话模型 0-否 1-是
|
||||
is_owner SMALLINT NOT NULL DEFAULT 99, -- 1=当前用户创建的,0=超级管理员的
|
||||
api_key VARCHAR(256) NOT NULL DEFAULT '', -- 调用凭证,密钥
|
||||
prompt TEXT NOT NULL DEFAULT '', -- 提示词内容(文本)
|
||||
form_json JSONB NOT NULL DEFAULT '{}'::jsonb, -- 表单结构(用于前端渲染)
|
||||
request_mapping JSONB NOT NULL DEFAULT '{}'::jsonb -- 请求映射
|
||||
response_mapping JSONB NOT NULL DEFAULT '{}'::jsonb, -- 返回映射
|
||||
response_body JSONB NOT NULL DEFAULT '{}'::jsonb, -- 返回主体
|
||||
max_concurrency INT NOT NULL DEFAULT 10, -- 单模型最大并发
|
||||
queue_limit INT NOT NULL DEFAULT 1000, -- 排队上限(近似控制)
|
||||
timeout_seconds INT NOT NULL DEFAULT 600, -- 调用模型服务超时(秒)
|
||||
expected_seconds INT NOT NULL DEFAULT 600, -- 模型预计执行时间(秒)
|
||||
retry_times SMALLINT NOT NULL DEFAULT 3, -- 失败重试次数
|
||||
retry_queue_max_seconds INT NOT NULL DEFAULT 600, -- 失败重试最大排队时间(秒 0=插队到队首;>0=排队超过该时间后插队,否则仍到队尾)
|
||||
auto_clean_seconds INT NOT NULL DEFAULT 86400, -- 已下载(state=4 后的保留时间(秒),到期清理)
|
||||
remark TEXT DEFAULT '' -- 备注
|
||||
token_mapping VARCHAR(128) NOT NULL DEFAULT ''; -- token 映射
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_gateway_models (
|
||||
id int8 PRIMARY KEY,
|
||||
tenant_id int8 NOT NULL DEFAULT 0,
|
||||
creator varchar(64) NOT NULL,
|
||||
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater varchar(64) NOT NULL,
|
||||
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamp(6),
|
||||
model_name varchar(128) NOT NULL,
|
||||
model_type int2 NOT NULL DEFAULT 0,
|
||||
operator_name varchar(64) NOT NULL DEFAULT '',
|
||||
base_url varchar(256) NOT NULL,
|
||||
http_method varchar(8) NOT NULL DEFAULT 'POST',
|
||||
head_msg jsonb NOT NULL DEFAULT '{}',
|
||||
api_key varchar(256) NOT NULL DEFAULT '',
|
||||
is_private int2 NOT NULL DEFAULT 0,
|
||||
enabled int2 NOT NULL DEFAULT 1,
|
||||
is_chat_model int2 NOT NULL DEFAULT 0,
|
||||
is_owner int2 NOT NULL DEFAULT 99,
|
||||
form_json jsonb NOT NULL DEFAULT '{}',
|
||||
request_mapping jsonb NOT NULL DEFAULT '{}',
|
||||
response_mapping jsonb NOT NULL DEFAULT '{}',
|
||||
response_body varchar(128) NOT NULL DEFAULT '',
|
||||
token_config jsonb NOT NULL DEFAULT '{}',
|
||||
extend_mapping jsonb NOT NULL DEFAULT '{}',
|
||||
query_config jsonb NOT NULL DEFAULT '{}',
|
||||
stream_config jsonb NOT NULL DEFAULT '{}',
|
||||
first_frame varchar(128) NOT NULL DEFAULT '',
|
||||
last_frame varchar(128) NOT NULL DEFAULT '',
|
||||
max_concurrency int4 NOT NULL DEFAULT 10,
|
||||
timeout_seconds int4 NOT NULL DEFAULT 600,
|
||||
retry_times int2 NOT NULL DEFAULT 3,
|
||||
response_token_field varchar(128) NOT NULL DEFAULT '',
|
||||
call_mode int2 NOT NULL DEFAULT 0,
|
||||
required_fields jsonb NOT NULL DEFAULT '[]',
|
||||
max_tokens int4 DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_asynch_models_tenant_creator_chat ON asynch_models(tenant_id, creator) WHERE is_chat_model = 1 AND deleted_at IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_asynch_models_tenant_model_name ON asynch_models(tenant_id, creator, model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_models_tenant_id ON asynch_models(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_models_model_name ON asynch_models(model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_models_model_type ON asynch_models(model_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_models_enabled ON asynch_models(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_models_deleted_at ON asynch_models(deleted_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_model_gateway_models_tenant_creator_model ON model_gateway_models (tenant_id, creator, model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_model_name ON model_gateway_models (model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_model_type ON model_gateway_models (model_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_tenant_id ON model_gateway_models (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_deleted_at ON model_gateway_models (deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_enabled ON model_gateway_models (enabled);
|
||||
|
||||
COMMENT ON TABLE asynch_models IS '模型配置表';
|
||||
COMMENT ON COLUMN asynch_models.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN asynch_models.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN asynch_models.creator IS '创建人';
|
||||
COMMENT ON COLUMN asynch_models.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN asynch_models.updater IS '更新人';
|
||||
COMMENT ON COLUMN asynch_models.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN asynch_models.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON TABLE model_gateway_models IS '模型配置表';
|
||||
COMMENT ON COLUMN model_gateway_models.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN model_gateway_models.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN model_gateway_models.creator IS '创建人';
|
||||
COMMENT ON COLUMN model_gateway_models.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN model_gateway_models.updater IS '更新人';
|
||||
COMMENT ON COLUMN model_gateway_models.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN model_gateway_models.deleted_at IS '删除时间(软删)';
|
||||
|
||||
COMMENT ON COLUMN asynch_models.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN asynch_models.model_type IS '模型类型';
|
||||
COMMENT ON COLUMN asynch_models.base_url IS '模型地址';
|
||||
COMMENT ON COLUMN asynch_models.http_method IS '请求方式 GET/POST';
|
||||
COMMENT ON COLUMN asynch_models.head_msg IS '请求头绑定(支持多个,逗号分隔)示例 X-API:xxx,operation:true';
|
||||
COMMENT ON COLUMN asynch_models.is_private IS '是否私有化 0-私有 1-公共';
|
||||
COMMENT ON COLUMN asynch_models.enabled IS '是否启用 0停用 1-启用';
|
||||
COMMENT ON COLUMN asynch_models.is_chat_model IS '是否为对话模型 0-否 1-是';
|
||||
COMMENT ON COLUMN asynch_models.is_owner IS '1=当前用户创建的,0=超级管理员的';
|
||||
COMMENT ON COLUMN asynch_models.api_key IS '调用凭证,密钥';
|
||||
COMMENT ON COLUMN asynch_models.prompt IS '提示词内容(文本)';
|
||||
COMMENT ON COLUMN asynch_models.form_json IS '表单结构(用于前端渲染,也用于后端校验)';
|
||||
COMMENT ON COLUMN asynch_models.request_mapping IS '请求映射';
|
||||
COMMENT ON COLUMN asynch_models.response_mapping IS '返回映射';
|
||||
COMMENT ON COLUMN asynch_models.response_body IS '返回主体';
|
||||
COMMENT ON COLUMN asynch_models.max_concurrency IS '单模型最大并发';
|
||||
COMMENT ON COLUMN asynch_models.queue_limit IS '排队上限(近似控制)';
|
||||
COMMENT ON COLUMN asynch_models.timeout_seconds IS '调用模型服务超时(秒)';
|
||||
COMMENT ON COLUMN asynch_models.expected_seconds IS '模型预计执行时间(秒)';
|
||||
COMMENT ON COLUMN asynch_models.retry_times IS '失败重试次数';
|
||||
COMMENT ON COLUMN asynch_models.retry_queue_max_seconds IS '失败重试最大排队时间(秒 0=插队到队首;>0=排队超过该时间后插队,否则仍到队尾)';
|
||||
COMMENT ON COLUMN asynch_models.auto_clean_seconds IS '已下载(state=4 后的保留时间(秒),到期清理)';
|
||||
COMMENT ON COLUMN asynch_models.remark IS '备注';
|
||||
COMMENT ON COLUMN asynch_models.token_mapping IS 'token映射';
|
||||
COMMENT ON COLUMN model_gateway_models.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN model_gateway_models.model_type IS '模型类型';
|
||||
COMMENT ON COLUMN model_gateway_models.operator_name IS '运营商名称';
|
||||
COMMENT ON COLUMN model_gateway_models.base_url IS '模型地址';
|
||||
COMMENT ON COLUMN model_gateway_models.http_method IS '请求方式 GET/POST';
|
||||
COMMENT ON COLUMN model_gateway_models.head_msg IS '请求头信息';
|
||||
COMMENT ON COLUMN model_gateway_models.api_key IS '调用凭证/密钥';
|
||||
COMMENT ON COLUMN model_gateway_models.is_private IS '是否私有化:0-私有 1-公共';
|
||||
COMMENT ON COLUMN model_gateway_models.enabled IS '是否启用:0-停用 1-启用';
|
||||
COMMENT ON COLUMN model_gateway_models.is_chat_model IS '是否为对话模型:0-否 1-是';
|
||||
COMMENT ON COLUMN model_gateway_models.is_owner IS '1=当前用户创建 0=超级管理员';
|
||||
COMMENT ON COLUMN model_gateway_models.call_mode IS '调用模式:0-同步 1-异步 2-流式';
|
||||
COMMENT ON COLUMN model_gateway_models.form_json IS '动态表单结构';
|
||||
COMMENT ON COLUMN model_gateway_models.request_mapping IS '请求映射';
|
||||
COMMENT ON COLUMN model_gateway_models.response_mapping IS '返回映射';
|
||||
COMMENT ON COLUMN model_gateway_models.response_body IS '返回主体';
|
||||
COMMENT ON COLUMN model_gateway_models.token_config IS 'Token计算配置';
|
||||
COMMENT ON COLUMN model_gateway_models.extend_mapping IS '附加映射';
|
||||
COMMENT ON COLUMN model_gateway_models.query_config IS '查询/回调配置';
|
||||
COMMENT ON COLUMN model_gateway_models.stream_config IS '流式输出配置';
|
||||
COMMENT ON COLUMN model_gateway_models.first_frame IS '首帧图片参数';
|
||||
COMMENT ON COLUMN model_gateway_models.last_frame IS '尾帧图片参数';
|
||||
COMMENT ON COLUMN model_gateway_models.max_concurrency IS '最大并发数';
|
||||
COMMENT ON COLUMN model_gateway_models.timeout_seconds IS '调用模型超时(秒)';
|
||||
COMMENT ON COLUMN model_gateway_models.retry_times IS '失败重试次数';
|
||||
COMMENT ON COLUMN model_gateway_models.response_token_field IS '响应中消耗token的字段映射';
|
||||
COMMENT ON COLUMN model_gateway_models.required_fields IS '必选字段列表';
|
||||
COMMENT ON COLUMN model_gateway_models.max_tokens IS '最大 token 数,0 表示不传';
|
||||
|
||||
|
||||
|
||||
-- =========================
|
||||
-- 2) asynch_task
|
||||
-- model_gateway_task
|
||||
-- =========================
|
||||
CREATE TABLE IF NOT EXISTS asynch_task (
|
||||
-- 基础字段
|
||||
id BIGINT PRIMARY KEY, -- 主键ID(非自增)
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID
|
||||
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), -- 删除时间(软删)
|
||||
CREATE TABLE IF NOT EXISTS model_gateway_task (
|
||||
id int8 PRIMARY KEY,
|
||||
tenant_id int8 NOT NULL DEFAULT 0,
|
||||
creator varchar(64) NOT NULL,
|
||||
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater varchar(64) NOT NULL,
|
||||
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamp(6),
|
||||
model_name varchar(128) NOT NULL,
|
||||
task_id varchar(64) NOT NULL,
|
||||
biz_name varchar(128) NOT NULL DEFAULT '',
|
||||
callback_url varchar(512) DEFAULT '',
|
||||
state int2 NOT NULL DEFAULT 0,
|
||||
retry_count int4 NOT NULL DEFAULT 0,
|
||||
phase int2 NOT NULL DEFAULT 0,
|
||||
tmp_file text DEFAULT '',
|
||||
error_msg text DEFAULT '',
|
||||
result_file jsonb NOT NULL DEFAULT '{}',
|
||||
request_payload jsonb NOT NULL DEFAULT '{}',
|
||||
text_result jsonb NOT NULL DEFAULT '{}',
|
||||
expend_tokens int8 NOT NULL DEFAULT 0,
|
||||
duration_seconds int8 NOT NULL DEFAULT 0,
|
||||
epicycle_id varchar(64) NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- 业务字段
|
||||
model_name VARCHAR(128) NOT NULL, -- 模型名称
|
||||
task_id VARCHAR(64) NOT NULL, -- 任务ID(对外返回)
|
||||
biz_name VARCHAR(128) NOT NULL DEFAULT '', -- 业务名称(调用方模块/系统)
|
||||
callback_url VARCHAR(512) DEFAULT '', -- 回调地址(可选,用于后续业务通知)
|
||||
model_key VARCHAR(1024) DEFAULT '', -- 动态请求头(用于覆盖/补充模型配置 head_msg),如 X-API-Key:xxx
|
||||
state SMALLINT NOT NULL DEFAULT 0, -- 0排队中/1执行中/2成功/3失败/4已下载
|
||||
oss_file VARCHAR(512) DEFAULT '', -- 结果文件OSS地址
|
||||
file_type VARCHAR(32) DEFAULT '', -- 文件类型(mp3/mp4/png/...)
|
||||
file_size BIGINT NOT NULL DEFAULT 0, -- 文件大小(字节)
|
||||
error_msg TEXT DEFAULT '', -- 错误信息
|
||||
started_at TIMESTAMP, -- 开始执行时间
|
||||
finished_at TIMESTAMP, -- 执行结束时间
|
||||
duration_seconds BIGINT NOT NULL DEFAULT 0, -- 耗时(秒):从创建到完成(成功/失败)整体耗时
|
||||
expire_at TIMESTAMP, -- state=4 后写入,用于清理
|
||||
retry_count INT NOT NULL DEFAULT 0, -- 已重试次数(不含首次)
|
||||
enqueue_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 入队时间(用于排队顺序)
|
||||
phase SMALLINT NOT NULL DEFAULT 0, -- 0模型阶段/1OSS阶段
|
||||
tmp_file TEXT DEFAULT '', -- 临时结果文件路径(phase=1 时仅重试 OSS 上传)
|
||||
input_ref TEXT DEFAULT '', -- 输入引用(如OSS/业务资源ID等)
|
||||
request_payload JSONB, -- 请求参数(可选)
|
||||
text_result TEXT DEFAULT '', -- 文本类结果(可选,支持直接回调)
|
||||
epicycle_id VARCHAR(64) DEFAULT '', -- 轮次ID
|
||||
expend_tokens BIGINT NOT NULL DEFAULT 0 -- 消耗 token 数
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_model_gateway_task_tenant_creator_task_id ON model_gateway_task (tenant_id, creator, task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_task_task_id ON model_gateway_task (task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_task_state ON model_gateway_task (state);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_task_deleted_at ON model_gateway_task (deleted_at);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_asynch_task_tenant_task_id ON asynch_task(tenant_id, task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_tenant_id ON asynch_task(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_model_name ON asynch_task(model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_biz_name ON asynch_task(biz_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_model_key ON asynch_task(model_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_state ON asynch_task(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_enqueue_at ON asynch_task(enqueue_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_updated_at ON asynch_task(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_expire_at ON asynch_task(expire_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_deleted_at ON asynch_task(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_epicycle_id ON asynch_task(epicycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_asynch_task_expend_tokens ON asynch_task(expend_tokens);
|
||||
|
||||
COMMENT ON TABLE asynch_task IS '异步任务表';
|
||||
COMMENT ON COLUMN asynch_task.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN asynch_task.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN asynch_task.creator IS '创建人';
|
||||
COMMENT ON COLUMN asynch_task.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN asynch_task.updater IS '更新人';
|
||||
COMMENT ON COLUMN asynch_task.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN asynch_task.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN asynch_task.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN asynch_task.task_id IS '任务ID(对外返回)';
|
||||
COMMENT ON COLUMN asynch_task.biz_name IS '业务名称(调用方模块/系统)';
|
||||
COMMENT ON COLUMN asynch_task.callback_url IS '回调地址(可选,用于后续业务通知)';
|
||||
COMMENT ON COLUMN asynch_task.model_key IS '动态请求头(用于覆盖/补充模型配置 head_msg),如 X-API-Key:xxx';
|
||||
COMMENT ON COLUMN asynch_task.state IS '0排队中/1执行中/2成功/3失败/4已下载';
|
||||
COMMENT ON COLUMN asynch_task.oss_file IS '结果文件OSS地址';
|
||||
COMMENT ON COLUMN asynch_task.file_type IS '文件类型(mp3/mp4/png/...)';
|
||||
COMMENT ON COLUMN asynch_task.file_size IS '文件大小(字节)';
|
||||
COMMENT ON COLUMN asynch_task.error_msg IS '错误信息';
|
||||
COMMENT ON COLUMN asynch_task.started_at IS '开始执行时间';
|
||||
COMMENT ON COLUMN asynch_task.finished_at IS '执行结束时间';
|
||||
COMMENT ON COLUMN asynch_task.duration_seconds IS '耗时(秒):从创建到完成(成功/失败)整体耗时';
|
||||
COMMENT ON COLUMN asynch_task.expire_at IS 'state=4 后写入,用于清理';
|
||||
COMMENT ON COLUMN asynch_task.retry_count IS '已重试次数(不含首次)';
|
||||
COMMENT ON COLUMN asynch_task.enqueue_at IS '入队时间(用于排队顺序)';
|
||||
COMMENT ON COLUMN asynch_task.phase IS '执行阶段 模型阶段/1OSS阶段(模型已成功,等待上传OSS)';
|
||||
COMMENT ON COLUMN asynch_task.tmp_file IS '临时结果文件路径(phase=1 时仅重试 OSS 上传)';
|
||||
COMMENT ON COLUMN asynch_task.input_ref IS '输入引用(如OSS/业务资源ID等)';
|
||||
COMMENT ON COLUMN asynch_task.request_payload IS '请求参数(可选,JSON)';
|
||||
COMMENT ON COLUMN asynch_task.text_result IS '文本类结果(可选,支持直接回调)';
|
||||
COMMENT ON COLUMN asynch_task.epicycle_id IS '轮次ID(用于标识同一轮次的任务)';
|
||||
COMMENT ON COLUMN asynch_task.expend_tokens IS '消耗 token 数';
|
||||
COMMENT ON TABLE model_gateway_task IS '模型网关任务表';
|
||||
COMMENT ON COLUMN model_gateway_task.id IS '主键ID';
|
||||
COMMENT ON COLUMN model_gateway_task.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN model_gateway_task.creator IS '创建人';
|
||||
COMMENT ON COLUMN model_gateway_task.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN model_gateway_task.updater IS '更新人';
|
||||
COMMENT ON COLUMN model_gateway_task.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN model_gateway_task.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN model_gateway_task.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN model_gateway_task.task_id IS '任务ID(对外返回)';
|
||||
COMMENT ON COLUMN model_gateway_task.biz_name IS '业务名称(调用方模块/系统)';
|
||||
COMMENT ON COLUMN model_gateway_task.callback_url IS '回调地址';
|
||||
COMMENT ON COLUMN model_gateway_task.state IS '0排队中/1执行中/2成功/3失败/4已下载';
|
||||
COMMENT ON COLUMN model_gateway_task.retry_count IS '已重试次数';
|
||||
COMMENT ON COLUMN model_gateway_task.phase IS '执行阶段:0模型阶段/1OSS阶段';
|
||||
COMMENT ON COLUMN model_gateway_task.tmp_file IS '临时结果文件路径';
|
||||
COMMENT ON COLUMN model_gateway_task.error_msg IS '错误信息';
|
||||
COMMENT ON COLUMN model_gateway_task.result_file IS '结果文件:{oss_file, file_type, file_size}';
|
||||
COMMENT ON COLUMN model_gateway_task.request_payload IS '请求参数(JSON)';
|
||||
COMMENT ON COLUMN model_gateway_task.text_result IS '文本类结果';
|
||||
COMMENT ON COLUMN model_gateway_task.expend_tokens IS '消耗token数';
|
||||
COMMENT ON COLUMN model_gateway_task.duration_seconds IS '耗时(秒)';
|
||||
COMMENT ON COLUMN model_gateway_task.epicycle_id IS '轮次ID';
|
||||
|
||||
|
||||
|
||||
-- =========================
|
||||
-- 3) logs_model_op
|
||||
-- model_gateway_log_stat
|
||||
-- =========================
|
||||
CREATE TABLE IF NOT EXISTS logs_model_op (
|
||||
-- 基础字段
|
||||
id BIGINT PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
creator VARCHAR(64) NOT NULL,
|
||||
CREATE TABLE IF NOT EXISTS model_gateway_log_stat (
|
||||
day date NOT NULL,
|
||||
tenant_id int8 NOT NULL DEFAULT 0,
|
||||
creator varchar(64) NOT NULL DEFAULT '',
|
||||
model_name varchar(128) NOT NULL DEFAULT '',
|
||||
request_count int8 NOT NULL DEFAULT 0,
|
||||
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (day, tenant_id, creator, model_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_log_stat_day ON model_gateway_log_stat (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_log_stat_creator ON model_gateway_log_stat (creator);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_log_stat_model_name ON model_gateway_log_stat (model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_log_stat_tenant_day ON model_gateway_log_stat (tenant_id, day);
|
||||
|
||||
COMMENT ON TABLE model_gateway_log_stat IS '按天统计表';
|
||||
COMMENT ON COLUMN model_gateway_log_stat.day IS '天(YYYY-MM-DD)';
|
||||
COMMENT ON COLUMN model_gateway_log_stat.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN model_gateway_log_stat.creator IS '创建人';
|
||||
COMMENT ON COLUMN model_gateway_log_stat.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN model_gateway_log_stat.request_count IS '请求次数';
|
||||
COMMENT ON COLUMN model_gateway_log_stat.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN model_gateway_log_stat.updated_at IS '更新时间';
|
||||
|
||||
|
||||
-- =========================
|
||||
-- model_gateway_logs_op
|
||||
-- =========================
|
||||
CREATE TABLE IF NOT EXISTS model_gateway_logs_op (
|
||||
id int8 PRIMARY KEY,
|
||||
tenant_id int8 NOT NULL DEFAULT 0,
|
||||
creator varchar(64) NOT NULL,
|
||||
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater varchar(64) NOT NULL,
|
||||
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamp(6),
|
||||
ip varchar(64) DEFAULT '',
|
||||
user_agent varchar(256) DEFAULT '',
|
||||
api_path varchar(256) DEFAULT '',
|
||||
http_method varchar(16) DEFAULT '',
|
||||
biz_name varchar(128) NOT NULL DEFAULT '',
|
||||
model_name varchar(128) NOT NULL DEFAULT '',
|
||||
task_id varchar(64) NOT NULL DEFAULT '',
|
||||
op_type varchar(64) NOT NULL DEFAULT 'createTask',
|
||||
success int2 NOT NULL DEFAULT 1,
|
||||
error_msg text DEFAULT '',
|
||||
cost_ms int8 NOT NULL DEFAULT 0,
|
||||
request_payload jsonb,
|
||||
response_payload jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_task_id ON model_gateway_logs_op (task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_biz_name ON model_gateway_logs_op (biz_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_model_name ON model_gateway_logs_op (model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_op_type ON model_gateway_logs_op (op_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_deleted_at ON model_gateway_logs_op (deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_tenant_time ON model_gateway_logs_op (tenant_id, created_at);
|
||||
|
||||
COMMENT ON TABLE model_gateway_logs_op IS '操作日志表';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.creator IS '创建人';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.updater IS '更新人';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.ip IS '客户端IP';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.user_agent IS 'User-Agent';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.api_path IS '接口路径';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.http_method IS 'HTTP方法';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.biz_name IS '业务名称(调用方模块/系统)';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.task_id IS '任务ID';
|
||||
COMMENT ON COLUMN model_gateway_logs_op.op_type IS '操作类型';
|
||||
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';
|
||||
|
||||
|
||||
-- =====================================================================================
|
||||
|
||||
--------------------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),
|
||||
-- 基础审计信息
|
||||
ip VARCHAR(64) DEFAULT '',
|
||||
user_agent VARCHAR(256) DEFAULT '',
|
||||
api_path VARCHAR(256) DEFAULT '',
|
||||
http_method VARCHAR(16) DEFAULT '',
|
||||
-- 业务信息
|
||||
biz_name VARCHAR(128) NOT NULL DEFAULT '', -- 调用方业务模块/系统
|
||||
deleted_at timestamp(6),
|
||||
|
||||
model_supplier VARCHAR(64) NOT NULL DEFAULT '',
|
||||
model_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
task_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
-- 统计字段
|
||||
op_type VARCHAR(64) NOT NULL DEFAULT 'createTask', -- 操作类型(默认创建任务)
|
||||
success SMALLINT NOT NULL DEFAULT 1, -- 1成功/0失败
|
||||
error_msg TEXT DEFAULT '',
|
||||
cost_ms BIGINT NOT NULL DEFAULT 0, -- 耗时(毫秒)
|
||||
-- 请求/响应 JSON(用于后期统计分析)
|
||||
request_payload JSONB,
|
||||
response_payload JSONB
|
||||
);
|
||||
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 IF NOT EXISTS idx_logs_model_op_tenant_time ON logs_model_op(tenant_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_op_model_name ON logs_model_op(model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_op_biz_name ON logs_model_op(biz_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_op_task_id ON logs_model_op(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_op_op_type ON logs_model_op(op_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_op_deleted_at ON logs_model_op(deleted_at);
|
||||
-- 索引
|
||||
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 logs_model_op IS '操作记录日志表(创建任务等,用于统计)';
|
||||
COMMENT ON COLUMN logs_model_op.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN logs_model_op.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN logs_model_op.creator IS '创建人';
|
||||
COMMENT ON COLUMN logs_model_op.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN logs_model_op.updater IS '更新人';
|
||||
COMMENT ON COLUMN logs_model_op.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN logs_model_op.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN logs_model_op.ip IS '客户端IP';
|
||||
COMMENT ON COLUMN logs_model_op.user_agent IS 'User-Agent';
|
||||
COMMENT ON COLUMN logs_model_op.api_path IS '接口路径';
|
||||
COMMENT ON COLUMN logs_model_op.http_method IS 'HTTP方法';
|
||||
COMMENT ON COLUMN logs_model_op.biz_name IS '业务名称(调用方模块/系统)';
|
||||
COMMENT ON COLUMN logs_model_op.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN logs_model_op.task_id IS '任务ID';
|
||||
COMMENT ON COLUMN logs_model_op.op_type IS '操作类型(如 createTask/getTaskResult/getTaskBatch 等)';
|
||||
COMMENT ON COLUMN logs_model_op.success IS '是否成功:1成功/0失败';
|
||||
COMMENT ON COLUMN logs_model_op.error_msg IS '错误信息(失败时)';
|
||||
COMMENT ON COLUMN logs_model_op.cost_ms IS '耗时(毫秒)';
|
||||
COMMENT ON COLUMN logs_model_op.request_payload IS '请求 JSON';
|
||||
COMMENT ON COLUMN logs_model_op.response_payload IS '响应 JSON';
|
||||
-- 字段与表注释
|
||||
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表语句---------------------------
|
||||
|
||||
|
||||
-- =========================
|
||||
-- 4) logs_model_stat
|
||||
-- 计费规则:model_manage 新增 price_config(JSONB),model_session 新增 total_cost(NUMERIC)
|
||||
-- =========================
|
||||
CREATE TABLE IF NOT EXISTS logs_model_stat (
|
||||
day DATE NOT NULL, -- 天(YYYY-MM-DD)
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID
|
||||
creator VARCHAR(64) NOT NULL DEFAULT '', -- 创建人
|
||||
model_name VARCHAR(128) NOT NULL DEFAULT '', -- 模型名称
|
||||
request_count BIGINT NOT NULL DEFAULT 0, -- 请求次数
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(day, tenant_id, creator, model_name)
|
||||
);
|
||||
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处理)';
|
||||
|
||||
-- 便于时间段/租户/人/模型过滤
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_stat_tenant_day ON logs_model_stat(tenant_id, day);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_stat_day ON logs_model_stat(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_stat_model_name ON logs_model_stat(model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_model_stat_creator ON logs_model_stat(creator);
|
||||
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';
|
||||
|
||||
COMMENT ON TABLE logs_model_stat IS '按天模型请求统计(用于限流/监控)';
|
||||
COMMENT ON COLUMN logs_model_stat.day IS '天(YYYY-MM-DD)';
|
||||
COMMENT ON COLUMN logs_model_stat.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN logs_model_stat.creator IS '创建人';
|
||||
COMMENT ON COLUMN logs_model_stat.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN logs_model_stat.request_count IS '请求次数';
|
||||
COMMENT ON COLUMN logs_model_stat.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN logs_model_stat.updated_at IS '更新时间';
|
||||
-- =========================
|
||||
-- 异步任务计费: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