51 Commits
Author SHA1 Message Date
19904408334 3315f6cf17 Merge branch 'dev未优化' of https://gitea.redpowerfuture.com/red-future/model-gateway into dev未优化 2026-08-14 16:34:15 +08:00
19904408334 c69aed717d fix: 处理 RequestCanceled 模型调用失败 2026-08-14 16:33:54 +08:00
admin 8ac032c12f Merge remote-tracking branch 'origin/dev未优化' into dev未优化 2026-08-06 09:41:11 +08:00
admin f152c23a7e gitea,gitea-runner写入docker-compose 2026-08-06 09:40:32 +08:00
19904408334 84102121c5 refactor: 将 ModelType 类型从 int8 更改为 int 2026-07-23 17:44:38 +08:00
19904408334 8bdae52d4d feat: 新增模型管理模块功能 2026-07-23 17:22:06 +08:00
19904408334 6166c24412 feat: 添加模型并发控制与分布式锁
重新启用任务并发限制逻辑,使用 Redis 计数器控制模型并发数;
引入分布式锁防止重复创建,并优化优雅退出顺序确保请求完成;
移除 video_duration 计费计算,支持外部传入 taskId,为轮询查询添加独立超时上下文。
2026-07-09 13:47:08 +08:00
19904408334 a7f30d1640 fix: 支持解析单个JSON对象为单元素数组 2026-07-08 13:37:09 +08:00
19904408334 b8006329d1 feat: 新增会话与结果管理功能及扩展字段
重构执行列表树状结构,扁平化日期节点下的输出项,新增会话列表查询、结果删除与软删除标记,引入扩展字段与模板配置,升级依赖版本。
2026-07-08 09:22:53 +08:00
WangLiZhao d46f163cfd fix(task): 改进任务失败时的错误信息记录 2026-07-06 17:34:59 +08:00
WangLiZhao dcbdc462bd feat(billing): 添加TTS模型input_chars字符数自动计算功能 2026-07-06 17:00:20 +08:00
WangLiZhao 947a158a52 Merge remote-tracking branch 'origin/dev未优化' into dev未优化 2026-07-06 16:38:16 +08:00
WangLiZhao 4033a2ba69 feat(billing): 添加TTS模型计费功能 2026-07-06 16:31:07 +08:00
19904408334 b9dff27e33 chore: 移除计费相关调试打印日志 2026-07-04 20:05:52 +08:00
19904408334 fe64d178ad chore: 添加计费处理调试日志 2026-07-04 18:55:47 +08:00
19904408334 dc6375707f chore: 添加计费处理调试日志 2026-07-04 18:28:51 +08:00
19904408334 518f666ac6 fix: 提高租户余额不足的检查阈值 2026-07-02 11:20:11 +08:00
19904408334 260d6547f2 fix: 调整租户余额不足判断阈值为100 2026-07-02 08:58:37 +08:00
19904408334 313bb692c7 fix: 修复租户余额响应结构及租户ID引用错误
更新 TenantSurplusResp 结构以匹配实际的嵌套 JSON 响应格式,
并修正 worker 中 billing 逻辑错误引用的租户ID变量。
2026-07-01 19:32:52 +08:00
WangLiZhao 4d95ff75d2 fix(task): 修复任务执行中租户余额检查的参数传递错误
- 修正了 GetTenantSurplus 函数调用中的参数,从 task.TenantId 改为 model.TenantId
- 注释掉了模型调用中重复的余额检查逻辑以避免冗余验证
2026-07-01 18:55:16 +08:00
WangLiZhao a332aa438b refactor(task): 优化任务处理中的余额检查逻辑 2026-07-01 18:47:13 +08:00
WangLiZhao d18016e698 feat(gateway): 添加租户余额管理功能 2026-07-01 18:44:31 +08:00
WangLiZhao 7e849b1c0f fix(task): 修复任务处理中的参数传递和数据解析问题 2026-07-01 18:28:00 +08:00
WangLiZhao 84421b4ee5 refactor(gateway): 调整回调载荷结构定义 2026-07-01 18:04:39 +08:00
WangLiZhao 62003c25a0 fix(billing): 修复计费数据处理逻辑并更新配置
- 在请求阶段将请求数据作为计费记录的基础字段存入数组
- 修改计费处理逻辑,使用请求阶段数据作为基础补充返回数据后进行费用计算
- 重试时使用相同的计费数据处理逻辑确保数据一致性
- 将计费结果直接替换数组第一个元素而非追加
- 更新数据库配置从 192.168.0.83 到 192.168.3.30
- 更新 Redis、Consul、Jaeger 和文件上传服务地址到新 IP
- 修改数据库端口从 15432 到 5432 并更新密码
2026-07-01 18:03:09 +08:00
WangLiZhao 2edbe54fdc fix(task): 修复任务失败时文本结果未保存的问题 2026-07-01 17:51:04 +08:00
WangLiZhao 35e955a617 feat(billing): 实现多条计费数据支持并优化模型计费逻辑 2026-07-01 17:41:01 +08:00
WangLiZhao bdd5f93e2c Merge remote-tracking branch 'origin/dev未优化' into dev未优化 2026-07-01 15:54:11 +08:00
WangLiZhao 326a8acac9 feat(gateway): 添加计费日期字段到回调请求体
- 在CallbackPayload结构体中添加BillingDate字段
- 将任务的BillingData赋值给回调请求体的BillingDate
- 保持现有回调功能的其他字段不变
2026-07-01 15:54:01 +08:00
19904408334 3ca8f93778 fix: 修复视频时长接口响应结构及URL拼写错误
移除VideoDurationResp中多余的嵌套Data字段,直接访问TotalDuration等属性;
修正API路径中缺失的字母a(medi -> media)。
2026-07-01 15:53:28 +08:00
WangLiZhao 8ef6a180f7 refactor(billing): 优化条件判断逻辑并移除未使用的常量 2026-07-01 15:39:25 +08:00
WangLiZhao 29d3f7ffb2 Merge remote-tracking branch 'origin/dev未优化' into dev未优化 2026-07-01 15:17:26 +08:00
WangLiZhao 2bc9d76e18 feat(billing): 添加计费系统功能 2026-07-01 15:17:15 +08:00
admin b00e5d34f4 common版本更新 2026-06-25 17:05:13 +08:00
admin 85dd4e8e84 common版本更新 2026-06-25 14:49:02 +08:00
19904408334 3feb912d86 fix: 增加模型调用重试等待时间 2026-06-25 11:57:57 +08:00
19904408334 63092be679 fix: 忽略 "download failed" 错误避免误判任务失败 2026-06-25 11:48:27 +08:00
19904408334 e11155b604 fix: 忽略连接错误以避免任务误判失败 2026-06-25 11:07:07 +08:00
19904408334 c22a38da9a fix: 忽略下载错误以避免任务误失败 2026-06-24 16:17:25 +08:00
admin aee243d4eb common版本升级 2026-06-23 18:31:59 +08:00
19904408334 16f2967569 build: 升级 common 依赖至 v0.0.25 2026-06-23 17:00:05 +08:00
WangLiZhao 73f630636d feat(task): 添加构建模型名称字段支持 2026-06-23 16:18:00 +08:00
WangLiZhao eaa2942957 Merge remote-tracking branch 'origin/dev未优化' into dev未优化 2026-06-23 14:56:03 +08:00
WangLiZhao 525b391f09 feat(task): 添加构建模型名称字段支持动态校验
- 在ModelGatewayTask实体中新增BuildModelName字段
- 修改ParseAndValidate函数参数,支持传入requiredFields
- 在异步工作器中实现构建模型的动态字段校验逻辑
- 添加prompt构建服务中传递构建模型名称的功能
- 实现构建模型不存在时的兜底机制
2026-06-23 14:55:50 +08:00
admin 610481effd 1 2026-06-22 10:57:46 +08:00
19904408334 39b61f1867 chore: 更新配置并优化任务错误处理
将数据库、Redis、Consul及Jaeger配置切换至新环境地址,并在worker中忽略"Invalid video_url"与"Invalid audio track"错误,同时补充模型调用成功日志。
2026-06-22 09:08:12 +08:00
WangLiZhao 4cc44bf57c fix(task): 修正错误信息注入逻辑 2026-06-18 16:27:11 +08:00
WangLiZhao dc06d1bb9a refactor(config): 更新数据库配置和任务处理逻辑 2026-06-18 14:48:42 +08:00
WangLiZhao ecaaa5bdbc refactor(files): 优化文件处理和任务服务逻辑 2026-06-18 13:39:40 +08:00
19904408334 b21d7a8dbf fix: 修复请求头转发与任务状态流转问题
移除 util.ForwardHeaders,改为从原始请求精确提取 Authorization 或全部请求头;
任务创建时直接设为 Running 状态,避免二次更新与查询;
模型调用使用独立超时上下文,防止外层取消影响回调;
增加 OSS 上传耗时日志,调整数据库连接池参数。
2026-06-18 10:08:36 +08:00
WangLiZhao fddaf36f48 refactor(model): 优化模型网关的数据解析和任务处理逻辑 2026-06-17 14:34:48 +08:00
29 changed files with 2460 additions and 649 deletions
+1
View File
@@ -0,0 +1 @@
.git
+2 -1
View File
@@ -1,7 +1,8 @@
# 阶段1: 构建
FROM golang:alpine AS builder
RUN apk add --no-cache git ca-certificates tzdata
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
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
+332
View File
@@ -0,0 +1,332 @@
package util
import (
"context"
"fmt"
"math"
"strings"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/util/gconv"
)
// ======================== 计费入口 ========================
func CalculateBilling(config map[string]any, billingData map[string]any) map[string]any {
if len(config) == 0 {
return nil
}
switch config["type"] {
case "inference_tier": //推理模型计费
return calculateInferenceTierBilling(config, billingData)
case "video_resolution": //视频模型计费
return calculateVideoResolutionBilling(config, billingData)
case "tts":
return calculateTTSBilling(config, billingData)
}
return nil
}
// ======================== 推理模型计费 ========================
func calculateInferenceTierBilling(config map[string]any, data map[string]any) map[string]any {
promptTokens := gconv.Int64(data["prompt_tokens"])
completionTokens := gconv.Int64(data["completion_tokens"])
hasAudio := gconv.Bool(data["has_audio"])
inputK := promptTokens / 1000
tiers := config["pricing"].(map[string]any)["tiers"].([]any)
var matched map[string]any
for _, t := range tiers {
tier := t.(map[string]any)
if inputK >= gconv.Int64(tier["input_min"]) && inputK <= gconv.Int64(tier["input_max"]) {
matched = tier
break
}
}
if matched == nil {
return nil
}
var inputPrice float64
if hasAudio && matched["audio_input_price"] != nil {
inputPrice = gconv.Float64(matched["audio_input_price"])
} else {
inputPrice = gconv.Float64(matched["input_price"])
}
outputPrice := gconv.Float64(matched["output_price"])
inputCost := float64(promptTokens) * inputPrice / 1000000
outputCost := float64(completionTokens) * outputPrice / 1000000
// 推理模型
return map[string]any{
"model_name": data["model_name"],
"total_tokens": promptTokens + completionTokens,
"total_fee": inputCost + outputCost,
// 明细
"prompt_tokens": promptTokens,
"completion_tokens": completionTokens,
"has_audio": hasAudio,
"input_tier": fmt.Sprintf("[%v, %v]", matched["input_min"], matched["input_max"]),
"input_unit_price": inputPrice,
"output_unit_price": outputPrice,
"input_cost": inputCost,
"output_cost": outputCost,
}
}
// ======================== 视频模型计费 ========================
func calculateVideoResolutionBilling(config map[string]any, data map[string]any) map[string]any {
pricingPath := buildPricingPath(config, data)
pricing := config["pricing"].(map[string]any)
unitPrice := gconv.Float64(pricing[pricingPath])
var effectiveMinToken float64
if gconv.Bool(data["input_has_video"]) {
effectiveMinToken = matchMinToken(config, data)
}
completionTokens := gconv.Float64(data["actual_tokens"])
realChargeTokens := int64(math.Max(completionTokens, effectiveMinToken))
totalFee := float64(realChargeTokens) * unitPrice / 1000000
if gconv.Bool(config["enable_audio_charge"]) {
totalFee += gconv.Float64(data["audio_word_cnt"]) * gconv.Float64(config["audio_unit_price"])
}
// 视频模型
return map[string]any{
"model_name": data["model_name"],
"total_tokens": realChargeTokens,
"total_fee": totalFee,
// 明细
"prompt_tokens": 0,
"completion_tokens": int64(completionTokens),
"is_online": data["is_online"],
"video_resolution": data["video_resolution"],
"input_has_video": data["input_has_video"],
"output_duration_sec": data["output_duration_sec"],
"aspect_ratio": data["aspect_ratio"],
"resolution": data["resolution"],
"matched_path": pricingPath,
"token_unit_price": unitPrice,
"effective_min_token": effectiveMinToken,
}
}
func calculateTTSBilling(config map[string]any, data map[string]any) map[string]any {
usage := gconv.Float64(data["synthesize_text_length"])
unitPrice := gconv.Float64(config["pricing"])
totalFee := usage * unitPrice
return map[string]any{
"model_name": data["model_name"],
"total_tokens": int64(usage),
"total_fee": totalFee,
// 明细
"prompt_tokens": 0,
"completion_tokens": int64(usage),
"unit_price": unitPrice,
}
}
// ======================== 数据提取 ========================
func ExtractRequestBilling(ctx context.Context, config map[string]any, requestPayload map[string]any) map[string]any {
dimensions := config["dimensions"].(map[string]any)
fields := dimensions["request"].(map[string]any)
data := make(map[string]any)
for key, path := range fields {
val := extractValue(requestPayload, gconv.String(path))
// TTS 模型:input_chars 自动计算字符数
if key == "input_chars" {
if s, ok := val.(string); ok {
data[key] = len([]rune(s))
continue
}
}
data[key] = val
}
if defaults, ok := config["defaults"].(map[string]any); ok {
for k, v := range defaults {
if _, exists := data[k]; !exists || data[k] == nil {
data[k] = v
}
}
}
//if compute, ok := config["compute"].(map[string]any); ok {
// for targetField, rule := range compute {
// r := rule.(map[string]any)
// dependsOn := gconv.String(r["depends_on"])
// dependsValue := gconv.Bool(r["depends_value"])
//
// if gconv.Bool(data[dependsOn]) != dependsValue {
// continue
// }
//
// switch r["service"] {
// case "video_duration":
// urls := extractVideoUrls(requestPayload)
// if len(urls) > 0 {
// resp, err := gateway.GetVideoDuration(ctx, urls)
// if err == nil {
// data[targetField] = resp.TotalDuration
// }
// }
// }
// }
//}
return data
}
func ExtractResponseBilling(config map[string]any, response map[string]any) map[string]any {
dimensions := config["dimensions"].(map[string]any)
fields := dimensions["response"].(map[string]any)
data := make(map[string]any)
for key, path := range fields {
data[key] = gjson.New(response).Get(gconv.String(path)).Val()
}
return data
}
// ======================== 内部辅助 ========================
func buildPricingPath(config map[string]any, data map[string]any) string {
pathConfig := config["pricing_path"].(map[string]any)
segments := pathConfig["segments"].([]any)
mapping := pathConfig["mapping"].(map[string]any)
var parts []string
for _, seg := range segments {
segName := gconv.String(seg)
val := gconv.String(data[segName])
if m, ok := mapping[segName].(map[string]any); ok {
if mapped, exists := m[val]; exists {
val = gconv.String(mapped)
}
}
parts = append(parts, val)
}
return strings.Join(parts, ".")
}
func matchMinToken(config map[string]any, data map[string]any) float64 {
rule, ok := config["min_token_rule"].(map[string]any)
if !ok {
return 0
}
conditionDefs := rule["conditions"].([]any)
rows := rule["rows"].([]any)
for _, row := range rows {
r := row.(map[string]any)
conditions := r["conditions"].([]any)
matched := true
for i, cond := range conditions {
condStr := gconv.String(cond)
condDef := conditionDefs[i].(map[string]any)
condName := gconv.String(condDef["name"])
switch condDef["type"] {
case "range":
if !matchRange(condStr, gconv.Float64(data[condName])) {
matched = false
}
case "enum":
if gconv.String(data[condName]) != condStr {
matched = false
}
}
}
if matched {
return gconv.Float64(r["min_token"])
}
}
return 0
}
func matchRange(rangeStr string, val float64) bool {
rangeStr = strings.Trim(rangeStr, "[]()")
parts := strings.Split(rangeStr, ",")
if len(parts) != 2 {
return false
}
return val >= gconv.Float64(strings.TrimSpace(parts[0])) && val < gconv.Float64(strings.TrimSpace(parts[1]))
}
func extractValue(source map[string]any, path string) any {
// 数组求和:rounds.#.duration
if strings.HasPrefix(path, "rounds.#.") && !strings.Contains(path, "==") {
field := strings.TrimPrefix(path, "rounds.#.")
rounds := gjson.New(source).Get("rounds").Array()
var sum float64
for _, r := range rounds {
if strings.Contains(field, "#") {
parts := strings.SplitN(field, ".#.", 2)
arr := gjson.New(r).Get(parts[0]).Array()
for _, item := range arr {
sum += gconv.Float64(gjson.New(item).Get(parts[1]).Val())
}
} else {
sum += gconv.Float64(gjson.New(r).Get(field).Val())
}
}
return sum
}
// 条件判断:xxx.#.type==yyy 或 rounds.#.content.#.type==yyy
if strings.Contains(path, "==video_url") || strings.Contains(path, "==input_audio") {
parts := strings.Split(path, "==")
basePath := parts[0]
typ := parts[1]
segments := strings.Split(basePath, ".#.")
// 单层:content.#.type → segments = ["content", "type"]
if len(segments) == 2 && !strings.Contains(segments[0], ".") {
arr := gjson.New(source).Get(segments[0]).Array()
for _, item := range arr {
if gconv.String(gjson.New(item).Get(segments[1]).Val()) == typ {
return true
}
}
return false
}
// 嵌套:rounds.#.content.#.type → segments = ["rounds", "content", "type"]
topArr := gjson.New(source).Get(segments[0]).Array()
for _, item := range topArr {
subArr := gjson.New(item).Get(segments[1]).Array()
for _, sub := range subArr {
if gconv.String(gjson.New(sub).Get("type").Val()) == typ {
return true
}
}
}
return false
}
return gjson.New(source).Get(path).Val()
}
func extractVideoUrls(body map[string]any) []string {
var urls []string
content := gjson.New(body).Get("content").Array()
for _, c := range content {
item := c.(map[string]any)
if item["type"] == "video_url" {
if v, ok := item["video_url"].(map[string]any); ok {
if url, ok := v["url"].(string); ok {
urls = append(urls, url)
}
}
}
}
return urls
}
+22 -55
View File
@@ -1,7 +1,6 @@
package util
import (
"encoding/json"
"fmt"
"net/http"
"os"
@@ -9,107 +8,75 @@ import (
"strings"
)
// DetectFileType 根据返回的二进制内容推断 contentType + 扩展名(尽量稳定)
// DetectFileType 根据返回的二进制内容推断 contentType + 扩展名
func DetectFileType(data []byte) (contentType string, ext string) {
if len(data) == 0 {
return "application/octet-stream", ""
return "application/octet-stream", ".bin"
}
ct := http.DetectContentType(data)
// gateway.DetectContentType 可能带 charset 等参数:text/plain; charset=utf-8
if idx := strings.Index(ct, ";"); idx > 0 {
ct = strings.TrimSpace(ct[:idx])
}
switch ct {
case "audio/mpeg":
return ct, ".mp3"
case "audio/wave", "audio/wav", "audio/x-wav":
return ct, ".wav"
case "audio/mp4", "audio/x-m4a":
return ct, ".m4a"
case "video/mp4":
return ct, ".mp4"
case "video/webm":
return ct, ".webm"
case "image/png":
return ct, ".png"
case "image/jpeg":
return ct, ".jpg"
case "image/gif":
return ct, ".gif"
case "image/webp":
return ct, ".webp"
case "application/pdf":
return ct, ".pdf"
case "text/plain":
return ct, ".txt"
case "application/json":
return ct, ".json"
case "application/zip":
return ct, ".zip"
case "application/octet-stream":
return ct, ".bin"
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, ""
return ct, ".bin"
}
}
// SaveTmpResult 将模型输出写入临时文件,用于 OSS 上传失败后的“仅重试 OSS”。
// SaveTmpResult 将二进制数据写入临时文件
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
return "", fmt.Errorf("创建临时目录失败: %w", 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 "", fmt.Errorf("写入临时文件失败: %w", err)
}
return path, nil
}
// SaveTempFileByType
// 根据传入的数据自动判断:
// 若是 []byte 且后缀为 .mp3 → 保存二进制音频
// 若是任意结构体/map → 自动转 JSON 保存
// 返回:新临时文件路径、错误
func SaveTempFileByType(taskID string, data any, oldTmpFile string) (string, error) {
// 1. 先清理旧临时文件(统一逻辑)
if oldTmpFile != "" {
_ = os.Remove(oldTmpFile)
}
var tmpPath string
var tmpErr error
// 2. 判断是否是二进制音频([]byte + .mp3
if audioData, ok := data.([]byte); ok {
tmpPath, tmpErr = saveTmpResult(taskID, audioData, ".mp3")
} else {
// 3. 其他类型 → 序列化为 JSON 保存
mappedBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
if len(mappedBytes) == 0 {
return "", nil
}
tmpPath, tmpErr = saveTmpResult(taskID, mappedBytes, ".json")
}
if tmpErr != nil || tmpPath == "" {
return "", tmpErr
}
return tmpPath, nil
}
// saveTmpResult 你原有的底层保存文件方法(保留不动)
func saveTmpResult(taskID string, data []byte, ext string) (string, error) {
// 你原来实现,比如:
filename := taskID + ext
tmpPath := filepath.Join(os.TempDir(), filename)
err := os.WriteFile(tmpPath, data, 0644)
return tmpPath, err
}
+55 -30
View File
@@ -19,41 +19,59 @@ import (
tgjson "github.com/tidwall/gjson"
)
// ParseAndValidate 解析并校验结果
func ParseAndValidate(raw map[string]any, model *entity.ModelGatewayModel) (map[string]any, error) {
// 1) 解析 content 字符串为 rounds 数组
contentVal, ok := raw[model.ResponseBody]
if !ok {
return raw, fmt.Errorf("字段 %s 不存在", model.ResponseBody)
}
contentStr, ok := contentVal.(string)
if !ok || strings.TrimSpace(contentStr) == "" {
return raw, fmt.Errorf("字段 %s 为空或不是字符串", model.ResponseBody)
}
var arr []any
if err := json.Unmarshal([]byte(contentStr), &arr); err != nil {
return raw, fmt.Errorf("JSON解析失败: %w", err)
}
if len(arr) == 0 {
return raw, fmt.Errorf("解析后数组为空")
// ParseAndValidate 解析模型响应,并返回标准格式
func ParseAndValidate(raw map[string]any, requiredFields []string) (map[string]any, error) {
contentStr := gconv.String(raw[entity.ResponseBody])
if strings.TrimSpace(contentStr) == "" {
return raw, fmt.Errorf("字段 %s 为空", entity.ResponseBody)
}
// 2) 校验必填字段
if len(model.RequiredFields) > 0 {
for i, r := range arr {
round, ok := r.(map[string]any)
contentStr = strings.Map(func(r rune) rune {
if r < 32 && r != ' ' {
return -1
}
return r
}, contentStr)
// 第一步:先解析为通用 interface{},判断是对象还是数组
var data any
if err := json.Unmarshal([]byte(contentStr), &data); err != nil {
return raw, fmt.Errorf("JSON解析失败: %w", err)
}
var arr []any
switch val := data.(type) {
case []any:
// 本身就是数组,直接赋值
arr = val
case map[string]any:
// 单个对象,包装成单元素数组,统一后续逻辑
arr = []any{val}
default:
return raw, fmt.Errorf("不支持的JSON类型,仅允许对象/数组")
}
if len(arr) == 0 {
return raw, fmt.Errorf("解析后数据数组为空")
}
// 校验每一项的必填字段
for _, field := range requiredFields {
for i, item := range arr {
itemMap, ok := item.(map[string]any)
if !ok {
continue
return raw, fmt.Errorf("rounds[%d] 不是合法JSON对象", i)
}
for _, field := range model.RequiredFields {
if gjson.New(round).Get(field).IsNil() {
return raw, fmt.Errorf("rounds[%d] 缺少必填字段: %s", i, field)
}
if gjson.New(itemMap).Get(field).IsNil() {
return raw, fmt.Errorf("rounds[%d] 缺少必填字段: %s", i, field)
}
}
}
return map[string]any{"total_rounds": len(arr), "rounds": arr}, nil
return map[string]any{
"total_rounds": len(arr),
"rounds": arr,
}, nil
}
// ParseStructResult 解析结构结果
@@ -266,10 +284,17 @@ func PullTaskResult(ctx context.Context, body map[string]any, queryConfig map[st
}
if matchStatus(statusStr, statusValues["failed"]) {
g.Log().Errorf(ctx, "[PullTaskResult] 任务失败 taskID=%s", taskID)
return result, fmt.Errorf("任务失败")
errMsg := gconv.String(gjson.New(result).Get("error.message").Val())
if errMsg == "" {
errMsg = gconv.String(gjson.New(result).Get("error").Val())
}
if errMsg == "" {
rawBytes, _ := json.Marshal(result)
errMsg = string(rawBytes)
}
g.Log().Errorf(ctx, "[PullTaskResult] 任务失败 taskID=%s err=%s", taskID, errMsg)
return result, fmt.Errorf("任务失败: %s", errMsg)
}
time.Sleep(time.Duration(interval) * time.Second)
}
}
+7 -7
View File
@@ -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^*^"
@@ -28,7 +28,7 @@ database:
timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效
model_gateway:
- type: "pgsql"
host: "116.204.74.41"
host: "192.168.0.83"
port: "15432"
user: "postgres"
pass: "Bjang09@686^*^"
@@ -39,8 +39,8 @@ database:
dryRun: false
charset: "utf8"
timezone: "Asia/Shanghai"
maxIdle: 5
maxOpen: 20
maxIdle: 15
maxOpen: 60
maxLifetime: "30s"
maxIdleConnTime: "30s"
createdAt: "created_at"
@@ -50,14 +50,14 @@ database:
redis:
default:
address: 192.168.3.30:6379
address: 192.168.0.83:6379
db: 0
consul:
address: 192.168.3.30:8500
address: 192.168.0.83:8500
jaeger:
addr: 192.168.3.30:4318
addr: 192.168.0.83:4318
# 本地调试用:可选自动执行 worker/cleaner(默认关闭)
asynch:
+108
View File
@@ -0,0 +1,108 @@
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
SupplierAzure = 7
SupplierAWS = 8
SupplierGoogle = 9
SupplierDeepSeek = 10
SupplierMoonshot = 11
SupplierZhipu = 12
SupplierBaichuan = 13
SupplierMinimax = 14
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 OpenAI",
SupplierAWS: "AWS Bedrock",
SupplierGoogle: "Google Cloud",
SupplierDeepSeek: "DeepSeek",
SupplierMoonshot: "Moonshot",
SupplierZhipu: "智谱AI",
SupplierBaichuan: "百川智能",
SupplierMinimax: "MiniMax",
SupplierXunfei: "科大讯飞",
SupplierOthers: "其他",
}
// 供应商展示顺序
var supplierOrder = []int{
SupplierAliyun, SupplierVolcengine, SupplierTencent, SupplierHuawei, SupplierBaidu,
SupplierOpenAI, SupplierAzure, SupplierAWS, SupplierGoogle, 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
}
+186
View File
@@ -0,0 +1,186 @@
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 // 视频模型
// 图片子类型
ImageSubTextToImage = 201
ImageSubImageToImage = 202
ImageSubImageEdit = 203
ImageSubImageVariation = 204
ImageSubImageTextToImage = 205
// 音频子类型
AudioSubTextToSpeech = 301
AudioSubSpeechToText = 302
AudioSubSpeechToSpeech = 303
// 向量化子类型
VectorSubEmbedding = 401
VectorSubRerank = 402
// 全模态子类型
OmniSubTextImageAudio = 501
OmniSubVision = 502
// 视频子类型
VideoSubTextToVideo = 601
VideoSubImageToVideo = 602
VideoSubImageTextToVideo = 603
VideoSubVideoToVideo = 604
)
// 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: "视频模型",
ImageSubTextToImage: "文生图",
ImageSubImageToImage: "图生图",
ImageSubImageEdit: "图片编辑",
ImageSubImageVariation: "图片变体",
ImageSubImageTextToImage: "图文生图",
AudioSubTextToSpeech: "文生音",
AudioSubSpeechToText: "音生文",
AudioSubSpeechToSpeech: "音生音",
VectorSubEmbedding: "文本嵌入",
VectorSubRerank: "重排序",
OmniSubTextImageAudio: "文图音",
OmniSubVision: "视觉理解",
VideoSubTextToVideo: "文生视频",
VideoSubImageToVideo: "图生视频",
VideoSubImageTextToVideo: "图文生视频",
VideoSubVideoToVideo: "视频生视频",
}
// 父子级映射(仅存有子项的分类)
var parentChildMap = map[int][]int{
TypeImage: {ImageSubTextToImage, ImageSubImageToImage, ImageSubImageEdit, ImageSubImageVariation, ImageSubImageTextToImage},
TypeAudio: {AudioSubTextToSpeech, AudioSubSpeechToText, AudioSubSpeechToSpeech},
TypeVector: {VectorSubEmbedding, VectorSubRerank},
TypeOmni: {OmniSubTextImageAudio, OmniSubVision},
TypeVideo: {VideoSubTextToVideo, VideoSubImageToVideo, VideoSubImageTextToVideo, VideoSubVideoToVideo},
}
// 一级分类展示顺序
var parentTypeOrder = []int{
TypeInference, TypeImage, TypeAudio, TypeVector, TypeOmni, TypeVideo,
}
// 全局实例:一级 + 全部二级子类型,统一通过 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))
// 图片二级子类型
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))
// 向量化二级子类型
ModelVectorSubEmbedding = newItem(gconv.PtrInt(VectorSubEmbedding))
ModelVectorSubRerank = newItem(gconv.PtrInt(VectorSubRerank))
// 全模态二级子类型
ModelOmniSubTextImageAudio = newItem(gconv.PtrInt(OmniSubTextImageAudio))
ModelOmniSubVision = newItem(gconv.PtrInt(OmniSubVision))
// 视频二级子类型
ModelVideoSubTextToVideo = newItem(gconv.PtrInt(VideoSubTextToVideo))
ModelVideoSubImageToVideo = newItem(gconv.PtrInt(VideoSubImageToVideo))
ModelVideoSubImageTextToVideo = newItem(gconv.PtrInt(VideoSubImageTextToVideo))
ModelVideoSubVideoToVideo = newItem(gconv.PtrInt(VideoSubVideoToVideo))
)
// newItem 构造方法:自动从 typeNameMap 读取描述
func newItem(code ModelType) ModelTypeItem {
val := int(*code)
return ModelTypeItem{
Code: code,
Desc: typeNameMap[val],
}
}
// GetDescByCode 根据编码获取名称
func GetDescByCode(code int) string {
return typeNameMap[code]
}
// GetTypeTreeList 生成树形数据
func GetTypeTreeList() []*TypeTree {
var list []*TypeTree
for _, parentCode := range parentTypeOrder {
tree := &TypeTree{
Value: parentCode,
Label: typeNameMap[parentCode],
Children: make([]*public.Option, 0),
}
if childCodes, ok := parentChildMap[parentCode]; ok {
for _, c := range childCodes {
tree.Children = append(tree.Children, &public.Option{
Value: c,
Label: typeNameMap[c],
})
}
}
list = append(list, tree)
}
return list
}
// GetAllTypeOption 全量平铺选项
func GetAllTypeOption() []*public.Option {
var list []*public.Option
for code, label := range typeNameMap {
list = append(list, &public.Option{Value: code, Label: label})
}
return list
}
+27
View File
@@ -0,0 +1,27 @@
package model
import "github.com/gogf/gf/v2/util/gconv"
var (
ResponseTypeSync = newResponseType(gconv.PtrInt8(1), "sync") // 同步
ResponseTypeAsync = newResponseType(gconv.PtrInt8(2), "async") // 异步
ResponseTypeStream = newResponseType(gconv.PtrInt8(3), "stream") // 流
)
type ResponseType *int8
type responseType struct {
code ResponseType
desc string
}
func (s responseType) Code() ResponseType {
return s.code
}
func (s responseType) Desc() string {
return s.desc
}
func newResponseType(code ResponseType, desc string) responseType {
return responseType{code: code, desc: desc}
}
+6
View File
@@ -1,5 +1,11 @@
package public
// Option 通用下拉选项
type Option struct {
Value int `json:"value"`
Label string `json:"label"`
}
const (
CallModeSync = 0 // 同步调用
CallModeAsync = 1 // 异步调用
+5 -4
View File
@@ -5,8 +5,9 @@ const (
)
const (
TableNameModel = "model_gateway_models" // 模型表
TableNameTask = "model_gateway_task" // 任务表
TableNameOpLog = "model_gateway_logs_op" // 操作日志表
TableNameStat = "model_gateway_logs_stat" // 按天统计表
TableNameModel = "model_gateway_models" // 模型表
TableNameTask = "model_gateway_task" // 任务表
TableNameOpLog = "model_gateway_logs_op" // 操作日志表
TableNameStat = "model_gateway_logs_stat" // 按天统计表
TableNameModelManage = "model_gateway_model_manage"
)
+56
View File
@@ -0,0 +1,56 @@
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 *beans.ResponseEmpty, err error) {
err = service.ModelManage.Update(ctx, req)
return
}
// 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)
}
// 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)
}
+2 -1
View File
@@ -56,6 +56,7 @@ func (d *modelGatewayModelsDao) Get(ctx context.Context, req *entity.ModelGatewa
Where(entity.ModelGatewayModelCol.Id, req.Id).
Where(entity.ModelGatewayModelCol.Creator, req.Creator).
Where(entity.ModelGatewayModelCol.ModelName, req.ModelName).
Where(entity.ModelGatewayModelCol.IsChatModel, req.IsChatModel).
Fields(fields).One()
if err != nil {
return nil, err
@@ -122,7 +123,7 @@ func (d *modelGatewayModelsDao) GetByAcrossTenant(ctx context.Context, req *enti
func (d *modelGatewayModelsDao) GetByCreatorAndPlatform(ctx context.Context, req *dto.ListModelReq) (list []*entity.ModelGatewayModel, total int, err error) {
sql := `
SELECT DISTINCT ON (model_name) *
FROM asynch_models
FROM ` + public.TableNameModel + `
WHERE deleted_at IS NULL
AND (? = '' OR model_name LIKE ?)
`
+23 -24
View File
@@ -7,7 +7,6 @@ import (
"model-gateway/model/entity"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/util/gconv"
)
@@ -128,32 +127,32 @@ func (d *modelGatewayTaskDao) GetPendingAsyncTasks(ctx context.Context, limit in
// ClaimByID 按主键抢占,返回抢占后的任务
func (d *modelGatewayTaskDao) ClaimByID(ctx context.Context, id int64) (*entity.ModelGatewayTask, error) {
// 1) 先查任务
var task entity.ModelGatewayTask
err := gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
r, err := tx.Model(public.TableNameTask).
Where(entity.ModelGatewayTaskCol.Id, id).
Where(entity.ModelGatewayTaskCol.State, public.TaskStatusPending).
Limit(1).
LockUpdate().
One()
if err != nil {
return err
}
if r.IsEmpty() {
return fmt.Errorf("任务已被抢占或不存在: id=%d", id)
}
if err := r.Struct(&task); err != nil {
return err
}
_, err = tx.Model(public.TableNameTask).
Data(&entity.ModelGatewayTask{State: public.TaskStatusRunning}).
Where(entity.ModelGatewayTaskCol.Id, id).
OmitEmpty().
Update()
return err
})
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
Where(entity.ModelGatewayTaskCol.Id, id).
Where(entity.ModelGatewayTaskCol.State, public.TaskStatusPending).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, fmt.Errorf("任务已被抢占或不存在: id=%d", id)
}
if err = r.Struct(&task); err != nil {
return nil, err
}
// 2) 改为执行中
_, err = gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
Data(&entity.ModelGatewayTask{State: public.TaskStatusRunning}).
Where(entity.ModelGatewayTaskCol.Id, id).
Where(entity.ModelGatewayTaskCol.State, public.TaskStatusPending). // 防并发
OmitEmpty().
Update()
if err != nil {
return nil, err
}
return &task, nil
}
+193
View File
@@ -0,0 +1,193 @@
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
}
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
}
+44 -36
View File
@@ -3,7 +3,7 @@ module model-gateway
go 1.26.1
require (
gitea.redpowerfuture.com/red-future/common v0.0.23
gitea.redpowerfuture.com/red-future/common v0.0.30
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
@@ -12,22 +12,25 @@ require (
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dgraph-io/badger/v4 v4.2.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
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/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // 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-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // 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
@@ -36,57 +39,62 @@ require (
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/flatbuffers v1.12.1 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/hashicorp/consul/api v1.26.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/consul/api v1.33.5 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.5.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/serf v0.10.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // 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/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.3.0 // indirect
github.com/olekukonko/ll v0.1.8 // indirect
github.com/olekukonko/tablewriter v1.1.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/r3labs/diff/v2 v2.15.1 // indirect
github.com/redis/go-redis/v9 v9.12.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/redis/go-redis/v9 v9.21.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // 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
go.opentelemetry.io/otel v1.38.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.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
google.golang.org/protobuf v1.36.8 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+108 -94
View File
@@ -1,9 +1,11 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
gitea.redpowerfuture.com/red-future/common v0.0.23 h1:xieoA00iKOCDm5SO9iXn+cSyMKBAlZwI0fuEVPWrHLg=
gitea.redpowerfuture.com/red-future/common v0.0.23/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY=
gitea.redpowerfuture.com/red-future/common v0.0.29 h1:5McaN5pSewvrLUHQzWMX6EaUvD+B5I5bMYoU+clHJk4=
gitea.redpowerfuture.com/red-future/common v0.0.29/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY=
gitea.redpowerfuture.com/red-future/common v0.0.30 h1:UkWYubUsLPJQUhEhc9Ca2UPg5iLC6jzURo3ngztINYg=
gitea.redpowerfuture.com/red-future/common v0.0.30/go.mod h1:zuhqbWHd/YICalYJnmecY8Vqo5j4dtZOD63P96fiFeU=
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=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -36,6 +38,10 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -47,8 +53,6 @@ github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWa
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
@@ -61,10 +65,10 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
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=
@@ -77,6 +81,10 @@ github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4
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-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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=
@@ -114,10 +122,10 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -134,12 +142,12 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM=
github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A=
github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU=
github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/consul/api v1.33.5 h1:Nn6q87zudRU1rLBTJEgaWxz9STCNadilLCD7B8OA5aI=
github.com/hashicorp/consul/api v1.33.5/go.mod h1:pa6fJOSHKLOzNHpUVeqLDtxA5+J1D7NNzLasuk8eRXA=
github.com/hashicorp/consul/sdk v0.17.3 h1:oZMMxzQGSsiT+ToOH50y3Qcs0nc9Ud+7L5lRx+EmMU0=
github.com/hashicorp/consul/sdk v0.17.3/go.mod h1:jnOmYjiNfVRpBaujQ1DFFVs0N6g3S1y6wygSjLTzYfc=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -169,8 +177,8 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
@@ -185,8 +193,10 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/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,8 +206,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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -205,39 +215,39 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
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/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=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
@@ -264,13 +274,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/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=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
@@ -278,22 +285,26 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
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=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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.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/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s=
github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
@@ -305,28 +316,32 @@ 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=
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=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
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=
@@ -335,15 +350,15 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3
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/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=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -359,8 +374,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -369,8 +384,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -394,16 +409,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -413,8 +427,8 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
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=
@@ -429,17 +443,17 @@ google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
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=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -449,8 +463,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+4 -2
View File
@@ -26,6 +26,7 @@ func main() {
// 注册路由
http.RouteRegister([]interface{}{
controller.ModelManage,
controller.ModelGatewayModels,
controller.ModelGatewayTask,
controller.ModelGatewayLogsStat,
@@ -40,9 +41,10 @@ func main() {
<-quit
g.Log().Infof(ctx, "[main] 收到退出信号,开始优雅退出...")
cancel()
// 关闭 gateway serverRouteRegister 内部是 go Httpserver.Run() 启动的)
// 先关闭 gateway server,等待 in-flight 请求处理完成
_ = http.Httpserver.Shutdown()
// 再取消上下文,避免活跃请求被中断
cancel()
}
func startAutoRunner(ctx context.Context) {
+2
View File
@@ -13,6 +13,8 @@ type CreateTaskReq struct {
RequestPayload map[string]any `p:"requestPayload" json:"requestPayload" dc:"请求负载(透传给模型服务)"`
EpicycleId int64 `json:"epicycleId" dc:"轮次ID"`
BuildType int64 `json:"buildType" dc:"构建类型:1-提示词构建 2-节点构建"`
BuildModelName string `json:"buildModelName" json:"buildModelName" dc:"构建模型名称"`
TaskId string `json:"taskId" dc:"任务ID"`
}
type CreateTaskRes struct {
+127
View File
@@ -0,0 +1,127 @@
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:"new模型管理" summary:"new创建模型配置" dc:"new添加新的模型配置"`
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:"请求体映射"`
ResponseMapping map[string]any `json:"responseMapping" dc:"返回映射"`
ResponseBodyMapping map[string]string `json:"responseBodyMapping" 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预估价格单位"`
MaxTokens int `json:"maxTokens" dc:"最大token数"`
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:"new模型管理" summary:"new更新模型配置" dc:"new更新指定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:"请求体映射"`
ResponseMapping map[string]any `json:"responseMapping" dc:"返回映射"`
ResponseBodyMapping map[string]string `json:"responseBodyMapping" 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预估价格单位"`
MaxTokens int `json:"maxTokens" dc:"最大token数"`
MaxDuration int `json:"maxDuration" dc:"最大时长"`
LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"`
}
type DeleteModelManageReq struct {
g.Meta `path:"/deleteModelManage" method:"delete" tags:"new模型管理" summary:"new删除模型配置" dc:"new删除指定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:"new模型管理" summary:"new获取模型配置" dc:"new获取指定ID的模型配置"`
Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"`
}
type GetModelManageRes struct {
*entity.ModelManage `json:"modelManage"`
}
// ListModelManageReq 配置列表
type ListModelManageReq struct {
g.Meta `path:"/listModelManage" method:"get" tags:"new模型管理" summary:"new模型配置列表" dc:"new分页获取模型配置列表"`
*beans.Page `json:"page"`
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"`
ModelType int `p:"modelType" json:"modelType" 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:"new模型管理" summary:"new检查是否为聊天模型" dc:"new检查是否为聊天模型"`
}
type CheckChatModelRes struct {
IsChatModel bool `json:"isChatModel" dc:"是否为聊天模型"`
}
// ModelTypeReq 模型类型列表(分页)
type ModelTypeReq struct {
g.Meta `path:"/modelType" method:"get" tags:"new模型管理" summary:"new模型类型列表" dc:"new分页获取模型类型列表"`
}
type ModelTypeRes struct {
List []*model.TypeTree `json:"list" dc:"模型类型ID到名称的映射"`
}
type ModelSupplierReq struct {
g.Meta `path:"/modelSupplier" method:"get" tags:"new模型管理" summary:"new获取运营商列表" dc:"new获取运营商列表"`
}
type ModelSupplierRes struct {
List []*public.Option `json:"list" dc:"运营商名称到ID的映射"`
}
+88 -89
View File
@@ -4,99 +4,98 @@ import "gitea.redpowerfuture.com/red-future/common/beans"
type modelGatewayModelCol struct {
beans.SQLBaseCol
ModelName string
ModelType string
BaseURL string
HttpMethod string
HeadMsg string
FormJSON string
RequestMapping string
ResponseMapping string
ResponseBody string
ResponseTokenField string
RequiredFields string
IsPrivate string
IsChatModel string
CallMode string
ApiKey string
Enabled string
MaxConcurrency string
TimeoutSeconds string
RetryTimes string
AutoCleanSeconds string
IsOwner string
OperatorName string
TokenConfig string
ExtendMapping string
QueryConfig string
StreamConfig string
FirstFrame string
LastFrame string
MaxTokens string
ModelName string
ModelType string
BaseURL string
HttpMethod string
HeadMsg string
FormJSON string
RequestMapping string
ResponseMapping string
RequiredFields string
IsPrivate string
IsChatModel string
CallMode string
ApiKey string
Enabled string
MaxConcurrency string
TimeoutSeconds string
RetryTimes string
AutoCleanSeconds string
IsOwner string
OperatorName string
TokenConfig string
ExtendMapping string
QueryConfig string
StreamConfig string
FirstFrame string
LastFrame string
BillingConfig string
}
var ModelGatewayModelCol = modelGatewayModelCol{
SQLBaseCol: beans.DefSQLBaseCol,
ModelName: "model_name",
ModelType: "model_type",
BaseURL: "base_url",
HttpMethod: "http_method",
HeadMsg: "head_msg",
FormJSON: "form_json",
RequestMapping: "request_mapping",
ResponseMapping: "response_mapping",
ResponseBody: "response_body",
ResponseTokenField: "response_token_field",
RequiredFields: "required_fields",
IsPrivate: "is_private",
IsChatModel: "is_chat_model",
CallMode: "call_mode",
ApiKey: "api_key",
Enabled: "enabled",
MaxConcurrency: "max_concurrency",
TimeoutSeconds: "timeout_seconds",
RetryTimes: "retry_times",
AutoCleanSeconds: "auto_clean_seconds",
IsOwner: "is_owner",
OperatorName: "operator_name",
TokenConfig: "token_config",
ExtendMapping: "extend_mapping",
QueryConfig: "query_config",
StreamConfig: "stream_config",
FirstFrame: "first_frame",
LastFrame: "last_frame",
MaxTokens: "max_tokens",
SQLBaseCol: beans.DefSQLBaseCol,
ModelName: "model_name",
ModelType: "model_type",
BaseURL: "base_url",
HttpMethod: "http_method",
HeadMsg: "head_msg",
FormJSON: "form_json",
RequestMapping: "request_mapping",
ResponseMapping: "response_mapping",
RequiredFields: "required_fields",
IsPrivate: "is_private",
IsChatModel: "is_chat_model",
CallMode: "call_mode",
ApiKey: "api_key",
Enabled: "enabled",
MaxConcurrency: "max_concurrency",
TimeoutSeconds: "timeout_seconds",
RetryTimes: "retry_times",
AutoCleanSeconds: "auto_clean_seconds",
IsOwner: "is_owner",
OperatorName: "operator_name",
TokenConfig: "token_config",
ExtendMapping: "extend_mapping",
QueryConfig: "query_config",
StreamConfig: "stream_config",
FirstFrame: "first_frame",
LastFrame: "last_frame",
BillingConfig: "billing_config",
}
type ModelGatewayModel struct {
beans.SQLBaseDO `orm:",inline"`
ModelName string `orm:"model_name" json:"modelName"`
ModelType int `orm:"model_type" json:"modelType"`
BaseURL string `orm:"base_url" json:"baseUrl"`
HttpMethod string `orm:"http_method" json:"httpMethod"`
HeadMsg map[string]any `orm:"head_msg" json:"headMsg"`
Form []map[string]any `orm:"form_json" json:"form"`
RequestMapping map[string]any `orm:"request_mapping" json:"requestMapping"`
ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping"`
ResponseBody string `orm:"response_body" json:"responseBody"`
ResponseTokenField string `orm:"response_token_field" json:"tokenField"`
RequiredFields []string `orm:"required_fields" json:"requiredFields"`
IsPrivate *int `orm:"is_private" json:"isPrivate"`
IsChatModel *int `orm:"is_chat_model" json:"isChatModel"`
CallMode *int `orm:"call_mode" json:"callMode"`
ApiKey string `orm:"api_key" json:"apiKey"`
Enabled *int `orm:"enabled" json:"enabled"`
MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency"`
TimeoutSeconds int `orm:"timeout_seconds" json:"timeoutSeconds"`
RetryTimes int `orm:"retry_times" json:"retryTimes"`
AutoCleanSeconds int `orm:"auto_clean_seconds" json:"autoCleanSeconds"`
IsOwner *int `orm:"is_owner" json:"isOwner"`
OperatorName string `orm:"operator_name" json:"operatorName"`
TokenConfig map[string]any `orm:"token_config" json:"tokenConfig"`
ExtendMapping map[string]any `orm:"extend_mapping" json:"extendMapping"`
QueryConfig map[string]any `orm:"query_config" json:"queryConfig"`
StreamConfig map[string]any `orm:"stream_config" json:"streamConfig"`
FirstFrame string `orm:"first_frame" json:"firstFrame"`
LastFrame string `orm:"last_frame" json:"lastFrame"`
MaxTokens int `orm:"max_tokens" json:"maxTokens"`
beans.SQLBaseDO `orm:",inline"`
ModelName string `orm:"model_name" json:"modelName"`
ModelType int `orm:"model_type" json:"modelType"`
BaseURL string `orm:"base_url" json:"baseUrl"`
HttpMethod string `orm:"http_method" json:"httpMethod"`
HeadMsg map[string]any `orm:"head_msg" json:"headMsg"`
Form []map[string]any `orm:"form_json" json:"form"`
RequestMapping map[string]any `orm:"request_mapping" json:"requestMapping"`
ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping"`
RequiredFields []string `orm:"required_fields" json:"requiredFields"`
IsPrivate *int `orm:"is_private" json:"isPrivate"`
IsChatModel *int `orm:"is_chat_model" json:"isChatModel"`
CallMode *int `orm:"call_mode" json:"callMode"`
ApiKey string `orm:"api_key" json:"apiKey"`
Enabled *int `orm:"enabled" json:"enabled"`
MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency"`
TimeoutSeconds int `orm:"timeout_seconds" json:"timeoutSeconds"`
RetryTimes int `orm:"retry_times" json:"retryTimes"`
AutoCleanSeconds int `orm:"auto_clean_seconds" json:"autoCleanSeconds"`
IsOwner *int `orm:"is_owner" json:"isOwner"`
OperatorName string `orm:"operator_name" json:"operatorName"`
TokenConfig map[string]any `orm:"token_config" json:"tokenConfig"`
ExtendMapping map[string]any `orm:"extend_mapping" json:"extendMapping"`
QueryConfig map[string]any `orm:"query_config" json:"queryConfig"`
StreamConfig map[string]any `orm:"stream_config" json:"streamConfig"`
FirstFrame string `orm:"first_frame" json:"firstFrame"`
LastFrame string `orm:"last_frame" json:"lastFrame"`
BillingConfig map[string]any `orm:"billing_config" json:"billingConfig"`
}
const (
ResponseBody = "content" //返回主体(必填)
TotalTokens = "total_tokens" //总token数
)
+21 -15
View File
@@ -21,6 +21,8 @@ type modelGatewayTaskCol struct {
TmpFile string
RequestPayload string
EpicycleId string
BuildModelName string
BillingData string
}
var ModelGatewayTaskCol = modelGatewayTaskCol{
@@ -40,26 +42,30 @@ var ModelGatewayTaskCol = modelGatewayTaskCol{
TmpFile: "tmp_file",
RequestPayload: "request_payload",
EpicycleId: "epicycle_id",
BuildModelName: "build_model_name",
BillingData: "billing_data",
}
// ModelGatewayTask 模型网关任务
type ModelGatewayTask struct {
beans.SQLBaseDO `orm:",inline"`
ModelName string `orm:"model_name" json:"modelName"`
TaskID string `orm:"task_id" json:"taskId"`
BizName string `orm:"biz_name" json:"bizName"`
CallbackURL string `orm:"callback_url" json:"callbackUrl"`
State int `orm:"state" json:"state"`
Phase int `orm:"phase" json:"phase"`
ErrorMsg string `orm:"error_msg" json:"errorMsg"`
ResultFile *ResultFile `orm:"result_file" json:"resultFile"`
TextResult map[string]any `orm:"text_result" json:"text"`
ExpendTokens int64 `orm:"expend_tokens" json:"expendTokens"`
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds"`
RetryCount int `orm:"retry_count" json:"retryCount"`
TmpFile string `orm:"tmp_file" json:"tmpFile"`
RequestPayload *RequestPayload `orm:"request_payload" json:"requestPayload"`
EpicycleId int64 `orm:"epicycle_id" json:"epicycleId"`
ModelName string `orm:"model_name" json:"modelName"`
TaskID string `orm:"task_id" json:"taskId"`
BizName string `orm:"biz_name" json:"bizName"`
CallbackURL string `orm:"callback_url" json:"callbackUrl"`
State int `orm:"state" json:"state"`
Phase int `orm:"phase" json:"phase"`
ErrorMsg string `orm:"error_msg" json:"errorMsg"`
ResultFile *ResultFile `orm:"result_file" json:"resultFile"`
TextResult map[string]any `orm:"text_result" json:"text"`
ExpendTokens int64 `orm:"expend_tokens" json:"expendTokens"`
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds"`
RetryCount int `orm:"retry_count" json:"retryCount"`
TmpFile string `orm:"tmp_file" json:"tmpFile"`
RequestPayload *RequestPayload `orm:"request_payload" json:"requestPayload"`
EpicycleId int64 `orm:"epicycle_id" json:"epicycleId"`
BuildModelName string `orm:"build_model_name" json:"buildModelName"`
BillingData []map[string]any `orm:"billing_data" json:"billingData"`
}
// ResultFile OSS 结果文件
+102
View File
@@ -0,0 +1,102 @@
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
ResponseMapping string
ResponseBodyMapping string
MaxConcurrency string
TokenPredictPrice string
TokenPredictPriceUnit string
MaxTokens 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",
ResponseMapping: "response_mapping",
ResponseBodyMapping: "response_body_mapping",
MaxConcurrency: "max_concurrency",
TokenPredictPrice: "token_predict_price",
TokenPredictPriceUnit: "token_predict_price_unit",
MaxTokens: "max_tokens",
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:"请求体映射"`
ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping" description:"响应映射"`
ResponseBodyMapping map[string]string `orm:"response_body_mapping" json:"responseBodyMapping" 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,字数)"`
MaxTokens int `orm:"max_tokens" json:"maxTokens" description:"最大token数"`
MaxDuration int `orm:"max_duration" json:"maxDuration" description:"最大时长(秒)"`
LastFrame string `orm:"last_frame" json:"lastFrame" description:"视频的尾帧图像"`
}
type TokenMapping struct {
PromptTokens string `json:"promptTokens" dc:"输入token"`
CompletionTokens string `json:"completionTokens" dc:"输出token"`
TotalTokens string `json:"totalTokens" dc:"总token"`
}
type AsyncTaskMapping struct {
Url string `json:"url" dc:"url"`
HttpMethod string `json:"httpMethod" dc:"http方法" d:"POST"`
RequestHeadMapping map[string]string `json:"requestHeadMapping" description:"请求头映射"`
ResponseMapping map[string]any `json:"responseMapping" description:"响应映射"`
TaskId string `json:"taskId" dc:"任务id"`
TaskStatus string `json:"taskStatus" dc:"任务状态"`
TaskStatusPending string `json:"taskStatusPending" dc:"任务状态-待处理"`
TaskStatusRunning string `json:"taskStatusRunning" dc:"任务状态-运行中"`
TaskStatusSuccess string `json:"taskStatusSuccess" dc:"任务状态-成功"`
TaskStatusFailed string `json:"taskStatusFailed" dc:"任务状态-失败"`
TaskStatusCancel string `json:"taskStatusCancel" dc:"任务状态-取消"`
TaskStatusUnknown string `json:"taskStatusUnknown" dc:"任务状态-未知"`
}
+157 -18
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"mime/multipart"
"model-gateway/common/util"
"model-gateway/model/entity"
"time"
@@ -43,16 +42,25 @@ func UploadByTask(ctx context.Context, data []byte, fileExt string) (oss *Upload
if err != nil {
return nil, err
}
if _, err := part.Write(data); err != nil {
if _, err = part.Write(data); err != nil {
return nil, err
}
contentType := writer.FormDataContentType()
//contentType := writer.FormDataContentType()
if err = writer.Close(); err != nil {
return nil, err
}
headers := util.ForwardHeaders(ctx)
headers["Content-Type"] = contentType
//headers := util.ForwardHeaders(ctx)
//headers["Content-Type"] = contentType
headers := make(map[string]string)
headers["Content-Type"] = writer.FormDataContentType()
if r := g.RequestFromCtx(ctx); r != nil {
if auth := r.Header.Get("Authorization"); auth != "" {
headers["Authorization"] = auth
}
}
fullURL := "oss/file/uploadFile"
g.Log().Infof(ctx, "[OSS] upload start url=%s filename=%s size=%d", fullURL, filename, len(data))
@@ -69,23 +77,35 @@ func UploadByTask(ctx context.Context, data []byte, fileExt string) (oss *Upload
// CallbackPayload 回调请求体
type CallbackPayload struct {
TaskId string `json:"task_id"`
State int `json:"state"`
OssFile string `json:"oss_file"`
FileType string `json:"file_type"`
ErrorMsg string `json:"error_msg"`
TaskId string `json:"task_id"`
State int `json:"state"`
OssFile string `json:"oss_file"`
FileType string `json:"file_type"`
ErrorMsg string `json:"error_msg"`
BillingDate []map[string]any `json:"billing_data"`
}
// TriggerCallback 任务的回调
func TriggerCallback(ctx context.Context, t *entity.ModelGatewayTask) {
headers := util.ForwardHeaders(ctx)
//headers := util.ForwardHeaders(ctx)
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]
}
}
}
var resp struct{}
payload := CallbackPayload{
TaskId: t.TaskID,
State: t.State,
OssFile: t.ResultFile.OssFile,
FileType: t.ResultFile.FileType,
ErrorMsg: t.ErrorMsg,
TaskId: t.TaskID,
State: t.State,
ErrorMsg: t.ErrorMsg,
BillingDate: t.BillingData,
}
if !g.IsEmpty(t.ResultFile) {
payload.OssFile = t.ResultFile.OssFile
payload.FileType = t.ResultFile.FileType
}
jsonData, err := json.Marshal(payload)
if err != nil {
@@ -112,7 +132,15 @@ type PromptsCallbackPayload struct {
// TriggerPromptsCallback 任务成功后的提示词回调
func TriggerPromptsCallback(ctx context.Context, t *entity.ModelGatewayTask, epicycleId int64) {
callbackURL := "prompts-core/session/callback"
headers := util.ForwardHeaders(ctx)
//headers := util.ForwardHeaders(ctx)
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]
}
}
}
var resp struct{}
payload := PromptsCallbackPayload{
EpicycleId: epicycleId,
@@ -136,7 +164,15 @@ func TriggerPromptsCallback(ctx context.Context, t *entity.ModelGatewayTask, epi
// IsSuperAdmin 调用admin-go服务检查是否是超级管理员
func IsSuperAdmin(ctx context.Context) (res bool, err error) {
headers := util.ForwardHeaders(ctx)
//headers := util.ForwardHeaders(ctx)
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]
}
}
}
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
@@ -144,6 +180,109 @@ func IsSuperAdmin(ctx context.Context) (res bool, err error) {
return r["isSuperAdmin"], err
}
// VideoDurationResp 视频时长接口返回
type VideoDurationResp struct {
Videos []VideoInfo `json:"videos"`
Count int `json:"count"`
TotalDuration float64 `json:"totalDuration"`
TotalDurationStr string `json:"totalDurationStr"`
}
type VideoInfo struct {
Index int `json:"index"`
VideoUrl string `json:"videoUrl"`
Duration float64 `json:"duration"`
DurationStr string `json:"durationStr"`
}
// GetVideoDuration 获取视频时长
func GetVideoDuration(ctx context.Context, urls []string) (VideoDurationResp, error) {
apiURL := "media/video/duration"
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]
}
}
}
body := map[string]any{"video_urls": urls}
jsonData, _ := json.Marshal(body)
var resp VideoDurationResp
err := commonHttp.Post(ctx, apiURL, headers, &resp, jsonData)
if err != nil {
g.Log().Warningf(ctx, "[视频时长] 获取失败 err=%v", err)
return resp, err
}
g.Log().Infof(ctx, "[视频时长] 获取成功 count=%d totalDuration=%.2f", resp.Count, resp.TotalDuration)
return resp, nil
}
// DeductBalanceReq 扣减余额请求
type DeductBalanceReq struct {
Id uint64 `json:"id"`
Surplus float64 `json:"surplus"`
}
// DeductBalance 扣减租户余额
func DeductBalance(ctx context.Context, tenantId uint64, amount float64) error {
apiURL := "admin-go/api/v1/system/tenant/edit"
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]
}
}
}
body := DeductBalanceReq{
Id: tenantId,
Surplus: amount,
}
jsonData, _ := json.Marshal(body)
var resp struct{}
err := commonHttp.Put(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 获取租户余额
func GetTenantSurplus(ctx context.Context, tenantId uint64) (float64, error) {
apiURL := fmt.Sprintf("admin-go/api/v1/system/tenant/getTenantDetails?tenantId=%d", tenantId)
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]
}
}
}
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
}
//// callback 向回调地址 POST 任务结果(与查询接口 GetTaskRes 出参一致)
//func (s *audioTaskService) callback(ctx context.Context, taskID, status, errMsg, callbackURL string) {
// if callbackURL == "" {
+1 -1
View File
@@ -99,7 +99,7 @@ func (s *modelService) Get(ctx context.Context, req *dto.GetModelReq) (*dto.GetM
ModelName: req.ModelName,
IsChatModel: req.IsChatModel,
})
if err != nil {
if err != nil || model == nil {
return nil, err
}
return &dto.GetModelRes{
+204
View File
@@ -0,0 +1,204 @@
package service
import (
"context"
"fmt"
"model-gateway/consts/model"
"model-gateway/consts/public"
"model-gateway/dao"
"model-gateway/model/dto"
"model-gateway/model/entity"
"model-gateway/service/gateway"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var 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 = gateway.IsSuperAdmin(ctx)
if err != nil {
return
}
req.SystemModel = &isSuperAdmin
// 1)如果设为会话模型,先把该用户旧会话模型取消
err = s.CancelChatModel(ctx, req.ModelType, req.ChatModel, isSuperAdmin)
if err != nil {
return
}
// 2)插入数据
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) (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 {
if g.IsEmpty(req.ApiKey) {
return fmt.Errorf("模型apiKey不能为空")
}
d := new(dto.CreateModelManageReq)
err = gconv.Struct(req, d)
if err != nil {
return
}
_, err = s.Create(ctx, d)
if err != nil {
return err
}
return
}
return fmt.Errorf("无权限操作")
}
// 1)检查是否是超管
var isSuperAdmin bool
isSuperAdmin, err = gateway.IsSuperAdmin(ctx)
if err != nil {
return
}
// 1)如果设为会话模型,先把该用户旧会话模型取消
err = s.CancelChatModel(ctx, req.ModelType, req.ChatModel, isSuperAdmin)
if err != nil {
return
}
// 2)更新数据
_, 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 *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
}
_, 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
}
err = gconv.Struct(get, &res)
return
}
// List 获取模型列表
func (s *modelManageService) List(ctx context.Context, req *dto.ListModelManageReq) (res *dto.ListModelManageRes, err error) {
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) {
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.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
}
+194 -65
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"model-gateway/common/util"
"model-gateway/consts/public"
"model-gateway/service/queue"
"time"
"model-gateway/dao"
@@ -16,7 +15,10 @@ import (
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
"github.com/google/uuid"
)
@@ -27,13 +29,19 @@ type taskService struct{}
// Create 创建任务
func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) {
taskID := uuid.NewString()
taskID := req.TaskId
if taskID == "" {
taskID = uuid.NewString()
}
startAt := time.Now()
// 1) 检查模型配置,并且获取模型
// 1) 获取用户信息
userInfo, err := utils.GetUserInfo(ctx)
if err != nil {
return nil, err
}
// 2) 检查模型配置
model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
SQLBaseDO: beans.SQLBaseDO{
TenantId: userInfo.TenantId,
@@ -47,78 +55,196 @@ func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res *
if model == nil || (model.Enabled != nil && *model.Enabled != 1) {
return nil, errors.New("模型不存在或未启用")
}
lockKey := fmt.Sprintf("lock:tenantId-%s:model-%s", gconv.String(userInfo.TenantId), req.ModelName)
success, e := Lock(ctx, lockKey, -1, int64(time.Minute.Seconds()*5), func(ctx context.Context) error {
const (
keyExpireSec = 600 // 计数Key兜底过期时间 10min
waitInterval = 10 * time.Second // 轮询等待间隔
)
// Redis 操作统一使用独立上下文,避免外部 ctx canceled
redisCtx := context.WithoutCancel(ctx)
// 模型并发计数Key
concurrencyKey := fmt.Sprintf("model:concurrency:%s", req.ModelName)
maxCon := gconv.Int64(model.MaxConcurrency)
// 2) 排队上限(严格控制:Redis 原子闸门)
limit := queue.GetRuntimeQueueLimit(ctx, req.ModelName, model.MaxConcurrency*2)
if limit > 0 {
ok, err := queue.AcquireQueueSlot(ctx, req.ModelName, taskID, limit, model.TimeoutSeconds)
if err != nil {
return nil, err
// 循环尝试获取并发名额,超限则等待重试
var held bool // 标记当前是否持有未释放的计数
for {
// 检测全局上下文取消
if ctx.Err() != nil {
if held {
g.Redis().Decr(redisCtx, concurrencyKey)
}
return ctx.Err()
}
// 计数自增
currentCon, e := g.Redis().Incr(redisCtx, concurrencyKey)
if e != nil {
if held {
g.Redis().Decr(redisCtx, concurrencyKey)
}
glog.Errorf(ctx, "redis incr concurrency key err: %v", e)
return e
}
held = true
// 首次创建Key时设置过期时间(避免重复执行EXPIRE)
exists, errr := g.Redis().Exists(redisCtx, concurrencyKey)
if errr == nil && exists == 1 {
g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec)
}
// 未超限:跳出循环,执行业务
if currentCon <= maxCon {
glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, currentCon, maxCon)
break
}
// 超限立刻回减,撤销本次计数
g.Redis().Decr(redisCtx, concurrencyKey)
held = false
glog.Infof(ctx, "并发超限等待: %s %d/%d", concurrencyKey, currentCon, maxCon)
time.Sleep(waitInterval)
}
if !ok {
return nil, errors.New("任务排队已满,请稍后再试")
// 3) 构建任务实体
task := &entity.ModelGatewayTask{
ModelName: model.ModelName,
TaskID: taskID,
State: public.TaskStatusRunning,
BizName: req.BizName,
CallbackURL: req.CallbackUrl,
RequestPayload: &entity.RequestPayload{
Body: req.RequestPayload,
Headers: util.ParseHeadMsgHeaders(model.HeadMsg),
},
EpicycleId: req.EpicycleId,
BuildModelName: req.BuildModelName,
}
}
// 3) 插入任务记录
requestPayload := entity.RequestPayload{
Body: req.RequestPayload,
Headers: util.ParseHeadMsgHeaders(model.HeadMsg),
}
id, err := dao.ModelGatewayTask.Insert(ctx, &entity.ModelGatewayTask{
ModelName: req.ModelName,
TaskID: taskID,
State: public.TaskStatusPending,
BizName: req.BizName,
CallbackURL: req.CallbackUrl,
RequestPayload: &requestPayload,
EpicycleId: req.EpicycleId,
// 4) 插入任务记录
id, errr := dao.ModelGatewayTask.Insert(ctx, task)
if errr != nil {
g.Redis().Decr(redisCtx, concurrencyKey)
// TODO: 恢复排队逻辑后,此处需要回滚排队占位
//queue.ReleaseQueueSlot(ctx, req.ModelName, taskID)
return errr
}
task.Id = id
// 5) 记录操作日志(非关键路径,失败不影响主流程)
ip, ua := "", ""
if r := g.RequestFromCtx(ctx); r != nil {
ip = utils.GetLocalIP()
ua = r.UserAgent()
}
_, _ = dao.ModelGatewayLogsOp.Insert(ctx, &entity.ModelGatewayLogsOp{
IP: ip,
UserAgent: ua,
APIPath: "/task/createTask",
HttpMethod: "POST",
BizName: req.BizName,
ModelName: req.ModelName,
TaskID: taskID,
OpType: "createTask",
Success: 1,
CostMs: time.Since(startAt).Milliseconds(),
RequestPayload: task.RequestPayload,
ResponsePayload: gdb.Map{"taskId": taskID},
})
// 6) 模型计费
if len(model.BillingConfig) > 0 {
requestData := util.ExtractRequestBilling(ctx, model.BillingConfig, req.RequestPayload)
// 请求数据作为计费记录的基础字段,先存入数组
task.BillingData = append(task.BillingData, requestData)
_, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{
SQLBaseDO: beans.SQLBaseDO{Id: task.Id},
BillingData: task.BillingData,
})
}
// 7) 异步执行任务
go AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model, req)
return nil
})
if err != nil { // 入库失败:回滚闸门占位
queue.ReleaseQueueSlot(ctx, req.ModelName, taskID)
return nil, err
if e != nil {
err = e
return
}
// 4) 写操作日志(不影响主流程,失败忽略)
ip := ""
ua := ""
apiPath := "/task/createTask"
httpMethod := "POST"
if r := g.RequestFromCtx(ctx); r != nil {
ip = utils.GetLocalIP()
ua = r.UserAgent()
apiPath = r.URL.Path
httpMethod = r.Method
if !success {
err = gerror.New("任务排队已满,请稍后再试")
return
}
_, _ = dao.ModelGatewayLogsOp.Insert(ctx, &entity.ModelGatewayLogsOp{
IP: ip,
UserAgent: ua,
APIPath: apiPath,
HttpMethod: httpMethod,
BizName: req.BizName,
ModelName: req.ModelName,
TaskID: taskID,
OpType: "createTask",
Success: 1,
CostMs: time.Since(time.Now()).Milliseconds(),
RequestPayload: &requestPayload,
ResponsePayload: gdb.Map{
"taskId": taskID,
},
})
// 5) 获取任务信息
task, err := dao.ModelGatewayTask.ClaimByID(ctx, id)
if err != nil {
return nil, err
}
// 5) 创建成功后立即异步尝试执行当前任务
go AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model, req)
return &dto.CreateTaskRes{TaskID: taskID}, nil
}
// Lock 分布式锁 纯原生命令、无Lua、隔离上下文防 context canceled
func Lock(ctx context.Context, key string, limit, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) {
if limit <= 0 {
limit = -1
}
// 过期时间合法校验(单位:秒)
const maxExpireSec = 86400 * 7
if expireSeconds < 1 || expireSeconds > maxExpireSec {
glog.Warningf(ctx, "锁过期时间非法,原值:%d,兜底为60秒", expireSeconds)
expireSeconds = 60
}
lockVal := "1"
LOOP:
// 检测父级上下文取消,防止无限重试阻塞 goroutine
if ctx.Err() != nil {
return false, ctx.Err()
}
if limit != -1 {
if limit < 0 {
return false, errors.New("锁重试次数耗尽,获取锁失败")
}
limit--
}
// 核心:创建独立上下文,不受外部 ctx 取消影响
redisCtx := context.WithoutCancel(ctx)
// 加锁
val, err := g.Redis().Set(redisCtx, key, lockVal, gredis.SetOption{
TTLOption: gredis.TTLOption{
EX: &expireSeconds,
},
NX: true,
})
if err != nil {
glog.Errorf(ctx, "redis set lock failed: %v", err)
time.Sleep(time.Second)
goto LOOP
}
if val.Bool() {
// 执行业务逻辑(使用原上下文)
runErr := fn(ctx)
// 释放锁:同样使用独立上下文 + 先GET再DEL防误删
getRes, err := g.Redis().Get(redisCtx, key)
if err != nil {
glog.Errorf(ctx, "redis get lock value failed: %v", err)
} else if getRes.String() == lockVal {
_, delErr := g.Redis().Del(redisCtx, key)
if delErr != nil {
glog.Errorf(ctx, "redis del lock failed: %v", delErr)
}
}
return true, runErr
}
// 抢锁失败,休眠重试
time.Sleep(time.Second)
goto LOOP
}
// GetResult 获取任务结果
func (s *taskService) GetResult(ctx context.Context, taskID string) (res *dto.GetTaskResultRes, err error) {
t, err := dao.ModelGatewayTask.Get(ctx, &entity.ModelGatewayTask{
@@ -255,7 +381,10 @@ func (s *taskService) QueryPendingTasks(ctx context.Context, req *dto.QueryPendi
if err != nil || model == nil || model.QueryConfig == nil {
continue
}
result, err := util.PullTaskResult(ctx, nil, model.QueryConfig, model.HeadMsg)
// 每个任务使用独立的超时上下文,防止单个任务阻塞整个轮询
pullCtx, pullCancel := context.WithTimeout(ctx, 30*time.Second)
result, err := util.PullTaskResult(pullCtx, nil, model.QueryConfig, model.HeadMsg)
pullCancel()
if err != nil {
g.Log().Warningf(ctx, "[轮询] 查询失败 taskID=%s err=%v", t.TaskID, err)
continue
+264 -206
View File
@@ -6,22 +6,19 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"unicode/utf8"
"model-gateway/common/util"
"model-gateway/consts/public"
"model-gateway/dao"
"model-gateway/model/dto"
"model-gateway/model/entity"
"model-gateway/service/gateway"
"model-gateway/service/queue"
"net/http"
"strings"
"sync"
"time"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
@@ -38,73 +35,125 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa
body = task.RequestPayload.Body
maxRetry = model.RetryTimes
startTime = time.Now()
rawData []byte
result map[string]any
err error
surplus float64
)
g.Log().Infof(ctx, "[执行任务][开始] taskId=%s model=%s", task.TaskID, task.ModelName)
g.Log().Infof(ctx, "[handleOne] 开始 taskId=%s model=%s", task.TaskID, task.ModelName)
// ============================================
// 1) 分布式并发控制
// 1) 查询余额
// ============================================
semKey := fmt.Sprintf("asynch:sem:%s", task.ModelName)
maxC := queue.GetRuntimeMaxConcurrency(ctx, task.ModelName, model.MaxConcurrency)
acquired, err := queue.AcquireSemaphore(ctx, semKey, maxC, 3600)
if err != nil {
task.DurationSeconds = int64(time.Since(startTime).Seconds())
w.failTask(ctx, task, startTime, err.Error())
surplus, _ = gateway.GetTenantSurplus(ctx, model.TenantId)
if surplus <= 200 {
w.failTask(ctx, task, startTime, "租户余额不足")
return
}
if !acquired {
_, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{
SQLBaseDO: beans.SQLBaseDO{Id: task.Id},
State: public.TaskStatusPending,
})
g.Log().Infof(ctx, "[执行任务][排队] 并发已满,放回队列 taskId=%s", task.TaskID)
return
}
defer func() { _ = queue.ReleaseSemaphore(ctx, semKey) }()
g.Log().Infof(ctx, "[handleOne] 当前余额 tenantId=%d surplus=%.2f", model.TenantId, surplus)
// ============================================
// 2) 调用模型
// ============================================
switch {
case model.CallMode != nil && *model.CallMode == public.CallModeStream:
rawBytes, streamErr := w.callModelStream(ctx, task, model, body)
if streamErr != nil {
w.failTask(ctx, task, startTime, streamErr.Error())
for attempt := 0; ; attempt++ {
if attempt > 0 {
g.Log().Infof(ctx, "[handleOne] 调模型重试 第%d次 taskId=%s", attempt, task.TaskID)
time.Sleep(time.Duration(attempt) * 5 * time.Second)
}
rawData, err = InvokeModel(ctx, model, body)
switch {
case model.CallMode != nil && *model.CallMode == public.CallModeStream:
if err == nil {
result, err = util.ParseStreamResponse(rawData, model.StreamConfig)
}
case model.CallMode != nil && *model.CallMode == public.CallModeAsync:
if err == nil {
result = gjson.New(string(rawData)).Map()
result, err = util.PullTaskResult(ctx, result, model.QueryConfig, model.HeadMsg)
}
default:
if err == nil {
result = gjson.New(string(rawData)).Map()
}
}
if err == nil {
break
}
// 模型调用失败
if !strings.Contains(err.Error(), "Timeout") &&
!strings.Contains(err.Error(), "RequestCanceled") &&
!strings.Contains(err.Error(), "InternalServiceError") &&
!strings.Contains(err.Error(), "Invalid video_url") &&
!strings.Contains(err.Error(), "Invalid audio track") &&
!strings.Contains(err.Error(), "Error while downloading") &&
!strings.Contains(err.Error(), "Error while connecting") &&
!strings.Contains(err.Error(), "download failed") {
w.failTask(ctx, task, startTime, err.Error())
return
}
result, err = util.ParseStreamResponse(rawBytes, model.StreamConfig)
case model.CallMode != nil && *model.CallMode == public.CallModeAsync:
result, err = w.callModel(ctx, task, model, body)
if err == nil {
result, err = util.PullTaskResult(ctx, result, model.QueryConfig, model.HeadMsg)
g.Log().Warningf(ctx, "[handleOne] 调模型失败 taskId=%s attempt=%d err=%v", task.TaskID, attempt, err)
}
// ============================================
// 3) 解析返回映射 + 存储 token 相关信息
// ============================================
mapped, err := util.MapResponsePayload(model.ResponseMapping, result)
if err != nil {
w.failTask(ctx, task, startTime, err.Error())
return
}
// 计费处理
if len(model.BillingConfig) > 0 && len(task.BillingData) > 0 {
// 取请求阶段数据作为基础
billingInput := make(map[string]any)
for k, v := range task.BillingData[0] {
billingInput[k] = v
}
// 补充返回数据
responseData := util.ExtractResponseBilling(model.BillingConfig, mapped)
for k, v := range responseData {
billingInput[k] = v
}
// 计算费用,替换数组第一个元素
billingResult := util.CalculateBilling(model.BillingConfig, billingInput)
if billingResult != nil {
task.BillingData[0] = billingResult
}
if billingResult != nil {
task.BillingData[0] = billingResult
totalFee := gconv.Float64(billingResult["total_fee"])
if totalFee > 0 {
for attempt := 0; attempt <= maxRetry; attempt++ {
err = gateway.DeductBalance(util.AsyncCtx(ctx), model.TenantId, -totalFee)
if err == nil {
break
}
g.Log().Warningf(ctx, "[handleOne] 扣除余额失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err)
}
}
}
default:
result, err = w.callModel(ctx, task, model, body)
}
if err != nil {
w.failTask(ctx, task, startTime, err.Error())
task.ExpendTokens = gconv.Int64(mapped[entity.TotalTokens])
if _, err = dao.ModelGatewayTask.Update(ctx, task); err != nil {
g.Log().Errorf(ctx, "[handleOne] 更新DB失败 taskId=%s err=%v", task.TaskID, err)
return
}
// ============================================
// 3) 缓存临时文件
// 4) 处理提示词相关数据解析涵盖重试
// ============================================
if tmpPath, tmpErr := util.SaveTempFileByType(task.TaskID, result, task.TmpFile); tmpErr == nil && tmpPath != "" {
task.TmpFile = tmpPath
task.Phase = 1
_, _ = dao.ModelGatewayTask.Update(ctx, task)
}
// ============================================
// 4) 解析校验 + 响应映射(可重试)
// ============================================
result, err = w.parseAndRetry(ctx, result, task, model, req, maxRetry, startTime)
if err != nil {
task.TextResult = result
w.failTask(ctx, task, startTime, err.Error())
return
if req.BuildType == public.BuildTypePrompt {
mapped, err = w.parseAndRetry(ctx, mapped, model, task, maxRetry)
if err != nil {
task.TextResult = mapped
w.failTask(ctx, task, startTime, err.Error())
return
}
}
// ============================================
@@ -113,18 +162,14 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa
var oss *gateway.UploadFileResponse
for attempt := 0; attempt <= maxRetry; attempt++ {
if attempt > 0 {
g.Log().Infof(ctx, "[执行任务][重试] OSS上传 第%d/%d次 taskId=%s", attempt, maxRetry, task.TaskID)
g.Log().Infof(ctx, "[handleOne] OSS上传重试 第%d/%d次 taskId=%s", attempt, maxRetry, task.TaskID)
}
oss, err = gateway.UploadByTask(ctx, gjson.New(result).MustToJson(), "json")
oss, err = gateway.UploadByTask(ctx, gjson.New(mapped).MustToJson(), "json")
if err == nil {
break
}
g.Log().Errorf(ctx, "[执行任务][失败] OSS上传失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err)
g.Log().Errorf(ctx, "[handleOne] OSS上传失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err)
if attempt == maxRetry {
task.State = public.TaskStatusFailed
task.ErrorMsg = err.Error()
task.Phase = 1
_, _ = dao.ModelGatewayTask.Update(ctx, task)
w.failTask(ctx, task, startTime, fmt.Sprintf("OSS上传重试耗尽: %v", err))
return
}
@@ -140,53 +185,20 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa
FileType: oss.FileFormat,
FileSize: int64(oss.FileSize),
}
task.TextResult = result
task.TextResult = mapped
if _, err = dao.ModelGatewayTask.Update(ctx, task); err != nil {
g.Log().Errorf(ctx, "[执行任务][失败] 更新数据库失败 taskId=%s err=%v", task.TaskID, err)
g.Log().Errorf(ctx, "[handleOne] 更新DB失败 taskId=%s err=%v", task.TaskID, err)
return
}
queue.ReleaseQueueSlot(ctx, task.ModelName, task.TaskID)
go gateway.TriggerCallback(context.WithoutCancel(ctx), task)
concurrencyKey := fmt.Sprintf("model:concurrency:%s", req.ModelName)
g.Redis().Decr(ctx, concurrencyKey)
gateway.TriggerCallback(ctx, task)
if req.EpicycleId != 0 {
go gateway.TriggerPromptsCallback(context.WithoutCancel(ctx), task, req.EpicycleId)
gateway.TriggerPromptsCallback(ctx, task, req.EpicycleId)
}
g.Log().Infof(ctx, "[执行任务][成功] taskId=%s duration=%ds fileType=%s",
g.Log().Infof(ctx, "[handleOne] 成功 taskId=%s duration=%ds fileType=%s",
task.TaskID, task.DurationSeconds, oss.FileFormat)
_ = os.Remove(task.TmpFile)
}
// callModelStream 调用模型,返回原始字节(不做响应映射,用于流式输出)
func (w *asyncWorker) callModelStream(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, body map[string]any) ([]byte, error) {
var data []byte
var err error
if task.Phase == 1 && strings.TrimSpace(task.TmpFile) != "" {
data, err = os.ReadFile(task.TmpFile)
if err != nil || len(data) == 0 {
data = nil
}
}
if data == nil {
data, err = InvokeModel(ctx, model, body)
if err != nil {
return nil, err
}
tmpPath, tmpErr := util.SaveTmpResult(task.TaskID, data, "")
if tmpErr == nil && tmpPath != "" {
task.TmpFile = tmpPath
task.Phase = 1
_, err = dao.ModelGatewayTask.Update(ctx, task)
if err != nil {
g.Log().Errorf(ctx, "[执行任务][失败] 临时文件保存失败 taskId=%s err=%v", task.TaskID, tmpErr)
}
}
}
return data, nil
}
// asyncResult 异步任务结果
@@ -200,12 +212,13 @@ var asyncTaskChan = sync.Map{} // taskID → chan asyncResult
func (w *asyncWorker) callModelAsync(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, body map[string]any) (map[string]any, error) {
// 1. 提交异步任务
body, err := w.callModel(ctx, task, model, body)
rawData, err := InvokeModel(ctx, model, body)
if err != nil {
return nil, err
}
body = gjson.New(string(rawData)).Map()
// 2. 拿到 task_id
taskID := gjson.New(body).Get(model.ResponseBody).String()
taskID := gjson.New(body).Get(entity.ResponseBody).String()
// 3. 创建等待通道
ch := make(chan asyncResult, 1)
@@ -241,135 +254,170 @@ func NotifyAsyncResult(taskID string, result map[string]any, err error) {
}
}
// callModel 调用模型 + 检测文件类型 + 保存临时文件
// 返回: 解析后的响应体, error
func (w *asyncWorker) callModel(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, body map[string]any) (map[string]any, error) {
var data []byte
var err error
// 1) 如果已有临时文件且 phase=1,直接读取
if task.Phase == 1 && strings.TrimSpace(task.TmpFile) != "" {
data, err = os.ReadFile(task.TmpFile)
if err != nil || len(data) == 0 {
g.Log().Warningf(ctx, "[callModel] 读取临时文件失败,重新调用模型 taskId=%s err=%v", task.TaskID, err)
data = nil
}
}
// 2) 没有可用数据,调用模型
if data == nil {
data, err = InvokeModel(ctx, model, body)
if err != nil {
return nil, err
}
// 3) 检测文件类型,保存临时文件
_, ext := util.DetectFileType(data)
tmpPath, tmpErr := util.SaveTmpResult(task.TaskID, data, ext)
if tmpErr == nil && tmpPath != "" {
task.TmpFile = tmpPath
task.Phase = 1
_, err = dao.ModelGatewayTask.Update(ctx, task)
if err != nil {
g.Log().Errorf(ctx, "[执行任务][失败] 临时文件保存失败 taskId=%s err=%v", task.TaskID, tmpErr)
}
}
}
// 4) 检测文件类型,提取文本结果
contentType, _ := util.DetectFileType(data)
var textResult string
if utf8.Valid(data) && (strings.HasPrefix(contentType, "text/") || contentType == "application/json") {
textResult = string(data)
}
// 5) 非文本内容,返回错误
if textResult == "" {
return nil, fmt.Errorf("模型返回非文本内容,contentType=%s", contentType)
}
// 6) 解析并返回
return gjson.New(textResult).Map(), nil
}
//// callModel 调用模型 + 提取文本结果
//func (w *asyncWorker) callModel(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, body map[string]any) (map[string]any, error) {
// data, err := InvokeModel(ctx, model, body)
// if err != nil {
// return nil, err
// }
// contentType, _ := util.DetectFileType(data)
// var textResult string
// if utf8.Valid(data) && (strings.HasPrefix(contentType, "text/") || contentType == "application/json") {
// textResult = string(data)
// }
//
// if textResult == "" {
// return nil, fmt.Errorf("模型返回非文本内容,contentType=%s", contentType)
// }
//
// return gjson.New(textResult).Map(), nil
//}
// parseAndRetry 解析模型返回结果,并重试
func (w *asyncWorker) parseAndRetry(ctx context.Context, body map[string]any, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, req *dto.CreateTaskReq, maxRetry int, startTime time.Time) (map[string]any, error) {
func (w *asyncWorker) parseAndRetry(ctx context.Context, body map[string]any, model *entity.ModelGatewayModel, task *entity.ModelGatewayTask, maxRetry int) (map[string]any, error) {
// 获取构建模型的必填字段
user, err := utils.GetUserInfo(ctx)
if err != nil {
return nil, err
}
buildModel, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
SQLBaseDO: beans.SQLBaseDO{TenantId: user.TenantId, Creator: user.UserName},
ModelName: task.BuildModelName,
})
if err != nil {
return nil, err
}
var lastErr error
for attempt := 0; attempt <= maxRetry; attempt++ {
if attempt > 0 {
g.Log().Infof(ctx, "[执行任务][重试] JSON解析 第%d/%d次 taskId=%s", attempt, maxRetry, task.TaskID)
}
// 1) 响应映射
mapped, err := util.MapResponsePayload(model.ResponseMapping, body)
if err != nil {
g.Log().Warningf(ctx, "[执行任务][映射失败] taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err)
if attempt == maxRetry {
return nil, fmt.Errorf("响应映射重试耗尽: %w", err)
}
continue
}
// 2) 先存 token 到数据库,防止后续失败丢失
if _, ok := mapped[model.ResponseTokenField]; ok {
task.ExpendTokens = gconv.Int64(mapped[model.ResponseTokenField])
_, err = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{
SQLBaseDO: beans.SQLBaseDO{Id: task.Id},
ExpendTokens: task.ExpendTokens,
})
}
// 3) 解析 + 校验
var parsed map[string]any
switch req.BuildType {
case public.BuildTypePrompt, public.BuildTypeNode:
parsed, err = util.ParseAndValidate(mapped, model)
if err == nil {
return parsed, nil
}
case public.BuildTypeStruct:
parsed = util.ParseStructResult(mapped, model.ResponseBody)
// 解析 + 校验(用构建模型的 RequiredFields
parsed, err := util.ParseAndValidate(body, buildModel.RequiredFields)
if err == nil {
return parsed, nil
default:
return mapped, nil
}
lastErr = err
g.Log().Warningf(ctx, "[执行任务][解析失败] taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err)
if attempt == maxRetry {
return nil, fmt.Errorf("JSON解析重试耗尽: %w", err)
return nil, fmt.Errorf("JSON解析重试耗尽: %w", lastErr)
}
// 4) 重新调模型(直接调,不走缓存)
// 重试:重新调模型
task.RetryCount++
_, _ = dao.ModelGatewayTask.Update(ctx, task)
rawData, callErr := InvokeModel(ctx, model, task.RequestPayload.Body)
reqBody := injectErrorMessage(task.RequestPayload.Body, lastErr)
rawData, callErr := InvokeModel(ctx, model, reqBody)
if callErr != nil {
g.Log().Warningf(ctx, "[执行任务][重调模型失败] taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, callErr)
continue
}
// 5) 解析原始响应,覆盖 body 进入下一轮
var rawResp map[string]any
if err = json.Unmarshal(rawData, &rawResp); err != nil {
if err := json.Unmarshal(rawData, &rawResp); err != nil {
g.Log().Warningf(ctx, "[执行任务][Unmarshal失败] taskId=%s err=%v", task.TaskID, err)
continue
}
body = rawResp
mapped, mapErr := util.MapResponsePayload(model.ResponseMapping, rawResp)
if mapErr != nil {
g.Log().Warningf(ctx, "[执行任务][映射失败] taskId=%s err=%v", task.TaskID, mapErr)
continue
}
// 计费
if len(model.BillingConfig) > 0 && len(task.BillingData) > 0 {
requestData := task.BillingData[0]
retryData := make(map[string]any)
for k, v := range requestData {
retryData[k] = v
}
responseData := util.ExtractResponseBilling(model.BillingConfig, mapped)
for k, v := range responseData {
retryData[k] = v
}
billingResult := util.CalculateBilling(model.BillingConfig, retryData)
if billingResult != nil {
task.BillingData = append(task.BillingData, billingResult)
totalFee := gconv.Float64(billingResult["total_fee"])
if totalFee > 0 {
for a := 0; a <= maxRetry; a++ {
errr := gateway.DeductBalance(util.AsyncCtx(ctx), task.TenantId, -totalFee)
if errr == nil {
break
}
g.Log().Warningf(ctx, "[handleOne] 扣除余额失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, a, maxRetry, errr)
}
}
}
task.ExpendTokens += gconv.Int64(mapped[entity.TotalTokens])
_, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{
SQLBaseDO: beans.SQLBaseDO{Id: task.Id},
BillingData: task.BillingData,
ExpendTokens: task.ExpendTokens,
})
}
body = mapped
}
return body, nil
}
// injectErrorMessage 将错误信息插入到最后一个 user 消息之前
func injectErrorMessage(payload map[string]any, err error) map[string]any {
if err == nil {
return payload
}
messages, _ := payload["messages"].([]any)
if len(messages) == 0 {
return payload
}
errMsg := fmt.Sprintf("【上一轮输出错误,请修正】%s", err.Error())
// 找到最后一个 user 的位置
lastUserIdx := -1
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
if gconv.String(msg["role"]) == "user" {
lastUserIdx = i
break
}
}
if lastUserIdx == -1 {
return payload
}
// 在最后一个 user 之前插入错误消息
errMsgObj := map[string]any{
"role": "user",
"content": []map[string]any{{"type": "text", "text": errMsg}},
}
// 切片插入
messages = append(messages[:lastUserIdx], append([]any{errMsgObj}, messages[lastUserIdx:]...)...)
payload["messages"] = messages
return payload
}
// InvokeModel 调用模型服务,返回二进制结果
// modelKey 用于覆盖/补充模型配置 head_msg(例如每次请求携带不同的 X-API-Key)
func InvokeModel(ctx context.Context, model *entity.ModelGatewayModel, body map[string]any) ([]byte, error) {
// 1) 记录模型调用次数
_ = dao.ModelGatewayLogsStat.IncRequestCount(ctx, time.Now(), model.TenantId, model.Creator, model.ModelName)
// 2)请求参数映射:将标准 payload 按模型配置的 requestMapping 转为模型需要的格式
//—— 请求映射实际处理为提示词构建请求,因为有附加字段及其他字段的拼接。这里不方便做请求映射
//mappedPayload := util.ReverseMap(model.RequestMapping, payload)
//surplus, _ := gateway.GetTenantSurplus(ctx, model.TenantId)
//if surplus <= 0 {
// return nil, fmt.Errorf("租户余额不足")
//}
// 3)构建请求 URL 和超时
baseURL := strings.TrimRight(model.BaseURL, "/")
@@ -392,13 +440,20 @@ func InvokeModel(ctx context.Context, model *entity.ModelGatewayModel, body map[
baseURL = baseURL + "?" + q.Encode()
}
}
req, err = http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil)
// 改用独立超时ctx,隔绝外层截止
reqCtx, reqCancel := context.WithTimeout(context.Background(), timeout)
defer reqCancel()
req, err = http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL, nil)
//req, err = http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil)
default:
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(bodyBytes))
reqCtx, reqCancel := context.WithTimeout(context.Background(), timeout)
defer reqCancel()
req, err = http.NewRequestWithContext(reqCtx, http.MethodPost, baseURL, bytes.NewReader(bodyBytes))
//req, err = http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(bodyBytes))
}
// 5)注入请求头:先模型静态配置,再动态 modelKey(后者可覆盖前者)
@@ -430,6 +485,11 @@ func InvokeModel(ctx context.Context, model *entity.ModelGatewayModel, body map[
msg := string(b)
return nil, fmt.Errorf("模型服务返回非2xx: %d, body=%s", resp.StatusCode, msg)
}
//
//
g.Log().Debugf(ctx, "[执行任务][模型调用成功] StatusCode=%v", resp.StatusCode)
return b, nil
}
@@ -487,15 +547,13 @@ func InvokeModel(ctx context.Context, model *entity.ModelGatewayModel, body map[
// return mappedResponse, nil
// }
// failTask 任务失败统一处理:更新数据库 + 释放排队 + 回调
// failTask 任务失败统一处理
func (w *asyncWorker) failTask(ctx context.Context, t *entity.ModelGatewayTask, startTime time.Time, errMsg string) {
t.State = 3
t.ErrorMsg = errMsg
t.DurationSeconds = int64(time.Since(startTime).Seconds())
_, err := dao.ModelGatewayTask.Update(ctx, t)
if err != nil {
g.Log().Warningf(ctx, "[执行任务][更新数据库失败] taskId=%s err=%v", t.TaskID, err)
}
queue.ReleaseQueueSlot(ctx, t.ModelName, t.TaskID)
go gateway.TriggerCallback(context.WithoutCancel(ctx), t)
concurrencyKey := fmt.Sprintf("model:concurrency:%s", t.ModelName)
g.Redis().Decr(ctx, concurrencyKey)
_, _ = dao.ModelGatewayTask.Update(ctx, t) // 更新任务状态
go gateway.TriggerCallback(util.AsyncCtx(ctx), t) // 触发回调
}
+119 -1
View File
@@ -230,4 +230,122 @@ COMMENT ON COLUMN model_gateway_logs_op.success IS '是否成功:1成功/0失
COMMENT ON COLUMN model_gateway_logs_op.error_msg IS '错误信息(失败时)';
COMMENT ON COLUMN model_gateway_logs_op.cost_ms IS '耗时(毫秒)';
COMMENT ON COLUMN model_gateway_logs_op.request_payload IS '请求 JSON';
COMMENT ON COLUMN model_gateway_logs_op.response_payload IS '响应 JSON';
COMMENT ON COLUMN model_gateway_logs_op.response_payload IS '响应 JSON';
-- =========================================================================================================================
CREATE TABLE "public"."model_gateway_model_manage" (
"id" int8 NOT NULL,
"tenant_id" int8 NOT NULL DEFAULT 0,
"creator" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" timestamp(6),
"model_supplier" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
"model_name" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
"model_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
"base_url" varchar(512) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
"system_model" bool,
"http_method" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
"chat_model" bool,
"response_type" int2 NOT NULL DEFAULT 0,
"api_key" varchar(255) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
"enabled" bool,
"request_head_mapping" jsonb DEFAULT '{}'::jsonb,
"request_body_mapping" jsonb DEFAULT '{}'::jsonb,
"response_mapping" jsonb DEFAULT '{}'::jsonb,
"max_concurrency" int4 NOT NULL DEFAULT 0,
"token_mapping" jsonb,
"async_task_mapping" jsonb,
"token_predict_price" numeric(12,6) NOT NULL DEFAULT 0.000000,
"max_tokens" int4 NOT NULL DEFAULT 0,
"last_frame" varchar(512) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
"response_body_mapping" jsonb DEFAULT '{}'::jsonb,
"token_predict_price_unit" varchar(32) COLLATE "pg_catalog"."default",
"max_duration" int4,
CONSTRAINT "model_gateway_model_manage_pkey" PRIMARY KEY ("id")
)
;
ALTER TABLE "public"."model_gateway_model_manage"
OWNER TO "postgres";
CREATE INDEX "idx_model_manage_deleted_at" ON "public"."model_gateway_model_manage" USING btree (
"deleted_at" "pg_catalog"."timestamp_ops" ASC NULLS LAST
);
CREATE INDEX "idx_model_manage_model_type" ON "public"."model_gateway_model_manage" USING btree (
"model_type" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
);
CREATE INDEX "idx_model_manage_response_type" ON "public"."model_gateway_model_manage" USING btree (
"response_type" "pg_catalog"."int2_ops" ASC NULLS LAST
);
CREATE INDEX "idx_model_manage_supplier" ON "public"."model_gateway_model_manage" USING btree (
"model_supplier" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
);
CREATE INDEX "idx_model_manage_tenant_id" ON "public"."model_gateway_model_manage" USING btree (
"tenant_id" "pg_catalog"."int8_ops" ASC NULLS LAST
);
COMMENT ON COLUMN "public"."model_gateway_model_manage"."id" IS '主键ID';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."tenant_id" IS '租户ID';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."creator" IS '创建人';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."created_at" IS '创建时间';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."updater" IS '更新人';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."updated_at" IS '更新时间';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."deleted_at" IS '删除时间(软删)';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."model_supplier" IS '模型供应商';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."model_name" IS '模型名称';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."model_type" IS '模型类型';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."base_url" IS '模型地址';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."system_model" IS '是否系统模型';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."http_method" IS 'http请求方法';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."chat_model" IS '是否聊天模型';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."response_type" IS '返回类型:1同步,2异步,3流';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."api_key" IS '接口密钥';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."enabled" IS '是否启用';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."request_head_mapping" IS '请求头映射';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."request_body_mapping" IS '请求体映射';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."response_mapping" IS '响应映射';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."max_concurrency" IS '最大并发数';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."token_mapping" IS 'token映射';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."async_task_mapping" IS '异步任务映射';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."token_predict_price" IS '模型Token预估价格';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."max_tokens" IS '最大token数';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."last_frame" IS '视频尾帧图像地址';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."response_body_mapping" IS '响应主体映射';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."token_predict_price_unit" IS '模型token预估价格单位(秒,百万Token,千Token,字数)';
COMMENT ON COLUMN "public"."model_gateway_model_manage"."max_duration" IS '最大时长(秒)';
COMMENT ON TABLE "public"."model_gateway_model_manage" IS '模型管理表';