Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8a0b0c2f4 | ||
|
|
e5bba51b50 | ||
|
|
8598b63aa8 | ||
|
|
0dde11dbdf | ||
|
|
31bb973bc0 | ||
|
|
2d93b0f5ad | ||
|
|
94d408074a | ||
|
|
ea949a4b66 | ||
|
|
21447b67db | ||
|
|
a68b7b1017 | ||
|
|
bd649cb527 | ||
|
|
809c07ba3b | ||
|
|
a454826433 | ||
|
|
a356803809 | ||
|
|
e78b914b0b | ||
|
|
1ca25abca0 | ||
|
|
b663149977 | ||
|
|
e5f205e6e1 | ||
|
|
49c1cb1f42 | ||
|
|
db91d098bf | ||
|
|
923644db2b | ||
|
|
7dcfb6744d | ||
|
|
b165ef4f3e | ||
|
|
58d9891205 | ||
|
|
6cff7a934b | ||
|
|
c2758b3010 | ||
|
|
66f1d1020f | ||
|
|
b99f43b31f | ||
|
|
bc78ad2b2f | ||
|
|
4afb920456 | ||
|
|
928682f121 | ||
|
|
4a9ae2d412 | ||
|
|
e079f4ea28 | ||
|
|
657c53142c | ||
|
|
174eb7cf27 | ||
|
|
cb9d04648c | ||
|
|
9d9bd71468 | ||
|
|
76c55fbb73 | ||
|
|
55c797dd96 | ||
|
|
445ee02c5a |
@@ -1 +0,0 @@
|
||||
.git
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
/.idea/*
|
||||
/.idea/*
|
||||
/.superpowers/
|
||||
/docs/superpowers/
|
||||
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
# 阶段1: 构建
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
|
||||
apk add --no-cache git ca-certificates tzdata
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package util
|
||||
|
||||
import "github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
// ConvertTo 转换为指定类型
|
||||
func ConvertTo[T any](v interface{}) *T {
|
||||
var t T
|
||||
_ = gconv.Struct(v, &t)
|
||||
return &t
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DetectFileType 根据返回的二进制内容推断 contentType + 扩展名
|
||||
func DetectFileType(data []byte) (contentType string, ext string) {
|
||||
if len(data) == 0 {
|
||||
return "application/octet-stream", ".bin"
|
||||
}
|
||||
|
||||
ct := http.DetectContentType(data)
|
||||
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:
|
||||
if parts := strings.Split(ct, "/"); len(parts) == 2 {
|
||||
sub := parts[1]
|
||||
if idx := strings.Index(sub, ";"); idx > 0 {
|
||||
sub = strings.TrimSpace(sub[:idx])
|
||||
}
|
||||
return ct, "." + sub
|
||||
}
|
||||
return ct, ".bin"
|
||||
}
|
||||
}
|
||||
|
||||
// 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 "", 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 "", fmt.Errorf("写入临时文件失败: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// AsyncCtx 固化异步上下文中的 token 和用户信息,避免请求结束后丢失
|
||||
func AsyncCtx(ctx context.Context) context.Context {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
if token := r.Header.Get("Authorization"); token != "" {
|
||||
asyncCtx = context.WithValue(asyncCtx, "token", token)
|
||||
}
|
||||
if userInfo := r.Header.Get("X-User-Info"); userInfo != "" {
|
||||
asyncCtx = context.WithValue(asyncCtx, "xUserInfo", userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
if user, err := utils.GetUserInfo(ctx); err == nil && user != nil {
|
||||
asyncCtx = context.WithValue(asyncCtx, "user", user)
|
||||
}
|
||||
|
||||
return asyncCtx
|
||||
}
|
||||
|
||||
// ForwardHeaders 透传调用链路的头信息,优先使用 ctx 中的固化值
|
||||
func ForwardHeaders(ctx context.Context) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
SetHeaderFromContext(headers, ctx, "Authorization", "token")
|
||||
SetHeaderFromContext(headers, ctx, "X-User-Info", "xUserInfo")
|
||||
FallbackToRequestHeaders(headers, ctx)
|
||||
return headers
|
||||
}
|
||||
|
||||
// SetHeaderFromContext 从上下文中设置 header
|
||||
func SetHeaderFromContext(headers map[string]string, ctx context.Context, headerKey, ctxKey string) {
|
||||
if value, ok := ctx.Value(ctxKey).(string); ok && value != "" {
|
||||
headers[headerKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
// FallbackToRequestHeaders 从请求头中获取作为兜底
|
||||
func FallbackToRequestHeaders(headers map[string]string, ctx context.Context) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if headers["Authorization"] == "" {
|
||||
if token := r.Header.Get("Authorization"); token != "" {
|
||||
headers["Authorization"] = token
|
||||
}
|
||||
}
|
||||
|
||||
if headers["X-User-Info"] == "" {
|
||||
if userInfo := r.Header.Get("X-User-Info"); userInfo != "" {
|
||||
headers["X-User-Info"] = userInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetTaskHeadersToCtx 把任务入库时保存的 header 信息注入 ctx,给 worker 调 OSS 用
|
||||
func SetTaskHeadersToCtx(ctx context.Context, headers map[string]string) context.Context {
|
||||
if headers == nil {
|
||||
return ctx
|
||||
}
|
||||
if v := gconv.String(headers["Authorization"]); v != "" {
|
||||
ctx = context.WithValue(ctx, "token", v)
|
||||
}
|
||||
if v := gconv.String(headers["X-User-Info"]); v != "" {
|
||||
ctx = context.WithValue(ctx, "xUserInfo", v)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"model-gateway/model/entity"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
tgjson "github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return raw, fmt.Errorf("rounds[%d] 不是合法JSON对象", i)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ParseStructResult 解析结构结果
|
||||
func ParseStructResult(raw map[string]any, responseBody string) map[string]any {
|
||||
contentVal := raw[responseBody]
|
||||
// 是字符串,尝试解析
|
||||
contentStr := gconv.String(contentVal)
|
||||
if contentStr == "" || contentStr == "0" {
|
||||
return map[string]any{
|
||||
"total_rounds": 1,
|
||||
"rounds": []map[string]any{{responseBody: raw}},
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试解析为数组
|
||||
var arr []any
|
||||
if err := json.Unmarshal([]byte(contentStr), &arr); err == nil && len(arr) > 0 {
|
||||
return map[string]any{
|
||||
"total_rounds": 1,
|
||||
"rounds": []map[string]any{{responseBody: arr}},
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试解析为单个对象
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(contentStr), &parsed); err == nil {
|
||||
return map[string]any{
|
||||
"total_rounds": 1,
|
||||
"rounds": []map[string]any{{responseBody: parsed}},
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:原始字符串作为内容
|
||||
return map[string]any{
|
||||
"total_rounds": 1,
|
||||
"rounds": []map[string]any{{responseBody: contentStr}},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseHeadMsgHeaders 从 head_msg JSON 中提取请求头
|
||||
// head_msg 格式示例:
|
||||
//
|
||||
// {
|
||||
// "Authorization": "Bearer xxx",
|
||||
// "Content-Type": "application/json",
|
||||
// "X-Api-App-Id": "5147401364",
|
||||
// "X-Api-Access-Key": "VCqRX7..."
|
||||
// }
|
||||
func ParseHeadMsgHeaders(headMsg map[string]any) map[string]string {
|
||||
if len(headMsg) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(headMsg))
|
||||
for k, v := range headMsg {
|
||||
out[k] = gconv.String(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MapResponsePayload 映射模型响应为标准格式
|
||||
func MapResponsePayload(mapping map[string]any, result map[string]any) (map[string]any, error) {
|
||||
if len(mapping) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 把 result 转成 JSON 字符串,tidwall/gjson 需要字符串输入
|
||||
resultBytes, _ := json.Marshal(result)
|
||||
resultStr := string(resultBytes)
|
||||
|
||||
mapped := make(map[string]any)
|
||||
|
||||
for standardField, modelPath := range mapping {
|
||||
path := gconv.String(modelPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value := tgjson.Get(resultStr, path)
|
||||
if !value.Exists() {
|
||||
continue
|
||||
}
|
||||
// 如果是数组路径(含 #),取 Array;否则取单值
|
||||
if strings.Contains(path, "#") {
|
||||
var arr []any
|
||||
for _, v := range value.Array() {
|
||||
arr = append(arr, v.Value())
|
||||
}
|
||||
mapped[standardField] = arr
|
||||
} else {
|
||||
mapped[standardField] = value.Value()
|
||||
}
|
||||
}
|
||||
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
//
|
||||
//// GetModelBody 获取数据库中保存的模型信息
|
||||
//func GetModelBody(v map[string]any) map[string]any {
|
||||
// if v == nil {
|
||||
// return nil
|
||||
// }
|
||||
// if p, ok := v["body"]; ok {
|
||||
// return gconv.Map(p)
|
||||
// }
|
||||
// return v
|
||||
//}
|
||||
|
||||
// BodyToQuery 将 body 转为 url.Values
|
||||
func BodyToQuery(payload map[string]any) (url.Values, error) {
|
||||
q := url.Values{}
|
||||
for k, v := range payload {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
q.Set(k, gconv.String(v))
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// PullTaskResult 轮询查询异步任务结果直到完成
|
||||
func PullTaskResult(ctx context.Context, body map[string]any, queryConfig map[string]any, headMsg map[string]any) (map[string]any, error) {
|
||||
// 1) 解析配置
|
||||
// 1.1 提取 taskID
|
||||
taskIDPath := gconv.String(queryConfig["task_id"])
|
||||
taskID := gconv.String(gjson.New(body).Get(taskIDPath).Val())
|
||||
if taskID == "" {
|
||||
return nil, fmt.Errorf("无法从路径 %s 提取 taskID", taskIDPath)
|
||||
}
|
||||
g.Log().Infof(ctx, "[PullTaskResult] taskID=%s", taskID)
|
||||
|
||||
// 1.2 请求地址,替换 {id}
|
||||
queryUrl := gconv.String(queryConfig["url"])
|
||||
queryUrl = replaceURLParams(queryUrl, map[string]any{"id": taskID})
|
||||
|
||||
// 1.3 请求方式
|
||||
method := gconv.String(queryConfig["method"])
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
// 1.4 状态判断配置
|
||||
statusPath := gconv.String(queryConfig["status_path"])
|
||||
statusValues, _ := queryConfig["status_values"].(map[string]any)
|
||||
if statusPath == "" {
|
||||
statusPath = "status"
|
||||
}
|
||||
|
||||
// 1.5 轮询间隔
|
||||
interval := gconv.Int(queryConfig["interval_seconds"])
|
||||
if interval <= 0 {
|
||||
interval = 2
|
||||
}
|
||||
|
||||
// 1.6 请求体
|
||||
reqBodyMap := map[string]any{"task_id": taskID}
|
||||
|
||||
// 2) 轮询请求
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if method == "POST" {
|
||||
bs, _ := json.Marshal(reqBodyMap)
|
||||
reqBody = bytes.NewReader(bs)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, queryUrl, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 统一用 headMsg 注入请求头
|
||||
for hk, hv := range ParseHeadMsgHeaders(headMsg) {
|
||||
req.Header.Set(hk, hv)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[PullTaskResult] 请求失败 taskID=%s err=%v", taskID, err)
|
||||
time.Sleep(time.Duration(interval) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
g.Log().Infof(ctx, "[PullTaskResult] taskID=%s statusCode=%d body=%s", taskID, resp.StatusCode, string(raw))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
time.Sleep(time.Duration(interval) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
_ = json.Unmarshal(raw, &result)
|
||||
|
||||
statusVal := gjson.New(result).Get(statusPath).Val()
|
||||
statusStr := gconv.String(statusVal)
|
||||
g.Log().Infof(ctx, "[PullTaskResult] 状态 taskID=%s status=%v", taskID, statusVal)
|
||||
|
||||
if matchStatus(statusStr, statusValues["succeeded"]) {
|
||||
g.Log().Infof(ctx, "[PullTaskResult] 任务成功 taskID=%s", taskID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if matchStatus(statusStr, statusValues["failed"]) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func matchStatus(actual string, expected any) bool {
|
||||
expectedStr := gconv.String(expected)
|
||||
if actual == expectedStr {
|
||||
return true
|
||||
}
|
||||
switch v := expected.(type) {
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if actual == gconv.String(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// replaceURLParams 替换 URL 中的 {key}
|
||||
func replaceURLParams(url string, params map[string]any) string {
|
||||
re := regexp.MustCompile(`\{([^}]+)}`)
|
||||
return re.ReplaceAllStringFunc(url, func(s string) string {
|
||||
key := strings.Trim(s, "{}")
|
||||
if val, ok := params[key]; ok {
|
||||
return gconv.String(val)
|
||||
}
|
||||
return s
|
||||
})
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
)
|
||||
|
||||
// ================================================================
|
||||
|
||||
// ParseStreamResponse 流式响应解析(通用入口)
|
||||
func ParseStreamResponse(rawBytes []byte, streamConfig map[string]any) (map[string]any, error) {
|
||||
enabled, _ := streamConfig["enabled"].(bool)
|
||||
if !enabled {
|
||||
return gjson.New(string(rawBytes)).Map(), nil
|
||||
}
|
||||
|
||||
parser, _ := streamConfig["parser"].(string)
|
||||
if parser == "base64_concat" {
|
||||
return parseBase64Stream(rawBytes)
|
||||
}
|
||||
|
||||
return parseSSEStream(rawBytes, streamConfig)
|
||||
}
|
||||
|
||||
// parseBase64Stream 拼接流式 base64 并解码为二进制(TTS 等音频模型)
|
||||
func parseBase64Stream(rawBytes []byte) (map[string]any, error) {
|
||||
lines := strings.Split(string(rawBytes), "\n")
|
||||
var audioBase64 strings.Builder
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if data, ok := chunk["data"].(string); ok && data != "" {
|
||||
audioBase64.WriteString(data)
|
||||
}
|
||||
}
|
||||
|
||||
cleanBase64 := strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, audioBase64.String())
|
||||
|
||||
audioBytes, err := base64.StdEncoding.DecodeString(cleanBase64)
|
||||
if err != nil {
|
||||
audioBytes, err = base64.RawStdEncoding.DecodeString(cleanBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 解码失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{"audio": audioBytes}, nil
|
||||
}
|
||||
|
||||
// parseSSEStream SSE 流式解析(图片模型等)
|
||||
func parseSSEStream(rawBytes []byte, streamConfig map[string]any) (map[string]any, error) {
|
||||
events, _ := streamConfig["events"].([]any)
|
||||
if len(events) == 0 {
|
||||
return gjson.New(string(rawBytes)).Map(), nil
|
||||
}
|
||||
|
||||
lines := strings.Split(string(rawBytes), "\n")
|
||||
result := make(map[string]any)
|
||||
var partials []map[string]any
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
line = strings.TrimPrefix(line, "data:")
|
||||
line = strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
chunkType, _ := chunk["type"].(string)
|
||||
|
||||
for _, evt := range events {
|
||||
e, _ := evt.(map[string]any)
|
||||
match, _ := e["match"].(string)
|
||||
if !strings.Contains(chunkType, match) {
|
||||
continue
|
||||
}
|
||||
|
||||
fields, _ := e["fields"].(map[string]any)
|
||||
aggregateTo, _ := e["aggregate_to"].(string)
|
||||
evtType, _ := e["type"].(string)
|
||||
|
||||
switch evtType {
|
||||
case "partial":
|
||||
item := make(map[string]any)
|
||||
for localKey, chunkKey := range fields {
|
||||
item[localKey] = chunk[chunkKey.(string)]
|
||||
}
|
||||
partials = append(partials, item)
|
||||
|
||||
case "final":
|
||||
for localKey, chunkKey := range fields {
|
||||
val := gjson.New(chunk).Get(chunkKey.(string))
|
||||
if !val.IsNil() {
|
||||
if _, exists := result[aggregateTo]; !exists {
|
||||
result[aggregateTo] = make(map[string]any)
|
||||
}
|
||||
result[aggregateTo].(map[string]any)[localKey] = val.Val()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(partials) > 0 {
|
||||
for _, evt := range events {
|
||||
e, _ := evt.(map[string]any)
|
||||
if e["type"] == "partial" {
|
||||
if orderBy, ok := e["order_by"].(string); ok {
|
||||
sort.Slice(partials, func(i, j int) bool {
|
||||
return fmt.Sprint(partials[i][orderBy]) < fmt.Sprint(partials[j][orderBy])
|
||||
})
|
||||
}
|
||||
result[e["aggregate_to"].(string)] = partials
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mergedBytes, _ := json.Marshal(result)
|
||||
return gjson.New(mergedBytes).Map(), nil
|
||||
}
|
||||
+15
-5
@@ -9,8 +9,8 @@ database:
|
||||
- type: "pgsql"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
user: "sql9f15b63fd203b36e"
|
||||
pass: "1ec94b1acdaf57b66030242d418fad5a"
|
||||
name: "model-gateway"
|
||||
prefix: "" # (可选)表名前缀
|
||||
role: "master" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。
|
||||
@@ -30,10 +30,10 @@ database:
|
||||
- type: "pgsql"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
user: "sql9f15b63fd203b36e"
|
||||
pass: "1ec94b1acdaf57b66030242d418fad5a"
|
||||
name: "model-gateway"
|
||||
prefix: ""
|
||||
prefix: "model_gateway_"
|
||||
role: "master"
|
||||
debug: true
|
||||
dryRun: false
|
||||
@@ -59,6 +59,16 @@ consul:
|
||||
jaeger:
|
||||
addr: 192.168.0.83:4318
|
||||
|
||||
nats:
|
||||
addr: 192.168.0.83
|
||||
port: 4222
|
||||
|
||||
# schema_mapping 自动构建专用 LLM(OpenAI 兼容;密钥不要写死进代码)
|
||||
schemaMapping:
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
modelName: "doubao-seed-2-0-lite-260428"
|
||||
apiKey: "ark-9df744e8-a0de-4c54-9db3-18379bccd523-e6733"
|
||||
|
||||
# 本地调试用:可选自动执行 worker/cleaner(默认关闭)
|
||||
asynch:
|
||||
queryPending:
|
||||
|
||||
@@ -8,22 +8,22 @@ import (
|
||||
|
||||
// 供应商编码常量
|
||||
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
|
||||
SupplierAliyun = 1 // 阿里云百炼
|
||||
SupplierVolcengine = 2 // 火山引擎
|
||||
SupplierTencent = 3 // 腾讯云
|
||||
SupplierHuawei = 4 // 华为云
|
||||
SupplierBaidu = 5 // 百度智能云
|
||||
SupplierOpenAI = 6 // OpenAI
|
||||
SupplierAzure = 7 // 微软 Azure
|
||||
SupplierAWS = 8 // 亚马逊 AWS
|
||||
SupplierGoogle = 9 // Google
|
||||
SupplierDeepSeek = 10 // DeepSeek
|
||||
SupplierMoonshot = 11 // Moonshot(月之暗面)
|
||||
SupplierZhipu = 12 // 智谱AI
|
||||
SupplierBaichuan = 13 // 百川智能
|
||||
SupplierMinimax = 14 // MiniMax
|
||||
SupplierXunfei = 15 // 科大讯飞
|
||||
SupplierOthers = 16 // 其他
|
||||
)
|
||||
|
||||
// SupplierType 供应商编码类型
|
||||
@@ -43,11 +43,11 @@ var supplierNameMap = map[int]string{
|
||||
SupplierHuawei: "华为云",
|
||||
SupplierBaidu: "百度智能云",
|
||||
SupplierOpenAI: "OpenAI",
|
||||
SupplierAzure: "Azure OpenAI",
|
||||
SupplierAWS: "AWS Bedrock",
|
||||
SupplierGoogle: "Google Cloud",
|
||||
SupplierAzure: "微软 Azure",
|
||||
SupplierAWS: "亚马逊 AWS",
|
||||
SupplierGoogle: "Google",
|
||||
SupplierDeepSeek: "DeepSeek",
|
||||
SupplierMoonshot: "Moonshot",
|
||||
SupplierMoonshot: "Moonshot(月之暗面)",
|
||||
SupplierZhipu: "智谱AI",
|
||||
SupplierBaichuan: "百川智能",
|
||||
SupplierMinimax: "MiniMax",
|
||||
@@ -57,9 +57,14 @@ var supplierNameMap = map[int]string{
|
||||
|
||||
// 供应商展示顺序
|
||||
var supplierOrder = []int{
|
||||
// 国内云厂商
|
||||
SupplierAliyun, SupplierVolcengine, SupplierTencent, SupplierHuawei, SupplierBaidu,
|
||||
SupplierOpenAI, SupplierAzure, SupplierAWS, SupplierGoogle, SupplierDeepSeek,
|
||||
SupplierMoonshot, SupplierZhipu, SupplierBaichuan, SupplierMinimax, SupplierXunfei, SupplierOthers,
|
||||
// 海外头部
|
||||
SupplierOpenAI, SupplierGoogle, SupplierAWS, SupplierAzure,
|
||||
// 国内AI厂商
|
||||
SupplierDeepSeek, SupplierMoonshot, SupplierZhipu, SupplierBaichuan, SupplierMinimax, SupplierXunfei,
|
||||
// 兜底
|
||||
SupplierOthers,
|
||||
}
|
||||
|
||||
// 全局供应商实例
|
||||
|
||||
+120
-82
@@ -11,35 +11,48 @@ const (
|
||||
TypeInference = 100 // 推理模型
|
||||
TypeImage = 200 // 图片模型
|
||||
TypeAudio = 300 // 音频模型
|
||||
TypeVector = 400 // 向量化模型
|
||||
TypeOmni = 500 // 全模态模型
|
||||
TypeVector = 400 // 向量模型
|
||||
TypeOmni = 500 // 多模态模型
|
||||
TypeVideo = 600 // 视频模型
|
||||
TypeCode = 700 // 代码模型
|
||||
|
||||
// 图片子类型
|
||||
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
|
||||
//// 推理子类型
|
||||
//InferenceSubChat = 101 // 对话/补全
|
||||
//InferenceSubReason = 102 // 思维链/深度推理
|
||||
//InferenceSubFunction = 103 // 函数调用
|
||||
//
|
||||
//// 图片子类型
|
||||
//ImageSubTextToImage = 201 // 文生图
|
||||
//ImageSubImageToImage = 202 // 图生图
|
||||
//ImageSubImageEdit = 203 // 图片编辑
|
||||
//ImageSubImageVariation = 204 // 图片变体
|
||||
//ImageSubImageTextToImage = 205 // 图文生图
|
||||
//
|
||||
//// 音频子类型
|
||||
//AudioSubTextToSpeech = 301 // 文生音
|
||||
//AudioSubSpeechToText = 302 // 音生文
|
||||
//AudioSubSpeechToSpeech = 303 // 音生音
|
||||
//AudioSubVoiceClone = 304 // 声音克隆
|
||||
//
|
||||
//// 向量子类型
|
||||
//VectorSubEmbedding = 401 // 文本嵌入
|
||||
//VectorSubRerank = 402 // 重排序
|
||||
//
|
||||
//// 多模态子类型
|
||||
//OmniSubTextImageAudio = 501 // 文图音理解
|
||||
//OmniSubVision = 502 // 视觉理解
|
||||
//OmniSubVideoUnderstand = 503 // 视频理解
|
||||
//
|
||||
//// 视频子类型
|
||||
//VideoSubTextToVideo = 601 // 文生视频
|
||||
//VideoSubImageToVideo = 602 // 图生视频
|
||||
//VideoSubImageTextToVideo = 603 // 图文生视频
|
||||
//VideoSubVideoToVideo = 604 // 视频生视频
|
||||
//
|
||||
//// 代码子类型
|
||||
//CodeSubGeneration = 701 // 代码生成
|
||||
//CodeSubCompletion = 702 // 代码补全
|
||||
//CodeSubReview = 703 // 代码审查
|
||||
)
|
||||
|
||||
// ModelType 编码类型
|
||||
@@ -63,44 +76,57 @@ var typeNameMap = map[int]string{
|
||||
TypeInference: "推理模型",
|
||||
TypeImage: "图片模型",
|
||||
TypeAudio: "音频模型",
|
||||
TypeVector: "向量化模型",
|
||||
TypeOmni: "全模态模型",
|
||||
TypeVector: "向量模型",
|
||||
TypeOmni: "多模态模型",
|
||||
TypeVideo: "视频模型",
|
||||
TypeCode: "代码模型",
|
||||
|
||||
ImageSubTextToImage: "文生图",
|
||||
ImageSubImageToImage: "图生图",
|
||||
ImageSubImageEdit: "图片编辑",
|
||||
ImageSubImageVariation: "图片变体",
|
||||
ImageSubImageTextToImage: "图文生图",
|
||||
|
||||
AudioSubTextToSpeech: "文生音",
|
||||
AudioSubSpeechToText: "音生文",
|
||||
AudioSubSpeechToSpeech: "音生音",
|
||||
|
||||
VectorSubEmbedding: "文本嵌入",
|
||||
VectorSubRerank: "重排序",
|
||||
|
||||
OmniSubTextImageAudio: "文图音",
|
||||
OmniSubVision: "视觉理解",
|
||||
|
||||
VideoSubTextToVideo: "文生视频",
|
||||
VideoSubImageToVideo: "图生视频",
|
||||
VideoSubImageTextToVideo: "图文生视频",
|
||||
VideoSubVideoToVideo: "视频生视频",
|
||||
//InferenceSubChat: "对话/补全",
|
||||
//InferenceSubReason: "思维链/深度推理",
|
||||
//InferenceSubFunction: "函数调用",
|
||||
//
|
||||
//ImageSubTextToImage: "文生图",
|
||||
//ImageSubImageToImage: "图生图",
|
||||
//ImageSubImageEdit: "图片编辑",
|
||||
//ImageSubImageVariation: "图片变体",
|
||||
//ImageSubImageTextToImage: "图文生图",
|
||||
//
|
||||
//AudioSubTextToSpeech: "文生音",
|
||||
//AudioSubSpeechToText: "音生文",
|
||||
//AudioSubSpeechToSpeech: "音生音",
|
||||
//AudioSubVoiceClone: "声音克隆",
|
||||
//
|
||||
//VectorSubEmbedding: "文本嵌入",
|
||||
//VectorSubRerank: "重排序",
|
||||
//
|
||||
//OmniSubTextImageAudio: "文图音理解",
|
||||
//OmniSubVision: "视觉理解",
|
||||
//OmniSubVideoUnderstand: "视频理解",
|
||||
//
|
||||
//VideoSubTextToVideo: "文生视频",
|
||||
//VideoSubImageToVideo: "图生视频",
|
||||
//VideoSubImageTextToVideo: "图文生视频",
|
||||
//VideoSubVideoToVideo: "视频生视频",
|
||||
//
|
||||
//CodeSubGeneration: "代码生成",
|
||||
//CodeSubCompletion: "代码补全",
|
||||
//CodeSubReview: "代码审查",
|
||||
}
|
||||
|
||||
// 父子级映射(仅存有子项的分类)
|
||||
var parentChildMap = map[int][]int{
|
||||
TypeImage: {ImageSubTextToImage, ImageSubImageToImage, ImageSubImageEdit, ImageSubImageVariation, ImageSubImageTextToImage},
|
||||
TypeAudio: {AudioSubTextToSpeech, AudioSubSpeechToText, AudioSubSpeechToSpeech},
|
||||
TypeVector: {VectorSubEmbedding, VectorSubRerank},
|
||||
TypeOmni: {OmniSubTextImageAudio, OmniSubVision},
|
||||
TypeVideo: {VideoSubTextToVideo, VideoSubImageToVideo, VideoSubImageTextToVideo, VideoSubVideoToVideo},
|
||||
//TypeInference: {InferenceSubChat, InferenceSubReason, InferenceSubFunction},
|
||||
//TypeImage: {ImageSubTextToImage, ImageSubImageToImage, ImageSubImageEdit, ImageSubImageVariation, ImageSubImageTextToImage},
|
||||
//TypeAudio: {AudioSubTextToSpeech, AudioSubSpeechToText, AudioSubSpeechToSpeech, AudioSubVoiceClone},
|
||||
//TypeVector: {VectorSubEmbedding, VectorSubRerank},
|
||||
//TypeOmni: {OmniSubTextImageAudio, OmniSubVision, OmniSubVideoUnderstand},
|
||||
//TypeVideo: {VideoSubTextToVideo, VideoSubImageToVideo, VideoSubImageTextToVideo, VideoSubVideoToVideo},
|
||||
//TypeCode: {CodeSubGeneration, CodeSubCompletion, CodeSubReview},
|
||||
}
|
||||
|
||||
// 一级分类展示顺序
|
||||
var parentTypeOrder = []int{
|
||||
TypeInference, TypeImage, TypeAudio, TypeVector, TypeOmni, TypeVideo,
|
||||
TypeInference, TypeImage, TypeAudio, TypeVector, TypeOmni, TypeVideo, TypeCode,
|
||||
}
|
||||
|
||||
// 全局实例:一级 + 全部二级子类型,统一通过 newItem 构造,文案仅维护在 typeNameMap
|
||||
@@ -112,40 +138,52 @@ var (
|
||||
ModelTypeVector = newItem(gconv.PtrInt(TypeVector))
|
||||
ModelTypeOmni = newItem(gconv.PtrInt(TypeOmni))
|
||||
ModelTypeVideo = newItem(gconv.PtrInt(TypeVideo))
|
||||
ModelTypeCode = newItem(gconv.PtrInt(TypeCode))
|
||||
|
||||
// 图片二级子类型
|
||||
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))
|
||||
//// 推理二级子类型
|
||||
//ModelInferenceSubChat = newItem(gconv.PtrInt(InferenceSubChat))
|
||||
//ModelInferenceSubReason = newItem(gconv.PtrInt(InferenceSubReason))
|
||||
//ModelInferenceSubFunction = newItem(gconv.PtrInt(InferenceSubFunction))
|
||||
//
|
||||
//// 图片二级子类型
|
||||
//ModelImageSubTextToImage = newItem(gconv.PtrInt(ImageSubTextToImage))
|
||||
//ModelImageSubImageToImage = newItem(gconv.PtrInt(ImageSubImageToImage))
|
||||
//ModelImageSubImageEdit = newItem(gconv.PtrInt(ImageSubImageEdit))
|
||||
//ModelImageSubImageVariation = newItem(gconv.PtrInt(ImageSubImageVariation))
|
||||
//ModelImageSubImageTextToImage = newItem(gconv.PtrInt(ImageSubImageTextToImage))
|
||||
//
|
||||
//// 音频二级子类型
|
||||
//ModelAudioSubTextToSpeech = newItem(gconv.PtrInt(AudioSubTextToSpeech))
|
||||
//ModelAudioSubSpeechToText = newItem(gconv.PtrInt(AudioSubSpeechToText))
|
||||
//ModelAudioSubSpeechToSpeech = newItem(gconv.PtrInt(AudioSubSpeechToSpeech))
|
||||
//ModelAudioSubVoiceClone = newItem(gconv.PtrInt(AudioSubVoiceClone))
|
||||
//
|
||||
//// 向量二级子类型
|
||||
//ModelVectorSubEmbedding = newItem(gconv.PtrInt(VectorSubEmbedding))
|
||||
//ModelVectorSubRerank = newItem(gconv.PtrInt(VectorSubRerank))
|
||||
//
|
||||
//// 多模态二级子类型
|
||||
//ModelOmniSubTextImageAudio = newItem(gconv.PtrInt(OmniSubTextImageAudio))
|
||||
//ModelOmniSubVision = newItem(gconv.PtrInt(OmniSubVision))
|
||||
//ModelOmniSubVideoUnderstand = newItem(gconv.PtrInt(OmniSubVideoUnderstand))
|
||||
//
|
||||
//// 视频二级子类型
|
||||
//ModelVideoSubTextToVideo = newItem(gconv.PtrInt(VideoSubTextToVideo))
|
||||
//ModelVideoSubImageToVideo = newItem(gconv.PtrInt(VideoSubImageToVideo))
|
||||
//ModelVideoSubImageTextToVideo = newItem(gconv.PtrInt(VideoSubImageTextToVideo))
|
||||
//ModelVideoSubVideoToVideo = newItem(gconv.PtrInt(VideoSubVideoToVideo))
|
||||
//
|
||||
//// 代码二级子类型
|
||||
//ModelCodeSubGeneration = newItem(gconv.PtrInt(CodeSubGeneration))
|
||||
//ModelCodeSubCompletion = newItem(gconv.PtrInt(CodeSubCompletion))
|
||||
//ModelCodeSubReview = newItem(gconv.PtrInt(CodeSubReview))
|
||||
)
|
||||
|
||||
// newItem 构造方法:自动从 typeNameMap 读取描述
|
||||
func newItem(code ModelType) ModelTypeItem {
|
||||
val := int(*code)
|
||||
return ModelTypeItem{
|
||||
Code: code,
|
||||
Desc: typeNameMap[val],
|
||||
Desc: typeNameMap[*code],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package public
|
||||
|
||||
const GmqMsgPluginsName = "gmq_model_msg"
|
||||
|
||||
const KnowledgeLockEsKey = "knowledge:lock:knowledgeIdEs-%v"
|
||||
const KnowledgeLockSqlKey = "knowledge:lock:knowledgeIdSql-%v"
|
||||
const KnowledgeContentHashEsKey = "knowledge:knowledgeId:contentHashEs-%v"
|
||||
const KnowledgeContentHashSqlKey = "knowledge:knowledgeId:contentHashSql-%v"
|
||||
|
||||
// Option 通用下拉选项
|
||||
type Option struct {
|
||||
Value int `json:"value"`
|
||||
|
||||
@@ -5,9 +5,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
TableNameModel = "model_gateway_models" // 模型表
|
||||
TableNameTask = "model_gateway_task" // 任务表
|
||||
TableNameOpLog = "model_gateway_logs_op" // 操作日志表
|
||||
TableNameStat = "model_gateway_logs_stat" // 按天统计表
|
||||
TableNameModelManage = "model_gateway_model_manage"
|
||||
TableNameModelManage = "model_manage"
|
||||
TableNameModelSession = "model_session"
|
||||
TableNameModelTaskStart = "model_task_start"
|
||||
TableNameModelTaskEnd = "model_task_end"
|
||||
TableNameModelErrorMemory = "model_error_memory"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service"
|
||||
"net/http"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ModelCall 模型调用控制器
|
||||
var ModelCall = new(modelCall)
|
||||
|
||||
type modelCall struct{}
|
||||
|
||||
// ModelCall 模型调用
|
||||
func (c *modelCall) ModelCall(ctx context.Context, req *dto.ModelCallReq) (res *dto.ModelCallRes, err error) {
|
||||
return service.ModelCall.ModelCall(ctx, req)
|
||||
}
|
||||
|
||||
// CreateSessionStream 创建模型会话(流式)
|
||||
func (c *modelCall) CreateSessionStream(ctx context.Context, req *dto.ModelCallStreamReq) (res *beans.ResponseEmpty, err error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
w := r.Response.RawWriter()
|
||||
err = service.ModelCall.ModelCallStream(ctx, w, req)
|
||||
if err != nil {
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "application/json; charset=utf-8")
|
||||
errResp, _ := json.Marshal(map[string]interface{}{
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
})
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write(errResp)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// ModelErrorMemory 错误重试记忆控制器
|
||||
var ModelErrorMemory = new(modelErrorMemory)
|
||||
|
||||
type modelErrorMemory struct{}
|
||||
|
||||
// List 错误重试记忆列表
|
||||
func (c *modelErrorMemory) List(ctx context.Context, req *dto.GetErrorMemoryListReq) (res *dto.GetErrorMemoryListRes, err error) {
|
||||
return service.ModelErrorMemory.List(ctx, req)
|
||||
}
|
||||
|
||||
// Delete 删除错误重试记忆
|
||||
func (c *modelErrorMemory) Delete(ctx context.Context, req *dto.DeleteErrorMemoryReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.ModelErrorMemory.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
statService "model-gateway/service/stat"
|
||||
|
||||
"model-gateway/model/dto"
|
||||
)
|
||||
|
||||
// ModelGatewayLogsStat 统计控制器
|
||||
var ModelGatewayLogsStat = new(stat)
|
||||
|
||||
type stat struct{}
|
||||
|
||||
// ListModelStat 统计列表
|
||||
func (c *stat) ListModelStat(ctx context.Context, req *dto.ListModelStatReq) (res *dto.ListModelStatRes, err error) {
|
||||
return statService.ModelGatewayLogsStat.List(ctx, req)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/model/dto"
|
||||
modelService "model-gateway/service/model"
|
||||
"model-gateway/service/queue"
|
||||
)
|
||||
|
||||
// ModelGatewayModels 模型配置控制器
|
||||
var ModelGatewayModels = new(model)
|
||||
|
||||
type model struct{}
|
||||
|
||||
// CreateModel 添加配置
|
||||
func (c *model) CreateModel(ctx context.Context, req *dto.CreateModelReq) (res *dto.CreateModelRes, err error) {
|
||||
return modelService.ModelGatewayModels.Create(ctx, req)
|
||||
}
|
||||
|
||||
// UpdateModel 更改配置
|
||||
func (c *model) UpdateModel(ctx context.Context, req *dto.UpdateModelReq) (res *dto.UpdateModelRes, err error) {
|
||||
err = modelService.ModelGatewayModels.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteModel 删除配置
|
||||
func (c *model) DeleteModel(ctx context.Context, req *dto.DeleteModelReq) (res *dto.DeleteModelRes, err error) {
|
||||
err = modelService.ModelGatewayModels.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// GetModel 获取配置详情
|
||||
func (c *model) GetModel(ctx context.Context, req *dto.GetModelReq) (res *dto.GetModelRes, err error) {
|
||||
return modelService.ModelGatewayModels.Get(ctx, req)
|
||||
}
|
||||
|
||||
// ListModel 配置列表
|
||||
func (c *model) ListModel(ctx context.Context, req *dto.ListModelReq) (res *dto.ListModelRes, err error) {
|
||||
return modelService.ModelGatewayModels.List(ctx, req)
|
||||
}
|
||||
|
||||
// AutoTune 动态调参(由上层定时任务每小时触发一次)
|
||||
func (c *model) AutoTune(ctx context.Context, req *dto.AutoTuneReq) (res *dto.AutoTuneRes, err error) {
|
||||
return queue.AutoTune(ctx, req)
|
||||
}
|
||||
|
||||
// ListType 模型类型列表
|
||||
func (c *model) ListType(ctx context.Context, req *dto.ListTypeReq) (res *dto.TypeItem, err error) {
|
||||
return modelService.GetModelTypesFromConfig()
|
||||
}
|
||||
|
||||
// ListOperator 运营商列表
|
||||
func (c *model) ListOperator(ctx context.Context, req *dto.ListOperatorReq) (res *dto.ListOperatorRes, err error) {
|
||||
return modelService.GetOperatorList()
|
||||
}
|
||||
|
||||
// UpdateChatModel 更新是否为聊天模型
|
||||
func (c *model) UpdateChatModel(ctx context.Context, req *dto.UpdateChatModelReq) (res *dto.UpdateChatModelRes, err error) {
|
||||
err = modelService.ModelGatewayModels.UpdateChatModel(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// GetIsChatModel 获取当前会话模型
|
||||
func (c *model) GetIsChatModel(ctx context.Context, req *dto.GetIsChatModelReq) (res *dto.GetIsChatModelRes, err error) {
|
||||
return modelService.ModelGatewayModels.GetIsChatModel(ctx)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
taskService "model-gateway/service/task"
|
||||
|
||||
"model-gateway/model/dto"
|
||||
)
|
||||
|
||||
// ModelGatewayTask 任务控制器
|
||||
var ModelGatewayTask = new(task)
|
||||
|
||||
type task struct{}
|
||||
|
||||
// CreateTask 根据 modelName 创建异步任务,返回 taskId
|
||||
func (c *task) CreateTask(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) {
|
||||
return taskService.ModelGatewayTask.Create(ctx, req)
|
||||
}
|
||||
|
||||
// GetTaskResult 获取单条任务结果(返回 *dto.GetTaskResultRes)
|
||||
func (c *task) GetTaskResult(ctx context.Context, req *dto.GetTaskResultReq) (res *dto.GetTaskResultRes, err error) {
|
||||
return taskService.ModelGatewayTask.GetResult(ctx, req.TaskID)
|
||||
}
|
||||
|
||||
// GetTaskBatch 批量查询任务(返回 *[]dto.GetTaskBatchItem)
|
||||
func (c *task) GetTaskBatch(ctx context.Context, req *dto.GetTaskBatchReq) (res *dto.GetTaskBatchRes, err error) {
|
||||
return taskService.ModelGatewayTask.GetBatch(ctx, req)
|
||||
}
|
||||
|
||||
// ListTask 任务列表分页查询
|
||||
func (c *task) ListTask(ctx context.Context, req *dto.ListTaskReq) (res *dto.ListTaskRes, err error) {
|
||||
return taskService.ModelGatewayTask.List(ctx, req)
|
||||
}
|
||||
|
||||
// ModelTaskCallback 接收模型异步任务的回调通知 —— 待调整
|
||||
func (c *task) ModelTaskCallback(ctx context.Context, req *dto.ModelTaskCallbackReq) (res *dto.ModelTaskCallbackRes, err error) {
|
||||
return taskService.ModelGatewayTask.ModelTaskCallback(ctx, req)
|
||||
}
|
||||
|
||||
// QueryPendingTasks 批量轮询进行中的异步任务 —— 待调整
|
||||
func (c *task) QueryPendingTasks(ctx context.Context, req *dto.QueryPendingTasksReq) (res *dto.QueryPendingTasksRes, err error) {
|
||||
return taskService.ModelGatewayTask.QueryPendingTasks(ctx, req)
|
||||
}
|
||||
@@ -19,9 +19,8 @@ func (c *modelManage) CreateModel(ctx context.Context, req *dto.CreateModelManag
|
||||
}
|
||||
|
||||
// UpdateModel 更改配置
|
||||
func (c *modelManage) UpdateModel(ctx context.Context, req *dto.UpdateModelManageReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.ModelManage.Update(ctx, req)
|
||||
return
|
||||
func (c *modelManage) UpdateModel(ctx context.Context, req *dto.UpdateModelManageReq) (res *dto.GetModelManageRes, err error) {
|
||||
return service.ModelManage.Update(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteModel 删除配置
|
||||
@@ -35,6 +34,11 @@ func (c *modelManage) GetModel(ctx context.Context, req *dto.GetModelManageReq)
|
||||
return service.ModelManage.Get(ctx, req)
|
||||
}
|
||||
|
||||
// GetChatModel 获取聊天模型
|
||||
func (c *modelManage) GetChatModel(ctx context.Context, req *dto.GetChatModelReq) (res *dto.GetChatModelRes, err error) {
|
||||
return service.ModelManage.GetChatModel(ctx, req)
|
||||
}
|
||||
|
||||
// ListModel 配置列表
|
||||
func (c *modelManage) ListModel(ctx context.Context, req *dto.ListModelManageReq) (res *dto.ListModelManageRes, err error) {
|
||||
return service.ModelManage.List(ctx, req)
|
||||
@@ -54,3 +58,8 @@ func (c *modelManage) ListType(ctx context.Context, req *dto.ModelTypeReq) (res
|
||||
func (c *modelManage) ListOperator(ctx context.Context, req *dto.ModelSupplierReq) (res *dto.ModelSupplierRes, err error) {
|
||||
return service.ModelManage.GetModelSupplier(ctx, req)
|
||||
}
|
||||
|
||||
// BuildSchemaMapping 自动构建 Schema 映射
|
||||
func (c *modelManage) BuildSchemaMapping(ctx context.Context, req *dto.BuildSchemaMappingReq) (res *dto.BuildSchemaMappingRes, err error) {
|
||||
return service.SchemaMapping.BuildSchemaMapping(ctx, req)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
)
|
||||
|
||||
var ModelErrorMemory = &modelErrorMemoryDao{}
|
||||
|
||||
type modelErrorMemoryDao struct{}
|
||||
|
||||
// GetByKey 按记忆键查询(未命中返回 (nil, nil))
|
||||
// 错误记忆为全局表:NoTenantId 绕过租户过滤,跨租户共享;r.IsEmpty() 兜底 miss 契约,
|
||||
// 避免对空记录 r.Struct(&res) 上浮 sql.ErrNoRows 导致调用方 fail-closed。
|
||||
func (d *modelErrorMemoryDao) GetByKey(ctx context.Context, key string) (res *entity.ModelErrorMemory, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelErrorMemory).
|
||||
NoTenantId(ctx).
|
||||
Where(entity.ModelErrorMemoryCol.MemoryKey, key).
|
||||
One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert 存在则更新 retryable/reason/analyzed_by,不存在则插入
|
||||
func (d *modelErrorMemoryDao) Upsert(ctx context.Context, m *entity.ModelErrorMemory) (err error) {
|
||||
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelErrorMemory)
|
||||
// Count 同样全局化:跨租户已存在的记忆键需命中更新分支,而非重复插入
|
||||
n, err := model.NoTenantId(ctx).Where(entity.ModelErrorMemoryCol.MemoryKey, m.MemoryKey).Count()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
_, err = model.Where(entity.ModelErrorMemoryCol.MemoryKey, m.MemoryKey).Data(map[string]any{
|
||||
entity.ModelErrorMemoryCol.Retryable: m.Retryable,
|
||||
entity.ModelErrorMemoryCol.Reason: m.Reason,
|
||||
entity.ModelErrorMemoryCol.AnalyzedBy: m.AnalyzedBy,
|
||||
}).Update()
|
||||
return
|
||||
}
|
||||
_, err = model.Insert(m)
|
||||
return
|
||||
}
|
||||
|
||||
// List 分页查询(按 id 倒序);全局表,管理端列表展示所有租户记忆
|
||||
func (d *modelErrorMemoryDao) List(ctx context.Context, page, pageSize int) (list []entity.ModelErrorMemory, total int64, err error) {
|
||||
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelErrorMemory).NoTenantId(ctx)
|
||||
n, err := model.Count()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
total = int64(n)
|
||||
err = model.Page(page, pageSize).OrderDesc(entity.ModelErrorMemoryCol.Id).Scan(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 按 id 删除(软删除)
|
||||
func (d *modelErrorMemoryDao) Delete(ctx context.Context, id int64) (err error) {
|
||||
_, err = gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelErrorMemory).
|
||||
Where(entity.ModelErrorMemoryCol.Id, id).Delete()
|
||||
return
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
)
|
||||
|
||||
var ModelGatewayLogsOp = &modelGatewayLogsOpDao{}
|
||||
|
||||
type modelGatewayLogsOpDao struct{}
|
||||
|
||||
// Insert 插入操作日志
|
||||
func (d *modelGatewayLogsOpDao) Insert(ctx context.Context, req *entity.ModelGatewayLogsOp) (int64, error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameOpLog).Insert(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelGatewayLogsStat = &modelGatewayLogsStatDao{}
|
||||
|
||||
type modelGatewayLogsStatDao struct{}
|
||||
|
||||
// IncRequestCount 原子累加:按天+租户+创建人+模型 +1
|
||||
func (d *modelGatewayLogsStatDao) IncRequestCount(ctx context.Context, day time.Time, tenantId uint64, creator, modelName string) error {
|
||||
_, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameStat).
|
||||
Data(&entity.ModelGatewayLogsStat{
|
||||
Day: gtime.New(day),
|
||||
TenantId: tenantId,
|
||||
Creator: creator,
|
||||
ModelName: modelName,
|
||||
RequestCount: 1,
|
||||
}).
|
||||
OnDuplicate("request_count", "request_count+1").
|
||||
Insert()
|
||||
return err
|
||||
}
|
||||
|
||||
// List 分页查询统计
|
||||
func (d *modelGatewayLogsStatDao) List(ctx context.Context, pageNum, pageSize int, req *entity.ModelGatewayLogsStat) (list []*entity.ModelGatewayLogsStat, total int64, err error) {
|
||||
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameStat).
|
||||
OmitEmpty().
|
||||
Where(entity.ModelGatewayLogsStatCols.Creator, req.Creator).
|
||||
WhereLike(entity.ModelGatewayLogsStatCols.ModelName, "%"+req.ModelName+"%").
|
||||
OrderDesc(entity.ModelGatewayLogsStatCols.Day).
|
||||
OrderDesc(entity.ModelGatewayLogsStatCols.RequestCount)
|
||||
if pageNum > 0 && pageSize > 0 {
|
||||
model = model.Page(pageNum, pageSize)
|
||||
}
|
||||
r, totalInt, err := model.AllAndCount(false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total = gconv.Int64(totalInt)
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
"strconv"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var ModelGatewayModels = &modelGatewayModelsDao{}
|
||||
|
||||
type modelGatewayModelsDao struct{}
|
||||
|
||||
// Insert 插入
|
||||
func (d *modelGatewayModelsDao) Insert(ctx context.Context, req *entity.ModelGatewayModel) (int64, error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel).Insert(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// Update 更新
|
||||
func (d *modelGatewayModelsDao) Update(ctx context.Context, req *entity.ModelGatewayModel) (int64, error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel).
|
||||
OmitEmpty().
|
||||
Data(req).
|
||||
Where(entity.ModelGatewayModelCol.Id, req.Id).
|
||||
Update()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
// Delete 删除
|
||||
func (d *modelGatewayModelsDao) Delete(ctx context.Context, req *entity.ModelGatewayModel) (int64, error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel).
|
||||
OmitEmpty().
|
||||
Where(entity.ModelGatewayModelCol.Id, req.Id).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
// Get 获取模型
|
||||
func (d *modelGatewayModelsDao) Get(ctx context.Context, req *entity.ModelGatewayModel, fields ...string) (*entity.ModelGatewayModel, error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel).
|
||||
OmitEmpty().
|
||||
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
|
||||
}
|
||||
var m entity.ModelGatewayModel
|
||||
err = r.Struct(&m)
|
||||
return &m, err
|
||||
}
|
||||
|
||||
//// Get 按ID获取(带租户隔离,只查当前租户)
|
||||
//func (d *modelGatewayModelsDao) Get(ctx context.Context, req *entity.AsynchModel, fields ...string) (m *entity.AsynchModel, err error) {
|
||||
// var whereCondition strings.Builder
|
||||
// var queryParams []interface{}
|
||||
// if !g.IsEmpty(req.Id) {
|
||||
// whereCondition.WriteString(fmt.Sprintf(" AND %s = (?) ", entity.AsynchModelCol.Id))
|
||||
// queryParams = append(queryParams, req.Id)
|
||||
// }
|
||||
// if !g.IsEmpty(req.Creator) {
|
||||
// whereCondition.WriteString(fmt.Sprintf(" AND %s = (?) ", entity.AsynchModelCol.Creator))
|
||||
// queryParams = append(queryParams, req.Creator)
|
||||
// }
|
||||
// if !g.IsEmpty(req.IsChatModel) {
|
||||
// whereCondition.WriteString(fmt.Sprintf(" AND %s = (?) ", entity.AsynchModelCol.IsChatModel))
|
||||
// queryParams = append(queryParams, req.IsChatModel)
|
||||
// }
|
||||
// if !g.IsEmpty(req.ModelName) {
|
||||
// whereCondition.WriteString(fmt.Sprintf(" AND %s = (?) ", entity.AsynchModelCol.ModelName))
|
||||
// queryParams = append(queryParams, req.ModelName)
|
||||
// }
|
||||
// // 完整 SQL
|
||||
// sql := `SELECT * FROM "asynch_models" WHERE "deleted_at" IS NULL` + whereCondition.String()
|
||||
// r, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql, queryParams...)
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
// var i []*entity.AsynchModel
|
||||
// if err = r.Structs(&i); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// for _, item := range i {
|
||||
// m = item
|
||||
// }
|
||||
// return
|
||||
//}
|
||||
|
||||
// GetByAcrossTenant 跨租户查询
|
||||
func (d *modelGatewayModelsDao) GetByAcrossTenant(ctx context.Context, req *entity.ModelGatewayModel, fields ...string) (*entity.ModelGatewayModel, error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModel).
|
||||
NoTenantId(ctx).
|
||||
OmitEmpty().
|
||||
Where(entity.ModelGatewayModelCol.Id, req.Id).
|
||||
Where(entity.ModelGatewayModelCol.Creator, req.Creator).
|
||||
Where(entity.ModelGatewayModelCol.ModelName, req.ModelName).
|
||||
Fields(fields).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m entity.ModelGatewayModel
|
||||
err = r.Struct(&m)
|
||||
return &m, err
|
||||
}
|
||||
|
||||
// GetByCreatorAndPlatform 按创建者、平台获取
|
||||
func (d *modelGatewayModelsDao) GetByCreatorAndPlatform(ctx context.Context, req *dto.ListModelReq) (list []*entity.ModelGatewayModel, total int, err error) {
|
||||
sql := `
|
||||
SELECT DISTINCT ON (model_name) *
|
||||
FROM ` + public.TableNameModel + `
|
||||
WHERE deleted_at IS NULL
|
||||
AND (? = '' OR model_name LIKE ?)
|
||||
`
|
||||
args := []any{
|
||||
req.ModelName, "%" + req.ModelName + "%",
|
||||
}
|
||||
|
||||
// modelType: 传 6 模糊匹配 6%
|
||||
if req.ModelType > 0 {
|
||||
prefix := strconv.Itoa(req.ModelType)[:1] // 截取第一位
|
||||
sql += ` AND model_type::text LIKE ? `
|
||||
args = append(args, prefix+"%")
|
||||
}
|
||||
|
||||
if !g.IsEmpty(req.IsPrivate) {
|
||||
sql += ` AND is_private = ? `
|
||||
args = append(args, req.IsPrivate)
|
||||
}
|
||||
|
||||
if req.IsOwner != nil && *req.IsOwner == 0 {
|
||||
if req.Enabled != nil && *req.Enabled == 1 {
|
||||
sql += ` AND creator = ? AND is_owner = ? AND enabled=1 `
|
||||
} else if req.Enabled != nil && *req.Enabled == 0 {
|
||||
sql += ` AND creator = ? AND is_owner = ? AND enabled=0 `
|
||||
} else {
|
||||
sql += ` AND creator = ? AND is_owner = ? `
|
||||
}
|
||||
args = append(args, req.Creator, req.IsOwner)
|
||||
} else if req.IsOwner != nil && *req.IsOwner == 1 {
|
||||
if req.Enabled != nil && *req.Enabled == 1 {
|
||||
sql += ` AND ((creator = ? AND is_owner = ? AND enabled=1) OR (is_owner = 0 AND enabled=1)) `
|
||||
} else if req.Enabled != nil && *req.Enabled == 0 {
|
||||
sql += ` AND ((creator = ? AND is_owner = ? AND enabled=0) OR (is_owner = 0 AND enabled=1)) `
|
||||
} else {
|
||||
sql += ` AND ((creator = ? AND is_owner = ?) OR (is_owner = 0 AND enabled=1)) `
|
||||
}
|
||||
args = append(args, req.Creator, req.IsOwner)
|
||||
}
|
||||
|
||||
sql += ` ORDER BY model_name, is_owner DESC, created_at DESC`
|
||||
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = r.Structs(&list)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
total = len(list)
|
||||
return
|
||||
}
|
||||
|
||||
// GetByModelNameForTenant 后台任务使用:按 tenant_id + model_name 查询,不依赖 gfdb Hook/Trace/用户上下文
|
||||
func (d *modelGatewayModelsDao) GetByModelNameForTenant(ctx context.Context, tenantId uint64, modelName string) (*entity.ModelGatewayModel, error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx,
|
||||
"SELECT * FROM "+public.TableNameModel+" WHERE tenant_id=? AND model_name=? AND deleted_at IS NULL LIMIT 1",
|
||||
tenantId, modelName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
var list []*entity.ModelGatewayModel
|
||||
if err := r.Structs(&list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelGatewayTask = &modelGatewayTaskDao{}
|
||||
|
||||
type modelGatewayTaskDao struct{}
|
||||
|
||||
// Insert 插入
|
||||
func (d *modelGatewayTaskDao) Insert(ctx context.Context, req *entity.ModelGatewayTask) (id int64, err error) {
|
||||
m := new(entity.ModelGatewayTask)
|
||||
err = gconv.Struct(req, &m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).Insert(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// Update 更新(按ID)
|
||||
func (d *modelGatewayTaskDao) Update(ctx context.Context, req *entity.ModelGatewayTask) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
|
||||
OmitEmpty().
|
||||
Data(req).
|
||||
Where(entity.ModelGatewayTaskCol.Id, req.Id).
|
||||
Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
// Get 获取(按TaskID 或 ID)
|
||||
func (d *modelGatewayTaskDao) Get(ctx context.Context, req *entity.ModelGatewayTask) (m *entity.ModelGatewayTask, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
|
||||
OmitEmpty().
|
||||
Where(entity.ModelGatewayTaskCol.TaskID, req.TaskID).
|
||||
Where(entity.ModelGatewayTaskCol.Id, req.Id).
|
||||
One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Struct(&m)
|
||||
return
|
||||
}
|
||||
|
||||
// List 分页查询
|
||||
func (d *modelGatewayTaskDao) List(ctx context.Context, pageNum, pageSize int, req *entity.ModelGatewayTask) (list []*entity.ModelGatewayTask, total int64, err error) {
|
||||
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
|
||||
OmitEmpty().
|
||||
Where(entity.ModelGatewayTaskCol.Creator, req.Creator).
|
||||
Where(entity.ModelGatewayTaskCol.ModelName, "%"+req.ModelName+"%").
|
||||
Where(entity.ModelGatewayTaskCol.BizName, req.BizName).
|
||||
Where(entity.ModelGatewayTaskCol.State, req.State).
|
||||
Where(entity.ModelGatewayTaskCol.TaskID, req.TaskID).
|
||||
OrderDesc(entity.ModelGatewayTaskCol.CreatedAt)
|
||||
if pageNum > 0 && pageSize > 0 {
|
||||
model = model.Page(pageNum, pageSize)
|
||||
}
|
||||
r, totalInt, err := model.AllAndCount(false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total = gconv.Int64(totalInt)
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除(软删,按ID)
|
||||
func (d *modelGatewayTaskDao) Delete(ctx context.Context, req *entity.ModelGatewayTask) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
|
||||
Where(entity.ModelGatewayTaskCol.Id, req.Id).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
// ListByTaskIDs 批量查询
|
||||
func (d *modelGatewayTaskDao) ListByTaskIDs(ctx context.Context, taskIDs []string) (list []*entity.ModelGatewayTask, err error) {
|
||||
if len(taskIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
|
||||
WhereIn(entity.ModelGatewayTaskCol.TaskID, taskIDs).
|
||||
All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Structs(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// MarkDownloadedByID 标记已下载
|
||||
func (d *modelGatewayTaskDao) MarkDownloadedByID(ctx context.Context, id int64) error {
|
||||
_, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
|
||||
Where(entity.ModelGatewayTaskCol.Id, id).
|
||||
Where(entity.ModelGatewayTaskCol.State, 2).
|
||||
Data(map[string]any{entity.ModelGatewayTaskCol.State: 4}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPendingAsyncTasks 获取进行中的异步任务
|
||||
func (d *modelGatewayTaskDao) GetPendingAsyncTasks(ctx context.Context, limit int) ([]*entity.ModelGatewayTask, error) {
|
||||
var tasks []*entity.ModelGatewayTask
|
||||
err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
|
||||
Where(entity.ModelGatewayTaskCol.State, 1).
|
||||
Limit(limit).
|
||||
Scan(&tasks)
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
// ======================== 事务抢占 ========================
|
||||
|
||||
// ClaimByID 按主键抢占,返回抢占后的任务
|
||||
func (d *modelGatewayTaskDao) ClaimByID(ctx context.Context, id int64) (*entity.ModelGatewayTask, error) {
|
||||
// 1) 先查任务
|
||||
var task entity.ModelGatewayTask
|
||||
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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
@@ -62,6 +63,41 @@ func (d *modelManageDao) Get(ctx context.Context, req *dto.GetModelManage, field
|
||||
return
|
||||
}
|
||||
|
||||
// GetByCreatorAndName 按创建人+模型名精确查询(无缓存),用于同一用户下的同名唯一性校验。
|
||||
// 走 Model 链(自动过滤软删除),与 Get 一致但不带 Cache,避免缓存过期导致重复放行。
|
||||
func (d *modelManageDao) GetByCreatorAndName(ctx context.Context, creator, modelName string) (res *entity.ModelManage, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).
|
||||
OmitEmpty().
|
||||
Where(entity.ModelManageCol.ModelName, modelName).
|
||||
Where(entity.ModelManageCol.Creator, creator).
|
||||
One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// CountReferences 统计引用某系统模型的引用行数(Model 链自动过滤软删)
|
||||
func (d *modelManageDao) CountReferences(ctx context.Context, systemModelId int64) (count int, err error) {
|
||||
count, err = gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).
|
||||
Where(entity.ModelManageCol.RefSystemModelId, systemModelId).
|
||||
Count()
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateReferencesName 系统模型改名时同步引用行的 model_name(保列表 DISTINCT ON 去重正确)
|
||||
func (d *modelManageDao) UpdateReferencesName(ctx context.Context, systemModelId int64, newName string) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).
|
||||
Data(gdb.Map{entity.ModelManageCol.ModelName: newName}).
|
||||
Where(entity.ModelManageCol.RefSystemModelId, systemModelId).
|
||||
Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelSession = &modelSessionDao{}
|
||||
|
||||
type modelSessionDao struct{}
|
||||
|
||||
// Insert 插入
|
||||
func (d *modelSessionDao) Insert(ctx context.Context, req *dto.CreateModelSessionReq) (id int64, err error) {
|
||||
m := new(entity.ModelSession)
|
||||
err = gconv.Struct(req, &m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelSession).Insert(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// Update 更新(按ID)
|
||||
func (d *modelSessionDao) Update(ctx context.Context, req *dto.UpdateModelSessionReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelSession).
|
||||
OmitEmpty().
|
||||
Data(req).
|
||||
Where(entity.ModelSessionCol.Id, req.Id).
|
||||
Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelTaskEnd = &modelTaskEndDao{}
|
||||
|
||||
type modelTaskEndDao struct{}
|
||||
|
||||
// Insert 插入
|
||||
func (d *modelTaskEndDao) Insert(ctx context.Context, req *dto.CreateModelTaskEndReq) (id int64, err error) {
|
||||
m := new(entity.ModelTaskEnd)
|
||||
err = gconv.Struct(req, &m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskEnd).Insert(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelTaskStart = &modelTaskStartDao{}
|
||||
|
||||
type modelTaskStartDao struct{}
|
||||
|
||||
// Insert 插入
|
||||
func (d *modelTaskStartDao) Insert(ctx context.Context, req *dto.CreateModelTaskStartReq) (id int64, err error) {
|
||||
m := new(entity.ModelTaskStart)
|
||||
err = gconv.Struct(req, &m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskStart).Insert(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// Update 更新(按ID)
|
||||
func (d *modelTaskStartDao) Update(ctx context.Context, req *dto.UpdateModelTaskStartReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskStart).
|
||||
OmitEmpty().
|
||||
Data(req).
|
||||
Where(entity.ModelTaskStartCol.Id, req.Id).
|
||||
Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *modelTaskStartDao) Delete(ctx context.Context, req *dto.DeleteModelTaskStartReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskStart).
|
||||
Where(entity.ModelTaskStartCol.Id, req.Id).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *modelTaskStartDao) ListByLimitNotTenantId(ctx context.Context, req *dto.GetModelTaskStartListReq, fields ...string) (res []entity.ModelTaskStart, err error) {
|
||||
// 获取表前缀
|
||||
prefix := g.Cfg().MustGet(ctx, fmt.Sprintf("database.%s.0.prefix", public.DbNameModelGateway)).String()
|
||||
table := prefix + public.TableNameModelTaskStart
|
||||
// 动态拼接 SELECT 列
|
||||
var field string
|
||||
if !g.IsEmpty(fields) {
|
||||
for k, v := range fields {
|
||||
if k == len(fields)-1 {
|
||||
field = field + v
|
||||
} else {
|
||||
field = field + v + ","
|
||||
}
|
||||
}
|
||||
} else {
|
||||
field = "*"
|
||||
}
|
||||
// 动态拼接 WHERE 条件
|
||||
var whereCondition string
|
||||
whereCondition = whereCondition + fmt.Sprintf(" AND %s != '' ", entity.ModelTaskStartCol.TaskId)
|
||||
whereCondition = whereCondition + fmt.Sprintf(" AND %s IS NULL ", entity.ModelTaskStartCol.DeletedAt)
|
||||
// 排序
|
||||
orderSql := fmt.Sprintf(" ORDER BY %s ASC ", entity.ModelTaskStartCol.CreatedAt)
|
||||
// 分页
|
||||
limitSql := ""
|
||||
if req.Page != nil {
|
||||
pageNum := int(req.Page.PageNum)
|
||||
pageSize := int(req.Page.PageSize)
|
||||
offset := (pageNum - 1) * pageSize
|
||||
limitSql = fmt.Sprintf(" LIMIT %d OFFSET %d ", pageSize, offset)
|
||||
}
|
||||
// 查询
|
||||
sql := `SELECT ` + field + ` FROM ` + table + ` WHERE 1=1 ` + whereCondition + orderSql + limitSql + ``
|
||||
// 执行查询
|
||||
result, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = result.Structs(&res)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -3,34 +3,35 @@ module model-gateway
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.30
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.33
|
||||
github.com/bjang03/gmq v0.0.3
|
||||
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2
|
||||
github.com/gogf/gf/v2 v2.10.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/tidwall/gjson v1.19.0
|
||||
golang.org/x/sync v0.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/BurntSushi/toml v1.5.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.19.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/go-ego/gse v1.0.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect
|
||||
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
@@ -39,62 +40,68 @@ 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/btree v1.1.3 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/flatbuffers v1.12.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // 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.28.0 // indirect
|
||||
github.com/hashicorp/consul/api v1.33.5 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
||||
github.com/hashicorp/consul/api v1.26.1 // 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.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.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/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/mitchellh/go-homedir 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/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.0.9 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/r3labs/diff/v2 v2.15.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.21.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tiger1103/gfast-token v1.0.10 // indirect
|
||||
github.com/vcaesar/cedar v0.30.0 // indirect
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.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.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.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.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.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/proto/otlp v1.7.1 // 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
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-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
|
||||
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
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
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=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.33 h1:AhWJ6l9zrjc1U0UEfyIZu8wkkVFxNe0hfuA51vOnOIo=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.33/go.mod h1:FtI9KJJSKo4/K0emjVkbL8yoSIPHJdZXr27vnScQpmM=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
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/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=
|
||||
@@ -21,6 +19,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bjang03/gmq v0.0.3 h1:Yn9GZP1okOc8uh0f/1FFTooV5/mbO4pKrkcK9mTMjok=
|
||||
github.com/bjang03/gmq v0.0.3/go.mod h1:Y7TwWGuV4Cw97WUDaM7x+NC4kyFx1z44WAvNwJV3HV8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -38,10 +38,6 @@ 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=
|
||||
@@ -53,6 +49,8 @@ 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=
|
||||
@@ -65,10 +63,14 @@ 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.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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4=
|
||||
github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
@@ -80,11 +82,15 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/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=
|
||||
@@ -122,10 +128,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.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/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/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=
|
||||
@@ -142,12 +148,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.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/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/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=
|
||||
@@ -177,8 +183,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.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
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/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=
|
||||
@@ -193,10 +199,10 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.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/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -206,8 +212,10 @@ 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.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
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=
|
||||
@@ -215,39 +223,45 @@ 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.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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-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.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/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/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.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
|
||||
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
|
||||
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/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/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
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/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=
|
||||
@@ -274,37 +288,44 @@ 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.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/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
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/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=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.6.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.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
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/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s=
|
||||
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=
|
||||
@@ -316,28 +337,28 @@ github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaU
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/xxh3 v1.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=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.4.0 h1:Oq6BmUAAFTzMeh6AonuDlgZMuAuEiUxoAD1koK5MuFo=
|
||||
go.mongodb.org/mongo-driver/v2 v2.4.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
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=
|
||||
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.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/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/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=
|
||||
@@ -349,16 +370,18 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
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/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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -374,8 +397,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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -384,8 +407,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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -409,15 +432,16 @@ 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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -427,8 +451,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.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -443,17 +467,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-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/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/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.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
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=
|
||||
@@ -463,8 +487,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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
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=
|
||||
|
||||
@@ -2,8 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service/task"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/service"
|
||||
"model-gateway/service/utils"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -14,57 +15,54 @@ import (
|
||||
"gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/jaeger"
|
||||
_ "gitea.redpowerfuture.com/red-future/common/swagger"
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
|
||||
_ "github.com/gogf/gf/contrib/nosql/redis/v2"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtimer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ctx := context.Background()
|
||||
defer jaeger.ShutDown(ctx)
|
||||
|
||||
// 初始化全局协程池(最大 goroutine 数,从配置文件读取,默认 100)
|
||||
workerNum := g.Cfg().MustGet(ctx, "pool.workerNum", utils.DefaultWorkerNum).Int()
|
||||
utils.Init(workerNum)
|
||||
g.Log().Infof(ctx, "[main] 全局协程池已初始化, workerNum=%d", workerNum)
|
||||
|
||||
// 注册路由
|
||||
http.RouteRegister([]interface{}{
|
||||
controller.ModelCall,
|
||||
controller.ModelManage,
|
||||
controller.ModelGatewayModels,
|
||||
controller.ModelGatewayTask,
|
||||
controller.ModelGatewayLogsStat,
|
||||
controller.ModelErrorMemory,
|
||||
})
|
||||
|
||||
// 本地调试:可选自动触发 worker/cleaner(由配置文件控制)
|
||||
startAutoRunner(ctx)
|
||||
gmq.GmqRegister(public.GmqMsgPluginsName, &mq.NatsConn{
|
||||
NatsConfig: mq.NatsConfig{
|
||||
Addr: g.Config().MustGet(ctx, "nats.addr").String(),
|
||||
Port: g.Config().MustGet(ctx, "nats.port").String(),
|
||||
Username: g.Config().MustGet(ctx, "nats.username").String(),
|
||||
Password: g.Config().MustGet(ctx, "nats.password").String(),
|
||||
},
|
||||
})
|
||||
|
||||
// 监听退出信号,确保 Ctrl+C 能完整退出(停止 worker/cleaner 并关闭 gateway server)
|
||||
gtimer.AddSingleton(ctx, 10*time.Second, func(ctx context.Context) {
|
||||
err := service.ModelTaskEndService.GetTaskStartList(ctx)
|
||||
if err != nil {
|
||||
g.Log().Error(ctx, "模型视频任务处理失败 err: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听退出信号,确保 Ctrl+C 能完整退出(停掉定时器与协程池,等任务执行完成再关闭)
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
g.Log().Infof(ctx, "[main] 收到退出信号,开始优雅退出...")
|
||||
// 先关闭 gateway server,等待 in-flight 请求处理完成
|
||||
_ = http.Httpserver.Shutdown()
|
||||
// 再取消上下文,避免活跃请求被中断
|
||||
cancel()
|
||||
}
|
||||
utils.Shutdown()
|
||||
g.Log().Infof(ctx, "[main] 全局协程池已关闭")
|
||||
|
||||
func startAutoRunner(ctx context.Context) {
|
||||
// queryPending
|
||||
if g.Cfg().MustGet(ctx, "asynch.queryPending.enabled").Bool() {
|
||||
interval := g.Cfg().MustGet(ctx, "asynch.queryPending.intervalSeconds", 10).Int()
|
||||
limit := g.Cfg().MustGet(ctx, "asynch.queryPending.limit", 10).Int()
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := task.ModelGatewayTask.QueryPendingTasks(ctx, &dto.QueryPendingTasksReq{Limit: limit}); err != nil {
|
||||
g.Log().Warningf(ctx, "[auto-queryPending] run once failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// 收到退出信号后,关闭全局协程池,等待所有已提交的任务执行完成
|
||||
g.Log().Info(ctx, "服务正在关闭...")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package domain
|
||||
|
||||
// ChatFieldsReq 对话/推理模型业务字段映射
|
||||
// 适用于 推理模型(100) 和 多模态模型(500) 的子类型
|
||||
type ChatFieldsReq struct {
|
||||
MaxTokens string `json:"max_tokens" dc:"模型支持的最大输出 token 数"`
|
||||
Stream string `json:"stream" dc:"是否流式输出"`
|
||||
Tools string `json:"tools" dc:"工具"`
|
||||
ToolId string `json:"tool_id" dc:"工具 ID"`
|
||||
ToolPrompt string `json:"tool_prompt" dc:"工具提示词,用于描述工具的需求"`
|
||||
UserPrompt string `json:"user_prompt" dc:"用户提示词,用于描述用户的需求"`
|
||||
SystemPrompt string `json:"system_prompt" dc:"系统提示词,用于描述系统的需求"`
|
||||
AssistantPrompt string `json:"assistant_prompt" dc:"助手提示词,用于描述助手的需求"`
|
||||
ReferenceImage string `json:"reference_image" dc:"参考图片,用于生成角色形象和风格一致性参考"`
|
||||
ReferenceVideo string `json:"reference_video" dc:"参考视频,用于生成动作和场景一致性参考"`
|
||||
ReferenceAudio string `json:"reference_audio" dc:"参考音频,用于生成声音和风格一致性参考"`
|
||||
ImgReferenceTemplate string `json:"img_reference_template" dc:"prompt 中引用参考图片的标签格式,用 %d 作为编号占位符"`
|
||||
VideoReferenceTemplate string `json:"video_reference_template" dc:"prompt 中引用参考视频的标签格式,用 %d 作为编号占位符"`
|
||||
AudioReferenceTemplate string `json:"audio_reference_template" dc:"prompt 中引用参考音频的标签格式,用 %d 作为编号占位符"`
|
||||
}
|
||||
|
||||
// ChatFieldsRes 对话/推理模型业务字段映射
|
||||
// 适用于 推理模型(100) 和 多模态模型(500) 的子类型
|
||||
type ChatFieldsRes struct {
|
||||
Tools string `json:"tools" dc:"工具"`
|
||||
ReasoningContent string `json:"reasoning_content" dc:"推理内容"`
|
||||
}
|
||||
|
||||
// VideoFields 视频模型业务字段映射
|
||||
// 适用于 视频模型(600) 及其子类型
|
||||
type VideoFields struct {
|
||||
SystemPrompt string `json:"system_prompt" dc:"系统提示词,用于描述系统的需求"`
|
||||
MinDuration string `json:"min_duration" dc:"模型支持的最小视频时长(秒)"`
|
||||
MaxDuration string `json:"max_duration" dc:"模型支持的最大视频时长(秒)"`
|
||||
FirstFrame string `json:"first_frame" dc:"视频的首帧/初始画面,传入一张图片作为视频第一帧画面"`
|
||||
ReferenceImage string `json:"reference_image" dc:"参考图片,用于生成角色形象和风格一致性参考"`
|
||||
ReferenceVideo string `json:"reference_video" dc:"参考视频,用于生成动作和场景一致性参考"`
|
||||
MaxMediaItems string `json:"max_media_items" dc:"模型允许传入的最大参考媒体数量"`
|
||||
ImgReferenceTemplate string `json:"img_reference_template" dc:"prompt 中引用参考图片的标签格式,用 %d 作为编号占位符"`
|
||||
VideoReferenceTemplate string `json:"video_reference_template" dc:"prompt 中引用参考视频的标签格式,用 %d 作为编号占位符"`
|
||||
AudioReferenceTemplate string `json:"audio_reference_template" dc:"prompt 中引用参考音频的标签格式,用 %d 作为编号占位符"`
|
||||
Fps string `json:"fps" dc:"视频帧率"`
|
||||
Resolution string `json:"resolution" dc:"视频分辨率,如 1920x1080"`
|
||||
NegativePrompt string `json:"negative_prompt" dc:"反向提示词,描述不希望出现的内容"`
|
||||
CfgScale string `json:"cfg_scale" dc:"CFG 引导比例,控制对 prompt 的遵从程度"`
|
||||
}
|
||||
|
||||
// VideoFieldsRes 视频模型业务字段映射
|
||||
// 适用于 视频模型(600) 及其子类型
|
||||
type VideoFieldsRes struct {
|
||||
Duration int64 `json:"duration" dc:"视频时长(秒)"`
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ModelCallReq 模型调用请求
|
||||
type ModelCallReq struct {
|
||||
g.Meta `path:"/modelCall" method:"post" tags:"模型管理" summary:"模型调用" dc:"模型调用"`
|
||||
ModelId int64 `json:"modelId" v:"required#modelId不能为空" dc:"模型ID"`
|
||||
BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
|
||||
SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"`
|
||||
RequestParams map[string]any `json:"requestParams" dc:"请求参数(模板字段)"`
|
||||
BusinessParams map[string]any `json:"businessParams" dc:"业务参数(按业务字段名传,按 RequestBusinessFieldMapping 写入请求体)"`
|
||||
MsgTopic string `json:"msgTopic" dc:"消息主题(异步必要参数)"`
|
||||
}
|
||||
|
||||
type ModelCallRes struct {
|
||||
TaskId int64 `json:"id" dc:"任务ID"`
|
||||
ModelId int64 `json:"modelId" dc:"生效模型ID(引用行=解析后的系统模型ID,计价按此)"`
|
||||
MediaType string `json:"mediaType" dc:"输入媒体类型(shop词汇: text/audio/video)"`
|
||||
TotalTokens int64 `json:"totalTokens" dc:"总token"`
|
||||
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
|
||||
CompletionTokens int64 `json:"completionTokens" dc:"输出token"`
|
||||
Tools []ModelTool `json:"tools" dc:"工具"`
|
||||
ReasoningContent string `json:"reasoningContent" dc:"思考内容"`
|
||||
Content map[string]any `json:"content" dc:"内容"`
|
||||
Cost float64 `json:"cost" dc:"费用(元)"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
|
||||
type ModelTool struct {
|
||||
Id string `json:"id" dc:"工具ID"`
|
||||
Type string `json:"type" dc:"工具类型"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
// ModelCallStreamEvent 流式增量事件(SSE data 行)。文本增量事件省略 Type,
|
||||
// 流末 done 事件带 Type="done" 与 Tools。字段名由本结构体 json tag 统一管理。
|
||||
type ModelCallStreamEvent struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Content map[string]any `json:"content,omitempty"`
|
||||
ReasoningContent string `json:"reasoningContent,omitempty"`
|
||||
TotalTokens int64 `json:"totalTokens,omitempty"`
|
||||
PromptTokens int64 `json:"promptTokens,omitempty"`
|
||||
CompletionTokens int64 `json:"completionTokens,omitempty"`
|
||||
Tools []ModelTool `json:"tools,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty" dc:"费用(元),done事件携带最终费用,增量事件不带"`
|
||||
}
|
||||
|
||||
// ModelCallStreamReq 模型调用流式请求
|
||||
type ModelCallStreamReq struct {
|
||||
g.Meta `path:"/modelCallStream" method:"post" tags:"模型管理" summary:"模型调用流式" dc:"模型调用流式"`
|
||||
ModelId int64 `json:"modelId" v:"required#modelId不能为空" dc:"模型ID"`
|
||||
BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
|
||||
SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"`
|
||||
RequestParams map[string]any `json:"requestParams" dc:"请求参数(模板字段)"`
|
||||
BusinessParams map[string]any `json:"businessParams" dc:"业务参数(按业务字段名传,按 RequestBusinessFieldMapping 写入请求体)"`
|
||||
}
|
||||
|
||||
type ModelMsg struct {
|
||||
TaskID int64 `json:"id" dc:"任务ID"`
|
||||
ModelId int64 `json:"modelId" dc:"生效模型ID(引用行=解析后的系统模型ID,计价按此)"`
|
||||
MediaType string `json:"mediaType" dc:"输入媒体类型(shop词汇: text/audio/video)"`
|
||||
TotalTokens int64 `json:"totalTokens" dc:"总token"`
|
||||
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
|
||||
CompletionTokens int64 `json:"completionTokens" dc:"输出token"`
|
||||
Content map[string]any `json:"content" dc:"内容"`
|
||||
Cost float64 `json:"cost" dc:"费用(元)"`
|
||||
Duration int64 `json:"duration" dc:"时长(秒)"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
|
||||
//========================
|
||||
// Upload 文件上传定义
|
||||
//========================
|
||||
|
||||
// UploadFileBytesReq 上传文件请求(字节流)
|
||||
type UploadFileBytesReq struct {
|
||||
FileName string `json:"fileName" dc:"文件名"`
|
||||
FileBytes []byte `json:"fileBytes" dc:"文件字节流"`
|
||||
FileStoreURL string `json:"fileStoreURL" dc:"文件存储URL"`
|
||||
}
|
||||
|
||||
type UploadFileBytesRes struct {
|
||||
FileURL string `json:"fileURL" dc:"上传地址"`
|
||||
FileSize int `json:"fileSize" dc:"文件大小"`
|
||||
FileName string `json:"fileName" dc:"文件名称"`
|
||||
FileFormat string `json:"fileFormat" dc:"文件格式"`
|
||||
FileAddressPrefix string `json:"fileAddressPrefix"`
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Template 元数据模板定义
|
||||
// ===========================
|
||||
|
||||
// UploadRule 上传文件规则
|
||||
type UploadRule struct {
|
||||
Format string `json:"format" dc:"格式"`
|
||||
MaxSize int `json:"maxSize" dc:"最大大小"`
|
||||
MaxCount int `json:"maxCount" dc:"最大数量"`
|
||||
}
|
||||
|
||||
// Constraint 字段约束
|
||||
type Constraint struct {
|
||||
// 数字类型:int、float、double
|
||||
NumberType string `json:"numberType" dc:"数字类型"`
|
||||
Min any `json:"min" dc:"最小值"`
|
||||
Max any `json:"max" dc:"最大值"`
|
||||
|
||||
// 字符串类型:string、text
|
||||
MinLength int `json:"minLength" dc:"最小长度"`
|
||||
MaxLength int `json:"maxLength" dc:"最大长度"`
|
||||
Pattern string `json:"pattern" dc:"正则"`
|
||||
|
||||
// 上传文件类型
|
||||
UploadTotalMaxCount int `json:"uploadTotalMaxCount" dc:"上传文件最大数量"`
|
||||
UploadTotalMaxSize int `json:"uploadTotalMaxSize" dc:"上传文件最大大小"`
|
||||
UploadRules []UploadRule `json:"uploadRules" dc:"上传文件规则"`
|
||||
}
|
||||
|
||||
// SelectOptionT 选择框选项
|
||||
type SelectOptionT struct {
|
||||
Label string `json:"label" dc:"展示标签"`
|
||||
Value any `json:"value" dc:"选项值"`
|
||||
}
|
||||
|
||||
// Template 元数据模板结构体
|
||||
// 用于描述单个字段的元数据,包括类型、值、默认值、校验规则等
|
||||
type Template struct {
|
||||
Type string `json:"type" dc:"类型:string/boolean/number/object/array/null"`
|
||||
Value any `json:"value" dc:"值"`
|
||||
DefaultValue any `json:"defaultValue" dc:"默认值"`
|
||||
Required bool `json:"required" dc:"是否必填"`
|
||||
FieldType string `json:"fieldType" dc:"字段类型(输入框/选择框/多行文本/数字输入等)"`
|
||||
Attrs any `json:"attrs" dc:"子字段(object/array时使用)"`
|
||||
IsContainer bool `json:"isContainer" dc:"是否为容器"`
|
||||
LinkRules []map[string]string `json:"linkRules" dc:"链接规则"`
|
||||
Label string `json:"label" dc:"展示标签"`
|
||||
Loop bool `json:"loop" dc:"是否为循环"`
|
||||
IsForm bool `json:"isForm" dc:"是否为表单"`
|
||||
Constraint Constraint `json:"constraint" dc:"字段约束"`
|
||||
Options []SelectOptionT `json:"options" dc:"选项列表(字段类型为选择框时使用)"`
|
||||
FieldConstraint string `json:"fieldConstraint" dc:"字段额外约束"`
|
||||
EnumValues []any `json:"enumValues" dc:"枚举值列表"`
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// GetErrorMemoryListReq 错误重试记忆列表
|
||||
type GetErrorMemoryListReq struct {
|
||||
g.Meta `path:"/errorMemory/list" method:"get" tags:"错误记忆" summary:"错误重试记忆列表" dc:"查看错误→可重试结论记忆"`
|
||||
*beans.Page `json:"page"`
|
||||
}
|
||||
|
||||
type GetErrorMemoryListRes struct {
|
||||
List []ErrorMemoryItem `json:"list" dc:"记忆条目"`
|
||||
Total int64 `json:"total" dc:"总数"`
|
||||
}
|
||||
|
||||
type ErrorMemoryItem struct {
|
||||
Id int64 `json:"id"`
|
||||
MemoryKey string `json:"memoryKey"`
|
||||
Upstream string `json:"upstream"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
MsgFingerprint string `json:"msgFingerprint"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Reason string `json:"reason"`
|
||||
AnalyzedBy string `json:"analyzedBy"`
|
||||
}
|
||||
|
||||
// DeleteErrorMemoryReq 删除错误重试记忆
|
||||
type DeleteErrorMemoryReq struct {
|
||||
g.Meta `path:"/errorMemory/delete" method:"post" tags:"错误记忆" summary:"删除错误重试记忆" dc:"手动清理永久记忆条目"`
|
||||
Id int64 `json:"id" v:"required#id不能为空" dc:"记忆ID"`
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// ListModelStatReq 统计列表
|
||||
type ListModelStatReq struct {
|
||||
g.Meta `path:"/listModelStat" method:"get" tags:"统计" summary:"模型请求统计列表" dc:"按天统计模型请求次数,支持分页与条件筛选"`
|
||||
PageNum int `p:"pageNum" json:"pageNum" dc:"页码(默认1)"`
|
||||
PageSize int `p:"pageSize" json:"pageSize" dc:"每页条数(默认10)"`
|
||||
StartDay string `p:"startDay" json:"startDay" dc:"开始日期(YYYY-MM-DD,可选)"`
|
||||
EndDay string `p:"endDay" json:"endDay" dc:"结束日期(YYYY-MM-DD,可选)"`
|
||||
TenantID *int64 `p:"tenantId" json:"tenantId" dc:"租户ID(可选)"`
|
||||
Creator string `p:"creator" json:"creator" dc:"创建人(可选,模糊匹配)"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(可选,模糊匹配)"`
|
||||
}
|
||||
|
||||
type ListModelStatRes struct {
|
||||
List any `json:"list" dc:"列表数据"`
|
||||
Total int64 `json:"total" dc:"总数"`
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// CreateModelReq 添加模型配置
|
||||
type CreateModelReq struct {
|
||||
g.Meta `path:"/createModel" method:"post" tags:"模型管理" summary:"创建模型配置" dc:"添加新的模型配置"`
|
||||
ModelName string `p:"modelName" json:"modelName" v:"required#模型名称不能为空" dc:"模型名称(唯一标识)"`
|
||||
ModelType int `p:"modelType" json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"`
|
||||
BaseURL string `p:"baseUrl" json:"baseUrl" v:"required#模型地址不能为空" dc:"模型服务地址"`
|
||||
HttpMethod string `p:"httpMethod" json:"httpMethod" dc:"请求方式:GET/POST(默认POST)"`
|
||||
HeadMsg map[string]any `p:"headMsg" json:"headMsg" dc:"请求头JSON结构"`
|
||||
IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化:0-私有 1-公共"`
|
||||
Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-停用 1-启用"`
|
||||
IsChatModel *int `p:"isChatModel" json:"isChatModel" dc:"是否为对话模型:0-否 1-是"`
|
||||
CallModel *int `p:"callModel" json:"callModel" dc:"调用模式:0-同步 1-异步 2-流式"`
|
||||
RequiredFields []string `p:"requiredFields" json:"requiredFields" dc:"必填字段"`
|
||||
IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者:0-否 1-是"`
|
||||
ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥"`
|
||||
Form []map[string]any `p:"form" json:"form" dc:"动态表单配置"`
|
||||
RequestMapping map[string]any `p:"requestMapping" json:"requestMapping" dc:"请求映射"`
|
||||
ResponseMapping map[string]any `p:"responseMapping" json:"responseMapping" dc:"返回映射"`
|
||||
ResponseBody string `p:"responseBody" json:"responseBody" dc:"返回主体"`
|
||||
ResponseTokenField string `p:"responseTokenField" json:"responseTokenField" dc:"响应中消耗token的字段映射"`
|
||||
OperatorName string `p:"operatorName" json:"operatorName" dc:"运营商名称"`
|
||||
TokenConfig map[string]any `p:"tokenConfig" json:"tokenConfig" dc:"token计算配置"`
|
||||
ExtendMapping map[string]any `p:"extendMapping" json:"extendMapping" dc:"附加映射"`
|
||||
QueryConfig map[string]any `p:"queryConfig" json:"queryConfig" dc:"查询/回调配置"`
|
||||
StreamConfig map[string]any `p:"streamConfig" json:"streamConfig" dc:"流式输出配置"`
|
||||
FirstFrame string `p:"firstFrame" json:"firstFrame" dc:"首帧图片参数"`
|
||||
LastFrame string `p:"lastFrame" json:"lastFrame" dc:"尾帧图片参数"`
|
||||
MaxConcurrency int `p:"maxConcurrency" json:"maxConcurrency" dc:"最大并发数(默认10)"`
|
||||
TimeoutSeconds int `p:"timeoutSeconds" json:"timeoutSeconds" dc:"请求超时时间(秒,默认600)"`
|
||||
RetryTimes int `p:"retryTimes" json:"retryTimes" dc:"失败重试次数(默认3)"`
|
||||
AutoCleanSeconds int `p:"autoCleanSeconds" json:"autoCleanSeconds" dc:"任务完成后自动清理时间(秒,默认86400)"`
|
||||
CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址"`
|
||||
}
|
||||
|
||||
type CreateModelRes struct {
|
||||
ID int64 `json:"id,string" dc:"配置ID"`
|
||||
}
|
||||
|
||||
type UpdateModelReq struct {
|
||||
g.Meta `path:"/updateModel" method:"put" tags:"模型管理" summary:"更新模型配置" dc:"更新指定ID的模型配置"`
|
||||
ID int64 `p:"id" json:"id" v:"required#id不能为空" dc:"配置ID"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称"`
|
||||
ModelType int `p:"modelType" json:"modelType" dc:"模型类型"`
|
||||
BaseURL string `p:"baseUrl" json:"baseUrl" dc:"模型服务地址"`
|
||||
HttpMethod string `p:"httpMethod" json:"httpMethod" dc:"请求方式:GET/POST"`
|
||||
HeadMsg map[string]any `p:"headMsg" json:"headMsg" dc:"请求头JSON结构"`
|
||||
IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化:0-私有 1-公共"`
|
||||
Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-停用 1-启用"`
|
||||
IsChatModel *int `p:"isChatModel" json:"isChatModel" dc:"是否为对话模型:0-否 1-是"`
|
||||
CallModel *int `p:"callModel" json:"callModel" dc:"调用模式:0-同步 1-异步 2-流式"`
|
||||
RequiredFields []string `p:"requiredFields" json:"requiredFields" dc:"必填字段"`
|
||||
IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者:0-否 1-是"`
|
||||
ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥"`
|
||||
Form []map[string]any `p:"form" json:"form" dc:"动态表单配置"`
|
||||
RequestMapping map[string]any `p:"requestMapping" json:"requestMapping" dc:"请求映射"`
|
||||
ResponseMapping map[string]any `p:"responseMapping" json:"responseMapping" dc:"返回映射"`
|
||||
ResponseBody string `p:"responseBody" json:"responseBody" dc:"返回主体"`
|
||||
ResponseTokenField string `p:"responseTokenField" json:"responseTokenField" dc:"响应中消耗token的字段映射"`
|
||||
OperatorName string `p:"operatorName" json:"operatorName" dc:"运营商名称"`
|
||||
TokenConfig map[string]any `p:"tokenConfig" json:"tokenConfig" dc:"token计算配置"`
|
||||
ExtendMapping map[string]any `p:"extendMapping" json:"extendMapping" dc:"附加映射"`
|
||||
QueryConfig map[string]any `p:"queryConfig" json:"queryConfig" dc:"查询/回调配置"`
|
||||
StreamConfig map[string]any `p:"streamConfig" json:"streamConfig" dc:"流式输出配置"`
|
||||
FirstFrame string `p:"firstFrame" json:"firstFrame" dc:"首帧图片参数"`
|
||||
LastFrame string `p:"lastFrame" json:"lastFrame" dc:"尾帧图片参数"`
|
||||
MaxConcurrency int `p:"maxConcurrency" json:"maxConcurrency" dc:"最大并发数"`
|
||||
TimeoutSeconds int `p:"timeoutSeconds" json:"timeoutSeconds" dc:"请求超时时间(秒)"`
|
||||
RetryTimes int `p:"retryTimes" json:"retryTimes" dc:"失败重试次数"`
|
||||
AutoCleanSeconds int `p:"autoCleanSeconds" json:"autoCleanSeconds" dc:"任务完成后自动清理时间(秒)"`
|
||||
CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址"`
|
||||
}
|
||||
|
||||
type UpdateModelRes struct {
|
||||
ID int64 `json:"id,string" dc:"配置ID"`
|
||||
}
|
||||
|
||||
// DeleteModelReq 删除模型配置
|
||||
type DeleteModelReq struct {
|
||||
g.Meta `path:"/deleteModel" method:"delete" tags:"模型管理" summary:"删除模型配置" dc:"删除指定ID的模型配置"`
|
||||
ID int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"`
|
||||
}
|
||||
|
||||
type DeleteModelRes struct {
|
||||
ID int64 `json:"id,string" dc:"配置ID"`
|
||||
}
|
||||
|
||||
// GetModelReq 获取模型配置详情
|
||||
type GetModelReq struct {
|
||||
g.Meta `path:"/getModel" method:"get" tags:"模型管理" summary:"获取模型配置" dc:"根据模型ID获取配置详情"`
|
||||
ID int64 `p:"id" json:"id,string" dc:"配置ID"`
|
||||
Creator string `p:"creator" json:"creator" dc:"创建人"`
|
||||
IsChatModel *int `p:"isChatModel" json:"isChatModel" dc:"是否为聊天模型"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(唯一标识)"`
|
||||
}
|
||||
|
||||
type GetModelRes struct {
|
||||
Model *entity.ModelGatewayModel `json:"model" dc:"模型配置详情"`
|
||||
}
|
||||
|
||||
// ListModelReq 配置列表
|
||||
type ListModelReq struct {
|
||||
g.Meta `path:"/listModel" method:"get" tags:"模型管理" summary:"模型配置列表" dc:"分页获取模型配置列表"`
|
||||
Page *beans.Page `json:"page"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"`
|
||||
ModelType int `p:"modelType" json:"modelType" dc:"模型类型"`
|
||||
Enabled *int `p:"enabled" json:"enabled" dc:"是否启用:0-禁用,1-启用"`
|
||||
IsPrivate *int `p:"isPrivate" json:"isPrivate" dc:"是否私有化 0-私有 1-公共"`
|
||||
IsOwner *int `p:"isOwner" json:"isOwner" dc:"是否为所有者 0-否 1-是"`
|
||||
Creator string `p:"creator" json:"creator" dc:"创建人"`
|
||||
}
|
||||
|
||||
type ListModelRes struct {
|
||||
List any `json:"list" dc:"列表数据"`
|
||||
Total int `json:"total" dc:"总数"`
|
||||
}
|
||||
|
||||
// AutoTuneReq 动态调参(由上层定时任务每小时触发一次)
|
||||
type AutoTuneReq struct {
|
||||
g.Meta `path:"/autoTune" method:"post" tags:"模型管理" summary:"动态调参" dc:"按 model_name 维度统计指定时间窗口内执行耗时(P90),动态生成运行时 max_concurrency/queue_limit(不超过配置上限),写入 Redis 供 Worker/CreateTask 使用;windowSeconds 不传默认 3600"`
|
||||
WindowSeconds int `p:"windowSeconds" json:"windowSeconds" dc:"统计窗口秒数;不传/<=0 默认 3600(1小时)"`
|
||||
}
|
||||
|
||||
type AutoTuneRes struct {
|
||||
List any `json:"list" dc:"调参结果列表"`
|
||||
}
|
||||
|
||||
type ModelTypeModelItem struct {
|
||||
ID int64 `json:"id" dc:"模型主键ID"`
|
||||
Name string `json:"name" dc:"模型名称"`
|
||||
Form any `json:"form" dc:"动态表单配置(JSON数组),用于前端渲染"`
|
||||
}
|
||||
|
||||
// ListModelTypeReq 模型类型列表(分页)
|
||||
type ListTypeReq struct {
|
||||
g.Meta `path:"/listType" method:"get" tags:"模型类型列表" summary:"模型类型列表" dc:"分页获取模型类型列表"`
|
||||
}
|
||||
|
||||
type TypeItem struct {
|
||||
Type map[int]string `json:"type" dc:"模型类型ID到名称的映射"`
|
||||
}
|
||||
|
||||
type ListOperatorReq struct {
|
||||
g.Meta `path:"/listOperator" method:"get" tags:"模型管理" summary:"获取运营商列表" dc:"获取运营商列表"`
|
||||
}
|
||||
|
||||
type ListOperatorRes struct {
|
||||
List []string `json:"list" dc:"运营商名称到ID的映射"`
|
||||
}
|
||||
|
||||
type UpdateChatModelReq struct {
|
||||
g.Meta `path:"/updateChatModel" method:"post" tags:"模型管理" summary:"更新聊天模型" dc:"更新指定模型的聊天模型"`
|
||||
Id int64 `p:"id" json:"id" v:"required#model不能为空" dc:"模型id"`
|
||||
}
|
||||
type UpdateChatModelRes struct {
|
||||
ID int64 `json:"id,string" dc:"模型ID"`
|
||||
}
|
||||
|
||||
type GetIsChatModelReq struct {
|
||||
g.Meta `path:"/getIsChatModel" method:"get" tags:"模型管理" summary:"获取模型是否为聊天模型" dc:"根据模型ID获取是否为聊天模型"`
|
||||
}
|
||||
|
||||
type GetIsChatModelRes struct {
|
||||
Model any `json:"model" dc:"模型详情"`
|
||||
}
|
||||
|
||||
// NodeFormField 节点表单
|
||||
type NodeFormField struct {
|
||||
Value any `json:"value" dc:"字段值"`
|
||||
Field string `json:"field" dc:"字段标识"`
|
||||
Label string `json:"label" dc:"字段标签"`
|
||||
Type string `json:"type" dc:"字段类型"`
|
||||
Required bool `json:"required" dc:"是否必填"`
|
||||
Default any `json:"default,omitempty" dc:"默认值"`
|
||||
Options []SelectOption `json:"options" dc:"下拉选项列表"`
|
||||
FieldConstraint any `json:"fieldConstraint" dc:"字段约束"`
|
||||
}
|
||||
|
||||
type SelectOption struct {
|
||||
Label string `json:"label" dc:"选项标签"`
|
||||
Value string `json:"value" dc:"选项值"`
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// CreateTaskReq 创建异步任务
|
||||
type CreateTaskReq struct {
|
||||
g.Meta `path:"/createTask" method:"post" tags:"任务管理" summary:"创建异步任务" dc:"创建异步任务并返回任务ID;创建成功后会立即异步尝试执行当前任务,执行成功后按回调配置触发钩子"`
|
||||
ModelName string `p:"modelName" json:"modelName" v:"required#modelName不能为空" dc:"模型名称"`
|
||||
BizName string `p:"bizName" json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
|
||||
CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址(可选,用于后续业务通知)"`
|
||||
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 {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
type ModelTaskCallbackReq struct {
|
||||
g.Meta `path:"/modelCallback" method:"post" tags:"异步任务" summary:"模型任务回调通知"`
|
||||
TaskID string `json:"id" dc:"任务ID"`
|
||||
Status string `json:"status" dc:"queued/running/succeeded/failed/expired"`
|
||||
Content map[string]any `json:"content,omitempty" dc:"任务结果内容"`
|
||||
Usage map[string]any `json:"usage,omitempty" dc:"token用量"`
|
||||
}
|
||||
|
||||
type ModelTaskCallbackRes struct {
|
||||
Success bool `json:"success" dc:"是否接收成功"`
|
||||
}
|
||||
|
||||
// QueryPendingTasksReq 批量轮询请求
|
||||
type QueryPendingTasksReq struct {
|
||||
g.Meta `path:"/queryPending" method:"get" tags:"异步任务" summary:"批量轮询进行中的任务"`
|
||||
Limit int `p:"limit" json:"limit" dc:"查询数量,默认10"`
|
||||
}
|
||||
|
||||
// QueryPendingTasksRes 批量轮询响应
|
||||
type QueryPendingTasksRes struct {
|
||||
Total int `json:"total" dc:"本次查询数量"`
|
||||
Results []QueryTaskItem `json:"results" dc:"查询结果列表"`
|
||||
}
|
||||
|
||||
// QueryTaskItem 单个任务查询结果
|
||||
type QueryTaskItem struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
Status string `json:"status" dc:"任务状态"`
|
||||
Content map[string]any `json:"content,omitempty" dc:"结果内容"`
|
||||
Usage map[string]any `json:"usage,omitempty" dc:"token用量"`
|
||||
}
|
||||
|
||||
// GetTaskResultReq 获取结果(只返回 oss 地址)
|
||||
type GetTaskResultReq struct {
|
||||
g.Meta `path:"/getTaskResult" method:"get" tags:"任务管理" summary:"获取任务结果" dc:"根据任务ID获取结果(只返回OSS地址)"`
|
||||
TaskID string `p:"taskId" json:"taskId" v:"required#taskwId不能为空" dc:"任务ID"`
|
||||
}
|
||||
|
||||
type GetTaskResultRes struct {
|
||||
OssFile string `json:"ossFile" dc:"结果文件OSS地址"`
|
||||
State int `json:"state" dc:"任务状态"`
|
||||
}
|
||||
|
||||
// GetTaskBatchReq 批量查询任务(并对成功任务标记为已下载)
|
||||
type GetTaskBatchReq struct {
|
||||
g.Meta `path:"/getTaskBatch" method:"post" tags:"任务管理" summary:"批量查询任务" dc:"批量查询任务状态与OSS地址;对成功(state=2)的任务自动标记为已下载(state=4),并写入保留到期时间"`
|
||||
TaskIDs []string `p:"taskIds" json:"taskIds" v:"required#taskIds不能为空" dc:"任务ID列表"`
|
||||
}
|
||||
|
||||
type GetTaskBatchRes struct {
|
||||
List []GetTaskBatchItem `json:"list" dc:"任务列表"`
|
||||
}
|
||||
|
||||
type GetTaskBatchItem struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
State int `json:"state" dc:"任务状态"`
|
||||
OssFile string `json:"ossFile" dc:"结果文件OSS地址"`
|
||||
TextResult map[string]any `json:"textResult" dc:"文本结果"`
|
||||
}
|
||||
|
||||
// ListTaskReq 任务列表分页查询
|
||||
type ListTaskReq struct {
|
||||
g.Meta `path:"/listTask" method:"get" tags:"任务管理" summary:"任务列表" dc:"分页查询任务列表,支持按状态/模型名称/task_id过滤"`
|
||||
PageNum int `p:"pageNum" json:"pageNum" dc:"页码(默认1)"`
|
||||
PageSize int `p:"pageSize" json:"pageSize" dc:"每页条数(默认10)"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊匹配)"`
|
||||
BizName string `p:"bizName" json:"bizName" dc:"业务名称"`
|
||||
TaskID string `p:"taskId" json:"taskId" dc:"任务ID(模糊匹配)"`
|
||||
State int `p:"state" json:"state" dc:"任务状态(0/1/2/3/4,可选)"`
|
||||
}
|
||||
|
||||
type ListTaskRes struct {
|
||||
List any `json:"list" dc:"列表数据"`
|
||||
Total int64 `json:"total" dc:"总数"`
|
||||
}
|
||||
|
||||
// RunWorkReq 手动触发 worker 执行一次(由上层定时任务调用)
|
||||
type RunWorkReq struct {
|
||||
g.Meta `path:"/runWork" method:"post" tags:"任务管理" summary:"执行一次Worker" dc:"手动触发一次Worker抢占并处理排队中的任务;适合处理 createTask 立即执行时未处理到的任务以及积压队列"`
|
||||
BatchSize int `p:"batchSize" json:"batchSize" dc:"本次抢占任务数量(默认10)"`
|
||||
Goroutines int `p:"goroutines" json:"goroutines" dc:"本次并发数(默认1)"`
|
||||
}
|
||||
|
||||
type RunWorkRes struct {
|
||||
Claimed int `json:"claimed" dc:"本次抢占并处理的任务数"`
|
||||
}
|
||||
@@ -11,29 +11,34 @@ import (
|
||||
|
||||
// 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:"视频的尾帧图像"`
|
||||
g.Meta `path:"/createModelManage" method:"post" tags:"模型管理" summary:"创建模型配置" dc:"添加新的模型配置"`
|
||||
ModelSupplier model.SupplierType `json:"modelSupplier" v:"required#模型供应商不能为空" dc:"模型供应商"`
|
||||
ModelName string `json:"modelName" v:"required#模型名称不能为空" dc:"模型名称"`
|
||||
ModelType model.ModelType `json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"`
|
||||
BaseURL string `json:"baseUrl" v:"required#模型服务地址不能为空" dc:"模型服务地址"`
|
||||
SystemModel *bool `json:"systemModel" dc:"系统模型"`
|
||||
RefSystemModelId int64 `json:"refSystemModelId" dc:"引用的系统模型ID(引用创建时填,普通创建留空)"`
|
||||
HttpMethod string `json:"httpMethod" dc:"请求方式:GET/POST" d:"POST"`
|
||||
ChatModel *bool `json:"chatModel" dc:"对话模型"`
|
||||
ResponseType model.ResponseType `json:"responseType" v:"required#调用模式不能为空" dc:"调用模式:0-同步 1-异步 2-流式"`
|
||||
ApiKey string `json:"apiKey" dc:"调用凭证/密钥"`
|
||||
Enabled *bool `json:"enabled" dc:"启用"`
|
||||
RequestHeadMapping map[string]string `json:"requestHeadMapping" dc:"请求头映射"`
|
||||
RequestBodyMapping map[string]any `json:"requestBodyMapping" dc:"请求体映射"`
|
||||
RequestBusinessFieldMapping map[string]string `json:"requestBusinessFieldMapping" dc:"业务字段映射"`
|
||||
ResponseMapping map[string]any `json:"responseMapping" dc:"返回映射"`
|
||||
ResponseBodyMapping map[string]string `json:"responseBodyMapping" dc:"返回体映射"`
|
||||
ResponseBusinessFieldMapping map[string]string `json:"responseBusinessFieldMapping" dc:"业务字段映射"`
|
||||
MaxConcurrency int `json:"maxConcurrency" dc:"最大并发数(默认10)"`
|
||||
TokenMapping *entity.TokenMapping `json:"tokenMapping" dc:"token映射"`
|
||||
AsyncTaskMapping *entity.AsyncTaskMapping `json:"asyncTaskMapping" dc:"异步任务映射"`
|
||||
TokenPredictPrice float64 `json:"tokenPredictPrice" dc:"模型Token预估价格"`
|
||||
TokenPredictPriceUnit string `json:"tokenPredictPriceUnit" dc:"模型Token预估价格单位"`
|
||||
MaxTokens int `json:"maxTokens" dc:"最大token数"`
|
||||
MinDuration int `json:"minDuration" dc:"最小时长"`
|
||||
MaxDuration int `json:"maxDuration" dc:"最大时长"`
|
||||
LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"`
|
||||
ErrorMessageMapping map[string]any `json:"errorMessageMapping" dc:"错误消息映射(schema 树,解析模型错误用)"`
|
||||
}
|
||||
|
||||
type CreateModelManageRes struct {
|
||||
@@ -41,34 +46,39 @@ type CreateModelManageRes struct {
|
||||
}
|
||||
|
||||
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:"视频的尾帧图像"`
|
||||
g.Meta `path:"/updateModelManage" method:"put" tags:"模型管理" summary:"更新模型配置" dc:"更新指定ID的模型配置"`
|
||||
Id int64 `json:"id" v:"required#id不能为空" dc:"配置ID"`
|
||||
ModelSupplier model.SupplierType `json:"modelSupplier" dc:"模型供应商"`
|
||||
ModelName string `json:"modelName" dc:"模型名称"`
|
||||
ModelType model.ModelType `json:"modelType" dc:"模型类型"`
|
||||
BaseURL string `json:"baseUrl" dc:"模型服务地址"`
|
||||
SystemModel *bool `json:"systemModel" dc:"系统模型"`
|
||||
RefSystemModelId int64 `json:"refSystemModelId" dc:"引用的系统模型ID(引用行只改个人字段,本字段无效)"`
|
||||
HttpMethod string `json:"httpMethod" dc:"请求方式:GET/POST"`
|
||||
ChatModel *bool `json:"chatModel" dc:"对话模型"`
|
||||
ResponseType model.ResponseType `json:"responseType" dc:"调用模式:0-同步 1-异步 2-流式"`
|
||||
ApiKey string `json:"apiKey" dc:"调用凭证/密钥"`
|
||||
Enabled *bool `json:"enabled" dc:"启用"`
|
||||
RequestHeadMapping map[string]string `json:"requestHeadMapping" dc:"请求头映射"`
|
||||
RequestBodyMapping map[string]any `json:"requestBodyMapping" dc:"请求体映射"`
|
||||
RequestBusinessFieldMapping map[string]string `json:"requestBusinessFieldMapping" dc:"业务字段映射"`
|
||||
ResponseMapping map[string]any `json:"responseMapping" dc:"返回映射"`
|
||||
ResponseBodyMapping map[string]string `json:"responseBodyMapping" dc:"返回主体映射"`
|
||||
ResponseBusinessFieldMapping map[string]string `json:"responseBusinessFieldMapping" dc:"业务字段映射"`
|
||||
MaxConcurrency int `json:"maxConcurrency" dc:"最大并发数(默认10)"`
|
||||
TokenMapping *entity.TokenMapping `json:"tokenMapping" dc:"token映射"`
|
||||
AsyncTaskMapping *entity.AsyncTaskMapping `json:"asyncTaskMapping" dc:"异步任务映射"`
|
||||
TokenPredictPrice float64 `json:"tokenPredictPrice" dc:"模型Token预估价格"`
|
||||
TokenPredictPriceUnit string `json:"tokenPredictPriceUnit" dc:"模型Token预估价格单位"`
|
||||
MaxTokens int `json:"maxTokens" dc:"最大token数"`
|
||||
MinDuration int `json:"minDuration" dc:"最小时长"`
|
||||
MaxDuration int `json:"maxDuration" dc:"最大时长"`
|
||||
LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"`
|
||||
ErrorMessageMapping map[string]any `json:"errorMessageMapping" dc:"错误消息映射(schema 树,解析模型错误用)"`
|
||||
}
|
||||
|
||||
type DeleteModelManageReq struct {
|
||||
g.Meta `path:"/deleteModelManage" method:"delete" tags:"new模型管理" summary:"new删除模型配置" dc:"new删除指定ID的模型配置"`
|
||||
g.Meta `path:"/deleteModelManage" method:"delete" tags:"模型管理" summary:"删除模型配置" dc:"删除指定ID的模型配置"`
|
||||
Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"`
|
||||
}
|
||||
|
||||
@@ -79,21 +89,31 @@ type GetModelManage struct {
|
||||
}
|
||||
|
||||
type GetModelManageReq struct {
|
||||
g.Meta `path:"/getModelManage" method:"get" tags:"new模型管理" summary:"new获取模型配置" dc:"new获取指定ID的模型配置"`
|
||||
g.Meta `path:"/getModelManage" method:"get" tags:"模型管理" summary:"获取模型配置" dc:"获取指定ID的模型配置"`
|
||||
Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"`
|
||||
}
|
||||
|
||||
type GetModelManageRes struct {
|
||||
*entity.ModelManage `json:"modelManage"`
|
||||
ModelManage *entity.ModelManage `json:"modelManage"`
|
||||
}
|
||||
|
||||
type GetChatModelReq struct {
|
||||
g.Meta `path:"/getChatModel" method:"get" tags:"模型管理" summary:"获取聊天模型" dc:"获取聊天模型"`
|
||||
}
|
||||
|
||||
type GetChatModelRes struct {
|
||||
ModelManage *entity.ModelManage `json:"modelManage"`
|
||||
}
|
||||
|
||||
// ListModelManageReq 配置列表
|
||||
type ListModelManageReq struct {
|
||||
g.Meta `path:"/listModelManage" method:"get" tags:"new模型管理" summary:"new模型配置列表" dc:"new分页获取模型配置列表"`
|
||||
g.Meta `path:"/listModelManage" method:"get" tags:"模型管理" summary:"模型配置列表" dc:"分页获取模型配置列表"`
|
||||
*beans.Page `json:"page"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"`
|
||||
ModelType int `p:"modelType" json:"modelType" dc:"模型类型"`
|
||||
Creator string `json:"creator" dc:"创建人"`
|
||||
Id int64 `p:"id" json:"id,string" dc:"配置ID"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"`
|
||||
ModelType model.ModelType `p:"modelType" json:"modelType" dc:"模型类型"`
|
||||
IsSameType bool `p:"isSameType" json:"isSameType" dc:"是否相同类型"`
|
||||
Creator string `json:"creator" dc:"创建人"`
|
||||
}
|
||||
|
||||
type ListModelManageRes struct {
|
||||
@@ -102,7 +122,7 @@ type ListModelManageRes struct {
|
||||
}
|
||||
|
||||
type CheckChatModelReq struct {
|
||||
g.Meta `path:"/checkChatModel" method:"get" tags:"new模型管理" summary:"new检查是否为聊天模型" dc:"new检查是否为聊天模型"`
|
||||
g.Meta `path:"/checkChatModel" method:"get" tags:"模型管理" summary:"检查是否为聊天模型" dc:"检查是否为聊天模型"`
|
||||
}
|
||||
|
||||
type CheckChatModelRes struct {
|
||||
@@ -111,7 +131,7 @@ type CheckChatModelRes struct {
|
||||
|
||||
// ModelTypeReq 模型类型列表(分页)
|
||||
type ModelTypeReq struct {
|
||||
g.Meta `path:"/modelType" method:"get" tags:"new模型管理" summary:"new模型类型列表" dc:"new分页获取模型类型列表"`
|
||||
g.Meta `path:"/modelType" method:"get" tags:"模型管理" summary:"模型类型列表" dc:"分页获取模型类型列表"`
|
||||
}
|
||||
|
||||
type ModelTypeRes struct {
|
||||
@@ -119,9 +139,21 @@ type ModelTypeRes struct {
|
||||
}
|
||||
|
||||
type ModelSupplierReq struct {
|
||||
g.Meta `path:"/modelSupplier" method:"get" tags:"new模型管理" summary:"new获取运营商列表" dc:"new获取运营商列表"`
|
||||
g.Meta `path:"/modelSupplier" method:"get" tags:"模型管理" summary:"获取运营商列表" dc:"获取运营商列表"`
|
||||
}
|
||||
|
||||
type ModelSupplierRes struct {
|
||||
List []*public.Option `json:"list" dc:"运营商名称到ID的映射"`
|
||||
}
|
||||
|
||||
// BuildSchemaMappingReq 构建 Schema 映射请求
|
||||
type BuildSchemaMappingReq struct {
|
||||
g.Meta `path:"/buildSchemaMapping" method:"post" tags:"模型管理" summary:"自动构建 Schema 映射" dc:"根据模型类型和 Schema JSON,自动生成业务字段映射"`
|
||||
ModelType int `json:"modelType" v:"required#模型类型不能为空" dc:"模型类型编码"`
|
||||
Schema map[string]any `json:"schema" v:"required#Schema不能为空" dc:"模型的完整 Schema JSON"`
|
||||
}
|
||||
|
||||
type BuildSchemaMappingRes struct {
|
||||
SchemaMapping map[string]any `json:"schemaMapping" dc:"生成的 Schema 映射 JSON"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package dto
|
||||
|
||||
import "model-gateway/model/entity"
|
||||
|
||||
type CallModelSessionReq struct {
|
||||
Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"`
|
||||
ModelInfo *entity.ModelManage `json:"modelInfo" dc:"模型信息"`
|
||||
RequestParams map[string]any `json:"requestParams" dc:"新请求参数"`
|
||||
}
|
||||
|
||||
// CreateModelSessionReq 创建会话
|
||||
type CreateModelSessionReq struct {
|
||||
ModelId int64 `json:"modelId" dc:"模型ID"`
|
||||
BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
|
||||
SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"`
|
||||
RequestPath string `json:"requestPath" dc:"请求参数保存路径"`
|
||||
OriginalRequestPath string `json:"originalRequestPath" dc:"原始请求参数保存路径"`
|
||||
}
|
||||
|
||||
// UpdateModelSessionReq 修改会话
|
||||
type UpdateModelSessionReq struct {
|
||||
Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"`
|
||||
RetryCount int `json:"retryCount" dc:"重试"`
|
||||
ResponsePath string `json:"responsePath" dc:"响应结果保存路径"`
|
||||
OriginalResponsePath string `json:"originalResponsePath" dc:"原始响应结果保存路径"`
|
||||
DurationSeconds int64 `json:"durationSeconds" dc:"耗时(秒)"`
|
||||
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
|
||||
CompletionTokens int64 `json:"completionTokens" dc:"输出token"`
|
||||
TotalTokens int64 `json:"totalTokens" dc:"总token"`
|
||||
TotalCost float64 `json:"totalCost" dc:"总费用(元)"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dto
|
||||
|
||||
type CreateModelTaskEndReq struct {
|
||||
ModelId int64 `json:"modelId" dc:"模型ID"`
|
||||
BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
|
||||
MsgTopic string `json:"msgTopic" dc:"消息主题(可选,用于后续业务通知)"`
|
||||
TaskId string `json:"taskId" dc:"任务ID"`
|
||||
ResponseParams map[string]any `json:"responseParams" dc:"响应结果"`
|
||||
OriginalResponseParams map[string]any `json:"originalResponseParams" dc:"原始响应结果"`
|
||||
DurationSeconds int64 `json:"durationSeconds" dc:"耗时(秒)"`
|
||||
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
|
||||
CompletionTokens int64 `json:"completionTokens" dc:"输出token"`
|
||||
TotalTokens int64 `json:"totalTokens" dc:"总token"`
|
||||
TotalCost float64 `json:"totalCost" dc:"本次调用总费用(元),未配置计费规则为0"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type CallModelTaskStartReq struct {
|
||||
Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"`
|
||||
ModelInfo *entity.ModelManage `json:"modelInfo" dc:"模型信息"`
|
||||
RequestParams map[string]any `json:"requestParams" dc:"新请求参数"`
|
||||
}
|
||||
|
||||
// CreateModelTaskStartReq 创建任务
|
||||
type CreateModelTaskStartReq struct {
|
||||
ModelId int64 `json:"modelId" dc:"模型ID"`
|
||||
BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
|
||||
MsgTopic string `json:"msgTopic" dc:"消息主题(可选,用于后续业务通知)"`
|
||||
RequestPath string `json:"requestPath" dc:"请求参数保存路径"`
|
||||
OriginalRequestPath string `json:"originalRequestPath" dc:"原始请求参数保存路径"`
|
||||
MediaType string `json:"mediaType" dc:"输入媒体类型快照(audio/video,空=无媒体引用;shop 计费词汇,创建任务时按请求体推导)"`
|
||||
}
|
||||
|
||||
type CreateModelTaskStartRes struct {
|
||||
Id int64 `json:"id" dc:"任务ID"`
|
||||
}
|
||||
|
||||
// UpdateModelTaskStartReq 修改任务
|
||||
type UpdateModelTaskStartReq struct {
|
||||
Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"`
|
||||
RetryCount int `json:"retryCount" dc:"重试"`
|
||||
ResponseParams map[string]any `json:"responseParams" dc:"响应结果"`
|
||||
OriginalResponseParams map[string]any `json:"originalResponseParams" dc:"原始响应结果"`
|
||||
TaskId string `json:"taskId" dc:"任务ID"`
|
||||
DurationSeconds int64 `json:"durationSeconds" dc:"耗时(秒)"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
|
||||
type DeleteModelTaskStartReq struct {
|
||||
Id int64 `json:"id" v:"required#ids不能为空" dc:"任务ID"`
|
||||
}
|
||||
|
||||
type GetModelTaskStartListReq struct {
|
||||
Page *beans.Page `json:"page"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package entity
|
||||
|
||||
import "gitea.redpowerfuture.com/red-future/common/beans"
|
||||
|
||||
type modelErrorMemoryCol struct {
|
||||
beans.SQLBaseCol
|
||||
MemoryKey string
|
||||
Upstream string
|
||||
ErrorCode string
|
||||
MsgFingerprint string
|
||||
Retryable string
|
||||
Reason string
|
||||
AnalyzedBy string
|
||||
}
|
||||
|
||||
var ModelErrorMemoryCol = modelErrorMemoryCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
MemoryKey: "memory_key",
|
||||
Upstream: "upstream",
|
||||
ErrorCode: "error_code",
|
||||
MsgFingerprint: "msg_fingerprint",
|
||||
Retryable: "retryable",
|
||||
Reason: "reason",
|
||||
AnalyzedBy: "analyzed_by",
|
||||
}
|
||||
|
||||
// ModelErrorMemory 错误重试记忆(LLM 分析结论持久化,永久有效)
|
||||
type ModelErrorMemory struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
MemoryKey string `orm:"memory_key" json:"memoryKey" dc:"记忆键=SHA-256(upstream|code|归一化消息)"`
|
||||
Upstream string `orm:"upstream" json:"upstream" dc:"失败上游BaseURL"`
|
||||
ErrorCode string `orm:"error_code" json:"errorCode" dc:"错误码"`
|
||||
MsgFingerprint string `orm:"msg_fingerprint" json:"msgFingerprint" dc:"归一化消息md5"`
|
||||
Retryable bool `orm:"retryable" json:"retryable" dc:"是否可重试"`
|
||||
Reason string `orm:"reason" json:"reason" dc:"分析原因"`
|
||||
AnalyzedBy string `orm:"analyzed_by" json:"analyzedBy" dc:"分析模型名"`
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package entity
|
||||
|
||||
import "gitea.redpowerfuture.com/red-future/common/beans"
|
||||
|
||||
// ModelGatewayLogsOpCol 字段常量
|
||||
type modelGatewayLogsOpCol struct {
|
||||
beans.SQLBaseCol
|
||||
IP string
|
||||
UserAgent string
|
||||
APIPath string
|
||||
HttpMethod string
|
||||
BizName string
|
||||
ModelName string
|
||||
TaskID string
|
||||
OpType string
|
||||
Success string
|
||||
ErrorMsg string
|
||||
CostMs string
|
||||
RequestPayload string
|
||||
ResponsePayload string
|
||||
}
|
||||
|
||||
var ModelGatewayLogsOpCol = modelGatewayLogsOpCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
IP: "ip",
|
||||
UserAgent: "user_agent",
|
||||
APIPath: "api_path",
|
||||
HttpMethod: "http_method",
|
||||
BizName: "biz_name",
|
||||
ModelName: "model_name",
|
||||
TaskID: "task_id",
|
||||
OpType: "op_type",
|
||||
Success: "success",
|
||||
ErrorMsg: "error_msg",
|
||||
CostMs: "cost_ms",
|
||||
RequestPayload: "request_payload",
|
||||
ResponsePayload: "response_payload",
|
||||
}
|
||||
|
||||
// ModelGatewayLogsOp 操作日志
|
||||
type ModelGatewayLogsOp struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
IP string `orm:"ip" json:"ip"`
|
||||
UserAgent string `orm:"user_agent" json:"userAgent"`
|
||||
APIPath string `orm:"api_path" json:"apiPath"`
|
||||
HttpMethod string `orm:"http_method" json:"httpMethod"`
|
||||
BizName string `orm:"biz_name" json:"bizName"`
|
||||
ModelName string `orm:"model_name" json:"modelName"`
|
||||
TaskID string `orm:"task_id" json:"taskId"`
|
||||
OpType string `orm:"op_type" json:"opType"`
|
||||
Success int `orm:"success" json:"success"`
|
||||
ErrorMsg string `orm:"error_msg" json:"errorMsg"`
|
||||
CostMs int64 `orm:"cost_ms" json:"costMs"`
|
||||
RequestPayload *RequestPayload `orm:"request_payload" json:"requestPayload"`
|
||||
ResponsePayload map[string]any `orm:"response_payload" json:"responsePayload"`
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// ModelGatewayLogsStatCol 字段常量
|
||||
type ModelGatewayLogsStatCol struct {
|
||||
Day string
|
||||
TenantId string
|
||||
Creator string
|
||||
ModelName string
|
||||
RequestCount string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
var ModelGatewayLogsStatCols = ModelGatewayLogsStatCol{
|
||||
Day: "day",
|
||||
TenantId: "tenant_id",
|
||||
Creator: "creator",
|
||||
ModelName: "model_name",
|
||||
RequestCount: "request_count",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// ModelGatewayLogsStat 按天统计
|
||||
type ModelGatewayLogsStat struct {
|
||||
Day *gtime.Time `orm:"day" json:"day"`
|
||||
TenantId uint64 `orm:"tenant_id" json:"tenantId"`
|
||||
Creator string `orm:"creator" json:"creator"`
|
||||
ModelName string `orm:"model_name" json:"modelName"`
|
||||
RequestCount int64 `orm:"request_count" json:"requestCount"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package entity
|
||||
|
||||
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
|
||||
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",
|
||||
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"`
|
||||
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数
|
||||
)
|
||||
@@ -1,82 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type modelGatewayTaskCol struct {
|
||||
beans.SQLBaseCol
|
||||
ModelName string
|
||||
TaskID string
|
||||
BizName string
|
||||
CallbackURL string
|
||||
State string
|
||||
Phase string
|
||||
ErrorMsg string
|
||||
ResultFile string
|
||||
TextResult string
|
||||
ExpendTokens string
|
||||
DurationSeconds string
|
||||
RetryCount string
|
||||
TmpFile string
|
||||
RequestPayload string
|
||||
EpicycleId string
|
||||
BuildModelName string
|
||||
BillingData string
|
||||
}
|
||||
|
||||
var ModelGatewayTaskCol = modelGatewayTaskCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ModelName: "model_name",
|
||||
TaskID: "task_id",
|
||||
BizName: "biz_name",
|
||||
CallbackURL: "callback_url",
|
||||
State: "state",
|
||||
Phase: "phase",
|
||||
ErrorMsg: "error_msg",
|
||||
ResultFile: "result_file",
|
||||
TextResult: "text_result",
|
||||
ExpendTokens: "expend_tokens",
|
||||
DurationSeconds: "duration_seconds",
|
||||
RetryCount: "retry_count",
|
||||
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"`
|
||||
BuildModelName string `orm:"build_model_name" json:"buildModelName"`
|
||||
BillingData []map[string]any `orm:"billing_data" json:"billingData"`
|
||||
}
|
||||
|
||||
// ResultFile OSS 结果文件
|
||||
type ResultFile struct {
|
||||
OssFile string `json:"ossFile"`
|
||||
FileType string `json:"fileType"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
}
|
||||
|
||||
// RequestPayload 请求参数结构体
|
||||
type RequestPayload struct {
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
@@ -8,76 +8,89 @@ import (
|
||||
|
||||
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
|
||||
ModelSupplier string
|
||||
ModelName string
|
||||
ModelType string
|
||||
BaseURL string
|
||||
SystemModel string
|
||||
RefSystemModelId string
|
||||
HttpMethod string
|
||||
ChatModel string
|
||||
ResponseType string
|
||||
ApiKey string
|
||||
Enabled string
|
||||
RequestHeadMapping string
|
||||
RequestBodyMapping string
|
||||
RequestBusinessFieldMapping string
|
||||
ResponseMapping string
|
||||
ResponseBodyMapping string
|
||||
ResponseBusinessFieldMapping string
|
||||
MaxConcurrency string
|
||||
TokenPredictPrice string
|
||||
TokenPredictPriceUnit string
|
||||
MaxTokens string
|
||||
MinDuration string
|
||||
MaxDuration string
|
||||
LastFrame string
|
||||
}
|
||||
|
||||
var ModelManageCol = modelManageCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ModelSupplier: "model_supplier",
|
||||
ModelName: "model_name",
|
||||
ModelType: "model_type",
|
||||
BaseURL: "base_url",
|
||||
SystemModel: "system_model",
|
||||
HttpMethod: "http_method",
|
||||
ChatModel: "chat_model",
|
||||
ResponseType: "response_type",
|
||||
ApiKey: "api_key",
|
||||
Enabled: "enabled",
|
||||
RequestHeadMapping: "request_head_mapping",
|
||||
RequestBodyMapping: "request_body_mapping",
|
||||
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",
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ModelSupplier: "model_supplier",
|
||||
ModelName: "model_name",
|
||||
ModelType: "model_type",
|
||||
BaseURL: "base_url",
|
||||
SystemModel: "system_model",
|
||||
RefSystemModelId: "ref_system_model_id",
|
||||
HttpMethod: "http_method",
|
||||
ChatModel: "chat_model",
|
||||
ResponseType: "response_type",
|
||||
ApiKey: "api_key",
|
||||
Enabled: "enabled",
|
||||
RequestHeadMapping: "request_head_mapping",
|
||||
RequestBodyMapping: "request_body_mapping",
|
||||
RequestBusinessFieldMapping: "request_business_field_mapping",
|
||||
ResponseMapping: "response_mapping",
|
||||
ResponseBodyMapping: "response_body_mapping",
|
||||
ResponseBusinessFieldMapping: "response_business_field_mapping",
|
||||
MaxConcurrency: "max_concurrency",
|
||||
TokenPredictPrice: "token_predict_price",
|
||||
TokenPredictPriceUnit: "token_predict_price_unit",
|
||||
MaxTokens: "max_tokens",
|
||||
MinDuration: "min_duration",
|
||||
MaxDuration: "max_duration",
|
||||
LastFrame: "last_frame",
|
||||
}
|
||||
|
||||
type ModelManage struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
ModelSupplier model.SupplierType `orm:"model_supplier" json:"modelSupplier" description:"模型供应商"`
|
||||
ModelName string `orm:"model_name" json:"modelName" description:"模型名称"`
|
||||
ModelType model.ModelType `orm:"model_type" json:"modelType" description:"模型类型"`
|
||||
BaseURL string `orm:"base_url" json:"baseUrl" description:"模型地址"`
|
||||
SystemModel *bool `orm:"system_model" json:"systemModel" description:"系统模型"`
|
||||
HttpMethod string `orm:"http_method" json:"httpMethod" description:"http方法"`
|
||||
ChatModel *bool `orm:"chat_model" json:"ChatModel" description:"是否聊天模型"`
|
||||
ResponseType model.ResponseType `orm:"response_type" json:"responseType" description:"返回类型:1同步,2异步,3流"`
|
||||
ApiKey string `orm:"api_key" json:"apiKey" description:"api key"`
|
||||
Enabled *bool `orm:"enabled" json:"enabled" description:"是否启用"`
|
||||
RequestHeadMapping map[string]string `orm:"request_head_mapping" json:"requestHeadMapping" description:"请求头映射"`
|
||||
RequestBodyMapping map[string]any `orm:"request_body_mapping" json:"requestBodyMapping" description:"请求体映射"`
|
||||
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:"视频的尾帧图像"`
|
||||
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:"系统模型"`
|
||||
RefSystemModelId int64 `orm:"ref_system_model_id" json:"refSystemModelId" description:"引用的系统模型ID(NULL=非引用行)"`
|
||||
HttpMethod string `orm:"http_method" json:"httpMethod" description:"http方法"`
|
||||
ChatModel *bool `orm:"chat_model" json:"chatModel" description:"是否聊天模型"`
|
||||
ResponseType model.ResponseType `orm:"response_type" json:"responseType" description:"返回类型:1同步,2异步,3流"`
|
||||
ApiKey string `orm:"api_key" json:"apiKey" description:"api key"`
|
||||
Enabled *bool `orm:"enabled" json:"enabled" description:"是否启用"`
|
||||
RequestHeadMapping map[string]string `orm:"request_head_mapping" json:"requestHeadMapping" description:"请求头映射"`
|
||||
RequestBodyMapping map[string]any `orm:"request_body_mapping" json:"requestBodyMapping" description:"请求体映射"`
|
||||
RequestBusinessFieldMapping map[string]string `orm:"request_business_field_mapping" json:"requestBusinessFieldMapping" description:"请求业务字段映射"`
|
||||
ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping" description:"响应映射"`
|
||||
ResponseBodyMapping map[string]string `orm:"response_body_mapping" json:"responseBodyMapping" description:"响应主体映射"`
|
||||
ResponseBusinessFieldMapping map[string]string `orm:"response_business_field_mapping" json:"responseBusinessFieldMapping" description:"响应业务字段映射"`
|
||||
MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency" description:"最大并发数"`
|
||||
TokenMapping *TokenMapping `orm:"token_mapping" json:"tokenMapping" description:"token映射"`
|
||||
AsyncTaskMapping *AsyncTaskMapping `orm:"async_task_mapping" json:"asyncTaskMapping" description:"异步任务映射"`
|
||||
TokenPredictPrice float64 `orm:"token_predict_price" json:"tokenPredictPrice" description:"模型Token预估价格"`
|
||||
TokenPredictPriceUnit string `orm:"token_predict_price_unit" json:"tokenPredictPriceUnit" description:"模型token预估价格单位(秒,百万Token,千Token,字数)"`
|
||||
MaxTokens int `orm:"max_tokens" json:"maxTokens" description:"最大token数"`
|
||||
MinDuration int `orm:"min_duration" json:"minDuration" description:"最小时长(秒)"`
|
||||
MaxDuration int `orm:"max_duration" json:"maxDuration" description:"最大时长(秒)"`
|
||||
LastFrame string `orm:"last_frame" json:"lastFrame" description:"视频的尾帧图像"`
|
||||
ErrorMessageMapping map[string]any `orm:"error_message_mapping" json:"errorMessageMapping" description:"错误消息映射"`
|
||||
}
|
||||
|
||||
type TokenMapping struct {
|
||||
@@ -89,6 +102,7 @@ type TokenMapping struct {
|
||||
type AsyncTaskMapping struct {
|
||||
Url string `json:"url" dc:"url"`
|
||||
HttpMethod string `json:"httpMethod" dc:"http方法" d:"POST"`
|
||||
RequestBodyMapping map[string]any `json:"requestBodyMapping" description:"请求体映射"`
|
||||
RequestHeadMapping map[string]string `json:"requestHeadMapping" description:"请求头映射"`
|
||||
ResponseMapping map[string]any `json:"responseMapping" description:"响应映射"`
|
||||
TaskId string `json:"taskId" dc:"任务id"`
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type modelSessionCol struct {
|
||||
beans.SQLBaseCol
|
||||
ModelId string
|
||||
BizName string
|
||||
SessionId string
|
||||
RetryCount string
|
||||
RequestPath string
|
||||
ResponsePath string
|
||||
OriginalRequestPath string
|
||||
OriginalResponsePath string
|
||||
DurationSeconds string
|
||||
PromptTokens string
|
||||
CompletionTokens string
|
||||
TotalTokens string
|
||||
TotalCost string
|
||||
ErrorMsg string
|
||||
}
|
||||
|
||||
var ModelSessionCol = modelSessionCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ModelId: "model_id",
|
||||
BizName: "biz_name",
|
||||
SessionId: "session_id",
|
||||
RetryCount: "retry_count",
|
||||
RequestPath: "request_path",
|
||||
ResponsePath: "response_path",
|
||||
OriginalRequestPath: "original_request_path",
|
||||
OriginalResponsePath: "original_response_path",
|
||||
DurationSeconds: "duration_seconds",
|
||||
PromptTokens: "prompt_tokens",
|
||||
CompletionTokens: "completion_tokens",
|
||||
TotalTokens: "total_tokens",
|
||||
TotalCost: "total_cost",
|
||||
ErrorMsg: "error_msg",
|
||||
}
|
||||
|
||||
// ModelSession 模型网关任务
|
||||
type ModelSession struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
ModelId int64 `orm:"model_id" json:"modelId" dc:"模型ID"`
|
||||
BizName string `orm:"biz_name" json:"bizName" dc:"业务名称"`
|
||||
SessionId string `orm:"session_id" json:"sessionId" dc:"会话ID"`
|
||||
RetryCount int `orm:"retry_count" json:"retryCount" dc:"重试"`
|
||||
RequestPath string `orm:"request_path" json:"requestPath" dc:"请求参数保存路径"`
|
||||
ResponsePath string `orm:"response_path" json:"responsePath" dc:"响应结果保存路径"`
|
||||
OriginalRequestPath string `orm:"original_request_path" json:"originalRequestPath" dc:"原始请求参数保存路径"`
|
||||
OriginalResponsePath string `orm:"original_response_path" json:"originalResponsePath" dc:"原始响应结果保存路径"`
|
||||
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"`
|
||||
PromptTokens int64 `orm:"prompt_tokens" json:"promptTokens" dc:"输入token"`
|
||||
CompletionTokens int64 `orm:"completion_tokens" json:"completionTokens" dc:"输出token"`
|
||||
TotalTokens int64 `orm:"total_tokens" json:"totalTokens" dc:"总token"`
|
||||
TotalCost float64 `orm:"total_cost" json:"totalCost" dc:"总费用(元)"`
|
||||
ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type modelTaskEndCol struct {
|
||||
beans.SQLBaseCol
|
||||
ModelId string
|
||||
BizName string
|
||||
MsgTopic string
|
||||
RetryCount string
|
||||
ResponseParams string
|
||||
OriginalResponseParams string
|
||||
DurationSeconds string
|
||||
TaskId string
|
||||
PromptTokens string
|
||||
CompletionTokens string
|
||||
TotalTokens string
|
||||
TotalCost string
|
||||
ErrorMsg string
|
||||
}
|
||||
|
||||
var ModelTaskEndCol = modelTaskEndCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ModelId: "model_id",
|
||||
BizName: "biz_name",
|
||||
MsgTopic: "msg_topic",
|
||||
RetryCount: "retry_count",
|
||||
ResponseParams: "response_params",
|
||||
OriginalResponseParams: "original_response_params",
|
||||
DurationSeconds: "duration_seconds",
|
||||
TaskId: "task_id",
|
||||
PromptTokens: "prompt_tokens",
|
||||
CompletionTokens: "completion_tokens",
|
||||
TotalTokens: "total_tokens",
|
||||
TotalCost: "total_cost",
|
||||
ErrorMsg: "error_msg",
|
||||
}
|
||||
|
||||
// ModelTaskEnd 模型网关任务
|
||||
type ModelTaskEnd struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
ModelId int64 `orm:"model_id" json:"modelId" dc:"模型ID"`
|
||||
BizName string `orm:"biz_name" json:"bizName" dc:"业务名称"`
|
||||
MsgTopic string `orm:"msg_topic" json:"msgTopic" dc:"消息主题"`
|
||||
RetryCount int `orm:"retry_count" json:"retryCount" dc:"重试"`
|
||||
ResponseParams string `orm:"response_params" json:"responseParams" dc:"响应结果"`
|
||||
OriginalResponseParams string `orm:"original_response_params" json:"originalResponseParams" dc:"原始响应结果"`
|
||||
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"`
|
||||
TaskId string `orm:"task_id" json:"taskId" dc:"任务ID"`
|
||||
PromptTokens int64 `orm:"prompt_tokens" json:"promptTokens" dc:"输入token"`
|
||||
CompletionTokens int64 `orm:"completion_tokens" json:"completionTokens" dc:"输出token"`
|
||||
TotalTokens int64 `orm:"total_tokens" json:"totalTokens" dc:"总token"`
|
||||
TotalCost float64 `orm:"total_cost" json:"totalCost" dc:"本次调用总费用(元),未配置计费规则为0"`
|
||||
ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type modelTaskStartCol struct {
|
||||
beans.SQLBaseCol
|
||||
ModelId string
|
||||
BizName string
|
||||
MsgTopic string
|
||||
RetryCount string
|
||||
RequestPath string
|
||||
OriginalRequestPath string
|
||||
ResponseParams string
|
||||
OriginalResponseParams string
|
||||
DurationSeconds string
|
||||
TaskId string
|
||||
MediaType string
|
||||
ErrorMsg string
|
||||
}
|
||||
|
||||
var ModelTaskStartCol = modelTaskStartCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ModelId: "model_id",
|
||||
BizName: "biz_name",
|
||||
MsgTopic: "msg_topic",
|
||||
RetryCount: "retry_count",
|
||||
RequestPath: "request_path",
|
||||
OriginalRequestPath: "original_request_path",
|
||||
ResponseParams: "response_params",
|
||||
OriginalResponseParams: "original_response_params",
|
||||
DurationSeconds: "duration_seconds",
|
||||
TaskId: "task_id",
|
||||
MediaType: "media_type",
|
||||
ErrorMsg: "error_msg",
|
||||
}
|
||||
|
||||
// ModelTaskStart 模型网关任务
|
||||
type ModelTaskStart struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
ModelId int64 `orm:"model_id" json:"modelId" dc:"模型ID"`
|
||||
BizName string `orm:"biz_name" json:"bizName" dc:"业务名称"`
|
||||
MsgTopic string `orm:"msg_topic" json:"msgTopic" dc:"消息主题"`
|
||||
RetryCount int `orm:"retry_count" json:"retryCount" dc:"重试"`
|
||||
RequestPath string `orm:"request_path" json:"requestPath" dc:"请求参数保存路径"`
|
||||
OriginalRequestPath string `orm:"original_request_path" json:"originalRequestPath" dc:"原始请求参数保存路径"`
|
||||
ResponseParams map[string]any `orm:"response_params" json:"responseParams" dc:"响应结果"`
|
||||
OriginalResponseParams map[string]any `orm:"original_response_params" json:"originalResponseParams" dc:"原始响应结果"`
|
||||
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"`
|
||||
TaskId string `orm:"task_id" json:"taskId" dc:"任务ID"`
|
||||
MediaType string `orm:"media_type" json:"mediaType" dc:"输入媒体类型快照(audio/video,空=无媒体引用;shop 计费词汇,创建任务时按请求体推导)"`
|
||||
ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// parseAnalysisResponse 解析分析模型输出的判定 JSON。
|
||||
// 容错:剥 ```json 代码块/首尾空白/多余文字,取首个 {...}。
|
||||
func parseAnalysisResponse(content string) (retryable bool, reason string, err error) {
|
||||
s := strings.TrimSpace(content)
|
||||
s = strings.TrimPrefix(s, "```json")
|
||||
s = strings.TrimSuffix(s, "```")
|
||||
s = strings.TrimSpace(s)
|
||||
start, end := strings.IndexByte(s, '{'), strings.LastIndexByte(s, '}')
|
||||
if start < 0 || end <= start {
|
||||
return false, "", fmt.Errorf("分析响应中未找到JSON对象: %q", content)
|
||||
}
|
||||
var obj struct {
|
||||
Retryable bool `json:"retryable"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(s[start:end+1]), &obj); err != nil {
|
||||
return false, "", fmt.Errorf("分析响应JSON解析失败: %v", err)
|
||||
}
|
||||
return obj.Retryable, obj.Reason, nil
|
||||
}
|
||||
|
||||
const (
|
||||
analysisTimeout = 15 * time.Second
|
||||
analysisMaxBody = 2000
|
||||
analysisMaxTokens = 256
|
||||
)
|
||||
|
||||
const analysisSystemPrompt = `你是 AI 模型网关的错误分析器。上游 AI 模型调用返回了一个错误,你需要判断该错误是否"值得指数退避后重试"。
|
||||
|
||||
## 值得重试(retryable: true)
|
||||
- 限流:429、rate limit、请求过密、并发超限
|
||||
- 服务端瞬时故障:5xx、InternalServiceError、服务过载、上游临时不可用
|
||||
- 超时/取消/连接:Timeout、RequestCanceled、Error while connecting、连接抖动
|
||||
- 媒体源暂不可用(视频/音频生成类上游常见):Invalid video_url、Invalid audio track、Error while downloading、download failed —— 通常是源尚未就绪或下载瞬断,重试可成功,不要误判为永久参数错误
|
||||
- 资源暂时不足:quota 暂时受限
|
||||
|
||||
## 不值得重试(retryable: false)
|
||||
- 请求/参数错误:400、invalid_argument、格式错误(注意 Invalid video_url / Invalid audio track 属上类的媒体源错误,不归此类)
|
||||
- 鉴权失败:401、403、invalid_api_key、签名错误
|
||||
- 模型不存在:404、model_not_found
|
||||
- 余额不足:insufficient_quota
|
||||
- 内容违规:内容安全拦截
|
||||
- 明确的永久性配置错误
|
||||
|
||||
## 输出
|
||||
只输出一个 JSON 对象,不要任何多余文字、解释或代码块标记:
|
||||
{"retryable": true 或 false, "reason": "不超过20字的简要原因"}`
|
||||
|
||||
// truncateStr 按字节截断到 max(中文可能截半个字符,仅用于分析输入,可接受)
|
||||
func truncateStr(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
|
||||
// buildAnalysisBody 构造分析请求体(OpenAI 兼容 messages 格式),纯函数便于单测。
|
||||
func buildAnalysisBody(ctx context.Context, modelName, code, msg, body string) map[string]any {
|
||||
user := fmt.Sprintf("错误码: %s\n错误消息: %s\n错误响应体: %s", code, msg, truncateStr(body, analysisMaxBody))
|
||||
// 打印错误信息
|
||||
g.Log().Debugf(ctx, "分析请求体: %s", user)
|
||||
|
||||
return map[string]any{
|
||||
"model": modelName,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": analysisSystemPrompt},
|
||||
{"role": "user", "content": user},
|
||||
},
|
||||
"max_tokens": analysisMaxTokens,
|
||||
"temperature": 0,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAnalysisModel 选择分析模型:失败模型自身是对话模型则复用,否则取当前用户对话模型。
|
||||
// 取不到返回 ok=false,调用方 fail-closed 不重试。
|
||||
func resolveAnalysisModel(ctx context.Context, modelInfo *entity.ModelManage) (model *entity.ModelManage, ok bool) {
|
||||
if modelInfo != nil && modelInfo.ChatModel != nil && *modelInfo.ChatModel {
|
||||
return modelInfo, true
|
||||
}
|
||||
chat, err := ModelManage.GetChatModel(ctx, &dto.GetChatModelReq{})
|
||||
if err == nil && chat != nil && chat.ModelManage != nil {
|
||||
return chat.ModelManage, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// callAnalysisLLM 调分析模型(对话模型)判定错误是否可重试。
|
||||
// 独立短超时 http.Client;非 200 / 解析失败 / 超时 → 返回 err,调用方 fail-closed。
|
||||
func callAnalysisLLM(ctx context.Context, model *entity.ModelManage, code, msg, body string) (retryable bool, reason string, err error) {
|
||||
reqBody, err := json.Marshal(buildAnalysisBody(ctx, model.ModelName, code, msg, body))
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("marshal分析请求失败: %w", err)
|
||||
}
|
||||
httpMethod := model.HttpMethod
|
||||
if httpMethod == "" {
|
||||
httpMethod = http.MethodPost
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, httpMethod, strings.TrimRight(model.BaseURL, "/"), bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("创建分析请求失败: %w", err)
|
||||
}
|
||||
for k, v := range model.RequestHeadMapping {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+model.ApiKey)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: analysisTimeout}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("分析请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("读取分析响应失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, "", fmt.Errorf("分析接口非200: status=%d body=%s", resp.StatusCode, truncateStr(string(respBody), 500))
|
||||
}
|
||||
var apiResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err = json.Unmarshal(respBody, &apiResp); err != nil {
|
||||
return false, "", fmt.Errorf("解析分析响应失败: %w", err)
|
||||
}
|
||||
if len(apiResp.Choices) == 0 {
|
||||
return false, "", fmt.Errorf("分析响应无choices")
|
||||
}
|
||||
return parseAnalysisResponse(apiResp.Choices[0].Message.Content)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
reUUID = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
|
||||
reISOTime = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?`)
|
||||
reUnixMs = regexp.MustCompile(`\b1[4-9]\d{12}\b`) // unix 毫秒级时间戳
|
||||
reRequestID = regexp.MustCompile(`\b(req[-_]?|request[-_]?|rid[-_:]?)[-_:]?[0-9a-zA-Z-]{4,}\b`)
|
||||
reLongNum = regexp.MustCompile(`\b\d{4,}\b`) // 连续≥4位数字
|
||||
)
|
||||
|
||||
// normalizeErrorMsg 归一化错误消息:剔除易变片段(UUID/时间戳/请求ID/连续数字),
|
||||
// 使同因不同实例的错误命中同一记忆键。
|
||||
func normalizeErrorMsg(msg string) string {
|
||||
m := msg
|
||||
m = reUUID.ReplaceAllString(m, "{uuid}")
|
||||
m = reISOTime.ReplaceAllString(m, "{time}")
|
||||
m = reUnixMs.ReplaceAllString(m, "{ts}")
|
||||
m = reRequestID.ReplaceAllString(m, "{reqid}")
|
||||
m = reLongNum.ReplaceAllString(m, "{num}")
|
||||
return strings.TrimSpace(m)
|
||||
}
|
||||
|
||||
// buildMemoryKey 构造记忆键 = SHA-256(upstream|error_code|归一化消息)。
|
||||
// 含失败上游维度:不同上游的同类错误互不串扰。
|
||||
func buildMemoryKey(upstream, code, msg string) string {
|
||||
raw := strings.Join([]string{upstream, code, normalizeErrorMsg(msg)}, "|")
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// msgFingerprint 归一化消息的 md5(观测/展示用)。
|
||||
func msgFingerprint(msg string) string {
|
||||
sum := md5.Sum([]byte(normalizeErrorMsg(msg)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"model-gateway/model/entity"
|
||||
"time"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/guid"
|
||||
)
|
||||
|
||||
type UploadFileResponse struct {
|
||||
FileURL string `json:"fileURL"` // 文件 URL
|
||||
FileSize int `json:"fileSize"` // 文件大小(字节)
|
||||
FileName string `json:"fileName"` // 文件名
|
||||
FileFormat string `json:"fileFormat"` // 文件格式
|
||||
FileAddressPrefix string `json:"fileAddressPrefix"` // 文件地址前缀
|
||||
}
|
||||
|
||||
// UploadByTask 通过任务上传文件
|
||||
func UploadByTask(ctx context.Context, data []byte, fileExt string) (oss *UploadFileResponse, err error) {
|
||||
// multipart
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
ext := fileExt
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
if ext[0] != '.' {
|
||||
ext = "." + ext
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("asynch_%d_%s%s", time.Now().Unix(), guid.S(), ext)
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = part.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//contentType := writer.FormDataContentType()
|
||||
if err = writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//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))
|
||||
|
||||
var resp UploadFileResponse
|
||||
if err = commonHttp.Post(ctx, fullURL, headers, &resp, body.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if &resp == nil {
|
||||
return nil, errors.New("[OSS] 上传文件失败")
|
||||
}
|
||||
g.Log().Infof(ctx, "[OSS] 上传成功 url=%s size=%d format=%s", resp.FileURL, resp.FileSize, resp.FileFormat)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// 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"`
|
||||
BillingDate []map[string]any `json:"billing_data"`
|
||||
}
|
||||
|
||||
// TriggerCallback 任务的回调
|
||||
func TriggerCallback(ctx context.Context, t *entity.ModelGatewayTask) {
|
||||
//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,
|
||||
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 {
|
||||
g.Log().Warningf(ctx, "[回调] JSON序列化失败 taskId=%s 错误=%v", t.TaskID, err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[回调] 开始发送 taskId=%s 回调地址=%s 请求头数量=%d 消息体大小=%d字节",
|
||||
t.TaskID, t.CallbackURL, len(headers), len(jsonData))
|
||||
|
||||
err = commonHttp.Post(ctx, t.CallbackURL, headers, &resp, jsonData)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[回调] 发送失败 taskId=%s 回调地址=%s 错误=%v", t.TaskID, t.CallbackURL, err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[回调] 发送成功 taskId=%s 回调地址=%s 消息体大小=%d字节", t.TaskID, t.CallbackURL, len(jsonData))
|
||||
}
|
||||
|
||||
// PromptsCallbackPayload 提示词回调请求体
|
||||
type PromptsCallbackPayload struct {
|
||||
EpicycleId int64 `json:"epicycleId"`
|
||||
Messages map[string]any `json:"messages"`
|
||||
}
|
||||
|
||||
// TriggerPromptsCallback 任务成功后的提示词回调
|
||||
func TriggerPromptsCallback(ctx context.Context, t *entity.ModelGatewayTask, epicycleId int64) {
|
||||
callbackURL := "prompts-core/session/callback"
|
||||
//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,
|
||||
Messages: t.TextResult,
|
||||
}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[提示词回调] JSON序列化失败 epicycleId=%d 错误=%v", epicycleId, err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[提示词回调] 开始发送 epicycleId=%d 回调地址=%s 请求头数量=%d 消息体大小=%d字节",
|
||||
t.EpicycleId, callbackURL, len(headers), len(jsonData))
|
||||
|
||||
err = commonHttp.Post(ctx, callbackURL, headers, &resp, jsonData)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[提示词回调] 发送失败 epicycleId=%d 回调地址=%s 错误=%v", t.EpicycleId, callbackURL, err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[提示词回调] 发送成功 epicycleId=%d 回调地址=%s 消息体大小=%d字节", t.EpicycleId, callbackURL, len(jsonData))
|
||||
}
|
||||
|
||||
// IsSuperAdmin 调用admin-go服务检查是否是超级管理员
|
||||
func IsSuperAdmin(ctx context.Context) (res bool, err error) {
|
||||
//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
|
||||
}
|
||||
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 == "" {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// task, _ := dao.TranscribeTask.GetByTaskID(ctx, taskID)
|
||||
// if task == nil {
|
||||
// g.Log().Errorf(ctx, "[回调 %s] 任务不存在", taskID)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// detailList, _ := dao.TranscribeTaskDetail.ListByTaskID(ctx, taskID)
|
||||
// detailItems := make([]dto.TranscribeTaskDetailItem, 0, len(detailList))
|
||||
// for i := range detailList {
|
||||
// detailItems = append(detailItems, dao.DetailEntityToItem(&detailList[i]))
|
||||
// }
|
||||
//
|
||||
// // 构建与查询接口一致的 taskInfo
|
||||
// taskInfo := dao.EntityToItem(task)
|
||||
//
|
||||
// // 兼容历史数据: 从 result 中补全 scenes 等字段
|
||||
// detailItems = enrichDetailsFromResult(task.Result, detailItems)
|
||||
//
|
||||
// payload := dto.CallbackPayload{
|
||||
// TaskInfo: taskInfo,
|
||||
// DetailList: detailItems,
|
||||
// }
|
||||
//
|
||||
// body, _ := json.Marshal(payload)
|
||||
//
|
||||
// // 透传调用方的用户信息
|
||||
// userJSON, _ := json.Marshal(beans.User{UserName: "admin", TenantId: 1})
|
||||
//
|
||||
// req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
|
||||
// req.Header.Set("Content-Type", "application/json")
|
||||
// req.Header.Set("X-User-Info", string(userJSON))
|
||||
//
|
||||
// resp, reqErr := http.DefaultClient.Do(req)
|
||||
// if reqErr != nil {
|
||||
// g.Log().Errorf(ctx, "[回调 %s] 请求失败: %v", taskID, reqErr)
|
||||
// return
|
||||
// }
|
||||
// defer resp.Body.Close()
|
||||
//
|
||||
// respBody, _ := io.ReadAll(resp.Body)
|
||||
// g.Log().Infof(ctx, "[回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
|
||||
//}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package httpclient 模型网关的传输层:模型 HTTP 请求(含瞬时网络错误重试)与 SSE 流式解析。
|
||||
// 纯基础设施,不依赖 session/task/call 等业务逻辑;业务代码只通过三个导出函数使用。
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/gclient"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// modelCallHeaderTimeout 模型响应头等待超时。
|
||||
// commonHttp 底层 gclient 默认 ResponseHeaderTimeout 只有 30s,模型生成首字节
|
||||
// (尤其非流式、大 max_tokens)经常超过 30s,导致 http2: timeout awaiting response
|
||||
// headers。模型调用必须用独立 client 并把该超时调大,与模型配置的超时保持一致。
|
||||
const modelCallHeaderTimeout = 30 * time.Minute
|
||||
|
||||
// modelHTTPClient 构建模型调用专用 HTTP client:
|
||||
// 克隆 commonHttp 客户端(保留 ContentJson、header 注入等行为),但把
|
||||
// ResponseHeaderTimeout 从默认 30s 调大到 modelCallHeaderTimeout。
|
||||
func modelHTTPClient() *gclient.Client {
|
||||
client := commonHttp.Httpclient.Clone()
|
||||
if tr, ok := client.Transport.(*http.Transport); ok {
|
||||
tr = tr.Clone() // 独立拷贝,避免改动全局共享 transport
|
||||
tr.ResponseHeaderTimeout = modelCallHeaderTimeout
|
||||
client.Transport = tr
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// modelNetRetryTimes 模型请求瞬时网络错误最大重试次数(不含首次);modelNetRetryBackoff 为退避基数。
|
||||
// 模型域名 DNS 解析失败(Docker 内 127.0.0.11 偶发 no such host)是瞬时错误,短退避重试即可恢复。
|
||||
// 重试在 HTTP 层完成,覆盖同步/异步/流式全部调用路径;流式场景发生在写 SSE 响应头之前,重试安全。
|
||||
const (
|
||||
modelNetRetryTimes = 3
|
||||
modelNetRetryBackoff = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// isTransientNetError 判定是否可重试的瞬时网络错误。仅命中 DNS 解析失败(no such host):
|
||||
// 模型域名解析抖动可重试恢复;连接拒绝/超时等其他网络错误可能反映真实配置问题,不纳入,避免掩盖错误。
|
||||
func isTransientNetError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "no such host") || strings.Contains(err.Error(), "timeout")
|
||||
}
|
||||
|
||||
// modelDoRaw 模型 HTTP 请求(等价 commonHttp.doRequestRaw,但使用调大超时的 client)。
|
||||
// DNS 解析失败等瞬时网络错误在请求层短退避重试(modelNetRetryTimes 次);
|
||||
// 其余错误(含上游业务错误码)原样返回,由上层按错误码决定是否重试。
|
||||
func modelDoRaw(ctx context.Context, method string, url string, headers map[string]string, data ...any) (*gclient.Response, error) {
|
||||
client := modelHTTPClient()
|
||||
|
||||
if (method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete) && len(data) > 0 {
|
||||
client = client.ContentJson()
|
||||
}
|
||||
|
||||
if len(headers) > 0 {
|
||||
client.SetHeaderMap(headers)
|
||||
} else if r := g.RequestFromCtx(ctx); r != nil {
|
||||
client.SetHeader("Authorization", r.Request.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
doOnce := func() (*gclient.Response, error) {
|
||||
if method == http.MethodGet && len(data) > 0 && len(data)%2 == 0 {
|
||||
queryParams := make(map[string]string)
|
||||
for i := 0; i < len(data); i += 2 {
|
||||
if key, ok := data[i].(string); ok && i+1 < len(data) {
|
||||
queryParams[key] = gconv.String(data[i+1])
|
||||
}
|
||||
}
|
||||
return client.DoRequest(ctx, method, url, queryParams)
|
||||
}
|
||||
if len(data) == 1 {
|
||||
return client.DoRequest(ctx, method, url, data[0])
|
||||
}
|
||||
return client.DoRequest(ctx, method, url, data...)
|
||||
}
|
||||
|
||||
var response *gclient.Response
|
||||
var err error
|
||||
for attempt := 0; ; attempt++ {
|
||||
response, err = doOnce()
|
||||
if err == nil || !isTransientNetError(err) {
|
||||
return response, err
|
||||
}
|
||||
if attempt >= modelNetRetryTimes {
|
||||
break
|
||||
}
|
||||
wait := time.Duration(1<<attempt) * modelNetRetryBackoff
|
||||
g.Log().Warningf(ctx, "[HttpModel] 模型请求瞬时网络错误,第 %d/%d 次重试(等待 %v): %v", attempt+1, modelNetRetryTimes, wait, err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
// ModelHttpNormalRequest 同步/异步 普通HTTP全量请求
|
||||
func ModelHttpNormalRequest(ctx context.Context, url string, headers map[string]string, httpMethod string, body map[string]any) (res []byte, err error) {
|
||||
response, err := modelDoRaw(ctx, httpMethod, url, headers, body)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[HttpModel] 模型请求失败 [Error]: %v", err)
|
||||
return nil, fmt.Errorf("模型请求失败: %w", err)
|
||||
}
|
||||
defer response.Close()
|
||||
return response.ReadAll(), nil
|
||||
}
|
||||
|
||||
// ModelHttpStreamRequest 通用流式请求
|
||||
// stream=true 时设置 SSE 头并验证 Flusher;stream=false 时只返回 Reader,不设置响应头
|
||||
func ModelHttpStreamRequest(ctx context.Context, w http.ResponseWriter, url string, headers map[string]string, httpMethod string, body map[string]any) (io.Reader, error) {
|
||||
// 1) 先发起上游请求(此时还没写任何 SSE 头,失败可以正常返回 error)
|
||||
response, err := modelDoRaw(ctx, httpMethod, url, headers, body)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[HttpModel] 模型流式请求失败 [Error]: %v", err)
|
||||
return nil, fmt.Errorf("模型流式请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查 HTTP 状态码
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(response.Body)
|
||||
response.Close()
|
||||
return nil, fmt.Errorf("[HTTP][Stream] 状态码异常: %d, body=%s", response.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
if w != nil {
|
||||
// 2) 上游连接成功,再设置 SSE 头
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
|
||||
if _, ok := w.(http.Flusher); !ok {
|
||||
response.Close()
|
||||
return nil, errors.New("response writer not support flush")
|
||||
}
|
||||
}
|
||||
|
||||
// 下层统一托管关闭:用包装器保证流最终关闭
|
||||
return &autoCloseReader{r: response.Body}, nil
|
||||
}
|
||||
|
||||
// autoCloseReader 包装 io.ReadCloser,读取结束/销毁时自动 Close
|
||||
type autoCloseReader struct {
|
||||
r io.ReadCloser
|
||||
}
|
||||
|
||||
func (a *autoCloseReader) Read(p []byte) (int, error) {
|
||||
n, err := a.r.Read(p)
|
||||
// 读取完毕 / 读出错,主动关闭流
|
||||
if err != nil {
|
||||
_ = a.r.Close()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SSE 常量
|
||||
const (
|
||||
ssePrefixData = "data:"
|
||||
ssePrefixEvent = "event:"
|
||||
ssePrefixComment = ":"
|
||||
sseStreamDone = "[DONE]"
|
||||
|
||||
scanBufInitSize = 64 * 1024 // 64KB
|
||||
scanMaxLineSize = 1024 * 1024 // 单行最大 1MB
|
||||
)
|
||||
|
||||
// ParseSSEStream 标准 SSE 流式解析,逐分片回调,支持多行data、上下文取消
|
||||
func ParseSSEStream(ctx context.Context, respBody io.Reader, onChunk func(ctx context.Context, chunk map[string]any) error) {
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
scanner.Buffer(make([]byte, 0, scanBufInitSize), scanMaxLineSize)
|
||||
|
||||
var dataBuilder strings.Builder
|
||||
|
||||
for scanner.Scan() {
|
||||
// 监听上下文取消,及时终止
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
g.Log().Infof(ctx, "[SSE] 上下文取消,终止流读取: %v", ctx.Err())
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
|
||||
// 跳过注释、事件行
|
||||
if strings.HasPrefix(line, ssePrefixComment) || strings.HasPrefix(line, ssePrefixEvent) {
|
||||
continue
|
||||
}
|
||||
|
||||
lineTrim := strings.TrimSpace(line)
|
||||
// 空行 = 一个SSE事件结束
|
||||
if lineTrim == "" {
|
||||
if dataBuilder.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
dataStr := dataBuilder.String()
|
||||
dataBuilder.Reset()
|
||||
|
||||
if dataStr == sseStreamDone {
|
||||
continue
|
||||
}
|
||||
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
|
||||
g.Log().Debugf(ctx, "[SSE] JSON解析失败: %s, err: %v", dataStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if onChunk != nil {
|
||||
onChunk(ctx, chunk)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 拼接多行 data 数据
|
||||
if strings.HasPrefix(line, ssePrefixData) {
|
||||
raw := strings.TrimPrefix(line, ssePrefixData)
|
||||
dataBuilder.WriteString(strings.TrimSpace(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// 捕获读取异常
|
||||
if err := scanner.Err(); err != nil {
|
||||
g.Log().Errorf(ctx, "[SSE] 流读取异常: %v", err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[SSE] 流式读取正常结束")
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"model-gateway/common/util"
|
||||
"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 ModelGatewayModels = &modelService{}
|
||||
|
||||
type modelService struct{}
|
||||
|
||||
// Create 创建模型
|
||||
func (s *modelService) Create(ctx context.Context, req *dto.CreateModelReq) (*dto.CreateModelRes, error) {
|
||||
// 1)如果设为会话模型,先把该用户旧会话模型取消
|
||||
if !g.IsEmpty(req.IsChatModel) && *req.IsChatModel == 1 {
|
||||
if err := s.clearUserChatModel(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 2)判断是否超管,决定 isOwner
|
||||
req.IsOwner = gconv.PtrInt(1)
|
||||
if isAdmin, _ := gateway.IsSuperAdmin(ctx); isAdmin {
|
||||
req.IsOwner = gconv.PtrInt(0)
|
||||
}
|
||||
|
||||
// 3)入库
|
||||
id, err := dao.ModelGatewayModels.Insert(ctx, util.ConvertTo[entity.ModelGatewayModel](req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CreateModelRes{ID: id}, nil
|
||||
}
|
||||
|
||||
// Update 更新模型配置
|
||||
func (s *modelService) Update(ctx context.Context, req *dto.UpdateModelReq) error {
|
||||
// 1)会话模型唯一性校验
|
||||
if req.IsChatModel != nil && *req.IsChatModel == 1 {
|
||||
if err := s.checkChatModelUnique(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 2)超管创建/普通用户更新
|
||||
req.IsOwner = gconv.PtrInt(1)
|
||||
if isAdmin, _ := gateway.IsSuperAdmin(ctx); isAdmin {
|
||||
req.IsOwner = gconv.PtrInt(0)
|
||||
_, err := dao.ModelGatewayModels.Update(ctx, util.ConvertTo[entity.ModelGatewayModel](req))
|
||||
return err
|
||||
}
|
||||
// 3)跨租户判断:超管的模型不允许直接修改,走插入新记录
|
||||
model, err := dao.ModelGatewayModels.GetByAcrossTenant(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: req.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if model.TenantId == 1 {
|
||||
_, err = dao.ModelGatewayModels.Insert(ctx, util.ConvertTo[entity.ModelGatewayModel](req))
|
||||
return err
|
||||
}
|
||||
_, err = dao.ModelGatewayModels.Update(ctx, util.ConvertTo[entity.ModelGatewayModel](req))
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete 删除模型
|
||||
func (s *modelService) Delete(ctx context.Context, req *dto.DeleteModelReq) error {
|
||||
_, err := dao.ModelGatewayModels.Delete(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: req.ID},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Get 获取模型详情
|
||||
func (s *modelService) Get(ctx context.Context, req *dto.GetModelReq) (*dto.GetModelRes, error) {
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.IsEmpty(req.ID) {
|
||||
req.Creator = user.UserName
|
||||
}
|
||||
model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{
|
||||
Id: req.ID,
|
||||
Creator: user.UserName,
|
||||
},
|
||||
ModelName: req.ModelName,
|
||||
IsChatModel: req.IsChatModel,
|
||||
})
|
||||
if err != nil || model == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.GetModelRes{
|
||||
Model: model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// List 获取模型列表
|
||||
func (s *modelService) List(ctx context.Context, req *dto.ListModelReq) (*dto.ListModelRes, error) {
|
||||
// 1)判断超管
|
||||
req.IsOwner = gconv.PtrInt(1)
|
||||
if isAdmin, _ := gateway.IsSuperAdmin(ctx); isAdmin {
|
||||
req.IsOwner = gconv.PtrInt(0)
|
||||
}
|
||||
|
||||
// 2)获取当前用户
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Creator = user.UserName
|
||||
|
||||
// 3)查询
|
||||
models, total, err := dao.ModelGatewayModels.GetByCreatorAndPlatform(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.ListModelRes{List: models, Total: total}, nil
|
||||
}
|
||||
|
||||
// UpdateChatModel 设置会话模型
|
||||
func (s *modelService) UpdateChatModel(ctx context.Context, req *dto.UpdateChatModelReq) error {
|
||||
// 1)校验新模型存在
|
||||
newModel, err := dao.ModelGatewayModels.GetByAcrossTenant(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: req.Id},
|
||||
})
|
||||
if err != nil || newModel == nil {
|
||||
return errors.New("新会话模型不存在")
|
||||
}
|
||||
|
||||
// 2)获取当前用户的会话模型
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentModel, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Creator: user.UserName},
|
||||
IsChatModel: gconv.PtrInt(1),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3)事务:取消旧的 + 设置新的
|
||||
return gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if !g.IsEmpty(currentModel) {
|
||||
if currentModel.ModelType != public.ModelTypeInference {
|
||||
return errors.New("当前模型为非推理模型,不能设置为会话模型")
|
||||
}
|
||||
if currentModel.Id != req.Id {
|
||||
_, err = dao.ModelGatewayModels.Update(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: currentModel.Id},
|
||||
IsChatModel: gconv.PtrInt(0),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = dao.ModelGatewayModels.Update(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: req.Id},
|
||||
IsChatModel: gconv.PtrInt(1),
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// GetIsChatModel 获取当前用户会话模型
|
||||
func (s *modelService) GetIsChatModel(ctx context.Context) (*dto.GetIsChatModelRes, error) {
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Creator: user.UserName},
|
||||
IsChatModel: gconv.PtrInt(1),
|
||||
})
|
||||
if err != nil || model == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.GetIsChatModelRes{Model: model}, nil
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
// clearUserChatModel 清除当前用户旧会话模型
|
||||
func (s *modelService) clearUserChatModel(ctx context.Context) error {
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Creator: user.UserName},
|
||||
IsChatModel: gconv.PtrInt(1),
|
||||
})
|
||||
if err != nil || model == nil {
|
||||
return nil
|
||||
}
|
||||
_, err = dao.ModelGatewayModels.Update(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: model.Id},
|
||||
IsChatModel: gconv.PtrInt(0),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// checkChatModelUnique 校验用户是否已有会话模型
|
||||
func (s *modelService) checkChatModelUnique(ctx context.Context) error {
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
|
||||
SQLBaseDO: beans.SQLBaseDO{Creator: user.UserName},
|
||||
IsChatModel: gconv.PtrInt(1),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if model != nil {
|
||||
return errors.New("用户已存在会话模型")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetModelTypesFromConfig 从配置文件读取模型类型
|
||||
func GetModelTypesFromConfig() (res *dto.TypeItem, err error) {
|
||||
// 返回副本,避免外部修改
|
||||
types := make(map[int]string, len(public.ModelTypeName))
|
||||
for k, v := range public.ModelTypeName {
|
||||
types[k] = v
|
||||
}
|
||||
return &dto.TypeItem{
|
||||
Type: types,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetOperatorList 获取运营商列表
|
||||
func GetOperatorList() (res *dto.ListOperatorRes, err error) {
|
||||
return &dto.ListOperatorRes{
|
||||
List: public.OperatorList,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"model-gateway/consts/model"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelCall = &modelCallService{}
|
||||
|
||||
type modelCallService struct{}
|
||||
|
||||
func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq) (res *dto.ModelCallRes, err error) {
|
||||
// 1) 检查模型配置
|
||||
var modelInfo *entity.ModelManage
|
||||
modelInfo, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{
|
||||
Id: req.ModelId,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取模型配置失败: %v", err)
|
||||
}
|
||||
if modelInfo == nil {
|
||||
return nil, fmt.Errorf("模型不存在")
|
||||
}
|
||||
// 引用行 → 解析为系统模型配置+本人 apiKey;系统模型已删除等解析失败 → 阻塞调用
|
||||
modelInfo, err = modelUtils.ResolveModelConfig(ctx, modelInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if modelInfo.Enabled != nil && !*modelInfo.Enabled {
|
||||
return nil, fmt.Errorf("模型不存在或未启用")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
userInfo, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !g.IsEmpty(modelInfo.RefSystemModelId) {
|
||||
// 调用前检查模型计价配置(shop-user-trade):未配置/未启用 → 阻塞调用(subject=解析后的系统模型 id)
|
||||
if err = modelBillable(ctx, modelInfo.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) {
|
||||
if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() || *modelInfo.ResponseType == *model.ResponseTypeStream.Code() {
|
||||
var newRequestParams map[string]any
|
||||
var id int64
|
||||
id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.IsEmpty(id) || g.IsEmpty(newRequestParams) {
|
||||
return fmt.Errorf("保存模型请求参数失败")
|
||||
}
|
||||
if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() {
|
||||
res, err = ModelSession.CreateSession(ctx, &dto.CallModelSessionReq{
|
||||
Id: id,
|
||||
ModelInfo: modelInfo,
|
||||
RequestParams: newRequestParams,
|
||||
})
|
||||
} else {
|
||||
res, err = ModelSession.CreateSessionStreamOnce(ctx, &dto.CallModelSessionReq{
|
||||
Id: id,
|
||||
ModelInfo: modelInfo,
|
||||
RequestParams: newRequestParams,
|
||||
})
|
||||
}
|
||||
}
|
||||
if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() {
|
||||
if g.IsEmpty(req.MsgTopic) {
|
||||
return fmt.Errorf("请指定消息主题")
|
||||
}
|
||||
var newRequestParams map[string]any
|
||||
var id int64
|
||||
id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.IsEmpty(id) || g.IsEmpty(newRequestParams) {
|
||||
return fmt.Errorf("保存模型请求参数失败")
|
||||
}
|
||||
res, err = ModelTaskStart.CreateTask(ctx, &dto.CallModelTaskStartReq{
|
||||
Id: id,
|
||||
ModelInfo: modelInfo,
|
||||
RequestParams: newRequestParams,
|
||||
})
|
||||
}
|
||||
return
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseWriter, req *dto.ModelCallStreamReq) (err error) {
|
||||
// 1) 检查模型配置
|
||||
var modelInfo *entity.ModelManage
|
||||
modelInfo, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{
|
||||
Id: req.ModelId,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取模型配置失败: %v", err)
|
||||
}
|
||||
if modelInfo == nil {
|
||||
return fmt.Errorf("模型不存在")
|
||||
}
|
||||
// 引用行 → 解析为系统模型配置+本人 apiKey;系统模型已删除等解析失败 → 阻塞调用
|
||||
modelInfo, err = modelUtils.ResolveModelConfig(ctx, modelInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if modelInfo.Enabled != nil && !*modelInfo.Enabled {
|
||||
return fmt.Errorf("模型不存在或未启用")
|
||||
}
|
||||
if *modelInfo.ResponseType == *model.ResponseTypeStream.Code() {
|
||||
now := time.Now()
|
||||
userInfo, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !g.IsEmpty(modelInfo.RefSystemModelId) {
|
||||
// 调用前检查模型计价配置(shop-user-trade):未配置/未启用 → 阻塞调用(subject=解析后的系统模型 id)
|
||||
if err = modelBillable(ctx, modelInfo.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) {
|
||||
var newRequestParams map[string]any
|
||||
var id int64
|
||||
id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, &dto.ModelCallReq{
|
||||
ModelId: req.ModelId,
|
||||
RequestParams: req.RequestParams,
|
||||
BusinessParams: req.BusinessParams,
|
||||
SessionId: req.SessionId,
|
||||
BizName: req.BizName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.IsEmpty(id) || g.IsEmpty(newRequestParams) {
|
||||
return fmt.Errorf("保存模型请求参数失败")
|
||||
}
|
||||
_, err = ModelSession.CreateSessionStream(ctx, w, &dto.CallModelSessionReq{
|
||||
Id: id,
|
||||
ModelInfo: modelInfo,
|
||||
RequestParams: newRequestParams,
|
||||
})
|
||||
return
|
||||
})
|
||||
} else {
|
||||
return fmt.Errorf("模型响应类型错误")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// saveModelRequestParams 保存模型请求参数
|
||||
func (s *modelCallService) saveModelRequestParams(ctx context.Context, now time.Time, modelInfo *entity.ModelManage, req *dto.ModelCallReq) (id int64, newRequestParams map[string]any, err error) {
|
||||
|
||||
// 统一走模板校验+构建:requestParams 只装模板字段,businessParams 只装业务字段
|
||||
out, err := buildChatRequestParams(modelInfo, req.RequestParams, req.BusinessParams)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// 1) 上传模型原始请求参数文件(requestParams + businessParams 合并,保证审计完整)
|
||||
originalParams := make(map[string]any, len(req.RequestParams)+len(req.BusinessParams))
|
||||
for k, v := range req.RequestParams {
|
||||
originalParams[k] = v
|
||||
}
|
||||
for k, v := range req.BusinessParams {
|
||||
originalParams[k] = v
|
||||
}
|
||||
uploadOriginalReq, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileBytes: gconv.Bytes(gconv.String(originalParams)),
|
||||
FileName: fmt.Sprintf("modelRequestParams:%v.json", now.UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("上传模型原始请求参数文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 2) 上传模型解析成功的请求参数文件
|
||||
uploadNewReq, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileBytes: gconv.Bytes(gconv.String(out)),
|
||||
FileName: fmt.Sprintf("modelNewRequestParams:%v.json", now.UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("上传模型解析请求参数文件失败:%v", err)
|
||||
}
|
||||
|
||||
// 3) 保存模型请求信息(快照媒体类型=shop 计费词汇 audio/video,空=无媒体引用,任务完成时直接用于算费;模型计费配置任务完成时按 modelId 现查)
|
||||
if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() {
|
||||
id, err = dao.ModelTaskStart.Insert(ctx, &dto.CreateModelTaskStartReq{
|
||||
ModelId: req.ModelId,
|
||||
BizName: req.BizName,
|
||||
MsgTopic: req.MsgTopic,
|
||||
RequestPath: uploadNewReq.FileURL,
|
||||
OriginalRequestPath: uploadOriginalReq.FileURL,
|
||||
MediaType: modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, out),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
id, err = dao.ModelSession.Insert(ctx, &dto.CreateModelSessionReq{
|
||||
ModelId: req.ModelId,
|
||||
BizName: req.BizName,
|
||||
SessionId: req.SessionId,
|
||||
RequestPath: uploadNewReq.FileURL,
|
||||
OriginalRequestPath: uploadOriginalReq.FileURL,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err)
|
||||
}
|
||||
}
|
||||
return id, out, nil
|
||||
}
|
||||
|
||||
func queue(ctx context.Context, modelName string, tenantId uint64, maxCon int64, f func(ctx context.Context) (err error)) (err error) {
|
||||
const (
|
||||
keyExpireSec = 600 // 名额Key兜底过期时间 10min(进程崩溃后自愈)
|
||||
refreshStep = keyExpireSec / 3 // 执行期间续期间隔
|
||||
waitInterval = 10 * time.Second // 超限轮询等待间隔
|
||||
)
|
||||
// Redis 操作统一使用独立上下文,避免外部 ctx canceled
|
||||
redisCtx := context.WithoutCancel(ctx)
|
||||
concurrencyKey := fmt.Sprintf("model:concurrency:%d:%s", tenantId, modelName)
|
||||
|
||||
// 1) 原子占用并发名额:utils.SemaphoreAcquire 在 WATCH 事务内完成 判满→INCR→首设EXPIRE→超限不写,
|
||||
// 不再需要旧 reserveSlot 的「分布式锁 + Incr + 回滚」组合(组合已事务化,外层锁冗余)。
|
||||
// 超限(false)按 waitInterval 轮询重试;max<=0 视为不限制(SemaphoreAcquire 内部直接放行)。
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
ok, e := utils.SemaphoreAcquire(redisCtx, concurrencyKey, int(maxCon), keyExpireSec)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if ok {
|
||||
// 展示当前并发数(占用后 GET,与旧 reserveSlot 的 Incr 后计数值语义一致)
|
||||
if v, e := g.Redis().Get(redisCtx, concurrencyKey); e == nil {
|
||||
glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, v.Int64(), maxCon)
|
||||
}
|
||||
break
|
||||
}
|
||||
glog.Infof(ctx, "并发超限等待: %s max=%d", concurrencyKey, maxCon)
|
||||
time.Sleep(waitInterval)
|
||||
}
|
||||
|
||||
// 2) 执行业务期间周期续期名额Key:SemaphoreAcquire 仅在首次占用(计数从 0 起)时设 TTL,
|
||||
// 长耗时调用靠本循环持续保活——Key 过期后计数归零会突破 max 并发上限造成超发。
|
||||
stop := make(chan struct{})
|
||||
go refreshTTL(redisCtx, concurrencyKey, keyExpireSec, refreshStep, stop)
|
||||
// 3) 无论业务正常返回还是 panic,都停掉续期并释放名额(幂等,计数归零自动删除 key)
|
||||
defer func() {
|
||||
close(stop)
|
||||
_ = utils.SemaphoreRelease(redisCtx, concurrencyKey)
|
||||
}()
|
||||
|
||||
// 4) 执行业务
|
||||
return f(ctx)
|
||||
}
|
||||
|
||||
// refreshTTL 周期给名额 Key 续期,直到 stop 关闭;防止长耗时执行期间 Key 提前过期。
|
||||
func refreshTTL(redisCtx context.Context, concurrencyKey string, keyExpireSec, step int64, stop <-chan struct{}) {
|
||||
interval := step
|
||||
if interval < 1 {
|
||||
interval = 1
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if _, err := g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec); err != nil {
|
||||
glog.Errorf(context.TODO(), "redis refresh concurrency ttl err: %v", err)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildChatRequestParams 按模型配置的请求模板 + 业务字段映射构建请求体(ModelCall 请求路径共用):
|
||||
// 1. requestParams 只装模板字段,按 requestBodyMapping 模板校验(CheckParams)+ 构建(ParseConfigTemplate);
|
||||
// 未配置映射的字段(如未配置映射的 messages/tools)会被模板拒绝,明确报错
|
||||
// 2. requestParams 为空时按配置模板构建请求结构(模板 defaultValue 生效),
|
||||
// 避免"结构由模板声明、值全走业务字段"的场景因请求体为空而构建失败
|
||||
// 3. businessParams 只装业务字段,按业务字段名(RequestBusinessFieldMapping 的 key)传值,
|
||||
// TakeBusinessFields 解析为写入路径,构建完成后由 WriteBusinessFields 按路径写入最终请求体
|
||||
func buildChatRequestParams(modelInfo *entity.ModelManage, requestParams, businessParams map[string]any) (map[string]any, error) {
|
||||
|
||||
// requestParams 可能混有扁平路径 key(messages.enumValues...)与已是对象/数组的值(stream)。
|
||||
// IsFlatMap 遇 map/slice 值即整体返回 false 会跳过 unflatten;sjson.Set 能处理任意值类型作为子树,
|
||||
// 带点 key 按路径展开、无点 key 直接赋值,故始终 unflatten
|
||||
var err error
|
||||
requestParams, err = utils.UnFlatBySjson(requestParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rest := make(map[string]any, len(requestParams))
|
||||
for k, v := range requestParams {
|
||||
rest[k] = v
|
||||
}
|
||||
// 请求结构源:模板字段兜底(模板 value/defaultValue 生效)+ requestParams 覆盖同名;
|
||||
// 保证模板声明的结构字段(如 stream_options 对象)即使 requestParams 未传也进请求体
|
||||
// 用户已传字段按模板类型元数据递归合并(补 type 包装、补默认字段),数组字段仅在用户提供时才合并
|
||||
src := rest
|
||||
for k, v := range modelInfo.RequestBodyMapping {
|
||||
tmplMap, _ := v.(map[string]any)
|
||||
if tmplMap != nil {
|
||||
if t, _ := tmplMap["type"].(string); t == "array" {
|
||||
if _, has := src[k]; !has {
|
||||
continue
|
||||
}
|
||||
src[k] = modelUtils.MergeNode(src[k], v)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if userVal, has := src[k]; has {
|
||||
src[k] = modelUtils.MergeNode(userVal, v)
|
||||
} else {
|
||||
src[k] = modelUtils.DeepCopyNode(v)
|
||||
}
|
||||
}
|
||||
if len(requestParams) > 0 {
|
||||
// requestParams 非空才按模板严格校验模板字段(空值回填 default);
|
||||
// 为空时跳过,避免业务字段未写入就误报必填缺失
|
||||
if err := modelUtils.CheckParams(src, modelInfo.RequestBodyMapping); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
out := modelUtils.ParseConfigTemplate(src)
|
||||
if !g.IsEmpty(businessParams) {
|
||||
// 业务字段:businessParams 按业务字段名传值,解析为映射路径后写入
|
||||
bizValues, err := modelUtils.TakeBusinessFields(businessParams, modelInfo.RequestBusinessFieldMapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 业务字段按映射路径写入最终请求体(写 out 而非 rest:rest 只是模板字段容器,out 才是下发模型的请求体)
|
||||
if err = modelUtils.WriteBusinessFields(out, bizValues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 合并后按模板约束整体校验:必填/长度/范围
|
||||
if err = modelUtils.CheckBody(out, modelInfo.RequestBodyMapping); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 按模板声明的 type 归一字段值类型(模板字段 value / 业务字段写入值都可能与声明类型不符)
|
||||
out = modelUtils.CoerceBodyTypes(out, modelInfo.RequestBodyMapping)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var analysisGroup singleflight.Group
|
||||
|
||||
// shouldRetryWithMemory 统一重试判定:查持久记忆,未命中则调分析模型并落库。
|
||||
// 记忆/分析/DB 任一环节失败均 fail-closed 不重试。
|
||||
func shouldRetryWithMemory(ctx context.Context, modelInfo *entity.ModelManage, code, msg, rawBody string) (retry bool) {
|
||||
if modelInfo == nil || (code == "" && msg == "") {
|
||||
return false
|
||||
}
|
||||
key := buildMemoryKey(modelInfo.BaseURL, code, msg)
|
||||
if row, err := dao.ModelErrorMemory.GetByKey(ctx, key); err != nil {
|
||||
g.Log().Errorf(ctx, "查询错误重试记忆失败: %v", err)
|
||||
return false
|
||||
} else if row != nil {
|
||||
return row.Retryable
|
||||
}
|
||||
retry, _ = analyzeOnce(key, func() (bool, string) {
|
||||
model, ok := resolveAnalysisModel(ctx, modelInfo)
|
||||
if !ok {
|
||||
g.Log().Warningf(ctx, "无可用分析模型(对话模型),错误不重试: code=%s", code)
|
||||
return false, ""
|
||||
}
|
||||
r, reason, err := callAnalysisLLM(ctx, model, code, msg, rawBody)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "错误分析失败,fail-closed不重试: %v", err)
|
||||
return false, ""
|
||||
}
|
||||
row := &entity.ModelErrorMemory{
|
||||
MemoryKey: key,
|
||||
Upstream: modelInfo.BaseURL,
|
||||
ErrorCode: code,
|
||||
MsgFingerprint: msgFingerprint(msg),
|
||||
Retryable: r,
|
||||
Reason: reason,
|
||||
AnalyzedBy: model.ModelName,
|
||||
}
|
||||
if err := dao.ModelErrorMemory.Upsert(ctx, row); err != nil {
|
||||
g.Log().Errorf(ctx, "错误重试记忆落库失败: %v", err)
|
||||
}
|
||||
return r, reason
|
||||
})
|
||||
return retry
|
||||
}
|
||||
|
||||
// analyzeOnce 按记忆键合并并发分析请求(singleflight)。
|
||||
// 注:fn 失败时结果在本次突发内共享(后续新错误会重新分析)。
|
||||
func analyzeOnce(key string, fn func() (bool, string)) (bool, string) {
|
||||
v, err, _ := analysisGroup.Do(key, func() (any, error) {
|
||||
retry, reason := fn()
|
||||
return []any{retry, reason}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
vals := v.([]any)
|
||||
return vals[0].(bool), vals[1].(string)
|
||||
}
|
||||
|
||||
var ModelErrorMemory = &modelErrorMemoryService{}
|
||||
|
||||
type modelErrorMemoryService struct{}
|
||||
|
||||
// List 错误重试记忆列表
|
||||
func (s *modelErrorMemoryService) List(ctx context.Context, req *dto.GetErrorMemoryListReq) (res *dto.GetErrorMemoryListRes, err error) {
|
||||
page, size := 1, 20
|
||||
if req.Page != nil && req.Page.PageNum > 0 {
|
||||
page = int(req.Page.PageNum)
|
||||
}
|
||||
if req.Page != nil && req.Page.PageSize > 0 {
|
||||
size = int(req.Page.PageSize)
|
||||
}
|
||||
list, total, err := dao.ModelErrorMemory.List(ctx, page, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = &dto.GetErrorMemoryListRes{Total: total, List: make([]dto.ErrorMemoryItem, 0, len(list))}
|
||||
for _, m := range list {
|
||||
res.List = append(res.List, dto.ErrorMemoryItem{
|
||||
Id: m.Id,
|
||||
MemoryKey: m.MemoryKey,
|
||||
Upstream: m.Upstream,
|
||||
ErrorCode: m.ErrorCode,
|
||||
MsgFingerprint: m.MsgFingerprint,
|
||||
Retryable: m.Retryable,
|
||||
Reason: m.Reason,
|
||||
AnalyzedBy: m.AnalyzedBy,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Delete 删除错误重试记忆(手动纠错永久记忆)
|
||||
func (s *modelErrorMemoryService) Delete(ctx context.Context, req *dto.DeleteErrorMemoryReq) (err error) {
|
||||
return dao.ModelErrorMemory.Delete(ctx, req.Id)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/model/dto"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
)
|
||||
|
||||
// IsSuperAdmin 调用admin-go服务检查是否是管理员
|
||||
func IsSuperAdmin(ctx context.Context) (res bool, err error) {
|
||||
headers := utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true})
|
||||
var r = make(map[string]bool)
|
||||
if err = commonHttp.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headers, &r); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return r["isSuperAdmin"], err
|
||||
}
|
||||
|
||||
// Upload 上传文件到 OSS。统一走 common/oss(multipart field=file、X-User-Info 三态注入与旧 setCtxHeader 等价)。
|
||||
func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) {
|
||||
res, err := oss.UploadFileBytes(ctx, req.FileName, req.FileBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.UploadFileBytesRes{
|
||||
FileURL: res.FileURL,
|
||||
FileSize: res.FileSize,
|
||||
FileName: res.FileName,
|
||||
FileFormat: res.FileFormat,
|
||||
FileAddressPrefix: res.FileAddressPrefix,
|
||||
}, nil
|
||||
}
|
||||
+220
-38
@@ -8,7 +8,7 @@ import (
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
"model-gateway/service/gateway"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
@@ -27,7 +27,7 @@ func (s *modelManageService) Create(ctx context.Context, req *dto.CreateModelMan
|
||||
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)
|
||||
isSuperAdmin, err = IsSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -37,7 +37,23 @@ func (s *modelManageService) Create(ctx context.Context, req *dto.CreateModelMan
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 2)插入数据
|
||||
// 2)模型名称唯一性:同一用户下不允许同名模型
|
||||
var user *beans.User
|
||||
user, err = utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !g.IsEmpty(req.ModelName) {
|
||||
var exist *entity.ModelManage
|
||||
exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, req.ModelName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if exist != nil {
|
||||
return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", req.ModelName)
|
||||
}
|
||||
}
|
||||
// 3)插入数据
|
||||
id, err := dao.ModelManage.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -49,7 +65,10 @@ func (s *modelManageService) Create(ctx context.Context, req *dto.CreateModelMan
|
||||
}
|
||||
|
||||
// Update 更新模型配置
|
||||
func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelManageReq) (err error) {
|
||||
// 引用行:只改 apiKey/enabled/chatModel,配置锁定跟随系统模型(其余字段忽略);
|
||||
// 系统模型:仅创建者(超管)可改配置,改名时同步引用行 model_name;
|
||||
// 非超管编辑系统模型 → 建引用行(不拷贝配置,apiKey 必填);用户自有模型:创建者可改全配置。非创建者操作他人行 → 无权限。
|
||||
func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelManageReq) (res *dto.GetModelManageRes, err error) {
|
||||
err = gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
|
||||
var get *entity.ModelManage
|
||||
get, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{
|
||||
@@ -58,43 +77,108 @@ func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelMan
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if get == nil {
|
||||
return fmt.Errorf("模型不存在")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 引用行:只改 apiKey/enabled/chatModel,配置锁定跟随系统模型(其余字段忽略;isSuperAdmin=false=个人会话模型开关)
|
||||
if get.RefSystemModelId > 0 {
|
||||
if get.Creator != user.UserName {
|
||||
return fmt.Errorf("无权限操作")
|
||||
}
|
||||
if err = s.CancelChatModel(ctx, get.ModelType, req.ChatModel, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dao.ModelManage.Update(ctx, &dto.UpdateModelManageReq{
|
||||
Id: req.Id,
|
||||
ApiKey: req.ApiKey,
|
||||
Enabled: req.Enabled,
|
||||
ChatModel: req.ChatModel,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 同名唯一性:同一用户下不允许同名模型(排除自身,允许不改名编辑)
|
||||
if !g.IsEmpty(req.ModelName) {
|
||||
var exist *entity.ModelManage
|
||||
exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, req.ModelName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return fmt.Errorf("无权限操作")
|
||||
if exist != nil && exist.Id != req.Id {
|
||||
res, err = s.Get(ctx, &dto.GetModelManageReq{Id: exist.Id})
|
||||
return
|
||||
//return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", req.ModelName)
|
||||
}
|
||||
}
|
||||
|
||||
// 1)检查是否是超管
|
||||
var isSuperAdmin bool
|
||||
isSuperAdmin, err = gateway.IsSuperAdmin(ctx)
|
||||
isSuperAdmin, err = IsSuperAdmin(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 1)如果设为会话模型,先把该用户旧会话模型取消
|
||||
err = s.CancelChatModel(ctx, req.ModelType, req.ChatModel, isSuperAdmin)
|
||||
if err != nil {
|
||||
return
|
||||
if err = s.CancelChatModel(ctx, get.ModelType, req.ChatModel, isSuperAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
// 2)更新数据
|
||||
if isSuperAdmin {
|
||||
if get.Creator != user.UserName {
|
||||
return fmt.Errorf("无权限操作")
|
||||
}
|
||||
// 3)系统模型改名 → 同步引用行 model_name(保列表 DISTINCT ON 去重正确)
|
||||
if get.SystemModel != nil && *get.SystemModel && req.ModelName != "" && req.ModelName != get.ModelName {
|
||||
if _, err = dao.ModelManage.UpdateReferencesName(ctx, get.Id, req.ModelName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if get.SystemModel != nil && *get.SystemModel {
|
||||
// 用户引用系统模型:apiKey 必填(与旧拷贝分支一致,空 key 引用行无意义)
|
||||
if g.IsEmpty(req.ApiKey) {
|
||||
return fmt.Errorf("模型apiKey不能为空")
|
||||
}
|
||||
var exist *entity.ModelManage
|
||||
exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, get.ModelName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if exist != nil {
|
||||
return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", get.ModelName)
|
||||
}
|
||||
// 4)插引用行(配置列零值即可,解析层只读系统行配置)
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
var id int64
|
||||
id, err = dao.ModelManage.Insert(ctx, &dto.CreateModelManageReq{
|
||||
ModelSupplier: get.ModelSupplier,
|
||||
ModelName: get.ModelName,
|
||||
ModelType: get.ModelType,
|
||||
SystemModel: gconv.PtrBool(false),
|
||||
ChatModel: req.ChatModel,
|
||||
ApiKey: req.ApiKey,
|
||||
Enabled: gconv.PtrBool(enabled),
|
||||
RefSystemModelId: get.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err = s.Get(ctx, &dto.GetModelManageReq{
|
||||
Id: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
if get.Creator != user.UserName {
|
||||
return fmt.Errorf("无权限操作")
|
||||
}
|
||||
}
|
||||
// 4)更新数据
|
||||
_, err = dao.ModelManage.Update(ctx, req)
|
||||
return
|
||||
})
|
||||
@@ -103,7 +187,7 @@ func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelMan
|
||||
|
||||
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 !g.IsEmpty(modelType) && *modelType == *model.ModelTypeInference.Code {
|
||||
if isSuperAdmin {
|
||||
return fmt.Errorf("超级管理员不能设置会话模型")
|
||||
}
|
||||
@@ -122,6 +206,9 @@ func (s *modelManageService) CancelChatModel(ctx context.Context, modelType mode
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(get) {
|
||||
return
|
||||
}
|
||||
_, err = dao.ModelManage.Update(ctx, &dto.UpdateModelManageReq{
|
||||
Id: get.Id,
|
||||
ChatModel: gconv.PtrBool(false),
|
||||
@@ -136,9 +223,36 @@ func (s *modelManageService) CancelChatModel(ctx context.Context, modelType mode
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除模型
|
||||
// Delete 删除模型:系统模型被引用 → 拒绝;引用行/自有行仅创建者可删
|
||||
func (s *modelManageService) Delete(ctx context.Context, req *dto.DeleteModelManageReq) error {
|
||||
_, err := dao.ModelManage.Delete(ctx, req)
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
get, err := dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: req.Id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if get == nil {
|
||||
return nil
|
||||
}
|
||||
// 系统模型被引用 → 拒绝删除(防悬挂)
|
||||
if get.SystemModel != nil && *get.SystemModel {
|
||||
n, err := dao.ModelManage.CountReferences(ctx, get.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("系统模型已被 %d 个用户引用,不能删除", n)
|
||||
}
|
||||
_, err = dao.ModelManage.Delete(ctx, req)
|
||||
return err
|
||||
}
|
||||
// 引用行/自有行:仅创建者可删(删引用行即解除引用)
|
||||
if get.Creator != user.UserName {
|
||||
return fmt.Errorf("无权限操作")
|
||||
}
|
||||
_, err = dao.ModelManage.Delete(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -147,12 +261,62 @@ func (s *modelManageService) Get(ctx context.Context, req *dto.GetModelManageReq
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gconv.Struct(get, &res)
|
||||
if get == nil {
|
||||
return new(dto.GetModelManageRes), nil
|
||||
}
|
||||
// 引用行 → 合入系统模型配置返回(前端展示/编辑需要完整配置;保留引用行自身 id 供更新/删除)
|
||||
if get.RefSystemModelId > 0 {
|
||||
sys, e := dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: get.RefSystemModelId})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if sys != nil {
|
||||
get = modelUtils.MergeReferenceConfigForQuery(get, sys)
|
||||
}
|
||||
}
|
||||
// 系统模型 apiKey 对非创建者脱敏(引用行/自有行仅本人可见)
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if get.SystemModel != nil && *get.SystemModel && get.Creator != user.UserName {
|
||||
get.ApiKey = ""
|
||||
}
|
||||
res = new(dto.GetModelManageRes)
|
||||
err = gconv.Struct(get, &res.ModelManage)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *modelManageService) GetChatModel(ctx context.Context, req *dto.GetChatModelReq) (res *dto.GetChatModelRes, err error) {
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
get, err := dao.ModelManage.Get(ctx, &dto.GetModelManage{
|
||||
Creator: user.UserName,
|
||||
ChatModel: gconv.PtrBool(true),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.GetChatModelRes{
|
||||
ModelManage: get,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取模型列表
|
||||
func (s *modelManageService) List(ctx context.Context, req *dto.ListModelManageReq) (res *dto.ListModelManageRes, err error) {
|
||||
if req.IsSameType && !g.IsEmpty(req.Id) {
|
||||
var get *entity.ModelManage
|
||||
get, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{
|
||||
Id: req.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.ModelType = get.ModelType
|
||||
}
|
||||
var user *beans.User
|
||||
user, err = utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
@@ -163,6 +327,31 @@ func (s *modelManageService) List(ctx context.Context, req *dto.ListModelManageR
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 引用行 → 合入系统模型配置返回(与 Get 展示一致;保留引用行自身 Id/SystemModel 供更新/脱敏判断)
|
||||
sysCache := make(map[int64]*entity.ModelManage)
|
||||
for _, row := range list {
|
||||
if row.RefSystemModelId <= 0 {
|
||||
continue
|
||||
}
|
||||
sys, ok := sysCache[row.RefSystemModelId]
|
||||
if !ok {
|
||||
sys, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: row.RefSystemModelId})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if sys == nil {
|
||||
continue
|
||||
}
|
||||
sysCache[row.RefSystemModelId] = sys
|
||||
}
|
||||
*row = *modelUtils.MergeReferenceConfigForQuery(row, sys)
|
||||
}
|
||||
// 系统模型 apiKey 对非创建者脱敏(引用行/自有行仅本人可见)
|
||||
for _, row := range list {
|
||||
if row.SystemModel != nil && *row.SystemModel && row.Creator != user.UserName {
|
||||
row.ApiKey = ""
|
||||
}
|
||||
}
|
||||
res = &dto.ListModelManageRes{
|
||||
Total: total,
|
||||
}
|
||||
@@ -171,14 +360,7 @@ func (s *modelManageService) List(ctx context.Context, req *dto.ListModelManageR
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
get, err := s.GetChatModel(ctx, &dto.GetChatModelReq{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/domain"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
"model-gateway/service/httpclient"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
"github.com/bjang03/gmq/types"
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelTaskEndService = &modelTaskEndService{}
|
||||
|
||||
type modelTaskEndService struct{}
|
||||
|
||||
// GetTaskStartList 获取待执行任务
|
||||
func (s *modelTaskEndService) GetTaskStartList(ctx context.Context) (err error) {
|
||||
workerNum := g.Cfg().MustGet(ctx, "pool.workerNum", modelUtils.DefaultWorkerNum).Int64()
|
||||
|
||||
redisKey := "model_video_task:"
|
||||
var (
|
||||
pageNum = gconv.Int64(1)
|
||||
remain = workerNum
|
||||
)
|
||||
// 字段列表
|
||||
cols := []string{
|
||||
entity.ModelTaskStartCol.Id,
|
||||
entity.ModelTaskStartCol.TaskId,
|
||||
entity.ModelTaskStartCol.ModelId,
|
||||
entity.ModelTaskStartCol.BizName,
|
||||
entity.ModelTaskStartCol.Creator,
|
||||
entity.ModelTaskStartCol.TenantId,
|
||||
entity.ModelTaskStartCol.MsgTopic,
|
||||
entity.ModelTaskStartCol.MediaType,
|
||||
}
|
||||
|
||||
for remain > 0 {
|
||||
req := &dto.GetModelTaskStartListReq{
|
||||
Page: &beans.Page{
|
||||
PageNum: pageNum,
|
||||
PageSize: remain, // 每页只查当前需要的数量
|
||||
},
|
||||
}
|
||||
|
||||
var list []entity.ModelTaskStart
|
||||
list, err = dao.ModelTaskStart.ListByLimitNotTenantId(ctx, req, cols...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询任务失败: %w", err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 3. 组装锁key,批量查询Redis(性能最优)
|
||||
taskMap := make(map[string]*entity.ModelTaskStart, len(list))
|
||||
lockKeys := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
key := redisKey + gconv.String(item.Id)
|
||||
taskMap[key] = &item
|
||||
lockKeys = append(lockKeys, key)
|
||||
}
|
||||
|
||||
var mGetRes map[string]*gvar.Var
|
||||
mGetRes, err = g.Redis().MGet(ctx, lockKeys...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("批量查询锁状态失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 提交异步处理:锁在 goroutine 内抢(WithLock 单次尝试),此处 MGet 只做快速预筛
|
||||
for _, key := range lockKeys {
|
||||
val := gconv.String(mGetRes[key])
|
||||
// 已被其他实例抢占,跳过(MGet 只做快速预筛;真正互斥靠 goroutine 内的原子抢锁)
|
||||
if val != "" {
|
||||
continue
|
||||
}
|
||||
err = s.handleSingleTask(ctx, taskMap[key], key)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "提交任务失败: %v", err)
|
||||
}
|
||||
remain-- // 占用一个槽位
|
||||
if remain <= 0 {
|
||||
break // 槽位已满,终止遍历
|
||||
}
|
||||
}
|
||||
pageNum++ // 页码动态累加,不再写死2
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskLockTTL 任务锁 TTL(秒)。utils.WithLock 自动续期锁住整个任务处理,TTL 仅作崩溃兜底:
|
||||
// worker 崩溃后续期停止,TTL 过期后其他 worker 重新抢占。
|
||||
const taskLockTTL = 1200
|
||||
|
||||
var urlParamReg = regexp.MustCompile(`\{.+?\}`)
|
||||
|
||||
// handleSingleTask 提交异步处理:锁在 goroutine 内抢(utils.WithLock 自动续期 + 单次尝试)。
|
||||
// 自动续期:锁持满整个任务处理,任务 >20min 不提前过期,避免其它 worker 重新抢到导致重复处理;
|
||||
// 单次尝试:锁被其它 worker 持有(任务已被别人处理)时立刻跳过——等待会拿着过期 item 在行删除后
|
||||
// 重复扣费/重复回调。Submit 失败(池关闭)goroutine 不运行、从没抢锁,无锁泄漏路径。
|
||||
func (s *modelTaskEndService) handleSingleTask(ctx context.Context, item *entity.ModelTaskStart, lockKey string) error {
|
||||
return modelUtils.Submit(ctx, func(ctx context.Context) {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
// OSS 桶名依赖 ctx 中的用户(GetBucketName → tenantid-{tenantId}),
|
||||
// 响应临时路径转存 OSS 需要用户信息,故在任务体最前面注入
|
||||
asyncCtx = context.WithValue(asyncCtx, "user", &beans.User{
|
||||
UserName: item.Creator,
|
||||
TenantId: item.TenantId,
|
||||
})
|
||||
ok, err := utils.WithLock(asyncCtx, lockKey, taskLockTTL, func(ctx context.Context) error {
|
||||
return s.processClaimedTask(ctx, item)
|
||||
}, 1)
|
||||
if err != nil || !ok {
|
||||
// 锁被其它实例持有或抢锁失败:跳过,任务行保留由持有方处理,下轮扫描不再命中
|
||||
g.Log().Warningf(asyncCtx, "任务锁未抢占,跳过 taskId=%d: %v", item.Id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// processClaimedTask 抢到任务锁后的完整处理:轮询模型结果 → 终态落库 + 发布。
|
||||
// 终态结果统一承载:成功/错误/解析失败任何路径都写进 docMsg.ErrorMsg 后走 finalize 落库+发布,
|
||||
// 避免早期直接 return 把任务丢弃——任务行不删、无结果落库、调用方永远收不到通知,只会被其他 worker 反复重捡。
|
||||
func (s *modelTaskEndService) processClaimedTask(asyncCtx context.Context, item *entity.ModelTaskStart) error {
|
||||
startTime := time.Now()
|
||||
docMsg := new(dto.ModelMsg)
|
||||
docMsg.TaskID = item.Id
|
||||
var respObj map[string]any
|
||||
|
||||
// 终态处理:删任务行 → 插结果行(含 ErrorMsg)→ NATS 发布结果给调用方
|
||||
finalize := func() {
|
||||
err := gfdb.DB(asyncCtx, public.DbNameModelGateway).Transaction(asyncCtx, func(asyncCtx context.Context, tx gdb.TX) (err error) {
|
||||
// 删除视频任务
|
||||
_, err = dao.ModelTaskStart.Delete(asyncCtx, &dto.DeleteModelTaskStartReq{
|
||||
Id: item.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存视频任务结果
|
||||
_, err = dao.ModelTaskEnd.Insert(asyncCtx, &dto.CreateModelTaskEndReq{
|
||||
ModelId: item.ModelId,
|
||||
BizName: item.BizName,
|
||||
MsgTopic: item.MsgTopic,
|
||||
TaskId: item.TaskId,
|
||||
ResponseParams: docMsg.Content,
|
||||
OriginalResponseParams: respObj,
|
||||
DurationSeconds: int64(time.Since(startTime).Seconds()),
|
||||
PromptTokens: docMsg.PromptTokens,
|
||||
CompletionTokens: docMsg.CompletionTokens,
|
||||
TotalTokens: docMsg.TotalTokens,
|
||||
TotalCost: docMsg.Cost,
|
||||
ErrorMsg: docMsg.ErrorMsg,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(asyncCtx, "保存视频任务结果失败: %v", err)
|
||||
}
|
||||
// 发布消息
|
||||
if err = TaskMsgPublish(asyncCtx, item.MsgTopic, docMsg); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型消息发布失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 modelId 现查模型配置(异步映射/token 映射/计费规则不随任务快照,任务完成时取当前配置)
|
||||
modelInfo, err := dao.ModelManage.GetNotTenantId(asyncCtx, &dto.GetModelManageReq{Id: item.ModelId})
|
||||
if err != nil {
|
||||
g.Log().Errorf(asyncCtx, "查询模型配置失败: modelId=%d err=%v", item.ModelId, err)
|
||||
docMsg.ErrorMsg = fmt.Sprintf("查询模型配置失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
if modelInfo == nil {
|
||||
g.Log().Errorf(asyncCtx, "模型配置不存在: modelId=%d", item.ModelId)
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型配置不存在: modelId=%d", item.ModelId)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 引用行 → 解析为系统模型配置+本人 apiKey(轮询/计价均用系统模型)
|
||||
modelInfo, err = modelUtils.ResolveModelConfig(asyncCtx, modelInfo)
|
||||
if err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型配置解析失败: modelId=%d err=%v", item.ModelId, err)
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型配置解析失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 连续轮询失败上限:瞬时抖动(HTTP 错/空响应/解析失败)先有限重试,超限按终态错误落库
|
||||
const maxPollErrRetries = 3
|
||||
pollErrCnt := 0
|
||||
LOOP:
|
||||
// 替换URL占位符
|
||||
url := urlParamReg.ReplaceAllString(modelInfo.AsyncTaskMapping.Url, item.TaskId)
|
||||
// 组装查询请求体:POST 查询接口需要 body(从 RequestBodyMapping 出发,替换 {…} 占位符为任务 ID)
|
||||
reqBody := buildAsyncTaskBody(modelInfo.AsyncTaskMapping.RequestBodyMapping, item.TaskId)
|
||||
// 发起HTTP请求
|
||||
modelRespBody, err := httpclient.ModelHttpNormalRequest(
|
||||
asyncCtx,
|
||||
url,
|
||||
modelInfo.AsyncTaskMapping.RequestHeadMapping,
|
||||
modelInfo.AsyncTaskMapping.HttpMethod, reqBody,
|
||||
)
|
||||
if err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型请求失败: %v", err)
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型请求失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
if modelRespBody == nil {
|
||||
g.Log().Errorf(asyncCtx, "模型返回参数为空")
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = "模型返回参数为空"
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
pollErrCnt = 0 // 请求成功一次即重置连续失败计数
|
||||
|
||||
// 异常响应识别:按模型 ErrorMessageMapping 解析,无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody, modelInfo.ErrorMessageMapping); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err)
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型返回参数解析失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
// 统一字段路径(GetByPath)读取基于该对象
|
||||
if err = json.Unmarshal(modelRespBody, &respObj); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err)
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = fmt.Sprintf("模型返回参数解析失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 无错误时才组装成功内容(错误响应按终态处理,跳过成功解析/轮询)
|
||||
if docMsg.ErrorMsg == "" {
|
||||
// 组装业务返回内容
|
||||
respBodyMap := modelUtils.CleanMapFieldPath(modelInfo.ResponseBodyMapping)
|
||||
content := make(map[string]any, len(respBodyMap))
|
||||
for bizKey, jsonPath := range respBodyMap {
|
||||
content[bizKey] = oss.TempURLToOSS(asyncCtx, modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(jsonPath)))
|
||||
}
|
||||
docMsg.Content = content
|
||||
|
||||
// 解析 ResponseBusinessFieldMapping 字段
|
||||
businessField := make(map[string]any, len(modelInfo.ResponseBusinessFieldMapping))
|
||||
for key, value := range modelInfo.ResponseBusinessFieldMapping {
|
||||
businessField[key] = modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(value))
|
||||
}
|
||||
businessFieldRes := new(domain.VideoFieldsRes)
|
||||
err = gconv.Struct(businessField, businessFieldRes)
|
||||
if err != nil {
|
||||
docMsg.ErrorMsg = fmt.Sprintf("解析 ResponseBusinessFieldMapping 字段失败: %v", err)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
docMsg.Duration = businessFieldRes.Duration
|
||||
|
||||
// 解析Token
|
||||
totalTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)
|
||||
promptTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)
|
||||
compTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)
|
||||
|
||||
docMsg.TotalTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, totalTokPath))
|
||||
docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, promptTokPath))
|
||||
docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, compTokPath))
|
||||
|
||||
// 判断任务状态,轮询等待
|
||||
statusPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskStatus)
|
||||
status := gconv.String(modelUtils.GetByPathValue(respObj, statusPath))
|
||||
if status == modelInfo.AsyncTaskMapping.TaskStatusPending || status == modelInfo.AsyncTaskMapping.TaskStatusRunning {
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
|
||||
// 调 shop-user-trade 按用量算费(媒体类型取任务创建时的快照;subject=解析后的系统模型 id)
|
||||
docMsg.ModelId = modelInfo.Id // 引用行=系统模型 id,供 per_token 结算按系统模型计价
|
||||
docMsg.MediaType = item.MediaType
|
||||
docMsg.Cost = calcModelCost(asyncCtx, modelInfo.Id,
|
||||
buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, item.MediaType, docMsg.Duration))
|
||||
}
|
||||
// 成功或已识别出错误的终态统一落库+发布(内容组装完成/错误消息已写入 docMsg)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAsyncTaskBody 组装异步任务查询请求体:从 AsyncTaskMapping.RequestBodyMapping 出发,
|
||||
// 把 {…} 占位符(如 {taskId})替换为实际任务 ID,兼容 POST 查询接口需要请求体的场景。
|
||||
// 未配置映射时返回 nil(GET 查询/无需 body 的场景)。
|
||||
func buildAsyncTaskBody(mapping map[string]any, taskID string) map[string]any {
|
||||
if len(mapping) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(mapping))
|
||||
for k, v := range mapping {
|
||||
out[k] = replaceTaskPlaceholder(v, taskID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// replaceTaskPlaceholder 递归替换结构体中的 {…} 占位符为任务 ID
|
||||
func replaceTaskPlaceholder(v any, taskID string) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return urlParamReg.ReplaceAllString(val, taskID)
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(val))
|
||||
for k, x := range val {
|
||||
m[k] = replaceTaskPlaceholder(x, taskID)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
arr := make([]any, len(val))
|
||||
for i, x := range val {
|
||||
arr[i] = replaceTaskPlaceholder(x, taskID)
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func TaskMsgPublish(ctx context.Context, topic string, data *dto.ModelMsg) (err error) {
|
||||
err = gmq.GetGmq(public.GmqMsgPluginsName).GmqPublish(ctx, &mq.NatsPubMessage{
|
||||
PubMessage: types.PubMessage{
|
||||
Topic: topic,
|
||||
Data: data,
|
||||
},
|
||||
Durable: true,
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[TaskMsgPublish] 发布消息失败 [Error]: %v", err)
|
||||
return fmt.Errorf("发布消息失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service/httpclient"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelTaskStart = &modelTaskStartService{}
|
||||
|
||||
type modelTaskStartService struct{}
|
||||
|
||||
// CreateTask 创建任务
|
||||
func (s *modelTaskStartService) CreateTask(ctx context.Context, req *dto.CallModelTaskStartReq) (res *dto.ModelCallRes, err error) {
|
||||
startTime := time.Now()
|
||||
attempt := 0
|
||||
|
||||
id := req.Id
|
||||
modelInfo := req.ModelInfo
|
||||
newRequestParams := req.RequestParams
|
||||
|
||||
LOOP:
|
||||
// 6) 模型请求
|
||||
modelRespBody, err := httpclient.ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
if attempt < modelCallMaxRetries && shouldRetryWithMemory(ctx, modelInfo, "", err.Error(), "") {
|
||||
attempt++
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
return nil, fmt.Errorf("模型请求失败: %v", err)
|
||||
}
|
||||
if modelRespBody == nil {
|
||||
return nil, fmt.Errorf("模型返回参数是空")
|
||||
}
|
||||
// 7) 更新视频任务信息(统一字段路径 GetByPath 基于该对象读取)
|
||||
var respObj map[string]any
|
||||
if err = json.Unmarshal(modelRespBody, &respObj); err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
updateModelReq := dto.UpdateModelTaskStartReq{
|
||||
Id: id,
|
||||
OriginalResponseParams: respObj,
|
||||
}
|
||||
docMsg := new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
// 按模型 ErrorMessageMapping 解析错误响应,无错误返回空串
|
||||
var errCode string
|
||||
if errCode, docMsg.ErrorMsg, err = parseModelError(modelRespBody, modelInfo.ErrorMessageMapping); err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
if docMsg.ErrorMsg != "" {
|
||||
if attempt < modelCallMaxRetries && shouldRetryWithMemory(ctx, modelInfo, errCode, docMsg.ErrorMsg, string(modelRespBody)) {
|
||||
attempt++
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
updateModelReq.ErrorMsg = docMsg.ErrorMsg
|
||||
}
|
||||
if docMsg.ErrorMsg == "" {
|
||||
taskIDPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskId)
|
||||
docMsg.Content = map[string]any{
|
||||
"respBody": modelUtils.GetByPathValue(respObj, taskIDPath),
|
||||
}
|
||||
}
|
||||
if !g.IsEmpty(docMsg.Content) {
|
||||
updateModelReq.ResponseParams = docMsg.Content
|
||||
updateModelReq.TaskId = gconv.String(docMsg.Content["respBody"])
|
||||
}
|
||||
updateModelReq.DurationSeconds = int64(time.Since(startTime).Seconds())
|
||||
// 8) 更新模型视频任务信息
|
||||
_, err = dao.ModelTaskStart.Update(ctx, &updateModelReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新模型视频任务信息失败: %v", err)
|
||||
}
|
||||
|
||||
return docMsg, nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
modelUtils "model-gateway/service/utils"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// parseModelError 按模型配置的 ErrorMessageMapping 解析错误响应,返回错误码与错误消息(无错误均返回空串)。
|
||||
// ErrorMessageMapping 为 schema 树(与请求模板同格式:type/value/label/isForm/required/fieldType/defaultValue/attrs),
|
||||
// 解析时剔除包装字段取 code/message 的字段路径(复用 normalizeSchemaPath 归一 attrs/数组段),
|
||||
// 经 GetByPath 从响应体提取。成功判定:
|
||||
// - code 提取值为空(nil/空串/数字零)→ 成功
|
||||
// - code 节点配置了 defaultValue 且提取值等于它 → 成功
|
||||
// - 否则 → 错误(返回 code + message)
|
||||
//
|
||||
// 未配置 ErrorMessageMapping → 不识别错误,一律返回成功(纯配置驱动)。
|
||||
// 解析失败返回 err,由调用方决定重试/终态。
|
||||
func parseModelError(body []byte, mapping map[string]any) (code, msg string, err error) {
|
||||
if len(mapping) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
var respObj map[string]any
|
||||
if err = json.Unmarshal(body, &respObj); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
codePath, msgPath, hasCodeDefault, codeDefault := collectErrorMapping(mapping)
|
||||
codeVal := modelUtils.GetByPathValue(respObj, codePath)
|
||||
msgVal := modelUtils.GetByPathValue(respObj, msgPath)
|
||||
if codePath != "" {
|
||||
if isEmptyErrorCode(codeVal) {
|
||||
return "", "", nil
|
||||
}
|
||||
if hasCodeDefault && sameErrorValue(codeVal, codeDefault) {
|
||||
return "", "", nil
|
||||
}
|
||||
return gconv.String(codeVal), gconv.String(msgVal), nil
|
||||
}
|
||||
// 未配置 code 路径:以 message 是否为空判定错误
|
||||
if !isEmptyErrorCode(msgVal) {
|
||||
return "", gconv.String(msgVal), nil
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// collectErrorMapping 遍历 ErrorMessageMapping schema 树,提取 code/message 的字段路径与 code 节点的 defaultValue。
|
||||
// 支持 schema 节点(含 type/attrs 等包装)与纯字符串路径两种形态;数组段经 normalizeSchemaPath 归一到 [*]。
|
||||
// 返回路径为已归一字段路径;未配置返回空串。
|
||||
func collectErrorMapping(mapping map[string]any) (codePath, msgPath string, hasCodeDefault bool, codeDefault any) {
|
||||
var walk func(node map[string]any, prefix string)
|
||||
walk = func(node map[string]any, prefix string) {
|
||||
for key, val := range node {
|
||||
if isErrorMetaKey(key) {
|
||||
continue
|
||||
}
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
// 纯值形态:字段值直接是字段路径字符串
|
||||
if s, ok := val.(string); ok {
|
||||
path := normalizeSchemaPath(joinErrorFieldPath(prefix, s))
|
||||
if key == "code" && codePath == "" {
|
||||
codePath = path
|
||||
} else if key == "message" && msgPath == "" {
|
||||
msgPath = path
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
nodeType, _ := m["type"].(string)
|
||||
hasDef := false
|
||||
var def any
|
||||
if d, has := m["defaultValue"]; has && d != nil {
|
||||
hasDef, def = true, d
|
||||
}
|
||||
switch {
|
||||
case nodeType == "object":
|
||||
if attrs, ok := m["attrs"].(map[string]any); ok {
|
||||
walk(attrs, joinErrorFieldPath(prefix, key))
|
||||
} else if attrs, ok := m["attrs"].([]any); ok && len(attrs) > 0 {
|
||||
if elm, ok := attrs[0].(map[string]any); ok {
|
||||
walkErrorArrayElement(elm, joinErrorArrayPath(prefix, key), walk)
|
||||
}
|
||||
} else {
|
||||
walk(m, joinErrorFieldPath(prefix, key))
|
||||
}
|
||||
case nodeType == "array":
|
||||
walkErrorArrayContainer(m, joinErrorArrayPath(prefix, key), walk)
|
||||
case nodeType == "":
|
||||
// 无 type 键:纯容器(字段直接作为键),递归下钻
|
||||
walk(m, joinErrorFieldPath(prefix, key))
|
||||
default:
|
||||
// 标量叶子字段
|
||||
path := normalizeSchemaPath(joinErrorFieldPath(prefix, key))
|
||||
if key == "code" && codePath == "" {
|
||||
codePath, hasCodeDefault, codeDefault = path, hasDef, def
|
||||
} else if key == "message" && msgPath == "" {
|
||||
msgPath = path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(mapping, "")
|
||||
return
|
||||
}
|
||||
|
||||
// walkErrorArrayContainer 数组节点:子字段容器依次尝试 attrs([]any)/enumValues/value([]any)
|
||||
func walkErrorArrayContainer(node map[string]any, prefix string, walk func(map[string]any, string)) {
|
||||
for _, container := range []string{"attrs", "enumValues", "value"} {
|
||||
items, ok := node[container].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
if elm, ok := items[0].(map[string]any); ok {
|
||||
walkErrorArrayElement(elm, prefix, walk)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// walkErrorArrayElement 数组元素:字段在其 attrs 下(对象元素)或直接作为键(纯元素)
|
||||
func walkErrorArrayElement(elm map[string]any, prefix string, walk func(map[string]any, string)) {
|
||||
if attrs, ok := elm["attrs"].(map[string]any); ok {
|
||||
walk(attrs, prefix)
|
||||
return
|
||||
}
|
||||
walk(elm, prefix)
|
||||
}
|
||||
|
||||
// isErrorMetaKey 判断是否为 schema 节点元数据字段(非业务字段,剔除)
|
||||
func isErrorMetaKey(key string) bool {
|
||||
switch key {
|
||||
case "type", "value", "label", "isForm", "required", "fieldType", "defaultValue", "description":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// joinErrorFieldPath 拼接字段路径(数组段 [0] 由 normalizeSchemaPath 归一为 [*])
|
||||
func joinErrorFieldPath(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
// joinErrorArrayPath 数组字段路径:元素下标 [0] 由 normalizeSchemaPath 归一为 [*]
|
||||
func joinErrorArrayPath(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key + "[0]"
|
||||
}
|
||||
return prefix + "." + key + "[0]"
|
||||
}
|
||||
|
||||
// isEmptyErrorCode 判断错误码是否为空(空=无错误):nil / 空串 / 布尔 false / 数字零值
|
||||
func isEmptyErrorCode(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t == ""
|
||||
case bool:
|
||||
return !t
|
||||
}
|
||||
if isNumericType(v) {
|
||||
return gconv.Float64(v) == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sameErrorValue 判断提取值是否等于配置的 defaultValue(数值/字符串跨类型兼容)
|
||||
func sameErrorValue(a, b any) bool {
|
||||
if isNumericType(a) && isNumericType(b) {
|
||||
return gconv.Float64(a) == gconv.Float64(b)
|
||||
}
|
||||
return gconv.String(a) == gconv.String(b)
|
||||
}
|
||||
|
||||
// isNumericType 判断是否为数值类型
|
||||
func isNumericType(v any) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
switch reflect.TypeOf(v).Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||
reflect.Float32, reflect.Float64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ====================== shop-user-trade 计价对接(独立 module,本地 JSON 对齐) ======================
|
||||
|
||||
// shopPricingConfig shop-user-trade config/get 响应(仅取启用标记与最低余额门限;费率规则在 shop-user-trade 侧,model-gateway 不消费)
|
||||
type shopPricingConfig struct {
|
||||
Enabled int `json:"enabled"`
|
||||
MinBalance float64 `json:"minBalance" dc:"门禁:调用前可用余额须>=该值(元),0=不校验"`
|
||||
}
|
||||
|
||||
// shopWalletAccount shop-user-trade wallet/account/get 响应
|
||||
type shopWalletAccount struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
Balance float64 `json:"balance" dc:"可用余额(元),负值=欠费"`
|
||||
Currency string `json:"currency"`
|
||||
Status int `json:"status" dc:"状态:1启用 0禁用 -1冻结"`
|
||||
}
|
||||
|
||||
// shopCalcFeeRes shop-user-trade /calc 响应
|
||||
type shopCalcFeeRes struct {
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
|
||||
// pricingURL 组装 shop-user-trade 计价接口地址(common http.RouteRegister 推导前缀,同 ai-agent billing.go)
|
||||
func pricingURL(sub string) string {
|
||||
return "shop-user-trade/pricing/controller/" + sub
|
||||
}
|
||||
|
||||
// walletURL 组装 shop-user-trade 钱包接口地址(accountController → account/controller,与 pricing 同 RouteRegister 推导规则)
|
||||
func walletURL(sub string) string {
|
||||
return "shop-user-trade/account/controller/" + sub
|
||||
}
|
||||
|
||||
// modelBillable 调用前门禁:模型须在 shop-user-trade 已配置且启用计价,否则阻塞调用。
|
||||
// 替换原 CheckTenantBalance(admin-go 租户余额门禁);未配置→config/get 返回错误,未启用→enabled!=1。
|
||||
// minBalance>0 时追加最低余额门禁:钱包须存在且可用余额 >= 门限(与 shop-user-trade open_order 同语义,0=不校验)。
|
||||
func modelBillable(ctx context.Context, modelId int64) error {
|
||||
var cfg shopPricingConfig
|
||||
err := commonHttp.Get(ctx, pricingURL("config/get"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &cfg,
|
||||
"subjectType", "model", "subjectId", fmt.Sprintf("%d", modelId))
|
||||
if err != nil {
|
||||
return fmt.Errorf("模型未配置计价,无法调用: %w", err)
|
||||
}
|
||||
if cfg.Enabled != 1 {
|
||||
return fmt.Errorf("模型未启用计价,无法调用")
|
||||
}
|
||||
if cfg.MinBalance <= 0 {
|
||||
return nil
|
||||
}
|
||||
user, e := utils.GetUserInfo(ctx)
|
||||
if e != nil || user == nil || user.Id == 0 {
|
||||
return fmt.Errorf("取不到用户,无法校验最低余额")
|
||||
}
|
||||
var acc shopWalletAccount
|
||||
if e = commonHttp.Get(ctx, walletURL("get"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &acc,
|
||||
"userId", fmt.Sprintf("%d", user.Id)); e != nil {
|
||||
return fmt.Errorf("获取钱包失败,无法校验最低余额: %w", e)
|
||||
}
|
||||
if acc.Status != 1 {
|
||||
return fmt.Errorf("钱包不可用,无法调用")
|
||||
}
|
||||
if acc.Balance < cfg.MinBalance {
|
||||
return fmt.Errorf("余额不足:可用余额须不低于 %.2f 元才能发起", cfg.MinBalance)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildModelUsage 组装算费用量 JSON 对象(ChargeUsage 形状)。mediaType 为 shop 计费词汇
|
||||
// (audio/video,空=无媒体引用走默认价;DetectMediaType/异步快照已直接为该词汇,不再二次转换)。
|
||||
// per_char 模型把输出字数映射到 completionTokens(TokenMapping),随该字段传给 shop /calc 计价。
|
||||
func buildModelUsage(prompt, completion, cached int64, mediaType string, durationSec int64) map[string]any {
|
||||
if durationSec < 0 {
|
||||
durationSec = 0
|
||||
}
|
||||
return map[string]any{
|
||||
"promptTokens": prompt,
|
||||
"completionTokens": completion,
|
||||
"cachedTokens": cached,
|
||||
"mediaType": mediaType,
|
||||
"durationSec": durationSec,
|
||||
}
|
||||
}
|
||||
|
||||
// calcModelCost 调 shop-user-trade /calc 按用量算费(不建单不扣费)。
|
||||
// 调用前门禁已保证配置存在;此处失败(配置中途删除/网络抖动)→ 记日志返回 0,不拖垮已完成的模型调用。
|
||||
func calcModelCost(ctx context.Context, modelId int64, usage map[string]any) float64 {
|
||||
var res shopCalcFeeRes
|
||||
err := commonHttp.Post(ctx, pricingURL("calc"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &res, &struct {
|
||||
SubjectType string `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
Usage map[string]any `json:"usage"`
|
||||
}{
|
||||
SubjectType: "model", SubjectID: fmt.Sprintf("%d", modelId), Usage: usage,
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[算费] 调用 shop-user-trade 失败 modelId=%d: %v", modelId, err)
|
||||
return 0
|
||||
}
|
||||
return res.Cost
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"model-gateway/model/dto"
|
||||
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AutoTuneResult 单次调参结果(按 model_name)
|
||||
type AutoTuneResult struct {
|
||||
ModelName string `json:"modelName"` // 模型名称(asynch_models.model_name)
|
||||
Samples int `json:"samples"` // 统计样本数(窗口内 state=2/3 且 started_at/finished_at 非空的任务数量)
|
||||
P90Exec float64 `json:"p90ExecSeconds"` // 执行耗时 P90(秒),口径:finished_at - started_at
|
||||
|
||||
CapMaxConcurrency int `json:"capMaxConcurrency"` // 配置上限:asynch_models.max_concurrency(cap,不会被动态调参覆盖)
|
||||
OldMaxConcurrency int `json:"oldMaxConcurrency"` // 调参前运行时值(Redis),若无则等于 cap
|
||||
NewMaxConcurrency int `json:"newMaxConcurrency"` // 本次计算出的运行时值(将写入 Redis),受 ±50% 约束且不超过 cap
|
||||
|
||||
CapQueueLimit int `json:"capQueueLimit"` // 配置上限:asynch_models.queue_limit(cap,不会被动态调参覆盖)
|
||||
OldQueueLimit int `json:"oldQueueLimit"` // 调参前运行时值(Redis),若无则等于 cap
|
||||
NewQueueLimit int `json:"newQueueLimit"` // 本次计算出的运行时值(将写入 Redis),受 ±50% 约束且不超过 cap
|
||||
|
||||
}
|
||||
|
||||
// AutoTune 由上层定时任务通过接口触发:
|
||||
// - 统计指定时间窗口内该模型任务的执行耗时(finished_at - started_at,取 P90)
|
||||
// - 基于吞吐与 P90 执行耗时估算 max_concurrency 的运行时值(不超过 cap)
|
||||
// - queue_limit 与 expected_seconds 绑定(允许排队时间 = expected_seconds * 2),生成运行时值(不超过 cap)
|
||||
// - 单次调整幅度限制 ±50%,写入 Redis(带 TTL)
|
||||
func AutoTune(ctx context.Context, req *dto.AutoTuneReq) (res *dto.AutoTuneRes, err error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("request cannot be nil")
|
||||
}
|
||||
if req.WindowSeconds <= 0 {
|
||||
req.WindowSeconds = 3600 // 默认1小时
|
||||
}
|
||||
// 1) 读取模型配置(cap),按 model_name 聚合去重(如果表里有多租户重复数据,取较大上限)
|
||||
var modelRows []*entity.ModelGatewayModel
|
||||
if err := gfdb.DB(ctx).Model(ctx, public.TableNameModel).
|
||||
Where("deleted_at IS NULL").
|
||||
Where(entity.ModelGatewayModelCol.Enabled, 1).
|
||||
Scan(&modelRows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelMap := make(map[string]*entity.ModelGatewayModel)
|
||||
for _, m := range modelRows {
|
||||
if m == nil || m.ModelName == "" {
|
||||
continue
|
||||
}
|
||||
cur := modelMap[m.ModelName]
|
||||
if cur == nil {
|
||||
modelMap[m.ModelName] = m
|
||||
continue
|
||||
}
|
||||
// 取更大的 cap
|
||||
if m.MaxConcurrency > cur.MaxConcurrency {
|
||||
cur.MaxConcurrency = m.MaxConcurrency
|
||||
}
|
||||
if m.MaxConcurrency*2 > cur.MaxConcurrency*2 {
|
||||
cur.MaxConcurrency = m.MaxConcurrency
|
||||
}
|
||||
if m.TimeoutSeconds > cur.TimeoutSeconds {
|
||||
cur.TimeoutSeconds = m.TimeoutSeconds
|
||||
}
|
||||
}
|
||||
if len(modelMap) == 0 {
|
||||
return nil, errors.New("no models found")
|
||||
}
|
||||
|
||||
// 2) 统计指定窗口:按 model_name 计算 cnt 和 P90 执行耗时
|
||||
type statRow struct {
|
||||
ModelName string
|
||||
Cnt int
|
||||
P90Exec float64
|
||||
}
|
||||
var stats []statRow
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT model_name,
|
||||
COUNT(1) AS cnt,
|
||||
COALESCE(percentile_cont(0.9) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (finished_at - started_at))), 0) AS p90_exec
|
||||
FROM %s
|
||||
WHERE deleted_at IS NULL
|
||||
AND state IN (2,3)
|
||||
AND started_at IS NOT NULL
|
||||
AND finished_at IS NOT NULL
|
||||
AND finished_at >= (NOW() - (? || ' seconds')::interval)
|
||||
GROUP BY model_name`, public.TableNameTask)
|
||||
r, err := gfdb.DB(ctx).GetAll(ctx, sql, req.WindowSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = r.Structs(&stats)
|
||||
statMap := make(map[string]statRow, len(stats))
|
||||
for _, s := range stats {
|
||||
statMap[s.ModelName] = s
|
||||
}
|
||||
|
||||
// 3) 调参计算
|
||||
const utilization = 0.8
|
||||
const maxChangeRatio = 0.5 // ±50%
|
||||
const queueFactor = 2.0 // 与 expected_seconds 绑定:W_target = expected_seconds * 2
|
||||
|
||||
out := make([]AutoTuneResult, 0, len(modelMap))
|
||||
for modelName, m := range modelMap {
|
||||
s := statMap[modelName]
|
||||
capMax := m.MaxConcurrency
|
||||
capQueue := m.MaxConcurrency * 2
|
||||
oldMax := GetRuntimeMaxConcurrency(ctx, modelName, capMax)
|
||||
oldQueue := GetRuntimeQueueLimit(ctx, modelName, capQueue)
|
||||
|
||||
// 默认:无样本则不调整
|
||||
if s.Cnt <= 0 || s.P90Exec <= 0 {
|
||||
out = append(out, AutoTuneResult{
|
||||
ModelName: modelName,
|
||||
Samples: s.Cnt,
|
||||
P90Exec: s.P90Exec,
|
||||
CapMaxConcurrency: capMax,
|
||||
OldMaxConcurrency: oldMax,
|
||||
NewMaxConcurrency: oldMax,
|
||||
CapQueueLimit: capQueue,
|
||||
OldQueueLimit: oldQueue,
|
||||
NewQueueLimit: oldQueue,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// arrival_rate ≈ 完成数/3600
|
||||
arrivalRate := float64(s.Cnt) / 3600.0
|
||||
|
||||
// desiredMax = ceil(arrivalRate * p90 / utilization)
|
||||
desiredMax := int(math.Ceil(arrivalRate * s.P90Exec / utilization))
|
||||
if desiredMax < 1 {
|
||||
desiredMax = 1
|
||||
}
|
||||
// 单次变化幅度限制
|
||||
minMax := int(math.Floor(float64(oldMax) * (1 - maxChangeRatio)))
|
||||
maxMax := int(math.Ceil(float64(oldMax) * (1 + maxChangeRatio)))
|
||||
if minMax < 1 {
|
||||
minMax = 1
|
||||
}
|
||||
newMax := clampInt(desiredMax, minMax, maxMax)
|
||||
if capMax > 0 {
|
||||
newMax = clampInt(newMax, 1, capMax)
|
||||
}
|
||||
setRuntimeInt(ctx, runtimeMaxConcurrencyKey(modelName), newMax)
|
||||
|
||||
// queue_limit:W_target = expected_seconds * queueFactor
|
||||
exp := m.TimeoutSeconds
|
||||
if exp <= 0 {
|
||||
exp = 60
|
||||
}
|
||||
wTarget := float64(exp) * queueFactor
|
||||
desiredQueue := int(math.Ceil(arrivalRate*wTarget)) + newMax
|
||||
if desiredQueue < newMax {
|
||||
desiredQueue = newMax
|
||||
}
|
||||
|
||||
newQueue := oldQueue
|
||||
if capQueue > 0 {
|
||||
minQ := int(math.Floor(float64(oldQueue) * (1 - maxChangeRatio)))
|
||||
maxQ := int(math.Ceil(float64(oldQueue) * (1 + maxChangeRatio)))
|
||||
if minQ < newMax {
|
||||
minQ = newMax
|
||||
}
|
||||
if maxQ < minQ {
|
||||
maxQ = minQ
|
||||
}
|
||||
newQueue = clampInt(desiredQueue, minQ, maxQ)
|
||||
newQueue = clampInt(newQueue, newMax, capQueue)
|
||||
setRuntimeInt(ctx, runtimeQueueLimitKey(modelName), newQueue)
|
||||
}
|
||||
|
||||
out = append(out, AutoTuneResult{
|
||||
ModelName: modelName,
|
||||
Samples: s.Cnt,
|
||||
P90Exec: s.P90Exec,
|
||||
CapMaxConcurrency: capMax,
|
||||
OldMaxConcurrency: oldMax,
|
||||
NewMaxConcurrency: newMax,
|
||||
CapQueueLimit: capQueue,
|
||||
OldQueueLimit: oldQueue,
|
||||
NewQueueLimit: newQueue,
|
||||
})
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[auto_tune] done models=%d windowSeconds=%d", len(out), req.WindowSeconds)
|
||||
return &dto.AutoTuneRes{
|
||||
List: out,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ===== 严格 queue_limit:Redis 原子闸门 =====
|
||||
//
|
||||
// 背景:原来的 queue_limit 通过“Count + Insert”做近似控制,分布式并发创建时会短暂超限。
|
||||
// 目标:以 Redis Lua 脚本实现原子校验 + 入队占位,做到严格不超限。
|
||||
//
|
||||
// 计数口径与原逻辑保持一致:只统计 state=0/1(排队中/执行中)。
|
||||
// - CreateTask 成功入库后占用 1 个 slot
|
||||
// - 任务成功/失败(state->2/3)释放 slot
|
||||
// - 失败任务重试(state 3->0)需要再次占用 slot,若占位失败则暂不重试(留在 state=3,下次 cleaner 再尝试)
|
||||
//
|
||||
// 说明:为避免极端情况下“占位泄漏”导致永久占满,采用 ZSET + 过期时间的方式自动回收。
|
||||
// 只要任务实际生命周期远小于 gateTTLSeconds,就可保持严格。
|
||||
|
||||
const (
|
||||
queueGateKeyPrefix = "asynch:qgate:" // asynch:qgate:{modelName}
|
||||
)
|
||||
|
||||
// Lua:清理过期 slot,然后按 limit 做原子判定并占位
|
||||
var queueGateAcquireLua = `
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local limit = tonumber(ARGV[2])
|
||||
local expireAt = tonumber(ARGV[3])
|
||||
local member = ARGV[4]
|
||||
local keyTTL = tonumber(ARGV[5])
|
||||
|
||||
-- 先清理过期的占位
|
||||
redis.call("ZREMRANGEBYSCORE", key, "-inf", now)
|
||||
|
||||
local current = tonumber(redis.call("ZCARD", key) or "0")
|
||||
if current >= limit then
|
||||
return 0
|
||||
end
|
||||
redis.call("ZADD", key, expireAt, member)
|
||||
redis.call("EXPIRE", key, keyTTL)
|
||||
return 1
|
||||
`
|
||||
|
||||
// Lua:释放 slot(幂等)
|
||||
var queueGateReleaseLua = `
|
||||
local key = KEYS[1]
|
||||
local member = ARGV[1]
|
||||
redis.call("ZREM", key, member)
|
||||
return 1
|
||||
`
|
||||
|
||||
func queueGateKey(modelName string) string {
|
||||
return fmt.Sprintf("%s%s", queueGateKeyPrefix, modelName)
|
||||
}
|
||||
|
||||
// calcGateTTLSeconds 计算闸门占位的“自动回收 TTL”
|
||||
// 取 expectedSeconds 的倍数并做上下限,避免任务异常导致永久占位。
|
||||
func calcGateTTLSeconds(expectedSeconds int) int {
|
||||
// 默认至少 1 小时;最多 24 小时
|
||||
minTTL := 3600
|
||||
maxTTL := 24 * 3600
|
||||
if expectedSeconds <= 0 {
|
||||
return minTTL
|
||||
}
|
||||
ttl := int(math.Ceil(float64(expectedSeconds) * 10)) // 预计耗时 * 10 做兜底
|
||||
if ttl < minTTL {
|
||||
ttl = minTTL
|
||||
}
|
||||
if ttl > maxTTL {
|
||||
ttl = maxTTL
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
// AcquireQueueSlot 严格入队:原子占位(成功返回 true)
|
||||
func AcquireQueueSlot(ctx context.Context, modelName, taskId string, limit int, expectedSeconds int) (bool, error) {
|
||||
if limit <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
key := queueGateKey(modelName)
|
||||
now := time.Now().Unix()
|
||||
ttl := calcGateTTLSeconds(expectedSeconds)
|
||||
expireAt := now + int64(ttl)
|
||||
// keyTTL 要略大于 member TTL,避免 key 先过期导致计数丢失
|
||||
keyTTL := ttl + 60
|
||||
r, err := g.Redis().Do(ctx, "EVAL", queueGateAcquireLua, 1, key, now, limit, expireAt, taskId, keyTTL)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("queue gate acquire failed: %w", err)
|
||||
}
|
||||
return gconv.Int(r) == 1, nil
|
||||
}
|
||||
|
||||
// ReleaseQueueSlot 释放占位(幂等)
|
||||
func ReleaseQueueSlot(ctx context.Context, modelName, taskId string) {
|
||||
if taskId == "" || modelName == "" {
|
||||
return
|
||||
}
|
||||
key := queueGateKey(modelName)
|
||||
_, _ = g.Redis().Do(ctx, "EVAL", queueGateReleaseLua, 1, key, taskId)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// 运行时调参存储在 Redis,不修改 asynch_models 中的 cap(最大上限)。
|
||||
// 上层每小时调用 /model/autoTune 写入运行时值;Worker/CreateTask 读取运行时值生效。
|
||||
|
||||
const (
|
||||
runtimeMaxCKeyPrefix = "asynch:runtime:max_concurrency:" // + model_name
|
||||
runtimeQueueKeyPrefix = "asynch:runtime:queue_limit:" // + model_name
|
||||
runtimeTTLSeconds = 2 * 3600 // 2小时,避免一次调参失败导致立即回退
|
||||
)
|
||||
|
||||
func runtimeMaxConcurrencyKey(modelName string) string {
|
||||
return runtimeMaxCKeyPrefix + modelName
|
||||
}
|
||||
func runtimeQueueLimitKey(modelName string) string {
|
||||
return runtimeQueueKeyPrefix + modelName
|
||||
}
|
||||
|
||||
func getRuntimeInt(ctx context.Context, key string) (int, bool) {
|
||||
v, err := g.Redis().Do(ctx, "GET", key)
|
||||
if err != nil || v == nil {
|
||||
return 0, false
|
||||
}
|
||||
iv := gconv.Int(v)
|
||||
if iv <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return iv, true
|
||||
}
|
||||
|
||||
func setRuntimeInt(ctx context.Context, key string, val int) {
|
||||
if val <= 0 {
|
||||
return
|
||||
}
|
||||
// SETEX key ttl val
|
||||
_, _ = g.Redis().Do(ctx, "SETEX", key, runtimeTTLSeconds, val)
|
||||
}
|
||||
|
||||
// GetRuntimeMaxConcurrency 返回运行时并发上限(<= cap)。若不存在运行时值,则返回 cap。
|
||||
func GetRuntimeMaxConcurrency(ctx context.Context, modelName string, cap int) int {
|
||||
if cap <= 0 {
|
||||
return cap
|
||||
}
|
||||
if v, ok := getRuntimeInt(ctx, runtimeMaxConcurrencyKey(modelName)); ok {
|
||||
if v > cap {
|
||||
return cap
|
||||
}
|
||||
return v
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
// GetRuntimeQueueLimit 返回运行时队列上限(<= cap)。若不存在运行时值,则返回 cap。
|
||||
func GetRuntimeQueueLimit(ctx context.Context, modelName string, cap int) int {
|
||||
if cap <= 0 {
|
||||
return cap
|
||||
}
|
||||
if v, ok := getRuntimeInt(ctx, runtimeQueueLimitKey(modelName)); ok {
|
||||
if v > cap {
|
||||
return cap
|
||||
}
|
||||
return v
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
func clampInt(v, minV, maxV int) int {
|
||||
if v < minV {
|
||||
return minV
|
||||
}
|
||||
if v > maxV {
|
||||
return maxV
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var acquireLua = `
|
||||
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
|
||||
local max = tonumber(ARGV[1])
|
||||
local ttl = tonumber(ARGV[2])
|
||||
if current >= max then
|
||||
return 0
|
||||
end
|
||||
current = redis.call("INCR", KEYS[1])
|
||||
if current == 1 then
|
||||
redis.call("EXPIRE", KEYS[1], ttl)
|
||||
end
|
||||
if current > max then
|
||||
redis.call("DECR", KEYS[1])
|
||||
return 0
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
var releaseLua = `
|
||||
local current = tonumber(redis.call("DECR", KEYS[1]) or "0")
|
||||
if current <= 0 then
|
||||
redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
// AcquireSemaphore 获取并发令牌
|
||||
func AcquireSemaphore(ctx context.Context, key string, max int, ttlSeconds int64) (bool, error) {
|
||||
if max <= 0 {
|
||||
// 不限制
|
||||
return true, nil
|
||||
}
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = 3600
|
||||
}
|
||||
r, err := g.Redis().Do(ctx, "EVAL", acquireLua, 1, key, max, ttlSeconds)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("获取并发令牌失败: %w", err)
|
||||
}
|
||||
return gconv.Int(r) == 1, nil
|
||||
}
|
||||
|
||||
// ReleaseSemaphore 释放并发令牌
|
||||
func ReleaseSemaphore(ctx context.Context, key string) error {
|
||||
_, err := g.Redis().Do(ctx, "EVAL", releaseLua, 1, key)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// modelCallMaxRetries 上游调用最大重试次数
|
||||
const modelCallMaxRetries = 10
|
||||
|
||||
// retryWait 指数退避等待(第 attempt 次重试,等待 1<<attempt 秒)。
|
||||
// 返回 nil 表示可继续重试;ctx 已取消返回 ctx.Err(),调用方应停止。
|
||||
func retryWait(ctx context.Context, attempt int) error {
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// firstText 取任意值首位文本:数组取首个元素,其余原样转字符串
|
||||
func firstText(v any) string {
|
||||
if arr, ok := v.([]any); ok && len(arr) > 0 {
|
||||
return gconv.String(arr[0])
|
||||
}
|
||||
return gconv.String(v)
|
||||
}
|
||||
|
||||
// extractChunkText 从流式分片字段取值并转存 OSS,返回首位文本(数组取首个元素,去掉首尾空白)。
|
||||
// 空值 / 仅空白 / 数组全空 返回空串。
|
||||
func extractChunkText(ctx context.Context, v any) string {
|
||||
if v == nil || g.IsEmpty(v) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(firstText(oss.TempURLToOSS(ctx, v)))
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"model-gateway/model/domain"
|
||||
"model-gateway/model/dto"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var SchemaMapping = &schemaMappingService{}
|
||||
|
||||
type schemaMappingService struct{}
|
||||
|
||||
// buildFieldDescriptions 从结构体中反射读取字段定义,构建提示词中的目标字段说明
|
||||
func buildFieldDescriptions(t reflect.Type) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
jsonName := f.Tag.Get("json")
|
||||
desc := f.Tag.Get("dc")
|
||||
typeName := f.Type.String()
|
||||
if jsonName == "" || jsonName == "-" {
|
||||
continue
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("- **" + jsonName + "** (" + typeName + "): " + desc)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// getDomainTypeByModelType 根据模型类型返回对应的业务字段结构体反射类型
|
||||
// 如果找不到匹配,返回 nil
|
||||
func getDomainTypeByModelType(modelType int) reflect.Type {
|
||||
switch modelType {
|
||||
case 100, 101, 102, 103, 500, 501, 502, 503:
|
||||
return reflect.TypeOf((*domain.ChatFieldsReq)(nil)).Elem()
|
||||
case 600, 601, 602, 603, 604:
|
||||
return reflect.TypeOf((*domain.VideoFields)(nil)).Elem()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSchemaMapping 根据模型类型和 schema JSON,自动构建 schema_mapping(补充已有 mapping 的缺失字段)
|
||||
func (s *schemaMappingService) BuildSchemaMapping(ctx context.Context, req *dto.BuildSchemaMappingReq) (res *dto.BuildSchemaMappingRes, err error) {
|
||||
if g.IsEmpty(req.Schema) {
|
||||
return nil, fmt.Errorf("schema 不能为空")
|
||||
}
|
||||
|
||||
// 1. 根据模型类型获取对应的业务字段结构体
|
||||
domainType := getDomainTypeByModelType(req.ModelType)
|
||||
if domainType == nil {
|
||||
return nil, fmt.Errorf("不支持的模型类型: %d", req.ModelType)
|
||||
}
|
||||
|
||||
// 3. 构建 LLM 提示词 输出的 JSON 对象键是 json 字段名。每个字段的值是定位到该位置的完整点号路径。
|
||||
fieldDescs := buildFieldDescriptions(domainType)
|
||||
systemPrompt := fmt.Sprintf(`你是一个 JSON Schema 分析助手。我提供了一个 AI API 的完整 Schema JSON 和待填充的目标结构体。
|
||||
请你仔细阅读 Schema 中所有字段的名称、类型、description 描述、枚举值、约束范围等完整信息,
|
||||
结合对 API 功能的理解,将目标结构体的每个字段映射到 Schema 中恰当的位置。
|
||||
|
||||
## 输出格式
|
||||
|
||||
输出的 JSON 对象键是 json 字段名。每个字段的值有两类:
|
||||
|
||||
第一类(Schema 路径):若该概念在 Schema 中有直接定义位置(约束值或字段定义),输出定位到该位置的完整点号路径。若定位的是对象数组的特定元素及其属性,在路径后追加 ?实际筛选字段名=筛选值&实际值字段名=# 格式,其中 =# 标记的目标值字段名替换为 schema 中的实际字段名。
|
||||
|
||||
第二类(推导字符串):若该概念在 Schema 中没有直接对应的定义位置,输出根据 Schema 信息推导出的内容字符串。
|
||||
|
||||
## 重要规则
|
||||
|
||||
1. 输出的每个字段都必须出现在 JSON 中,一个都不能少
|
||||
2. 若无法从 Schema 推理出某个字段的值,就输出空字符串 ""
|
||||
|
||||
## 目标字段说明
|
||||
|
||||
%s`, fieldDescs)
|
||||
|
||||
userPrompt := fmt.Sprintf("请分析以下 Schema JSON,生成对应的 schema_mapping:\n\n%s", req.Schema)
|
||||
|
||||
// 4. 调用 LLM
|
||||
llmResp, err := callLLM(ctx, systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 归一化所有路径为固定点号语法(无论模型输出哪种写法)
|
||||
rawMap := gconv.Map(llmResp)
|
||||
for k, v := range rawMap {
|
||||
if s, ok := v.(string); ok {
|
||||
rawMap[k] = normalizeSchemaPath(s)
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.BuildSchemaMappingRes{
|
||||
SchemaMapping: rawMap,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// regNumIndexStar 匹配数字下标 [0]、[1] 等
|
||||
var regNumIndexStar = regexp.MustCompile(`\[\d+]`)
|
||||
|
||||
// normalizeSchemaPath 将 LLM 生成的 Schema 路径统一为固定点号语法:
|
||||
// - 移除模板包装字段 attrs / properties / items / defaultValue / required
|
||||
// - enumValues、items 及 attrs[数字] 标记上一字段为数组,补 [*]
|
||||
// - [数字] 下标统一转为 [*]
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// messages.attrs.enumValues.attrs.content.enumValues?type=image_url&image_url.url=#
|
||||
// → messages[*].content[*]?type=image_url&image_url.url=#
|
||||
// choices.attrs[0].attrs.message.attrs.content → choices[*].message.content
|
||||
func normalizeSchemaPath(p string) string {
|
||||
path, suffix := p, ""
|
||||
if i := strings.Index(p, "?"); i >= 0 {
|
||||
path, suffix = p[:i], p[i:]
|
||||
}
|
||||
segs := strings.Split(path, ".")
|
||||
var out []string
|
||||
for _, seg := range segs {
|
||||
seg = strings.TrimSpace(seg)
|
||||
switch {
|
||||
case seg == "":
|
||||
continue
|
||||
case seg == "attrs" || seg == "properties" || seg == "defaultValue" || seg == "required":
|
||||
continue
|
||||
case seg == "enumValues" || seg == "items" || (strings.HasPrefix(seg, "attrs[") && regNumIndexStar.MatchString(seg)):
|
||||
markPrevAsArray(&out)
|
||||
continue
|
||||
}
|
||||
seg = regNumIndexStar.ReplaceAllString(seg, "[*]")
|
||||
out = append(out, seg)
|
||||
}
|
||||
return strings.Join(out, ".") + suffix
|
||||
}
|
||||
|
||||
// markPrevAsArray 将输出序列最后一个字段标记为数组(补 [*])
|
||||
func markPrevAsArray(out *[]string) {
|
||||
if len(*out) == 0 {
|
||||
return
|
||||
}
|
||||
last := (*out)[len(*out)-1]
|
||||
if !strings.HasSuffix(last, "[]") && !strings.HasSuffix(last, "[*]") {
|
||||
(*out)[len(*out)-1] = last + "[*]"
|
||||
}
|
||||
}
|
||||
|
||||
// callLLM 调用大模型聊天接口(OpenAI 兼容格式)。
|
||||
// 模型地址/密钥走配置 schemaMapping 段,本地开发无配置时用默认值兜底。
|
||||
func callLLM(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||
modelName := g.Cfg().MustGet(ctx, "schemaMapping.modelName", "doubao-seed-2-0-lite-260428").String()
|
||||
baseURL := g.Cfg().MustGet(ctx, "schemaMapping.baseUrl", "https://ark.cn-beijing.volces.com/api/v3/chat/completions").String()
|
||||
apiKey := g.Cfg().MustGet(ctx, "schemaMapping.apiKey", "ark-9df744e8-a0de-4c54-9db3-18379bccd523-e6733").String()
|
||||
|
||||
body := map[string]any{
|
||||
"model": modelName,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.1,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request body failed: %w", err)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(baseURL, "/")
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request failed: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response failed (status=%d): %w", resp.StatusCode, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("API error status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var apiResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(respBody, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("parse response failed: %s", string(respBody))
|
||||
}
|
||||
|
||||
if apiResp.Error != nil {
|
||||
return "", fmt.Errorf("API error: %s", apiResp.Error.Message)
|
||||
}
|
||||
|
||||
if len(apiResp.Choices) == 0 {
|
||||
return "", fmt.Errorf("empty response")
|
||||
}
|
||||
|
||||
return apiResp.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
// extractJSONObject 从字符串中提取第一个完整的 JSON 对象({...})
|
||||
func extractJSONObject(s string) string {
|
||||
start := strings.Index(s, "{")
|
||||
if start < 0 {
|
||||
return s
|
||||
}
|
||||
for start > 0 {
|
||||
ch := s[start-1]
|
||||
if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' {
|
||||
start--
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
end := strings.LastIndex(s, "}")
|
||||
if end <= start {
|
||||
return s
|
||||
}
|
||||
|
||||
snippet := s[start : end+1]
|
||||
snippet = strings.TrimPrefix(snippet, "```json")
|
||||
snippet = strings.TrimPrefix(snippet, "```")
|
||||
snippet = strings.TrimSuffix(snippet, "```")
|
||||
snippet = strings.TrimSpace(snippet)
|
||||
return snippet
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/domain"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service/httpclient"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// CreateSessionStreamOnce 流式调用上游模型 → 缓冲全量后一次返回(走 gf 框架正常返回)。
|
||||
// 与同步请求一致:上游返回错误时按 shouldRetryWithMemory 判定是否指数退避重试(最多 modelCallMaxRetries 次)。
|
||||
func (s *modelSessionService) CreateSessionStreamOnce(ctx context.Context, req *dto.CallModelSessionReq) (docMsg *dto.ModelCallRes, err error) {
|
||||
startTime := time.Now()
|
||||
|
||||
id := req.Id
|
||||
modelInfo := req.ModelInfo
|
||||
newRequestParams := req.RequestParams
|
||||
|
||||
attempt := 0
|
||||
LOOP:
|
||||
// 获取上游流式 reader(stream=false → w 不会被使用,传 nil)。
|
||||
// 非 2xx 状态/网络错误在此返回;按 shouldRetryWithMemory 判定是否指数退避重试,与同步请求一致。
|
||||
streamReader, err := httpclient.ModelHttpStreamRequest(ctx, nil, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
if attempt < modelCallMaxRetries {
|
||||
if code, msg := streamErrorInfoOfError(err); shouldRetryWithMemory(ctx, modelInfo, code, msg, "") {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型流式请求异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, code, err)
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
recordSessionError(context.WithoutCancel(ctx), id, startTime, "调用取消: "+waitErr.Error())
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
}
|
||||
// 非重试错误/重试耗尽:请求失败即返回,把失败信息写入模型会话记录
|
||||
recordSessionError(ctx, id, startTime, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
docMsg = new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
var contentBuf strings.Builder
|
||||
|
||||
// 记录流内 error 事件(OpenAI 兼容 error 分片),供流结束后统一判定重试/报错
|
||||
var streamErrCode, streamErrMsg, streamErrBody string
|
||||
|
||||
// 路径预处理
|
||||
respMapping := make(map[string]string, len(modelInfo.ResponseBodyMapping))
|
||||
for k, _ := range modelInfo.ResponseBodyMapping {
|
||||
respMapping[k] = modelUtils.CleanFieldPath(k)
|
||||
}
|
||||
totalTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)
|
||||
promptTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)
|
||||
completionTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)
|
||||
|
||||
httpclient.ParseSSEStream(ctx, streamReader, func(ctx context.Context, chunk map[string]any) error {
|
||||
// 流内错误事件(OpenAI 兼容 error 分片):暂存错误码/消息,不做内容累加,由流结束后统一判定
|
||||
if code, msg := streamErrorOfChunk(chunk); code != "" {
|
||||
streamErrCode, streamErrMsg, streamErrBody = code, msg, gconv.String(chunk["error"])
|
||||
return nil
|
||||
}
|
||||
// 基于统一字段路径(GetByPath)在分片对象上取值,取首个数组元素文本累加
|
||||
for _, jsonPath := range respMapping {
|
||||
if realText := extractChunkText(ctx, modelUtils.GetByPathValue(chunk, jsonPath)); realText != "" {
|
||||
contentBuf.WriteString(realText)
|
||||
}
|
||||
}
|
||||
|
||||
// Token 累加
|
||||
docMsg.TotalTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, totalTokenPath))
|
||||
docMsg.PromptTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, promptTokenPath))
|
||||
docMsg.CompletionTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, completionTokenPath))
|
||||
return nil
|
||||
})
|
||||
|
||||
// 流内返回错误:丢弃本次部分内容,指数退避后重新请求(判定交给 shouldRetryWithMemory)
|
||||
if streamErrCode != "" {
|
||||
if attempt < modelCallMaxRetries && shouldRetryWithMemory(ctx, modelInfo, streamErrCode, streamErrMsg, streamErrBody) {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型流式调用异常,第 %d 次重试(等待 %v): code=%s msg=%s", attempt+1, wait, streamErrCode, streamErrMsg)
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
recordSessionError(context.WithoutCancel(ctx), id, startTime, "调用取消: "+waitErr.Error())
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
// 与同步一致:不可重试的错误码记录到 ErrorMsg 后正常走组装返回,不中断流程
|
||||
docMsg.ErrorMsg = streamErrMsg
|
||||
}
|
||||
|
||||
// 流结束后组装(流内出错时内容为空,与同步一致不再组装/上传空内容)
|
||||
if streamErrCode == "" {
|
||||
for k, _ := range modelInfo.ResponseBodyMapping {
|
||||
docMsg.Content = map[string]any{k: contentBuf.String()}
|
||||
}
|
||||
}
|
||||
|
||||
// 补充更新会话记录
|
||||
updateModelSessionReq := dto.UpdateModelSessionReq{
|
||||
Id: id,
|
||||
DurationSeconds: int64(time.Since(startTime).Seconds()),
|
||||
TotalTokens: docMsg.TotalTokens,
|
||||
PromptTokens: docMsg.PromptTokens,
|
||||
CompletionTokens: docMsg.CompletionTokens,
|
||||
ErrorMsg: docMsg.ErrorMsg,
|
||||
}
|
||||
if !g.IsEmpty(docMsg.Content) {
|
||||
uploadNewResp, uploadErr := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileBytes: gconv.Bytes(gconv.String(docMsg.Content)),
|
||||
FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()),
|
||||
})
|
||||
if uploadErr != nil {
|
||||
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", uploadErr)
|
||||
}
|
||||
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
|
||||
}
|
||||
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
|
||||
// 调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞)
|
||||
mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams)
|
||||
docMsg.ModelId = modelInfo.Id // 引用行=系统模型 id,供 per_token 结算按系统模型计价
|
||||
docMsg.MediaType = mediaType
|
||||
docMsg.Cost = calcModelCost(ctx, modelInfo.Id,
|
||||
buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, 0))
|
||||
updateModelSessionReq.TotalCost = docMsg.Cost
|
||||
if _, updateErr := dao.ModelSession.Update(ctx, &updateModelSessionReq); updateErr != nil {
|
||||
g.Log().Errorf(ctx, "更新流式会话信息失败: %v", updateErr)
|
||||
}
|
||||
|
||||
return docMsg, nil
|
||||
}
|
||||
|
||||
// CreateSessionStream 流式调用上游模型 → SSE 逐分片推送给前端;流结束返回本次调用的 token/费用(docMsg.Cost)供调用方扣减。
|
||||
func (s *modelSessionService) CreateSessionStream(ctx context.Context, w http.ResponseWriter, req *dto.CallModelSessionReq) (*dto.ModelCallRes, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
id := req.Id
|
||||
modelInfo := req.ModelInfo
|
||||
newRequestParams := req.RequestParams
|
||||
|
||||
// 获取上游流式 reader 并设置 SSE 响应头
|
||||
streamReader, err := httpclient.ModelHttpStreamRequest(ctx, w, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
// 请求建立前失败:把错误写入模型会话记录,避免留半截无错误信息记录
|
||||
recordSessionError(ctx, id, startTime, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flusher := w.(http.Flusher)
|
||||
|
||||
docMsg := new(dto.ModelCallRes)
|
||||
var contentBuf strings.Builder
|
||||
|
||||
// tool_calls 按 index 累加(OpenAI 兼容 delta),流末随 done 事件一次性返回
|
||||
toolAcc := make(map[int]*streamToolCallAcc)
|
||||
|
||||
// 路径预处理
|
||||
respMapping := make(map[string]string, len(modelInfo.ResponseBodyMapping))
|
||||
for k, _ := range modelInfo.ResponseBodyMapping {
|
||||
respMapping[modelUtils.CleanFieldPath(k)] = modelUtils.CleanFieldPath(k)
|
||||
}
|
||||
totalTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)
|
||||
promptTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)
|
||||
completionTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)
|
||||
|
||||
// 解析 ResponseBusinessFieldMapping 字段
|
||||
businessFieldRes := new(domain.ChatFieldsRes)
|
||||
err = gconv.Struct(modelInfo.ResponseBusinessFieldMapping, businessFieldRes)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "解析 ResponseBusinessFieldMapping 失败: %v", err)
|
||||
}
|
||||
// tool_calls 读取路径:优先走 ResponseBusinessFieldMapping 的 tools 配置,缺省兜底 OpenAI 兼容路径
|
||||
//toolsPath := "choices[0].delta.tool_calls"
|
||||
toolsPath := businessFieldRes.Tools
|
||||
// reasoning_content 读取路径:走 ResponseBusinessFieldMapping 的 reasoning_content 配置,未配置则不返回思考内容
|
||||
reasoningPath := modelUtils.CleanFieldPath(businessFieldRes.ReasoningContent)
|
||||
|
||||
httpclient.ParseSSEStream(ctx, streamReader, func(ctx context.Context, chunk map[string]any) error {
|
||||
// 基于统一字段路径(GetByPath)在分片对象上取值,取首个数组元素文本
|
||||
content := make(map[string]any, len(respMapping))
|
||||
for bizKey, jsonPath := range respMapping {
|
||||
if realText := extractChunkText(ctx, modelUtils.GetByPathValue(chunk, jsonPath)); realText != "" {
|
||||
content[bizKey] = realText
|
||||
contentBuf.WriteString(realText)
|
||||
}
|
||||
}
|
||||
|
||||
// Token 累加(记录增量:usage 常在无文本/思考的末分片出现,需据此放行推送)
|
||||
prevTotal, prevPrompt, prevCompletion := docMsg.TotalTokens, docMsg.PromptTokens, docMsg.CompletionTokens
|
||||
docMsg.TotalTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, totalTokenPath))
|
||||
docMsg.PromptTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, promptTokenPath))
|
||||
docMsg.CompletionTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, completionTokenPath))
|
||||
tokenDelta := docMsg.TotalTokens != prevTotal || docMsg.PromptTokens != prevPrompt || docMsg.CompletionTokens != prevCompletion
|
||||
|
||||
accumulateStreamToolCallsByPath(chunk, toolsPath, toolAcc)
|
||||
|
||||
// 思考内容提取(独立业务字段,不进回答全文)
|
||||
var reasoningContent string
|
||||
if reasoningPath != "" {
|
||||
if v := modelUtils.GetByPathValue(chunk, reasoningPath); v != nil && !g.IsEmpty(v) {
|
||||
reasoningContent = firstText(v)
|
||||
}
|
||||
}
|
||||
|
||||
// 纯 token 分片(无文本/思考)也放行:否则末分片 usage 被过滤,调用方拿不到 token 值
|
||||
if len(content) == 0 && reasoningContent == "" && !tokenDelta {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 逐 chunk SSE 推送给前端(字段名由 ModelCallStreamEvent 统一管理)
|
||||
event := &dto.ModelCallStreamEvent{
|
||||
Content: content,
|
||||
ReasoningContent: reasoningContent,
|
||||
TotalTokens: docMsg.TotalTokens,
|
||||
PromptTokens: docMsg.PromptTokens,
|
||||
CompletionTokens: docMsg.CompletionTokens,
|
||||
}
|
||||
outBytes, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[SSE] marshal response failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "data: %s\n\n", outBytes)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[SSE] write client failed: %v", err)
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 流结束:调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞)
|
||||
mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams)
|
||||
docMsg.ModelId = modelInfo.Id // 引用行=系统模型 id,供 per_token 结算按系统模型计价
|
||||
docMsg.MediaType = mediaType
|
||||
docMsg.Cost = calcModelCost(ctx, modelInfo.Id,
|
||||
buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, 0))
|
||||
|
||||
// 流末 done 事件:携带该步最终 token 与费用。工具调用时附带完整 tool_calls,
|
||||
// 纯文本流同样补发,使调用方拿到最终费用与 token;不识别 type=done 的消费方忽略该事件。
|
||||
event := &dto.ModelCallStreamEvent{
|
||||
Type: "done",
|
||||
TotalTokens: docMsg.TotalTokens,
|
||||
PromptTokens: docMsg.PromptTokens,
|
||||
CompletionTokens: docMsg.CompletionTokens,
|
||||
Cost: docMsg.Cost,
|
||||
}
|
||||
if tools := finalizeStreamToolCalls(toolAcc); len(tools) > 0 {
|
||||
var toolModels []dto.ModelTool
|
||||
if err := gconv.Structs(tools, &toolModels); err != nil {
|
||||
g.Log().Errorf(ctx, "[SSE] convert tools failed: %v", err)
|
||||
} else {
|
||||
event.Tools = toolModels
|
||||
}
|
||||
}
|
||||
outBytes, err := json.Marshal(event)
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", outBytes)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// 流结束后补充更新会话记录
|
||||
updateModelSessionReq := dto.UpdateModelSessionReq{
|
||||
Id: id,
|
||||
DurationSeconds: int64(time.Since(startTime).Seconds()),
|
||||
TotalTokens: docMsg.TotalTokens,
|
||||
PromptTokens: docMsg.PromptTokens,
|
||||
CompletionTokens: docMsg.CompletionTokens,
|
||||
TotalCost: docMsg.Cost,
|
||||
}
|
||||
if !g.IsEmpty(contentBuf.String()) {
|
||||
uploadNewResp, uploadErr := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileBytes: gconv.Bytes(gconv.String(map[string]any{"respBody": contentBuf.String()})),
|
||||
FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()),
|
||||
})
|
||||
if uploadErr != nil {
|
||||
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", uploadErr)
|
||||
}
|
||||
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
|
||||
}
|
||||
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
|
||||
if _, updateErr := dao.ModelSession.Update(ctx, &updateModelSessionReq); updateErr != nil {
|
||||
return nil, fmt.Errorf("更新会话信息失败: %v", updateErr)
|
||||
}
|
||||
|
||||
return docMsg, nil
|
||||
}
|
||||
|
||||
// recordSessionError 请求建立前失败(上游不可达/非 2xx 且非重试/重试耗尽/调用取消)时,
|
||||
// 把错误与耗时写入模型会话记录,避免流式调用留半截无错误信息记录。
|
||||
// 仅写 ErrorMsg/DurationSeconds(OmitEmpty 不会影响已落库字段);ctx 已取消时须传 WithoutCancel(ctx)。
|
||||
func recordSessionError(ctx context.Context, id int64, startTime time.Time, errMsg string) {
|
||||
if _, updateErr := dao.ModelSession.Update(ctx, &dto.UpdateModelSessionReq{
|
||||
Id: id,
|
||||
DurationSeconds: int64(time.Since(startTime).Seconds()),
|
||||
ErrorMsg: errMsg,
|
||||
}); updateErr != nil {
|
||||
g.Log().Errorf(ctx, "更新模型会话错误信息失败: %v", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
// streamErrorOfChunk 从流式分片提取错误码与消息:优先 OpenAI 兼容 error 事件,顶层 code 兜底。
|
||||
func streamErrorOfChunk(chunk map[string]any) (code, msg string) {
|
||||
if errObj := gconv.Map(chunk["error"]); errObj != nil {
|
||||
code = gconv.String(errObj["code"])
|
||||
msg = gconv.String(errObj["message"])
|
||||
}
|
||||
if code == "" {
|
||||
code = gconv.String(chunk["code"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// streamErrorInfoOfError 从流式请求错误中提取错误码与消息(不做固定清单过滤,交由 shouldRetryWithMemory 判定):
|
||||
// 优先解析错误体 error.code/顶层 code 与 message,其次取非 2xx 的 HTTP 状态码。
|
||||
// 纯网络错误等无错误码场景返回空串。
|
||||
func streamErrorInfoOfError(err error) (code, msg string) {
|
||||
if err == nil {
|
||||
return "", ""
|
||||
}
|
||||
e := err.Error()
|
||||
// 非 2xx 时 httpclient.ModelHttpStreamRequest 返回 "[HTTP][Stream] 状态码异常: %d, body={...}"
|
||||
if idx := strings.Index(e, "body="); idx >= 0 {
|
||||
body := e[idx+len("body="):]
|
||||
var errResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal([]byte(body), &errResp) == nil {
|
||||
if errResp.Error.Code != "" {
|
||||
return errResp.Error.Code, errResp.Error.Message
|
||||
}
|
||||
if errResp.Code != "" {
|
||||
return errResp.Code, errResp.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(e, "状态码异常: "); idx >= 0 {
|
||||
codeStr := strings.TrimSpace(e[idx+len("状态码异常: "):])
|
||||
if comma := strings.IndexByte(codeStr, ','); comma >= 0 {
|
||||
codeStr = codeStr[:comma]
|
||||
}
|
||||
return codeStr, ""
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/consts/model"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service/httpclient"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
"time"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ModelSession = &modelSessionService{}
|
||||
|
||||
type modelSessionService struct{}
|
||||
|
||||
// CreateSession 创建会话(同步调用,非流式)
|
||||
func (s *modelSessionService) CreateSession(ctx context.Context, req *dto.CallModelSessionReq) (res *dto.ModelCallRes, err error) {
|
||||
startTime := time.Now()
|
||||
attempt := 0
|
||||
id := req.Id
|
||||
modelInfo := req.ModelInfo
|
||||
newRequestParams := req.RequestParams
|
||||
LOOP:
|
||||
// 6) 模型请求
|
||||
modelRespBody, err := httpclient.ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if modelRespBody == nil {
|
||||
return nil, fmt.Errorf("模型返回参数是空")
|
||||
}
|
||||
// 7) 上传模型返回参数文件
|
||||
uploadOriginalResp, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileBytes: modelRespBody,
|
||||
FileName: fmt.Sprintf("modelRespParams:%v.json", time.Now().UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", err)
|
||||
}
|
||||
// 8) 更新模型会话信息
|
||||
updateModelSessionReq := dto.UpdateModelSessionReq{
|
||||
Id: id,
|
||||
OriginalResponsePath: uploadOriginalResp.FileURL,
|
||||
}
|
||||
docMsg := new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
errCode, errMsg, err := parseModelError(modelRespBody, modelInfo.ErrorMessageMapping)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
if errCode != "" {
|
||||
if attempt < modelCallMaxRetries && shouldRetryWithMemory(ctx, modelInfo, errCode, errMsg, string(modelRespBody)) {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型上游调用异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, errCode, errMsg)
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
docMsg.ErrorMsg = errMsg
|
||||
updateModelSessionReq.ErrorMsg = docMsg.ErrorMsg
|
||||
} else {
|
||||
if *model.ResponseTypeSync.Code() == *modelInfo.ResponseType {
|
||||
respBodyMap := make(map[string]string, len(modelInfo.ResponseBodyMapping))
|
||||
for k, _ := range modelInfo.ResponseBodyMapping {
|
||||
respBodyMap[k] = modelUtils.CleanFieldPath(k)
|
||||
}
|
||||
// 基于统一字段路径(GetByPath)按映射取值组装结果
|
||||
var respObj map[string]any
|
||||
if err = json.Unmarshal(modelRespBody, &respObj); err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
content := make(map[string]any, len(respBodyMap))
|
||||
for bizKey, jsonPath := range respBodyMap {
|
||||
content[bizKey] = oss.TempURLToOSS(ctx, modelUtils.GetByPathValue(respObj, jsonPath))
|
||||
}
|
||||
|
||||
businessField := make(map[string]any, len(modelInfo.ResponseBusinessFieldMapping))
|
||||
for key, value := range modelInfo.ResponseBusinessFieldMapping {
|
||||
businessField[key] = modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(value))
|
||||
}
|
||||
err = gconv.Struct(businessField, docMsg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
|
||||
docMsg.Content = content
|
||||
docMsg.TotalTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)))
|
||||
docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)))
|
||||
docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)))
|
||||
|
||||
updateModelSessionReq.PromptTokens = docMsg.PromptTokens
|
||||
updateModelSessionReq.CompletionTokens = docMsg.CompletionTokens
|
||||
updateModelSessionReq.TotalTokens = docMsg.TotalTokens
|
||||
} else {
|
||||
docMsg.Content = map[string]any{
|
||||
"respBody": modelRespBody,
|
||||
}
|
||||
}
|
||||
}
|
||||
if !g.IsEmpty(docMsg.Content) {
|
||||
// 9) 上传模型返回参数文件
|
||||
uploadNewResp, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileBytes: gconv.Bytes(gconv.String(docMsg.Content)),
|
||||
FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", err)
|
||||
}
|
||||
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
|
||||
}
|
||||
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
|
||||
// 9.5) 调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞)
|
||||
mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams)
|
||||
docMsg.ModelId = modelInfo.Id // 引用行=系统模型 id,供 per_token 结算按系统模型计价
|
||||
docMsg.MediaType = mediaType
|
||||
docMsg.Cost = calcModelCost(ctx, modelInfo.Id,
|
||||
buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, 0))
|
||||
updateModelSessionReq.TotalCost = docMsg.Cost
|
||||
// 10) 更新模型会话信息
|
||||
_, err = dao.ModelSession.Update(ctx, &updateModelSessionReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新模型会话信息失败: %v", err)
|
||||
}
|
||||
|
||||
return docMsg, nil
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package stat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
)
|
||||
|
||||
var ModelGatewayLogsStat = &logsStatService{}
|
||||
|
||||
type logsStatService struct{}
|
||||
|
||||
func (s *logsStatService) List(ctx context.Context, req *dto.ListModelStatReq) (*dto.ListModelStatRes, error) {
|
||||
if req == nil {
|
||||
req = &dto.ListModelStatReq{}
|
||||
}
|
||||
if req.PageNum <= 0 {
|
||||
req.PageNum = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
list, total, err := dao.ModelGatewayLogsStat.List(ctx, req.PageNum, req.PageSize, &entity.ModelGatewayLogsStat{
|
||||
Creator: req.Creator,
|
||||
ModelName: req.ModelName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ListModelStatRes{List: list, Total: total}, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
modelUtils "model-gateway/service/utils"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// streamToolCallAcc 流式 tool_call 按 index 累加的碎片(OpenAI 兼容 delta 格式)
|
||||
type streamToolCallAcc struct {
|
||||
id string
|
||||
typ string
|
||||
fnName string
|
||||
fnArgs strings.Builder
|
||||
}
|
||||
|
||||
// streamToolCallDelta OpenAI 兼容流式 tool_call 增量片段,字段名集中于此
|
||||
type streamToolCallDelta struct {
|
||||
Index int `json:"index"`
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
// toStreamToolCallDeltas 把 JSON 反序列化的 any 数组在边界转成强类型片段
|
||||
func toStreamToolCallDeltas(rawCalls []any) []streamToolCallDelta {
|
||||
var deltas []streamToolCallDelta
|
||||
if err := gconv.Structs(rawCalls, &deltas); err != nil {
|
||||
return nil
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
// accumulateStreamToolCallsByPath 按配置路径从 chunk 读取 tool_calls 数组并累加。
|
||||
// 路径未命中或值非数组时无副作用(不创建任何槽位)。
|
||||
func accumulateStreamToolCallsByPath(chunk map[string]any, toolsPath string, acc map[int]*streamToolCallAcc) {
|
||||
raw := modelUtils.GetByPathValue(chunk, modelUtils.CleanFieldPath(toolsPath))
|
||||
rawCalls, _ := raw.([]any)
|
||||
accumulateToolCallFragments(toStreamToolCallDeltas(rawCalls), acc)
|
||||
}
|
||||
|
||||
// accumulateToolCallFragments 按 index 累加 tool_calls 增量片段:
|
||||
// id/type/function.name 首片段补齐,function.arguments 为字符串片段需按 index 拼接。
|
||||
func accumulateToolCallFragments(rawCalls []streamToolCallDelta, acc map[int]*streamToolCallAcc) {
|
||||
for _, d := range rawCalls {
|
||||
slot, ok := acc[d.Index]
|
||||
if !ok {
|
||||
slot = &streamToolCallAcc{}
|
||||
acc[d.Index] = slot
|
||||
}
|
||||
if d.Id != "" {
|
||||
slot.id = d.Id
|
||||
}
|
||||
if d.Type != "" {
|
||||
slot.typ = d.Type
|
||||
}
|
||||
if d.Function.Name != "" {
|
||||
slot.fnName = d.Function.Name
|
||||
}
|
||||
slot.fnArgs.WriteString(d.Function.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeStreamToolCalls 把累加结果按 index 升序转为 []map[string]any,形状对齐 dto.ModelTool。
|
||||
// 无有效工具返回 nil。
|
||||
func finalizeStreamToolCalls(acc map[int]*streamToolCallAcc) []map[string]any {
|
||||
if len(acc) == 0 {
|
||||
return nil
|
||||
}
|
||||
idx := make([]int, 0, len(acc))
|
||||
for i := range acc {
|
||||
idx = append(idx, i)
|
||||
}
|
||||
sort.Ints(idx)
|
||||
tools := make([]map[string]any, 0, len(idx))
|
||||
for _, i := range idx {
|
||||
s := acc[i]
|
||||
fn := map[string]any{}
|
||||
if s.fnName != "" {
|
||||
fn["name"] = s.fnName
|
||||
}
|
||||
if s.fnArgs.Len() > 0 {
|
||||
fn["arguments"] = s.fnArgs.String()
|
||||
}
|
||||
tool := map[string]any{"function": fn}
|
||||
if s.id != "" {
|
||||
tool["id"] = s.id
|
||||
}
|
||||
if s.typ != "" {
|
||||
tool["type"] = s.typ
|
||||
}
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"model-gateway/common/util"
|
||||
"model-gateway/consts/public"
|
||||
"time"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var ModelGatewayTask = &taskService{}
|
||||
|
||||
type taskService struct{}
|
||||
|
||||
// Create 创建任务
|
||||
func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) {
|
||||
taskID := req.TaskId
|
||||
if taskID == "" {
|
||||
taskID = uuid.NewString()
|
||||
}
|
||||
startAt := time.Now()
|
||||
|
||||
// 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,
|
||||
Creator: userInfo.UserName,
|
||||
},
|
||||
ModelName: req.ModelName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
|
||||
// 循环尝试获取并发名额,超限则等待重试
|
||||
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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
// 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 e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
if !success {
|
||||
err = gerror.New("任务排队已满,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
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{
|
||||
TaskID: taskID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t == nil {
|
||||
return nil, errors.New("任务不存在")
|
||||
}
|
||||
return &dto.GetTaskResultRes{
|
||||
OssFile: t.ResultFile.OssFile,
|
||||
State: t.State,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBatch 批量查询任务;将成功(state=2)的任务更新为已下载(state=4),并写入过期时间
|
||||
func (s *taskService) GetBatch(ctx context.Context, req *dto.GetTaskBatchReq) (res *dto.GetTaskBatchRes, err error) {
|
||||
if req == nil || len(req.TaskIDs) == 0 {
|
||||
return &dto.GetTaskBatchRes{List: []dto.GetTaskBatchItem{}}, nil
|
||||
}
|
||||
// 1) 先查当前租户下的任务列表
|
||||
list, err := dao.ModelGatewayTask.ListByTaskIDs(ctx, req.TaskIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2) 对成功(state=2)的任务:标记为已下载(state=4)
|
||||
for _, t := range list {
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
if t.State != public.BuildTypeNode {
|
||||
continue
|
||||
}
|
||||
_ = dao.ModelGatewayTask.MarkDownloadedByID(ctx, t.Id)
|
||||
|
||||
// 为了本次返回一致性,内存里也更新
|
||||
t.State = public.TaskStatusDownloaded
|
||||
}
|
||||
|
||||
// 3) 组装返回
|
||||
items := make([]dto.GetTaskBatchItem, 0, len(list))
|
||||
for _, t := range list {
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, dto.GetTaskBatchItem{
|
||||
TaskID: t.TaskID,
|
||||
State: t.State,
|
||||
OssFile: t.ResultFile.OssFile,
|
||||
TextResult: t.TextResult,
|
||||
})
|
||||
}
|
||||
return &dto.GetTaskBatchRes{List: items}, nil
|
||||
}
|
||||
|
||||
// List 获取任务列表
|
||||
func (s *taskService) List(ctx context.Context, req *dto.ListTaskReq) (*dto.ListTaskRes, error) {
|
||||
if req.PageNum <= 0 {
|
||||
req.PageNum = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, total, err := dao.ModelGatewayTask.List(ctx, req.PageNum, req.PageSize, &entity.ModelGatewayTask{
|
||||
SQLBaseDO: beans.SQLBaseDO{
|
||||
Creator: user.UserName,
|
||||
},
|
||||
ModelName: req.ModelName,
|
||||
BizName: req.BizName,
|
||||
State: req.State,
|
||||
TaskID: req.TaskID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ListTaskRes{List: list, Total: total}, nil
|
||||
}
|
||||
|
||||
// ModelTaskCallback 模型异步任务的回调通知
|
||||
func (s *taskService) ModelTaskCallback(ctx context.Context, req *dto.ModelTaskCallbackReq) (*dto.ModelTaskCallbackRes, error) {
|
||||
g.Log().Infof(ctx, "[模型回调] 收到通知 taskID=%s status=%s", req.TaskID, req.Status)
|
||||
// 1. 查本地任务
|
||||
task, err := dao.ModelGatewayTask.Get(ctx, &entity.ModelGatewayTask{
|
||||
TaskID: req.TaskID,
|
||||
})
|
||||
if err != nil || task == nil {
|
||||
return nil, fmt.Errorf("任务不存在: %s", req.TaskID)
|
||||
}
|
||||
|
||||
// 2. 成功:取 video_url 和 usage
|
||||
if req.Status == "succeeded" {
|
||||
result := map[string]any{
|
||||
"video_url": req.Content["video_url"],
|
||||
"usage": req.Usage,
|
||||
}
|
||||
NotifyAsyncResult(req.TaskID, result, nil)
|
||||
return &dto.ModelTaskCallbackRes{Success: true}, nil
|
||||
}
|
||||
|
||||
// 3. 失败/过期
|
||||
if req.Status == "failed" || req.Status == "expired" {
|
||||
NotifyAsyncResult(req.TaskID, nil, fmt.Errorf(req.Status))
|
||||
return &dto.ModelTaskCallbackRes{Success: true}, nil
|
||||
}
|
||||
|
||||
return &dto.ModelTaskCallbackRes{Success: true}, nil
|
||||
}
|
||||
|
||||
// QueryPendingTasks 批量轮询进行中的异步任务
|
||||
func (s *taskService) QueryPendingTasks(ctx context.Context, req *dto.QueryPendingTasksReq) (*dto.QueryPendingTasksRes, error) {
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = g.Cfg().MustGet(ctx, "asynch.queryPending.limit", 10).Int()
|
||||
}
|
||||
|
||||
// 1. 查 state=1(执行中)的异步任务
|
||||
tasks, err := dao.ModelGatewayTask.GetPendingAsyncTasks(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 逐个查询
|
||||
var results []dto.QueryTaskItem
|
||||
for _, t := range tasks {
|
||||
// 拿到模型配置
|
||||
model, err := dao.ModelGatewayModels.GetByModelNameForTenant(ctx, t.TenantId, t.ModelName)
|
||||
if err != nil || model == nil || model.QueryConfig == nil {
|
||||
continue
|
||||
}
|
||||
// 每个任务使用独立的超时上下文,防止单个任务阻塞整个轮询
|
||||
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
|
||||
}
|
||||
|
||||
status := gconv.String(result["status"])
|
||||
item := dto.QueryTaskItem{
|
||||
TaskID: t.TaskID,
|
||||
Status: status,
|
||||
Content: result["content"].(map[string]any),
|
||||
Usage: result["usage"].(map[string]any),
|
||||
}
|
||||
results = append(results, item)
|
||||
|
||||
// 如果任务完成,通知等待通道
|
||||
if status == "succeeded" || status == "failed" || status == "expired" {
|
||||
NotifyAsyncResult(t.TaskID, result["content"].(map[string]any), nil)
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.QueryPendingTasksRes{
|
||||
Total: len(results),
|
||||
Results: results,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"model-gateway/common/util"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
"model-gateway/service/gateway"
|
||||
"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"
|
||||
)
|
||||
|
||||
var AsyncWorker = &asyncWorker{}
|
||||
|
||||
type asyncWorker struct {
|
||||
}
|
||||
|
||||
// handleOne 执行一次完整的任务
|
||||
func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTask, model *entity.ModelGatewayModel, req *dto.CreateTaskReq) {
|
||||
var (
|
||||
body = task.RequestPayload.Body
|
||||
maxRetry = model.RetryTimes
|
||||
startTime = time.Now()
|
||||
rawData []byte
|
||||
result map[string]any
|
||||
err error
|
||||
surplus float64
|
||||
)
|
||||
g.Log().Infof(ctx, "[handleOne] 开始 taskId=%s model=%s", task.TaskID, task.ModelName)
|
||||
|
||||
// ============================================
|
||||
// 1) 查询余额
|
||||
// ============================================
|
||||
surplus, _ = gateway.GetTenantSurplus(ctx, model.TenantId)
|
||||
if surplus <= 200 {
|
||||
w.failTask(ctx, task, startTime, "租户余额不足")
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[handleOne] 当前余额 tenantId=%d surplus=%.2f", model.TenantId, surplus)
|
||||
|
||||
// ============================================
|
||||
// 2) 调用模型
|
||||
// ============================================
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4) 处理提示词相关数据解析涵盖重试
|
||||
// ============================================
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5) 上传 OSS(可重试)
|
||||
// ============================================
|
||||
var oss *gateway.UploadFileResponse
|
||||
for attempt := 0; attempt <= maxRetry; attempt++ {
|
||||
if attempt > 0 {
|
||||
g.Log().Infof(ctx, "[handleOne] OSS上传重试 第%d/%d次 taskId=%s", attempt, maxRetry, task.TaskID)
|
||||
}
|
||||
oss, err = gateway.UploadByTask(ctx, gjson.New(mapped).MustToJson(), "json")
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
g.Log().Errorf(ctx, "[handleOne] OSS上传失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err)
|
||||
if attempt == maxRetry {
|
||||
w.failTask(ctx, task, startTime, fmt.Sprintf("OSS上传重试耗尽: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 6) 成功收尾
|
||||
// ============================================
|
||||
task.State = public.TaskStatusSuccess
|
||||
task.DurationSeconds = int64(time.Since(startTime).Seconds())
|
||||
task.ResultFile = &entity.ResultFile{
|
||||
OssFile: oss.FileAddressPrefix + oss.FileURL,
|
||||
FileType: oss.FileFormat,
|
||||
FileSize: int64(oss.FileSize),
|
||||
}
|
||||
task.TextResult = mapped
|
||||
if _, err = dao.ModelGatewayTask.Update(ctx, task); err != nil {
|
||||
g.Log().Errorf(ctx, "[handleOne] 更新DB失败 taskId=%s err=%v", task.TaskID, err)
|
||||
return
|
||||
}
|
||||
concurrencyKey := fmt.Sprintf("model:concurrency:%s", req.ModelName)
|
||||
g.Redis().Decr(ctx, concurrencyKey)
|
||||
gateway.TriggerCallback(ctx, task)
|
||||
if req.EpicycleId != 0 {
|
||||
gateway.TriggerPromptsCallback(ctx, task, req.EpicycleId)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[handleOne] 成功 taskId=%s duration=%ds fileType=%s",
|
||||
task.TaskID, task.DurationSeconds, oss.FileFormat)
|
||||
}
|
||||
|
||||
// asyncResult 异步任务结果
|
||||
type asyncResult struct {
|
||||
result map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
// asyncTaskChan 全局异步任务等待通道
|
||||
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. 提交异步任务
|
||||
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(entity.ResponseBody).String()
|
||||
|
||||
// 3. 创建等待通道
|
||||
ch := make(chan asyncResult, 1)
|
||||
asyncTaskChan.Store(taskID, ch)
|
||||
defer func() {
|
||||
asyncTaskChan.Delete(taskID)
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
// 4. 阻塞等待回调或超时
|
||||
timeout := time.Duration(model.TimeoutSeconds) * time.Second
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
g.Log().Infof(ctx, "[异步任务] 开始等待结果 taskID=%s timeout=%v", taskID, timeout)
|
||||
|
||||
select {
|
||||
case res, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("异步任务通道已关闭: taskID=%s", taskID)
|
||||
}
|
||||
g.Log().Infof(ctx, "[异步任务] 获取结果成功 taskID=%s", taskID)
|
||||
return res.result, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("异步任务超时: taskID=%s", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyAsyncResult 回调接口调用此方法通知结果
|
||||
func NotifyAsyncResult(taskID string, result map[string]any, err error) {
|
||||
if ch, ok := asyncTaskChan.Load(taskID); ok {
|
||||
ch.(chan asyncResult) <- asyncResult{result: result, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
//// 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, 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)
|
||||
}
|
||||
|
||||
// 解析 + 校验(用构建模型的 RequiredFields)
|
||||
parsed, err := util.ParseAndValidate(body, buildModel.RequiredFields)
|
||||
if err == nil {
|
||||
return parsed, 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", lastErr)
|
||||
}
|
||||
|
||||
// 重试:重新调模型
|
||||
task.RetryCount++
|
||||
_, _ = dao.ModelGatewayTask.Update(ctx, task)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var rawResp map[string]any
|
||||
if err := json.Unmarshal(rawData, &rawResp); err != nil {
|
||||
g.Log().Warningf(ctx, "[执行任务][Unmarshal失败] taskId=%s err=%v", task.TaskID, err)
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
//surplus, _ := gateway.GetTenantSurplus(ctx, model.TenantId)
|
||||
//if surplus <= 0 {
|
||||
// return nil, fmt.Errorf("租户余额不足")
|
||||
//}
|
||||
|
||||
// 3)构建请求 URL 和超时
|
||||
baseURL := strings.TrimRight(model.BaseURL, "/")
|
||||
timeout := time.Duration(model.TimeoutSeconds) * time.Second
|
||||
client := &http.Client{Timeout: timeout}
|
||||
method := strings.ToUpper(strings.TrimSpace(model.HttpMethod))
|
||||
|
||||
// 4)构建 HTTP 请求
|
||||
var req *http.Request
|
||||
switch method {
|
||||
case http.MethodGet:
|
||||
q, err := util.BodyToQuery(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(q) > 0 {
|
||||
if strings.Contains(baseURL, "?") {
|
||||
baseURL = baseURL + "&" + q.Encode()
|
||||
} else {
|
||||
baseURL = baseURL + "?" + q.Encode()
|
||||
}
|
||||
}
|
||||
// 改用独立超时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
|
||||
}
|
||||
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(后者可覆盖前者)
|
||||
for hk, hv := range util.ParseHeadMsgHeaders(model.HeadMsg) {
|
||||
req.Header.Set(hk, hv)
|
||||
}
|
||||
if model.ApiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+model.ApiKey)
|
||||
}
|
||||
if method != http.MethodGet {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// 6)发送请求
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 7)读取响应体
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8)检查 HTTP 状态码
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
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
|
||||
}
|
||||
|
||||
// // InvokeModel 调用模型服务,返回二进制结果
|
||||
//
|
||||
// func InvokeModel(ctx context.Context, m *entity.AsynchModel, payload any, modelKey string) ([]byte, error) {
|
||||
// if m == nil || m.BaseURL == "" {
|
||||
// return nil, fmt.Errorf("模型配置不完整")
|
||||
// }
|
||||
// // 请求参数映射
|
||||
// mappedPayload, err := mapRequestPayload(m.RequestMapping, payload)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("请求参数映射失败: %w", err)
|
||||
// }
|
||||
// // 合并请求头
|
||||
// headers := util.ForwardHeaders(ctx)
|
||||
// for hk, hv := range parseHeadMsgHeaders(m.HeadMsg) {
|
||||
// headers[hk] = hv
|
||||
// }
|
||||
// for hk, hv := range parseHeadMsgHeaders(modelKey) {
|
||||
// headers[hk] = hv
|
||||
// }
|
||||
//
|
||||
// // 设置超时
|
||||
// timeout := time.Duration(m.TimeoutSeconds) * time.Second
|
||||
// if timeout <= 0 {
|
||||
// timeout = 600 * time.Second
|
||||
// }
|
||||
// ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
// defer cancel()
|
||||
//
|
||||
// invokeUrl := strings.TrimRight(m.BaseURL, "/")
|
||||
// method := strings.ToUpper(strings.TrimSpace(m.HttpMethod))
|
||||
// if method == "" {
|
||||
// method = http.MethodPost
|
||||
// }
|
||||
//
|
||||
// var respBytes []byte
|
||||
//
|
||||
// switch method {
|
||||
// case http.MethodGet:
|
||||
// err = commonHttp.Get(ctx, invokeUrl, headers, &respBytes, mappedPayload)
|
||||
// default:
|
||||
// err = commonHttp.Post(ctx, invokeUrl, headers, &respBytes, mappedPayload)
|
||||
// }
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// // 响应参数映射
|
||||
// mappedResponse, err := mapResponsePayload(m.ResponseMapping, respBytes)
|
||||
// if err != nil {
|
||||
// g.Log().Warningf(ctx, "响应参数映射失败: %v,返回原始数据", err)
|
||||
// return respBytes, nil
|
||||
// }
|
||||
// return mappedResponse, nil
|
||||
// }
|
||||
|
||||
// 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())
|
||||
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) // 触发回调
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 业务字段读写:TakeBusinessFields 把 businessParams 按映射解析为写入路径,
|
||||
// WriteBusinessFields 按路径写入最终请求体(路径语法见 SetByPath)。
|
||||
// ============================================================
|
||||
|
||||
// TakeBusinessFields 把业务参数(businessParams)按映射解析为写入路径:
|
||||
// - 调用方按业务字段名(RequestBusinessFieldMapping 的 key)传值,这里是独立的 businessParams map,
|
||||
// 不再与模板字段混在 requestParams 中
|
||||
// - 业务字段名未配置映射 → 返回错误(不静默忽略)
|
||||
// - 解包 {type,value} 包裹格式为原始值
|
||||
// - 跳过空值(空串/空数组),避免写入请求体污染
|
||||
//
|
||||
// 返回 map[映射路径]原始值,构建完成后由 WriteBusinessFields 按路径写入请求体。
|
||||
func TakeBusinessFields(businessParams map[string]any, bizMapping map[string]string) (map[string]any, error) {
|
||||
if len(businessParams) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
keyToPath := make(map[string]string, len(bizMapping))
|
||||
for key, path := range bizMapping {
|
||||
if key != "" && path != "" {
|
||||
keyToPath[key] = path
|
||||
}
|
||||
}
|
||||
bizValues := make(map[string]any)
|
||||
for key, raw := range businessParams {
|
||||
path, isBiz := keyToPath[key]
|
||||
if !isBiz {
|
||||
return nil, fmt.Errorf("业务字段 [%s] 未配置映射(RequestBusinessFieldMapping 中不存在该业务字段名)", key)
|
||||
}
|
||||
v := unwrapBizValue(raw)
|
||||
if isEmptyBizValue(v) {
|
||||
continue
|
||||
}
|
||||
bizValues[path] = v
|
||||
}
|
||||
return bizValues, nil
|
||||
}
|
||||
|
||||
// unwrapBizValue 解包模板包裹格式 {type, value},返回原始值;非包裹格式原样返回
|
||||
func unwrapBizValue(v any) any {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
if _, hasType := m["type"]; hasType {
|
||||
if val, hasVal := m["value"]; hasVal {
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// isEmptyBizValue 判断业务字段值是否为空(空值不写入请求体)
|
||||
func isEmptyBizValue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return t == ""
|
||||
case []any:
|
||||
return len(t) == 0
|
||||
case map[string]any:
|
||||
return len(t) == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 统一字段路径语法(读/写共用,见 NormalizeFieldPath):
|
||||
//
|
||||
// a.b.c 普通点号路径
|
||||
// a[*].b [*] 表示数组段
|
||||
// a[*].b[*]?k=v&t=# 单层选择器:在数组中按 k==v 匹配元素,值/读取目标为 t
|
||||
// a[*]?k=v&b[*]?k2=v2&t=# 多级选择器:选择器体内可再嵌 [*]?选择器,级数不限。
|
||||
// 每级 k=v 既是匹配条件(命中已存在元素时),
|
||||
// 也是新建元素时写入该元素的字段(如 role=user 直接落为 role 字段);
|
||||
// 只有带 t=# 的那级是叶子目标(写值/读值的位置)。
|
||||
//
|
||||
// SetByPath(写,构建请求体)与 GetByPath(读,解析响应)共用 parsePath;
|
||||
// 读方向语义:数组段非末段取第 0 个元素继续下钻,[*] 为末段返回整个数组,选择器定位匹配元素;
|
||||
// 写方向语义:数组段非末段作用于最后一个元素,末段前置追加(业务值在前),选择器 upsert(命中更新/未命中新建),
|
||||
// 多值([]any)仅在叶子选择器展开为多个独立元素(多个参考图/视频等)。
|
||||
// ============================================================
|
||||
|
||||
// SetByPath 按业务字段映射路径把值写入请求结构(请求侧构建)。
|
||||
// 路径语法与 BuildSchemaMapping 输出一致(干净形态,无需 attrs 剔除)。
|
||||
//
|
||||
// 写入语义(业务字段一律前置追加,不覆盖已有值):
|
||||
// - 目标字段已存在且是数组 → 业务值(或其元素)前置插入数组头部,原元素依次后移
|
||||
// - 目标字段已存在且非数组(普通叶子路径)→ 业务值前置拼接(字符串拼接/数组包裹),原值保留在后
|
||||
// - 目标字段不存在 → 新建
|
||||
// - 数组段/选择器段目标字段不是数组(如字符串 content)→ 返回错误,不覆盖已有值
|
||||
// - 中间路径遇到非对象字段 → 返回错误
|
||||
// - 数组段无选择器且非末尾 → 作用于最后一个元素(追加语义),数组为空则补一个空元素
|
||||
// - 数组段带选择器 → 命中则更新目标字段,未命中则按选择器字段构造新元素追加;
|
||||
// 选择器段即使未标 [*] 也按数组处理(如 input.media?type=first_frame&url=#)
|
||||
// - 多级选择器 → 递归:中间级选择器定位/新建容器元素并继续下钻,叶子选择器写值
|
||||
// - 值本身是数组 → 叶子选择器逐值追加;普通数组段/点号路径前置追加(业务值在前,原值保留在后,不覆盖)
|
||||
func SetByPath(root map[string]any, path string, value any) error {
|
||||
steps := parsePath(NormalizeFieldPath(path))
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
return setBySteps(root, steps, value)
|
||||
}
|
||||
|
||||
// setBySteps 按步骤序列写入;选择器步骤(可能带嵌套)递归处理,非选择器步骤逐层下钻
|
||||
func setBySteps(cur map[string]any, steps []step, value any) error {
|
||||
first := steps[0]
|
||||
last := len(steps) == 1
|
||||
if first.sel != nil {
|
||||
// 选择器段:目标字段按数组处理(upsert),路径段未标 [*] 也按数组匹配
|
||||
arr, err := existingArray(cur, first.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newArr, err := upsertStep(arr, first, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur[first.key] = newArr
|
||||
return nil
|
||||
}
|
||||
if !first.isArray {
|
||||
if last {
|
||||
setLeaf(cur, first.key, value)
|
||||
return nil
|
||||
}
|
||||
next, err := ensureMap(cur, first.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setBySteps(next, steps[1:], value)
|
||||
}
|
||||
// 数组段(无选择器)
|
||||
arr, err := existingArray(cur, first.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if last {
|
||||
cur[first.key] = prependToArray(arr, value)
|
||||
return nil
|
||||
}
|
||||
// 无选择器数组段:作用于最后一个元素(追加语义)
|
||||
if len(arr) == 0 {
|
||||
arr = append(arr, map[string]any{})
|
||||
cur[first.key] = arr
|
||||
}
|
||||
lastElem, ok := arr[len(arr)-1].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("字段 [%s] 数组元素不是对象,无法继续下钻(当前类型 %T)", first.key, arr[len(arr)-1])
|
||||
}
|
||||
return setBySteps(lastElem, steps[1:], value)
|
||||
}
|
||||
|
||||
// WriteBusinessFields 把业务字段值写入最终请求体。
|
||||
// bizValues 的键为映射路径(如 input.media?type=reference_video&url=#),值由调用方按路径传入。
|
||||
// 按字典序升序写入:父路径是子路径的前缀(短者靠前),保证容器先写、子路径再 upsert,
|
||||
// 避免子路径先建出的结构被父路径整体覆盖(如 messages 容器与 messages[*].content[*] 内嵌目标并存)。
|
||||
// 任一路径写入失败(如数组段目标不是数组)→ 返回错误,由调用方拒绝本次请求。
|
||||
func WriteBusinessFields(out map[string]any, bizValues map[string]any) error {
|
||||
paths := make([]string, 0, len(bizValues))
|
||||
for path := range bizValues {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
if err := SetByPath(out, path, bizValues[path]); err != nil {
|
||||
return fmt.Errorf("业务字段写入失败 [%s]: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByPath 按字段路径读取响应值(与 SetByPath 同一套路径语法,读方向语义):
|
||||
// - 普通段:逐层进入对象取字段
|
||||
// - 数组段 [*]:非末段取数组第 0 个元素继续下钻;[*] 为末段返回整个数组
|
||||
// - 选择器段 ?k=v&t=#:定位 k==v 的元素,返回该元素 t 字段的值;多级选择器递归下钻
|
||||
//
|
||||
// 未命中(路径缺失 / 中间类型不符)返回 (nil, nil),不视为错误;语法错误返回 error。
|
||||
func GetByPath(root map[string]any, path string) (any, error) {
|
||||
steps := parsePath(NormalizeFieldPath(path))
|
||||
if len(steps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return getBySteps(root, steps)
|
||||
}
|
||||
|
||||
// getBySteps 按步骤序列读取;选择器步骤(可能带嵌套)递归处理
|
||||
func getBySteps(cur any, steps []step) (any, error) {
|
||||
if len(steps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
first := steps[0]
|
||||
rest := steps[1:]
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
// 选择器段:定位匹配元素,返回叶子目标或递归嵌套下钻
|
||||
if first.sel != nil {
|
||||
arr, ok := m[first.key].([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
for _, e := range arr {
|
||||
em, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if matchFilters(em, first.sel) {
|
||||
return getSelValue(em, first.sel, rest)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
v, ok := m[first.key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if first.isArray {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return arr, nil
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return getBySteps(arr[0], rest)
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return v, nil
|
||||
}
|
||||
return getBySteps(v, rest)
|
||||
}
|
||||
|
||||
// getSelValue 选择器命中元素后取值:有嵌套路径则递归下钻,否则取叶子目标字段
|
||||
// (target 可为点号路径,如 image_url.url=#,按 parseSteps 拆级下钻)
|
||||
func getSelValue(em map[string]any, sel *selNode, rest []step) (any, error) {
|
||||
if len(sel.nested) > 0 {
|
||||
return getBySteps(em, append(sel.nested, rest...))
|
||||
}
|
||||
if sel.target != "" {
|
||||
return getBySteps(em, append(parseSteps(sel.target), rest...))
|
||||
}
|
||||
return getBySteps(em, rest)
|
||||
}
|
||||
|
||||
// GetByPathValue 读取路径值,未命中或出错返回 nil(免去调用方处理双返回值)
|
||||
func GetByPathValue(root map[string]any, path string) any {
|
||||
v, err := GetByPath(root, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetByPathAll 按字段路径读取响应值(与 GetByPath 同一套语法),返回路径下**所有**命中值。
|
||||
// 与 GetByPath 的区别:GetByPath 命中即返回第一个匹配;GetByPathAll 遍历数组段/选择器段的全部
|
||||
// 元素并展开收集。适用于通配路径(messages[*]...[*]...)取全部匹配值(如收集所有图片 url)。
|
||||
// 无命中返回 nil。
|
||||
func GetByPathAll(root map[string]any, path string) []any {
|
||||
steps := parsePath(NormalizeFieldPath(path))
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
return getAllBySteps(root, steps)
|
||||
}
|
||||
|
||||
// getAllBySteps 按步骤序列收集全部匹配值;数组段/选择器段遍历所有元素展开,普通段单值包裹返回
|
||||
func getAllBySteps(cur any, steps []step) []any {
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
first := steps[0]
|
||||
rest := steps[1:]
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// 选择器段:遍历命中元素收集
|
||||
if first.sel != nil {
|
||||
arr, ok := m[first.key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []any
|
||||
for _, e := range arr {
|
||||
em, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if matchFilters(em, first.sel) {
|
||||
out = append(out, getSelValueAll(em, first.sel, rest)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
v, ok := m[first.key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if first.isArray {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return arr
|
||||
}
|
||||
var out []any
|
||||
for _, e := range arr {
|
||||
out = append(out, getAllBySteps(e, rest)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return []any{v}
|
||||
}
|
||||
return getAllBySteps(v, rest)
|
||||
}
|
||||
|
||||
// getSelValueAll 选择器命中元素后收集:有嵌套路径递归下钻,否则取叶子目标字段(全部)
|
||||
func getSelValueAll(em map[string]any, sel *selNode, rest []step) []any {
|
||||
if len(sel.nested) > 0 {
|
||||
return getAllBySteps(em, append(sel.nested, rest...))
|
||||
}
|
||||
if sel.target != "" {
|
||||
return getAllBySteps(em, append(parseSteps(sel.target), rest...))
|
||||
}
|
||||
return getAllBySteps(em, rest)
|
||||
}
|
||||
|
||||
// step 路径段;sel 非空表示该段带选择器(按数组处理)
|
||||
type step struct {
|
||||
key string
|
||||
isArray bool
|
||||
sel *selNode
|
||||
}
|
||||
|
||||
// selNode 选择器:
|
||||
// - filters:k=v 匹配条件,新建元素时也作为字段写入
|
||||
// - target:叶子目标字段(k=#),值/读取目标;target 为空且 nested 非空时为中间级选择器
|
||||
// - nested:下钻子路径(多级嵌套选择器,级数不限)
|
||||
type selNode struct {
|
||||
filters [][2]string
|
||||
target string
|
||||
nested []step
|
||||
}
|
||||
|
||||
// parsePath 解析路径为步骤序列。选择器体挂到最后一个步骤上;选择器体中的嵌套 [*]?选择器
|
||||
// 递归解析为 nested(级数不限)。
|
||||
func parsePath(p string) []step {
|
||||
base, suffix := p, ""
|
||||
if i := strings.Index(p, "?"); i >= 0 {
|
||||
base, suffix = p[:i], p[i+1:]
|
||||
}
|
||||
steps := parseSteps(base)
|
||||
if suffix != "" {
|
||||
parseSelector(suffix, &steps)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// parseSteps 解析点号分隔的普通步骤(含 [*] 数组段)
|
||||
func parseSteps(s string) []step {
|
||||
var steps []step
|
||||
for _, raw := range strings.Split(s, ".") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
st := step{}
|
||||
if strings.HasSuffix(raw, "[*]") {
|
||||
st.key = strings.TrimSuffix(raw, "[*]")
|
||||
st.isArray = true
|
||||
} else {
|
||||
st.key = raw
|
||||
}
|
||||
steps = append(steps, st)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// parseSelector 解析选择器体(? 之后的内容)并挂到最后一个步骤上。
|
||||
// 元素用顶层 & 分隔(? 之后的 & 属于嵌套选择器);k=v 为过滤/写入对,k=# 为叶子目标,
|
||||
// 含 [*] 或路径的块为嵌套下钻子路径(递归 parsePath)。
|
||||
func parseSelector(selStr string, steps *[]step) {
|
||||
if len(*steps) == 0 {
|
||||
return
|
||||
}
|
||||
sel := &selNode{}
|
||||
var nested []step
|
||||
for _, el := range splitTopLevel(selStr) {
|
||||
if isPair(el) {
|
||||
k, v, _ := strings.Cut(el, "=")
|
||||
if v == "#" {
|
||||
sel.target = k
|
||||
} else {
|
||||
sel.filters = append(sel.filters, [2]string{k, v})
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 嵌套路径(含自己的选择器):级数不限,递归解析
|
||||
nested = append(nested, parsePath(el)...)
|
||||
}
|
||||
if len(sel.filters) == 0 && sel.target == "" && len(nested) == 0 {
|
||||
return
|
||||
}
|
||||
last := &(*steps)[len(*steps)-1]
|
||||
if last.sel == nil {
|
||||
last.sel = sel
|
||||
}
|
||||
if len(nested) > 0 {
|
||||
last.sel.nested = nested
|
||||
}
|
||||
}
|
||||
|
||||
// splitTopLevel 按顶层 & 拆分选择器体;? 之后的 & 属于嵌套选择器,不在此层拆分
|
||||
func splitTopLevel(s string) []string {
|
||||
var elems []string
|
||||
var cur strings.Builder
|
||||
inNested := false
|
||||
for _, ch := range s {
|
||||
if ch == '?' {
|
||||
inNested = true
|
||||
}
|
||||
if ch == '&' && !inNested {
|
||||
elems = append(elems, cur.String())
|
||||
cur.Reset()
|
||||
continue
|
||||
}
|
||||
cur.WriteRune(ch)
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
elems = append(elems, cur.String())
|
||||
}
|
||||
return elems
|
||||
}
|
||||
|
||||
// isPair 判断元素是否为 k=v 对:= 出现在任何 [ ? 之前则是 pair,否则为嵌套路径。
|
||||
// 目标字段 k 本身可以是点号路径(image_url.url=#),故 . 不参与判别。
|
||||
func isPair(el string) bool {
|
||||
for i := 0; i < len(el); i++ {
|
||||
switch el[i] {
|
||||
case '=':
|
||||
return true
|
||||
case '[', '?':
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// existingArray 返回数组字段的当前数组:
|
||||
// - 字段不存在 → 空数组(允许按追加语义新建)
|
||||
// - 字段是数组 → 原样
|
||||
// - 字段是其他类型(如字符串 content)→ 返回错误,调用方拒绝写入,不覆盖已有值
|
||||
func existingArray(cur map[string]any, key string) ([]any, error) {
|
||||
v, ok := cur[key]
|
||||
if !ok {
|
||||
return []any{}, nil
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr, nil
|
||||
}
|
||||
return nil, fmt.Errorf("字段 [%s] 不是数组,无法按数组路径写入(当前类型 %T)", key, v)
|
||||
}
|
||||
|
||||
// upsertStep 选择器 upsert,返回追加后的数组:
|
||||
// - 叶子选择器(有 target)且值为数组 → 每个值追加一个独立元素
|
||||
// - 命中(所有 filters 匹配)→ 把值写入现有元素(叶子写 target,中间级递归 nested)
|
||||
// - 未命中 → 按选择器字段构造新元素并追加
|
||||
//
|
||||
// 返回新切片(append 可能重新分配底层数组),调用方需用返回值覆盖写回。
|
||||
func upsertStep(arr []any, st step, value any) ([]any, error) {
|
||||
sel := st.sel
|
||||
// 叶子选择器:多值逐个展开为独立元素(多个参考图/视频等)
|
||||
if sel.target != "" {
|
||||
if vals, ok := value.([]any); ok && len(vals) > 0 {
|
||||
for _, v := range vals {
|
||||
elem, err := buildStepElement(sel, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr = append(arr, elem)
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
}
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if matchFilters(m, sel) {
|
||||
if err := writeStepValue(m, sel, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
}
|
||||
elem, err := buildStepElement(sel, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(arr, elem), nil
|
||||
}
|
||||
|
||||
// matchFilters 判断元素是否匹配选择器全部过滤条件;无过滤条件时命中第一个元素
|
||||
func matchFilters(m map[string]any, sel *selNode) bool {
|
||||
if len(sel.filters) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range sel.filters {
|
||||
if gconv.String(m[f[0]]) != f[1] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeStepValue 把值写入已定位元素:中间级递归 nested 下钻,叶子写 target 字段
|
||||
func writeStepValue(m map[string]any, sel *selNode, value any) error {
|
||||
if len(sel.nested) > 0 {
|
||||
return setBySteps(m, sel.nested, value)
|
||||
}
|
||||
if sel.target != "" {
|
||||
return setLeafPath(m, sel.target, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildStepElement 按选择器构造新元素:{filterKey: filterVal, ...} + 叶子写 target / 中间级递归 nested
|
||||
func buildStepElement(sel *selNode, value any) (map[string]any, error) {
|
||||
elem := make(map[string]any, len(sel.filters)+1)
|
||||
for _, f := range sel.filters {
|
||||
elem[f[0]] = f[1]
|
||||
}
|
||||
if err := writeStepValue(elem, sel, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return elem, nil
|
||||
}
|
||||
|
||||
// setLeafPath 在对象内按点号路径写入值(叶子用 setLeaf 语义)
|
||||
func setLeafPath(m map[string]any, path string, value any) error {
|
||||
cur := m
|
||||
segs := strings.Split(path, ".")
|
||||
for i, k := range segs {
|
||||
if i == len(segs)-1 {
|
||||
setLeaf(cur, k, value)
|
||||
return nil
|
||||
}
|
||||
next, err := ensureMap(cur, k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setLeaf 叶子写入(业务字段语义:值前置追加而非覆盖):
|
||||
// 字段不存在 → 直接写入;已有值 → 业务字段值前置到原有值前面(数组前插/字符串拼接),原值保留在后。
|
||||
func setLeaf(parent map[string]any, key string, value any) {
|
||||
existing, ok := parent[key]
|
||||
if !ok {
|
||||
parent[key] = value
|
||||
return
|
||||
}
|
||||
parent[key] = prependValue(value, existing)
|
||||
}
|
||||
|
||||
// prependValue 把 value 前置到 existing 前(业务值在前,原值保留在后,不覆盖):
|
||||
// - existing 是数组 → value(或其元素)前插到数组头部
|
||||
// - value 是数组(existing 为标量)→ value 各元素在前,existing 作为末位元素
|
||||
// - 其余标量 → 字符串拼接,业务值在前
|
||||
func prependValue(value, existing any) any {
|
||||
if arr, isArr := existing.([]any); isArr {
|
||||
return prependToArray(arr, value)
|
||||
}
|
||||
if vals, isArr := value.([]any); isArr {
|
||||
out := make([]any, 0, len(vals)+1)
|
||||
out = append(out, vals...)
|
||||
out = append(out, existing)
|
||||
return out
|
||||
}
|
||||
return gconv.String(value) + gconv.String(existing)
|
||||
}
|
||||
|
||||
// prependToArray 把 value(或其元素)插到数组头部,原元素依次后移
|
||||
func prependToArray(arr []any, value any) []any {
|
||||
if vals, isArr := value.([]any); isArr {
|
||||
out := make([]any, 0, len(vals)+len(arr))
|
||||
out = append(out, vals...)
|
||||
out = append(out, arr...)
|
||||
return out
|
||||
}
|
||||
out := make([]any, 0, len(arr)+1)
|
||||
out = append(out, value)
|
||||
out = append(out, arr...)
|
||||
return out
|
||||
}
|
||||
|
||||
// ensureMap 确保键对应 map,不存在则新建;已存在但非对象 → 返回错误
|
||||
func ensureMap(parent map[string]any, key string) (map[string]any, error) {
|
||||
if v, ok := parent[key]; ok {
|
||||
if m, isMap := v.(map[string]any); isMap {
|
||||
return m, nil
|
||||
}
|
||||
return nil, fmt.Errorf("字段 [%s] 不是对象,无法按路径写入(当前类型 %T)", key, v)
|
||||
}
|
||||
m := map[string]any{}
|
||||
parent[key] = m
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package utils
|
||||
|
||||
// DetectMediaType 按模型业务字段映射从请求体推导输入媒体类型(替代硬编码的 media.type 路径)。
|
||||
// 直接返回 shop-user-trade 计费词汇(audio/video,对齐 ChargeUsage.MediaType),
|
||||
// 无需二次转换(原 mgMediaTypeToShop 已删除):
|
||||
// - reference_audio 映射路径在请求体中有值 → "audio"
|
||||
// - reference_video 映射路径在请求体中有值 → "video"
|
||||
// - 否则 → ""(无媒体引用,shop 侧 pickModelPrice 查不到 mediaPrices 键 → 落默认价)
|
||||
//
|
||||
// 判定完全由模型配置(RequestBusinessFieldMapping,业务字段名见 ChatFieldsReq/VideoFields)驱动,
|
||||
// 无请求结构硬编码;映射路径值即 GetByPathAll 路径(如 input.media?type=audio&url=#)。
|
||||
// 媒体类型仅供 shop-user-trade 算费用量(见 service/pricing_client.go buildModelUsage)。
|
||||
func DetectMediaType(reqBizMapping map[string]string, reqParams map[string]any) string {
|
||||
if hasMediaValue(reqBizMapping, reqParams, "reference_audio") {
|
||||
return "audio"
|
||||
}
|
||||
if hasMediaValue(reqBizMapping, reqParams, "reference_video") {
|
||||
return "video"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasMediaValue 业务字段映射路径在请求体中是否命中值
|
||||
func hasMediaValue(reqBizMapping map[string]string, reqParams map[string]any, bizField string) bool {
|
||||
path := reqBizMapping[bizField]
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
return len(GetByPathAll(reqParams, path)) > 0
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ====================== 模型引用解析层 ======================
|
||||
// 引用行(ref_system_model_id>0) 调用时实时取系统模型配置 + 本人 apiKey 合成可执行配置;
|
||||
// 系统模型调整零同步。Id 被覆盖为系统模型 id → 计价/并发键按系统模型走(会话/任务落库仍用引用行 id)。
|
||||
|
||||
const apiKeyPlaceholder = "{apiKey}"
|
||||
|
||||
// replacePlaceholder 递归替换 map/slice/string 中的占位符(泛化自 task_end 的 replaceTaskPlaceholder,非破坏式)。
|
||||
func replacePlaceholder(v any, from, to string) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return strings.ReplaceAll(val, from, to)
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(val))
|
||||
for k, x := range val {
|
||||
m[k] = replacePlaceholder(x, from, to)
|
||||
}
|
||||
return m
|
||||
case map[string]string:
|
||||
m := make(map[string]string, len(val))
|
||||
for k, x := range val {
|
||||
m[k] = strings.ReplaceAll(x, from, to)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
arr := make([]any, len(val))
|
||||
for i, x := range val {
|
||||
arr[i] = replacePlaceholder(x, from, to)
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// copyAndReplaceStringMap 非破坏式替换 map[string]string 值(新建 map,不污染入参)
|
||||
func copyAndReplaceStringMap(src map[string]string, from, to string) map[string]string {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
m[k] = strings.ReplaceAll(v, from, to)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// substituteAPIPlaceholder 把输入侧配置中的 {apiKey} 替换为生效 key(非破坏式:新建 map/struct,不污染入参)。
|
||||
// 覆盖 BaseURL / RequestHeadMapping / RequestBodyMapping / RequestBusinessFieldMapping /
|
||||
// AsyncTaskMapping(Url/RequestHeadMapping/RequestBodyMapping)。
|
||||
func substituteAPIPlaceholder(m *entity.ModelManage, key string) {
|
||||
m.BaseURL = strings.ReplaceAll(m.BaseURL, apiKeyPlaceholder, key)
|
||||
m.RequestHeadMapping = copyAndReplaceStringMap(m.RequestHeadMapping, apiKeyPlaceholder, key)
|
||||
m.RequestBusinessFieldMapping = copyAndReplaceStringMap(m.RequestBusinessFieldMapping, apiKeyPlaceholder, key)
|
||||
if v, ok := replacePlaceholder(m.RequestBodyMapping, apiKeyPlaceholder, key).(map[string]any); ok {
|
||||
m.RequestBodyMapping = v
|
||||
}
|
||||
if a := m.AsyncTaskMapping; a != nil {
|
||||
ac := *a
|
||||
ac.Url = strings.ReplaceAll(a.Url, apiKeyPlaceholder, key)
|
||||
ac.RequestHeadMapping = copyAndReplaceStringMap(a.RequestHeadMapping, apiKeyPlaceholder, key)
|
||||
if v, ok := replacePlaceholder(a.RequestBodyMapping, apiKeyPlaceholder, key).(map[string]any); ok {
|
||||
ac.RequestBodyMapping = v
|
||||
}
|
||||
m.AsyncTaskMapping = &ac
|
||||
}
|
||||
}
|
||||
|
||||
// mergeReferenceConfig 引用行 + 系统行 → 有效配置(纯函数,便于单测)。
|
||||
// 配置字段取系统行;个人字段(apiKey/enabled/chatModel)取引用行;enabled 取 AND(系统停用=引用失效)。
|
||||
func mergeReferenceConfig(stub, sys *entity.ModelManage) *entity.ModelManage {
|
||||
out := *sys
|
||||
out.RefSystemModelId = stub.RefSystemModelId // 保留引用标记,调用侧据此判断引用行门禁(Id 已覆盖为系统模型 id)
|
||||
out.ApiKey = stub.ApiKey
|
||||
if stub.Enabled != nil {
|
||||
out.Enabled = stub.Enabled
|
||||
}
|
||||
if stub.ChatModel != nil {
|
||||
out.ChatModel = stub.ChatModel
|
||||
}
|
||||
if sys.Enabled != nil && !*sys.Enabled {
|
||||
out.Enabled = gconv.PtrBool(false)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// MergeReferenceConfigForQuery 管理端 Get 查询展示用:以引用行为基底,把系统行的配置列合入,
|
||||
// 保留引用行自身 Id/RefSystemModelId/SystemModel/Creator/时间戳与个人字段(apiKey/enabled/chatModel)。
|
||||
// 与 mergeReferenceConfig 的区别:不替换 {apiKey}(Get 非引用行也不替换,展示模板),
|
||||
// enabled 不做 AND(展示引用行个人开关,调用时才按系统行生效状态门禁)。
|
||||
func MergeReferenceConfigForQuery(stub, sys *entity.ModelManage) *entity.ModelManage {
|
||||
out := *stub
|
||||
out.BaseURL = sys.BaseURL
|
||||
out.HttpMethod = sys.HttpMethod
|
||||
out.ResponseType = sys.ResponseType
|
||||
out.RequestHeadMapping = sys.RequestHeadMapping
|
||||
out.RequestBodyMapping = sys.RequestBodyMapping
|
||||
out.RequestBusinessFieldMapping = sys.RequestBusinessFieldMapping
|
||||
out.ResponseMapping = sys.ResponseMapping
|
||||
out.ResponseBodyMapping = sys.ResponseBodyMapping
|
||||
out.ResponseBusinessFieldMapping = sys.ResponseBusinessFieldMapping
|
||||
out.MaxConcurrency = sys.MaxConcurrency
|
||||
out.TokenMapping = sys.TokenMapping
|
||||
out.AsyncTaskMapping = sys.AsyncTaskMapping
|
||||
out.TokenPredictPrice = sys.TokenPredictPrice
|
||||
out.TokenPredictPriceUnit = sys.TokenPredictPriceUnit
|
||||
out.MaxTokens = sys.MaxTokens
|
||||
out.MinDuration = sys.MinDuration
|
||||
out.MaxDuration = sys.MaxDuration
|
||||
out.LastFrame = sys.LastFrame
|
||||
out.ErrorMessageMapping = sys.ErrorMessageMapping
|
||||
return &out
|
||||
}
|
||||
|
||||
// ResolveModelConfig 把请求命中的模型行解析为可执行配置:
|
||||
// 引用行 → 系统行配置 + 引用行 apiKey(Id 覆盖为系统模型 id);非引用行 → 原配置 + 自身 apiKey 替换占位。
|
||||
// 引用系统模型已删除 → 报错(调用方阻塞)。
|
||||
func ResolveModelConfig(ctx context.Context, m *entity.ModelManage) (*entity.ModelManage, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if m.RefSystemModelId > 0 {
|
||||
sys, err := dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: m.RefSystemModelId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sys == nil {
|
||||
return nil, fmt.Errorf("引用的系统模型已删除")
|
||||
}
|
||||
out := mergeReferenceConfig(m, sys)
|
||||
substituteAPIPlaceholder(out, out.ApiKey)
|
||||
return out, nil
|
||||
}
|
||||
out := *m
|
||||
substituteAPIPlaceholder(&out, out.ApiKey)
|
||||
return &out, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultPool atomic.Pointer[grpool.Pool]
|
||||
once sync.Once
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
const DefaultWorkerNum = 100
|
||||
|
||||
// Init 初始化全局协程池,首次调用生效,后续调用忽略。
|
||||
func Init(workerNum int) {
|
||||
once.Do(func() {
|
||||
if workerNum <= 0 {
|
||||
workerNum = DefaultWorkerNum
|
||||
}
|
||||
defaultPool.Store(grpool.New(workerNum))
|
||||
})
|
||||
}
|
||||
|
||||
// Submit 提交异步任务,上下文透传至 grpool。
|
||||
// Submit 也可在 Init 前调用(自动 Init),但 Shutdown 后返回 ErrPoolClosed。
|
||||
func Submit(ctx context.Context, task func(ctx context.Context)) error {
|
||||
p := defaultPool.Load()
|
||||
if p == nil {
|
||||
Init(DefaultWorkerNum)
|
||||
p = defaultPool.Load()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
err := p.AddWithRecover(ctx, func(ctx context.Context) {
|
||||
defer wg.Done()
|
||||
task(ctx)
|
||||
}, nil)
|
||||
if err != nil {
|
||||
wg.Done()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭:停止新任务,等待全部已完成/排队任务完成。
|
||||
func Shutdown() {
|
||||
p := defaultPool.Swap(nil)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
wg.Wait()
|
||||
p.Close()
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package utils
|
||||
|
||||
import "github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
// MergeNode 按模板节点的元数据把用户数据节点合并为模板可解析的节点:
|
||||
// - 用户节点无 type 键 → 套上模板类型包装(标量取用户值,空则回落模板 value/defaultValue)
|
||||
// - 用户节点有 type 键 → 递归合并子节点(子节点缺 type 时同样补模板类型,避免模板包装泄漏)
|
||||
// - 对象:模板 attrs 缺省字段补进用户容器(保留模板默认值)
|
||||
// - 数组:用户 enumValues/attrs 元素逐个与模板元素原型合并(模板未提供的槽位克隆原型)
|
||||
func MergeNode(user, tmpl any) any {
|
||||
tmplMap, ok := tmpl.(map[string]any)
|
||||
if !ok {
|
||||
return user
|
||||
}
|
||||
fieldType, _ := tmplMap["type"].(string)
|
||||
|
||||
// 用户节点已带 type:按用户类型递归合并子节点;标量叶子已完备,直接返回
|
||||
if userMap, ok := user.(map[string]any); ok {
|
||||
if ut, isTpl := userMap["type"]; isTpl {
|
||||
switch gconv.String(ut) {
|
||||
case TypeObject:
|
||||
return mergeObject(userMap, tmplMap)
|
||||
case TypeArray:
|
||||
return mergeArray(userMap, tmplMap)
|
||||
default:
|
||||
return userMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch fieldType {
|
||||
case TypeString, TypeBool, TypeNumber, TypeNumberInt, TypeNumberFloat:
|
||||
return wrapScalarNode(tmplMap, user)
|
||||
case TypeObject:
|
||||
return mergeObject(user, tmplMap)
|
||||
case TypeArray:
|
||||
return mergeArray(user, tmplMap)
|
||||
default:
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
// wrapScalarNode 把用户标量值包装为 {type, value} 节点;用户值为空时回落模板 value/defaultValue
|
||||
func wrapScalarNode(tmplMap map[string]any, user any) map[string]any {
|
||||
val := user
|
||||
if m, ok := user.(map[string]any); ok {
|
||||
if v, has := m["value"]; has {
|
||||
val = v
|
||||
} else {
|
||||
val = nil
|
||||
}
|
||||
}
|
||||
node := map[string]any{"type": normalizeScalarType(gconv.String(tmplMap["type"]))}
|
||||
if hasUsableValue(val) {
|
||||
node["value"] = val
|
||||
} else if v, has := tmplMap["value"]; has {
|
||||
node["value"] = v
|
||||
} else if d, has := tmplMap["defaultValue"]; has {
|
||||
node["value"] = d
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// normalizeScalarType integer/float 统一为 number(resolveField 只分发 string/boolean/number)
|
||||
func normalizeScalarType(t string) string {
|
||||
if t == TypeNumberInt || t == TypeNumberFloat {
|
||||
return TypeNumber
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// mergeObject 合并对象节点:模板 attrs 缺省字段补进用户容器(模板默认值保留)
|
||||
func mergeObject(user any, tmplMap map[string]any) map[string]any {
|
||||
tmplAttrs, _ := tmplMap["attrs"].(map[string]any)
|
||||
node := map[string]any{"type": TypeObject}
|
||||
if tmplAttrs == nil {
|
||||
node["attrs"] = user
|
||||
return node
|
||||
}
|
||||
var container map[string]any
|
||||
switch u := user.(type) {
|
||||
case map[string]any:
|
||||
if a, has := u["attrs"]; has {
|
||||
if am, ok := a.(map[string]any); ok {
|
||||
container = copyMap(am)
|
||||
} else {
|
||||
container = map[string]any{}
|
||||
}
|
||||
} else if v, has := u["value"]; has {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
container = copyMap(vm)
|
||||
} else {
|
||||
container = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
container = copyMap(u)
|
||||
}
|
||||
default:
|
||||
container = map[string]any{}
|
||||
}
|
||||
for k, subTmpl := range tmplAttrs {
|
||||
if _, has := container[k]; !has {
|
||||
container[k] = DeepCopyNode(subTmpl)
|
||||
} else {
|
||||
container[k] = MergeNode(container[k], subTmpl)
|
||||
}
|
||||
}
|
||||
node["attrs"] = container
|
||||
return node
|
||||
}
|
||||
|
||||
// mergeArray 合并数组节点:用户 enumValues/attrs 元素逐个与模板元素原型合并。
|
||||
// 用户未填的槽位(sjson null 填充)用模板对应槽位原型补位,保留下标不塌缩。
|
||||
func mergeArray(user any, tmplMap map[string]any) map[string]any {
|
||||
proto := arrayElementTemplate(tmplMap)
|
||||
tmplSlots, _ := tmplMap["enumValues"].([]any) // 模板各槽位原型,按下标一一对应
|
||||
node := map[string]any{"type": TypeArray}
|
||||
slotProto := func(i int) any {
|
||||
if i < len(tmplSlots) {
|
||||
return tmplSlots[i]
|
||||
}
|
||||
return proto
|
||||
}
|
||||
switch u := user.(type) {
|
||||
case map[string]any:
|
||||
if evs, ok := u["enumValues"].([]any); ok {
|
||||
out := make([]any, len(evs))
|
||||
for i, ev := range evs {
|
||||
if ev == nil {
|
||||
// 保留 null 槽位以维持下标;解析阶段 resolveArray 会丢弃空元素,
|
||||
// 避免用模板原型填充 null 而物化出"幽灵"元素(用户未填的数组槽位)
|
||||
out[i] = nil
|
||||
continue
|
||||
}
|
||||
out[i] = mergeArrayElement(ev, slotProto(i))
|
||||
}
|
||||
node["enumValues"] = out
|
||||
} else if a, ok := u["attrs"].([]any); ok {
|
||||
out := make([]any, len(a))
|
||||
for i, item := range a {
|
||||
out[i] = mergeArrayElement(item, proto)
|
||||
}
|
||||
node["attrs"] = out
|
||||
} else if v, has := u["value"]; has {
|
||||
node["value"] = v
|
||||
} else if len(u) > 0 {
|
||||
node["attrs"] = []any{mergeArrayElement(u, proto)}
|
||||
}
|
||||
case []any:
|
||||
if isSchemaElementList(u) {
|
||||
out := make([]any, len(u))
|
||||
for i, item := range u {
|
||||
out[i] = mergeArrayElement(item, proto)
|
||||
}
|
||||
node["attrs"] = out
|
||||
} else {
|
||||
node["value"] = u
|
||||
}
|
||||
default:
|
||||
node["value"] = user
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// mergeArrayElement 合并单个数组元素:元素缺 type 时按槽位原型补默认字段。
|
||||
// slotProto 可为 nil(模板未提供槽位原型时)→ 原样返回用户元素
|
||||
func mergeArrayElement(ev any, slotProto any) any {
|
||||
if ev == nil {
|
||||
return ev
|
||||
}
|
||||
proto, _ := slotProto.(map[string]any)
|
||||
if proto == nil {
|
||||
return ev
|
||||
}
|
||||
evMap, ok := ev.(map[string]any)
|
||||
if !ok {
|
||||
return ev
|
||||
}
|
||||
if _, isTpl := evMap["type"]; isTpl {
|
||||
return ev
|
||||
}
|
||||
// 元素原型自身是包装节点(嵌套对象/数组)→ 按模板类型合并
|
||||
if t, has := proto["type"].(string); has && t != "" {
|
||||
return MergeNode(ev, proto)
|
||||
}
|
||||
protoAttrs, _ := proto["attrs"].(map[string]any)
|
||||
var container map[string]any
|
||||
if a, has := evMap["attrs"]; has {
|
||||
if am, ok := a.(map[string]any); ok {
|
||||
container = copyMap(am)
|
||||
} else {
|
||||
container = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
container = copyMap(evMap)
|
||||
}
|
||||
for k, subTmpl := range protoAttrs {
|
||||
if _, has := container[k]; !has {
|
||||
container[k] = DeepCopyNode(subTmpl)
|
||||
} else {
|
||||
container[k] = MergeNode(container[k], subTmpl)
|
||||
}
|
||||
}
|
||||
return map[string]any{"attrs": container}
|
||||
}
|
||||
|
||||
// isSchemaElementList 判断 []any 是否为 schema 元素列表(元素均为 map,且带 type 或 attrs 包装)
|
||||
func isSchemaElementList(list []any) bool {
|
||||
if len(list) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, hasType := m["type"]; !hasType {
|
||||
if _, hasAttrs := m["attrs"]; !hasAttrs {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DeepCopyNode 深拷贝任意嵌套节点(模板节点整体拷贝进 src 时使用,避免共享引用被后续回填修改)
|
||||
func DeepCopyNode(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(val))
|
||||
for k, sub := range val {
|
||||
out[k] = DeepCopyNode(sub)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(val))
|
||||
for i, sub := range val {
|
||||
out[i] = DeepCopyNode(sub)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// hasUsableValue 值是否可写入节点 value;0/false 视为有效值
|
||||
func hasUsableValue(v any) bool {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
return val != ""
|
||||
case []any:
|
||||
return len(val) > 0
|
||||
case map[string]any:
|
||||
return len(val) > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var (
|
||||
// 匹配 [数字]
|
||||
regNumIndex = regexp.MustCompile(`\[\d+\]`)
|
||||
// 匹配 .attrs
|
||||
regAttrs = regexp.MustCompile(`\.attrs`)
|
||||
)
|
||||
|
||||
// NormalizeFieldPath 归一化字段路径到统一语法([*] 数组段):
|
||||
// - 移除模板残留 .attrs
|
||||
// - [数字] 下标 → [*](choices[0] → choices[*])
|
||||
// - 兼容 gjson 风格 .# / .数字 下标 → [*](choices.#、choices.0 → choices[*])
|
||||
//
|
||||
// 统一语法见 business_fields.go 的 SetByPath / GetByPath:
|
||||
//
|
||||
// a.b.c 普通点号路径
|
||||
// a[*].b [*] 表示数组段
|
||||
// a[*].b[*]?k=v&t=# 选择器:数组元素按 k==v 定位,值/读取目标为 t
|
||||
// a[*]?k=v&b[*]?k2=v2&t=# 多级选择器:级数不限,中间级定位容器元素,叶子写值
|
||||
//
|
||||
// 正则归一(.attrs / [数字] / .#)作用于整个路径(含多级选择器中的数组段);
|
||||
// 纯数字段(gjson 下标)归一只作用于首个 ? 之前的 base 路径。
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// usage.attrs.total_tokens → usage.total_tokens
|
||||
// choices.attrs[0].attrs.message.attrs.content → choices[*].message.content
|
||||
// choices.#.message.content → choices[*].message.content
|
||||
// choices.0.message.content → choices[*].message.content
|
||||
func NormalizeFieldPath(path string) string {
|
||||
s := regAttrs.ReplaceAllString(path, "")
|
||||
s = regNumIndex.ReplaceAllString(s, "[*]")
|
||||
s = strings.ReplaceAll(s, ".#", "[*]")
|
||||
base, suffix := s, ""
|
||||
if i := strings.Index(s, "?"); i >= 0 {
|
||||
base, suffix = s[:i], s[i:]
|
||||
}
|
||||
// 逐段把纯数字段(gjson 下标)归一为 [*]:附着到前一段字段(choices.0 → choices[*]),
|
||||
// 避免误伤数字开头的字段名;选择器体用 # 作目标、不用数字段下标,故只归一 base
|
||||
segs := strings.Split(base, ".")
|
||||
var out []string
|
||||
for _, seg := range segs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if isAllDigits(seg) {
|
||||
if len(out) > 0 {
|
||||
out[len(out)-1] += "[*]"
|
||||
} else {
|
||||
out = append(out, "[*]")
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, seg)
|
||||
}
|
||||
return strings.Join(out, ".") + suffix
|
||||
}
|
||||
|
||||
// isAllDigits 判断字符串是否全部为数字字符
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CleanFieldPath 清理字段路径(等价于 NormalizeFieldPath,保留旧名兼容)
|
||||
func CleanFieldPath(path string) string {
|
||||
return NormalizeFieldPath(path)
|
||||
}
|
||||
|
||||
// CleanMapFieldPath 清理字段路径(Map)
|
||||
func CleanMapFieldPath(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
newMap := make(map[string]string, len(m))
|
||||
for k, _ := range m {
|
||||
newMap[k] = CleanFieldPath(k)
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
|
||||
// ParseConfigTemplate 解析配置模板生成简化请求结构
|
||||
//
|
||||
// 输入: config 模板(含 type/value/defaultValue/attrs/enumValues 等元数据字段)
|
||||
// 输出: 简化后的请求结构体
|
||||
//
|
||||
// 规则:
|
||||
// - 标量字段(string/number/boolean): value 非零则用 value,为空则跳过(不再取 defaultValue)
|
||||
// - 对象字段(object): 递归处理 attrs
|
||||
// - 数组字段(array): 遍历 enumValues,每个 enumValue 独立判断是否产出元素
|
||||
// - 数组展开: enumValue 内某叶子字段 value 为数组时,按数组元素展开为多个项
|
||||
func ParseConfigTemplate(cfg map[string]interface{}) map[string]interface{} {
|
||||
var flattenJSON map[string]interface{}
|
||||
flatMap := utils.IsFlatMap(cfg)
|
||||
if flatMap {
|
||||
var err error
|
||||
flattenJSON, err = utils.UnFlatBySjson(cfg)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
flattenJSON = cfg
|
||||
}
|
||||
result := make(map[string]interface{})
|
||||
for key, val := range flattenJSON {
|
||||
field, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v := resolveField(field); v != nil {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveField 按 type 分发解析
|
||||
func resolveField(field map[string]interface{}) interface{} {
|
||||
fieldType, _ := field["type"].(string)
|
||||
switch fieldType {
|
||||
case TypeString, TypeBool, TypeNumber:
|
||||
return resolveScalar(field)
|
||||
case TypeObject:
|
||||
return resolveObject(field)
|
||||
case TypeArray:
|
||||
return resolveArray(field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveScalar 解析标量字段: value 非空则用 value,否则回落 defaultValue
|
||||
// (模板只声明结构、值由业务字段给出时,defaultValue 生效)
|
||||
func resolveScalar(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if vv != "" {
|
||||
return vv
|
||||
}
|
||||
case bool:
|
||||
return vv
|
||||
default:
|
||||
// 数值零值(int/float 各类型)视为未提供,跳过;bool/string 已在上方处理
|
||||
if isNumericZero(v) {
|
||||
return nil
|
||||
}
|
||||
return vv
|
||||
}
|
||||
}
|
||||
if d, has := field["defaultValue"]; has && d != nil {
|
||||
switch dv := d.(type) {
|
||||
case string:
|
||||
if dv != "" {
|
||||
return dv
|
||||
}
|
||||
case bool:
|
||||
return dv
|
||||
default:
|
||||
if isNumericZero(d) {
|
||||
return nil
|
||||
}
|
||||
return dv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNumericZero 判断是否为数值零值(模板 value 常为 int 字面量,经 gconv 可能为 float64)
|
||||
func isNumericZero(v interface{}) bool {
|
||||
switch vv := v.(type) {
|
||||
case int:
|
||||
return vv == 0
|
||||
case int8:
|
||||
return vv == 0
|
||||
case int16:
|
||||
return vv == 0
|
||||
case int32:
|
||||
return vv == 0
|
||||
case int64:
|
||||
return vv == 0
|
||||
case uint:
|
||||
return vv == 0
|
||||
case uint8:
|
||||
return vv == 0
|
||||
case uint16:
|
||||
return vv == 0
|
||||
case uint32:
|
||||
return vv == 0
|
||||
case uint64:
|
||||
return vv == 0
|
||||
case float32:
|
||||
return vv == 0
|
||||
case float64:
|
||||
return vv == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveObject 解析对象字段,递归处理 attrs
|
||||
//
|
||||
// 特殊处理「参数定义」结构:当 attrs 含 default 字段时,说明该对象是一个
|
||||
// 参数定义(含 default/description/min/max/type/enum/required 等元数据),
|
||||
// 此时只提取 default 的值作为该参数的值,其余元数据字段忽略。
|
||||
func resolveObject(field map[string]interface{}) interface{} {
|
||||
attrs, ok := field["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 参数定义:只提取 default 值,跳过元数据
|
||||
if defaultField, hasDefault := attrs["default"]; hasDefault {
|
||||
if df, ok := defaultField.(map[string]interface{}); ok {
|
||||
return extractRawValueKeepZero(df)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通对象:递归处理所有 attrs
|
||||
result := make(map[string]interface{})
|
||||
for key, val := range attrs {
|
||||
subField, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
result[key] = val // 纯值字段原样保留
|
||||
continue
|
||||
}
|
||||
if subType, _ := subField["type"].(string); subType == "" {
|
||||
result[key] = val // 无 type 键的纯对象原样保留
|
||||
continue
|
||||
}
|
||||
if v := resolveField(subField); v != nil {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveArray 解析数组字段,遍历 enumValues 或 attrs 生成元素列表
|
||||
func resolveArray(field map[string]interface{}) []interface{} {
|
||||
// 实际数据在 value(schema-editor 数据存放处),直接返回
|
||||
if v, has := field["value"]; has {
|
||||
if arr, ok := v.([]interface{}); ok && len(arr) > 0 {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
enumValues, ok := field["enumValues"].([]interface{})
|
||||
if ok {
|
||||
var result []interface{}
|
||||
for _, ev := range enumValues {
|
||||
evMap, ok := ev.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items := resolveEnumObject(evMap)
|
||||
result = append(result, items...)
|
||||
}
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// enumValues 取不到或为空时,尝试从 attrs(数组)中取
|
||||
attrs, ok := field["attrs"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []interface{}
|
||||
for _, item := range attrs {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v := resolveField(itemMap); v != nil {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveEnumObject 解析 enumValue 对象,支持数组展开
|
||||
func resolveEnumObject(ev map[string]interface{}) []interface{} {
|
||||
attrs, ok := ev["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// 将 enumValue 级别的 value 注入 attrs.type.value(如果 type.value 为空)
|
||||
if evVal, has := ev["value"]; has && evVal != nil {
|
||||
if s, ok := evVal.(string); ok && s != "" {
|
||||
if typeField, has := attrs["type"]; has {
|
||||
if typeMap, ok := typeField.(map[string]interface{}); ok {
|
||||
if existing, has := typeMap["value"]; !has || existing == nil || existing == "" {
|
||||
typeMap["value"] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveAttrs(attrs)
|
||||
}
|
||||
|
||||
// resolveAttrs 递归解析 attrs map,支持字段级数组展开
|
||||
func resolveAttrs(attrs map[string]interface{}) []interface{} {
|
||||
currentItems := []map[string]interface{}{{}}
|
||||
hasValue := false
|
||||
|
||||
for key, val := range attrs {
|
||||
subField, isMap := val.(map[string]interface{})
|
||||
var subType string
|
||||
if isMap {
|
||||
subType, _ = subField["type"].(string)
|
||||
}
|
||||
|
||||
var nextItems []map[string]interface{}
|
||||
|
||||
// 非包裹字段(纯值/纯对象,无 type 键):原样保留,数组值仍参与展开
|
||||
if !isMap || subType == "" {
|
||||
raw := val
|
||||
if raw == nil {
|
||||
nextItems = currentItems
|
||||
currentItems = nextItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
if arr, ok := raw.([]interface{}); ok && len(arr) > 0 {
|
||||
for _, item := range currentItems {
|
||||
for _, elem := range arr {
|
||||
cp := copyMap(item)
|
||||
cp[key] = elem
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = raw
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
currentItems = nextItems
|
||||
continue
|
||||
}
|
||||
|
||||
switch subType {
|
||||
case TypeString, TypeBool, TypeNumber:
|
||||
raw := extractRawValue(subField)
|
||||
if raw == nil {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
if arr, ok := raw.([]interface{}); ok && len(arr) > 0 {
|
||||
for _, item := range currentItems {
|
||||
for _, elem := range arr {
|
||||
cp := copyMap(item)
|
||||
cp[key] = elem
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = raw
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
|
||||
case TypeObject:
|
||||
subAttrs, ok := subField["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
subItems := resolveAttrs(subAttrs)
|
||||
if len(subItems) == 0 {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
for _, item := range currentItems {
|
||||
for _, subI := range subItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = subI
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
|
||||
case TypeArray:
|
||||
items := resolveArray(subField)
|
||||
if len(items) == 0 {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = items
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
|
||||
default:
|
||||
nextItems = currentItems
|
||||
}
|
||||
|
||||
currentItems = nextItems
|
||||
}
|
||||
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
result := make([]interface{}, len(currentItems))
|
||||
for i, item := range currentItems {
|
||||
result[i] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractRawValue 提取原始值(保留数组值供上层展开)
|
||||
func extractRawValue(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if vv != "" {
|
||||
return vv
|
||||
}
|
||||
case float64:
|
||||
if vv != 0 {
|
||||
return vv
|
||||
}
|
||||
case bool:
|
||||
return vv
|
||||
case []interface{}:
|
||||
if len(vv) > 0 {
|
||||
return vv
|
||||
}
|
||||
default:
|
||||
return vv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractRawValueKeepZero 同 extractRawValue,但不过滤零值
|
||||
// 在参数定义场景下,default 可能是 false/0/"",需要保留
|
||||
func extractRawValueKeepZero(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case float64:
|
||||
return vv
|
||||
case bool:
|
||||
return vv
|
||||
case []interface{}:
|
||||
if len(vv) > 0 {
|
||||
return vv
|
||||
}
|
||||
return vv
|
||||
default:
|
||||
return vv
|
||||
}
|
||||
}
|
||||
if dv, has := field["defaultValue"]; has && dv != nil {
|
||||
switch dvv := dv.(type) {
|
||||
case string:
|
||||
if field["type"] == TypeBool {
|
||||
if dvv == "true" {
|
||||
return true
|
||||
}
|
||||
if dvv == "false" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return dvv
|
||||
case float64:
|
||||
return dvv
|
||||
case bool:
|
||||
return dvv
|
||||
case []interface{}:
|
||||
if len(dvv) > 0 {
|
||||
return dvv
|
||||
}
|
||||
return dvv
|
||||
default:
|
||||
return dvv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyMap 浅拷贝 map
|
||||
func copyMap(src map[string]interface{}) map[string]interface{} {
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// CoerceBodyTypes 按模板声明的 type 递归归一请求体字段值类型:
|
||||
// - string → gconv.String;number → gconv.Float64;boolean → gconv.Bool
|
||||
// - object → 按模板 attrs 递归子字段;array → 按元素模板逐个递归
|
||||
// - 模板未声明的字段(业务字段写入且超出模板的部分)保持原样
|
||||
//
|
||||
// 用于构建请求体后统一修正:模板字段 value 与业务字段写入的值都可能携带与声明
|
||||
// 类型不一致的 Go 类型(如 number 字段 value 为字符串 "0.7"),在此统一转成模型
|
||||
// API 期望的 JSON 类型。仅做类型归一,不增删字段。
|
||||
func CoerceBodyTypes(out map[string]interface{}, templateParams map[string]interface{}) map[string]interface{} {
|
||||
if len(templateParams) == 0 {
|
||||
return out
|
||||
}
|
||||
for key, raw := range out {
|
||||
if tmplNode, has := templateParams[key]; has {
|
||||
out[key] = coerceNode(raw, tmplNode)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// coerceNode 按单个模板节点归一值类型
|
||||
func coerceNode(value interface{}, tmplNode interface{}) interface{} {
|
||||
tmplMap, ok := tmplNode.(map[string]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
fieldType, _ := tmplMap["type"].(string)
|
||||
switch fieldType {
|
||||
case TypeString:
|
||||
return gconv.String(value)
|
||||
case TypeNumber, TypeNumberInt, TypeNumberFloat:
|
||||
return gconv.Float64(value)
|
||||
case TypeBool:
|
||||
return gconv.Bool(value)
|
||||
case TypeObject:
|
||||
sub, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
if attrs, ok := tmplMap["attrs"].(map[string]interface{}); ok {
|
||||
return coerceObject(sub, attrs)
|
||||
}
|
||||
return value
|
||||
case TypeArray:
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
proto := arrayElementTemplate(tmplMap)
|
||||
if proto == nil {
|
||||
return value
|
||||
}
|
||||
out := make([]interface{}, len(arr))
|
||||
for i, elem := range arr {
|
||||
out[i] = coerceNode(elem, proto)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// coerceObject 按对象模板 attrs 归一对象子字段类型
|
||||
func coerceObject(sub, attrs map[string]interface{}) map[string]interface{} {
|
||||
for key, raw := range sub {
|
||||
if tmplNode, has := attrs[key]; has {
|
||||
sub[key] = coerceNode(raw, tmplNode)
|
||||
}
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// arrayElementTemplate 从数组模板节点提取元素模板(attrs 优先,其次 enumValues)。
|
||||
// 与 arrayElementPrototype 语义一致,但直接工作在原始模板 map 上,供类型归一使用。
|
||||
func arrayElementTemplate(field map[string]interface{}) map[string]interface{} {
|
||||
if attrs, ok := field["attrs"].([]interface{}); ok && len(attrs) > 0 {
|
||||
if m, ok := attrs[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
if evs, ok := field["enumValues"].([]interface{}); ok && len(evs) > 0 {
|
||||
if m, ok := evs[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"model-gateway/model/dto"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/gogf/gf/v2/util/gutil"
|
||||
)
|
||||
|
||||
// 数据类型常量
|
||||
const (
|
||||
TypeString = "string"
|
||||
TypeBool = "boolean"
|
||||
TypeNumber = "number"
|
||||
TypeNumberInt = "integer"
|
||||
TypeNumberFloat = "float"
|
||||
TypeNull = "null"
|
||||
TypeObject = "object"
|
||||
TypeArray = "array"
|
||||
)
|
||||
|
||||
// CheckParams 校验用户入参并回填默认值:
|
||||
// 用户只传 key/value,约束参数(type/required/constraint)全部取模板定义。
|
||||
// 模板定义必填的字段,用户未传或传空值都报错;用户值为空时用模板 defaultValue 回填。
|
||||
// 严格模式:未知字段报错。
|
||||
func CheckParams(userParams map[string]interface{}, templateParams map[string]interface{}) error {
|
||||
return checkParams(userParams, templateParams, true, true)
|
||||
}
|
||||
|
||||
// CheckBody 校验构建完成的请求体(ParseConfigTemplate + WriteBusinessFields 之后):
|
||||
// 业务字段按映射写入的路径可能超出模板声明,未知字段不报错;默认值已在构建期处理,不做回填。
|
||||
// 仍按模板约束校验必填/长度/范围。
|
||||
func CheckBody(body map[string]interface{}, templateParams map[string]interface{}) error {
|
||||
return checkParams(body, templateParams, false, true)
|
||||
}
|
||||
|
||||
// checkParams 按模板校验请求结构。strictUnknown:未知字段是否报错;backfill:空值是否回填 defaultValue。
|
||||
func checkParams(userParams map[string]interface{}, templateParams map[string]interface{}, strictUnknown, backfill bool) error {
|
||||
// 兼容扁平路径入参:还原为嵌套结构
|
||||
orig := userParams
|
||||
if utils.IsFlatMap(userParams) {
|
||||
nested, err := utils.UnFlatBySjson(userParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析用户参数: %w", err)
|
||||
}
|
||||
orig = nested
|
||||
}
|
||||
// 顶层未知字段检查
|
||||
if strictUnknown {
|
||||
for key := range orig {
|
||||
if _, has := templateParams[key]; !has {
|
||||
return fmt.Errorf("非法字段: %s 模板中不存在该字段", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, tmplNode := range templateParams {
|
||||
if err := validateNode(orig, key, tmplNode, key, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNode 按模板节点校验用户值,空值回填 defaultValue。
|
||||
// parent 为用户原始结构(模板格式 {type,value/attrs} 或纯值),key 为字段名;回填写回 parent[key]。
|
||||
func validateNode(parent map[string]interface{}, key string, tmplNode interface{}, path string, strictUnknown, backfill bool) error {
|
||||
raw, hasRaw := parent[key]
|
||||
|
||||
tmplMap, ok := tmplNode.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil // 模板节点不是对象,无约束可校验
|
||||
}
|
||||
var tmpl dto.Template
|
||||
if err := gconv.Struct(tmplMap, &tmpl); err != nil {
|
||||
return fmt.Errorf("字段 [%s] 模板解析错误: %w", path, err)
|
||||
}
|
||||
|
||||
label := tmpl.Label
|
||||
if label == "" {
|
||||
label = path
|
||||
}
|
||||
|
||||
switch tmpl.Type {
|
||||
case TypeObject:
|
||||
userMap, hasUser := userObjectValue(raw, hasRaw)
|
||||
if !hasUser {
|
||||
if tmpl.Required {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label)
|
||||
}
|
||||
return nil // 未传对象且非必填:跳过
|
||||
}
|
||||
attrs, ok := tmpl.Attrs.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// 未知子字段检查
|
||||
if strictUnknown {
|
||||
for k := range userMap {
|
||||
if _, has := attrs[k]; !has {
|
||||
return fmt.Errorf("非法字段: %s 模板中不存在该字段", path+"."+k)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 递归子字段(即使对象未传,子字段必填校验仍生效)
|
||||
for subKey, subTmpl := range attrs {
|
||||
if err := validateNode(userMap, subKey, subTmpl, path+"."+subKey, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case TypeArray:
|
||||
// 枚举项:逐项校验请求 enumValue.attrs 子字段并回填默认值
|
||||
if err := validateEnumValues(raw, hasRaw, path, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
userArr, hasUser := userArrayValue(raw, hasRaw)
|
||||
if !hasUser || len(userArr) == 0 {
|
||||
// 先回填 defaultValue(必填字段也可由默认值兜底),回填后重新判空
|
||||
if backfill {
|
||||
backfillDefault(parent, key, raw, hasRaw, &tmpl)
|
||||
}
|
||||
if tmpl.Required && isValueEmptyByType(&tmpl) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 数组数量约束:上限取 Constraint.UploadTotalMaxCount 或各 UploadRule.MaxCount 之和(schema 构建时配置)
|
||||
if limit := maxArrayCount(&tmpl); limit > 0 && len(userArr) > limit {
|
||||
return fmt.Errorf("字段 [%s] 数量 %d 超过限制 %d", label, len(userArr), limit)
|
||||
}
|
||||
proto := arrayElementPrototype(&tmpl)
|
||||
if proto == nil {
|
||||
return nil
|
||||
}
|
||||
var protoTmpl dto.Template
|
||||
if err := gconv.Struct(proto, &protoTmpl); err != nil {
|
||||
return nil
|
||||
}
|
||||
switch protoTmpl.Type {
|
||||
case TypeObject:
|
||||
// 对象元素:以元素 attrs 为容器递归校验子字段(模板对象节点 {type:object,attrs:{...}} 的
|
||||
// 子字段藏在 attrs 下;纯对象 map 直接以自身为容器)
|
||||
for i, elem := range userArr {
|
||||
elemMap, isMap := elem.(map[string]interface{})
|
||||
if !isMap {
|
||||
continue
|
||||
}
|
||||
container, has := userObjectValue(elemMap, true)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
// 元素带 type 键(schema 包裹):已由 validateEnumValues 按其自身 attrs 校验,跳过,
|
||||
// 避免用枚举首原型(可能是必填字段模板)误报其他槽位元素缺失
|
||||
if _, isWrapped := elemMap["type"]; isWrapped {
|
||||
continue
|
||||
}
|
||||
// 纯对象元素(解析后数组下标可能塌缩):按 attrs 键集结构匹配槽位原型,避免恒用首原型误报必填
|
||||
subAttrs := matchArraySlotProto(&tmpl, container)
|
||||
if subAttrs == nil {
|
||||
continue
|
||||
}
|
||||
for subKey, subTmpl := range subAttrs {
|
||||
subPath := fmt.Sprintf("%s[%d].%s", path, i, subKey)
|
||||
if err := validateNode(container, subKey, subTmpl, subPath, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 标量元素:逐元素校验(数组内元素不参与整体必填)
|
||||
for i, elem := range userArr {
|
||||
pt := protoTmpl
|
||||
pt.Value = elem
|
||||
pt.Required = false
|
||||
if err := checkScalar(&pt, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
// 标量类型:先回填 defaultValue(必填字段也可由默认值兜底),回填后重新判空,再校验必填/约束
|
||||
tmpl.Value = userScalarValue(raw, hasRaw)
|
||||
if isValueEmptyByType(&tmpl) {
|
||||
if backfill {
|
||||
backfillDefault(parent, key, raw, hasRaw, &tmpl)
|
||||
tmpl.Value = tmpl.DefaultValue
|
||||
}
|
||||
if isValueEmptyByType(&tmpl) {
|
||||
if tmpl.Required {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return checkScalar(&tmpl, label)
|
||||
}
|
||||
}
|
||||
|
||||
// checkScalar 校验标量值:必填 + 约束
|
||||
func checkScalar(tmpl *dto.Template, label string) error {
|
||||
if isValueEmptyByType(tmpl) {
|
||||
if tmpl.Required {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch tmpl.Type {
|
||||
case TypeString:
|
||||
return checkStringTmpl(tmpl)
|
||||
case TypeNumber:
|
||||
return checkNumberTmpl(tmpl)
|
||||
case TypeBool:
|
||||
return checkBoolTmpl(tmpl)
|
||||
case TypeNull:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("字段 [%s] 不支持的模板类型: %s", label, tmpl.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// userObjectValue 从原始请求节点提取对象值(模板格式取 attrs/value,纯值直接返回 map)
|
||||
func userObjectValue(raw interface{}, hasRaw bool) (map[string]interface{}, bool) {
|
||||
if !hasRaw || raw == nil {
|
||||
return nil, false
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if _, isTpl := m["type"]; isTpl {
|
||||
if v, has := m["attrs"]; has {
|
||||
if sub, ok := v.(map[string]interface{}); ok {
|
||||
return sub, true
|
||||
}
|
||||
}
|
||||
if v, has := m["value"]; has {
|
||||
if sub, ok := v.(map[string]interface{}); ok {
|
||||
return sub, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// userArrayValue 从原始请求节点提取数组值
|
||||
func userArrayValue(raw interface{}, hasRaw bool) ([]interface{}, bool) {
|
||||
if !hasRaw || raw == nil {
|
||||
return nil, false
|
||||
}
|
||||
if arr, ok := raw.([]interface{}); ok {
|
||||
return arr, true
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if _, isTpl := m["type"]; isTpl {
|
||||
if v, has := m["value"]; has {
|
||||
if sub, ok := v.([]interface{}); ok {
|
||||
return sub, true
|
||||
}
|
||||
}
|
||||
v1, has1 := m["attrs"]
|
||||
v2, has2 := m["enumValues"]
|
||||
if has1 || has2 {
|
||||
sub1, ok1 := v1.([]interface{})
|
||||
sub2, ok2 := v2.([]interface{})
|
||||
if ok1 {
|
||||
if ok2 {
|
||||
return sub2, true
|
||||
}
|
||||
return sub1, true
|
||||
}
|
||||
if ok2 {
|
||||
return sub2, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// userScalarValue 从原始请求节点提取标量值
|
||||
func userScalarValue(raw interface{}, hasRaw bool) interface{} {
|
||||
if !hasRaw {
|
||||
return nil
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if _, isTpl := m["type"]; isTpl {
|
||||
return m["value"]
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// backfillDefault 空值回填 defaultValue:
|
||||
// 模板格式节点写 value 键;纯值直接覆盖;字段缺失则补一个模板格式节点供下游产出默认值。
|
||||
func backfillDefault(parent map[string]interface{}, key string, raw interface{}, hasRaw bool, tmpl *dto.Template) {
|
||||
if tmpl.DefaultValue == nil {
|
||||
return
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if _, isTpl := m["type"]; isTpl {
|
||||
m["value"] = tmpl.DefaultValue
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasRaw {
|
||||
parent[key] = tmpl.DefaultValue
|
||||
return
|
||||
}
|
||||
parent[key] = map[string]interface{}{
|
||||
"type": tmpl.Type,
|
||||
"value": tmpl.DefaultValue,
|
||||
}
|
||||
}
|
||||
|
||||
// validateEnumValues 校验数组枚举项:逐项取请求 enumValue.attrs 作为字段容器,
|
||||
// 递归校验每个子字段(必填/约束)并回填空值的 defaultValue。与旧 checkArrayTmpl 行为对齐。
|
||||
func validateEnumValues(raw interface{}, hasRaw bool, path string, strictUnknown, backfill bool) error {
|
||||
if !hasRaw {
|
||||
return nil
|
||||
}
|
||||
rawMap, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
evs, ok := rawMap["enumValues"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for i, ev := range evs {
|
||||
evMap, ok := ev.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attrs, ok := evMap["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for subKey, subTmpl := range attrs {
|
||||
subPath := fmt.Sprintf("%s.enumValues[%d].%s", path, i, subKey)
|
||||
if err := validateNode(attrs, subKey, subTmpl, subPath, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxArrayCount 取数组字段的数量上限:UploadTotalMaxCount 优先,其次各 UploadRule.MaxCount 之和;未配置返回 0
|
||||
func maxArrayCount(tmpl *dto.Template) int {
|
||||
if tmpl.Constraint.UploadTotalMaxCount > 0 {
|
||||
return tmpl.Constraint.UploadTotalMaxCount
|
||||
}
|
||||
total := 0
|
||||
for _, rule := range tmpl.Constraint.UploadRules {
|
||||
total += rule.MaxCount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// arrayElementPrototype 取数组元素模板原型(attrs 优先,其次 enumValues)
|
||||
func arrayElementPrototype(tmpl *dto.Template) map[string]interface{} {
|
||||
if attrs, ok := tmpl.Attrs.([]interface{}); ok && len(attrs) > 0 {
|
||||
if m, ok := attrs[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
if len(tmpl.EnumValues) > 0 {
|
||||
if m, ok := tmpl.EnumValues[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchArraySlotProto 按元素 attrs 键集与各槽位原型 attrs 键集的重合度匹配最合适的槽位原型。
|
||||
// 解析后数组下标可能塌缩(resolveArray 丢弃空元素),不能按 index 对齐,故用结构匹配。
|
||||
// 键集完全无重合时返回 nil(跳过该校验,避免用错误原型误报必填)。
|
||||
func matchArraySlotProto(tmpl *dto.Template, container map[string]interface{}) map[string]interface{} {
|
||||
var best map[string]interface{}
|
||||
bestCount := -1
|
||||
for _, ev := range tmpl.EnumValues {
|
||||
evMap, ok := ev.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attrs, ok := evMap["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
count := 0
|
||||
for k := range container {
|
||||
if _, has := attrs[k]; has {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > bestCount {
|
||||
bestCount = count
|
||||
best = attrs
|
||||
}
|
||||
}
|
||||
if bestCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// isValueEmptyByType 按 tmpl.Type 判断是否为"业务空值"
|
||||
func isValueEmptyByType(tmpl *dto.Template) bool {
|
||||
switch tmpl.Type {
|
||||
case TypeString:
|
||||
return g.IsEmpty(gconv.String(tmpl.Value))
|
||||
case TypeNumber:
|
||||
return g.IsEmpty(gconv.Float64(tmpl.Value))
|
||||
case TypeBool:
|
||||
return tmpl.Value == nil
|
||||
case TypeObject:
|
||||
return g.IsEmpty(gconv.Map(tmpl.Value))
|
||||
case TypeArray:
|
||||
return g.IsEmpty(gconv.SliceAny(tmpl.Value))
|
||||
case TypeNull:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// checkStringTmpl 字符串类型校验
|
||||
func checkStringTmpl(tmpl *dto.Template) error {
|
||||
val := gconv.String(tmpl.Value)
|
||||
if tmpl.Required && gutil.IsEmpty(val) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label)
|
||||
}
|
||||
ct := tmpl.Constraint
|
||||
if gutil.IsEmpty(ct) {
|
||||
return nil
|
||||
}
|
||||
if tmpl.FieldType == "string" || tmpl.FieldType == "textarea" {
|
||||
if ct.MinLength > 0 && len(val) < ct.MinLength {
|
||||
return fmt.Errorf("字段 [%s] 长度应大于等于 %d,当前长度 %d", tmpl.Label, ct.MinLength, len(val))
|
||||
}
|
||||
if ct.MaxLength > 0 && len(val) > ct.MaxLength {
|
||||
return fmt.Errorf("字段 [%s] 长度应小于等于 %d,当前长度 %d", tmpl.Label, ct.MaxLength, len(val))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNumberTmpl 数字类型校验
|
||||
func checkNumberTmpl(tmpl *dto.Template) error {
|
||||
ct := tmpl.Constraint
|
||||
if gutil.IsEmpty(ct) {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch ct.NumberType {
|
||||
case TypeNumberInt:
|
||||
val := gconv.Int(tmpl.Value)
|
||||
if tmpl.Required && gutil.IsEmpty(val) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label)
|
||||
}
|
||||
minVal := gconv.Int(ct.Min)
|
||||
maxVal := gconv.Int(ct.Max)
|
||||
if !g.IsEmpty(minVal) && val < minVal {
|
||||
return fmt.Errorf("字段 [%s] 值 %d 不应小于 最小值 %d", tmpl.Label, val, minVal)
|
||||
}
|
||||
if !g.IsEmpty(maxVal) && val > maxVal {
|
||||
return fmt.Errorf("字段 [%s] 值 %d 不应大于 最大值 %d", tmpl.Label, val, maxVal)
|
||||
}
|
||||
|
||||
case TypeNumberFloat:
|
||||
val := gconv.Float64(tmpl.Value)
|
||||
if tmpl.Required && gutil.IsEmpty(val) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label)
|
||||
}
|
||||
minVal := gconv.Float64(ct.Min)
|
||||
maxVal := gconv.Float64(ct.Max)
|
||||
if !g.IsEmpty(minVal) && val < minVal {
|
||||
return fmt.Errorf("字段 [%s] 值 %.2f 不应小于 最小值 %.2f", tmpl.Label, val, minVal)
|
||||
}
|
||||
if !g.IsEmpty(maxVal) && val > maxVal {
|
||||
return fmt.Errorf("字段 [%s] 值 %.2f 不应大于 最大值 %.2f", tmpl.Label, val, maxVal)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("字段 [%s] 数字类型 [%s] 错误,仅支持 int/float", tmpl.Label, ct.NumberType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkBoolTmpl 布尔类型校验
|
||||
func checkBoolTmpl(tmpl *dto.Template) error {
|
||||
val := gconv.Bool(tmpl.Value)
|
||||
if tmpl.Required && gutil.IsEmpty(val) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+155
-117
@@ -33,7 +33,6 @@ CREATE TABLE IF NOT EXISTS model_gateway_models (
|
||||
max_concurrency int4 NOT NULL DEFAULT 10,
|
||||
timeout_seconds int4 NOT NULL DEFAULT 600,
|
||||
retry_times int2 NOT NULL DEFAULT 3,
|
||||
auto_clean_seconds int4 NOT NULL DEFAULT 86400,
|
||||
response_token_field varchar(128) NOT NULL DEFAULT '',
|
||||
call_mode int2 NOT NULL DEFAULT 0,
|
||||
required_fields jsonb NOT NULL DEFAULT '[]',
|
||||
@@ -55,6 +54,7 @@ COMMENT ON COLUMN model_gateway_models.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN model_gateway_models.updater IS '更新人';
|
||||
COMMENT ON COLUMN model_gateway_models.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN model_gateway_models.deleted_at IS '删除时间(软删)';
|
||||
|
||||
COMMENT ON COLUMN model_gateway_models.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN model_gateway_models.model_type IS '模型类型';
|
||||
COMMENT ON COLUMN model_gateway_models.operator_name IS '运营商名称';
|
||||
@@ -62,12 +62,11 @@ COMMENT ON COLUMN model_gateway_models.base_url IS '模型地址';
|
||||
COMMENT ON COLUMN model_gateway_models.http_method IS '请求方式 GET/POST';
|
||||
COMMENT ON COLUMN model_gateway_models.head_msg IS '请求头信息';
|
||||
COMMENT ON COLUMN model_gateway_models.api_key IS '调用凭证/密钥';
|
||||
|
||||
COMMENT ON COLUMN model_gateway_models.is_private IS '是否私有化:0-私有 1-公共';
|
||||
COMMENT ON COLUMN model_gateway_models.enabled IS '是否启用:0-停用 1-启用';
|
||||
COMMENT ON COLUMN model_gateway_models.is_chat_model IS '是否为对话模型:0-否 1-是';
|
||||
COMMENT ON COLUMN model_gateway_models.is_owner IS '1=当前用户创建 0=超级管理员';
|
||||
|
||||
COMMENT ON COLUMN model_gateway_models.call_mode IS '调用模式:0-同步 1-异步 2-流式';
|
||||
COMMENT ON COLUMN model_gateway_models.form_json IS '动态表单结构';
|
||||
COMMENT ON COLUMN model_gateway_models.request_mapping IS '请求映射';
|
||||
COMMENT ON COLUMN model_gateway_models.response_mapping IS '返回映射';
|
||||
@@ -81,9 +80,7 @@ COMMENT ON COLUMN model_gateway_models.last_frame IS '尾帧图片参数';
|
||||
COMMENT ON COLUMN model_gateway_models.max_concurrency IS '最大并发数';
|
||||
COMMENT ON COLUMN model_gateway_models.timeout_seconds IS '调用模型超时(秒)';
|
||||
COMMENT ON COLUMN model_gateway_models.retry_times IS '失败重试次数';
|
||||
COMMENT ON COLUMN model_gateway_models.auto_clean_seconds IS '任务完成后自动清理时间(秒)';
|
||||
COMMENT ON COLUMN model_gateway_models.response_token_field IS '响应中消耗token的字段映射';
|
||||
COMMENT ON COLUMN model_gateway_models.call_mode IS '调用模式:0-同步 1-异步 2-流式';
|
||||
COMMENT ON COLUMN model_gateway_models.required_fields IS '必选字段列表';
|
||||
COMMENT ON COLUMN model_gateway_models.max_tokens IS '最大 token 数,0 表示不传';
|
||||
|
||||
@@ -232,120 +229,161 @@ 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';
|
||||
|
||||
-- =========================================================================================================================
|
||||
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
|
||||
--------------------pgsql创建model_gateway_model_manage表语句---------------------------
|
||||
-- 模型管理表
|
||||
CREATE TABLE IF NOT EXISTS model_gateway_model_manage (
|
||||
id BIGINT PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
creator VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamp(6),
|
||||
|
||||
model_supplier VARCHAR(64) NOT NULL DEFAULT '',
|
||||
model_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
model_type INT NOT NULL DEFAULT 0,
|
||||
base_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||
http_method VARCHAR(32) NOT NULL DEFAULT '',
|
||||
system_model BOOLEAN NOT NULL DEFAULT false,
|
||||
private_model BOOLEAN NOT NULL DEFAULT false,
|
||||
chat_model BOOLEAN NOT NULL DEFAULT false,
|
||||
invoke_type VARCHAR(32) NOT NULL DEFAULT '',
|
||||
api_key VARCHAR(255) NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
request_mapping JSONB DEFAULT '{}',
|
||||
response_mapping JSONB DEFAULT '{}',
|
||||
max_concurrency INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_model_manage_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_tenant_id ON model_gateway_model_manage(tenant_id);
|
||||
CREATE INDEX idx_model_manage_supplier ON model_gateway_model_manage(model_supplier);
|
||||
CREATE INDEX idx_model_manage_type ON model_gateway_model_manage(model_type);
|
||||
CREATE INDEX idx_model_manage_enabled ON model_gateway_model_manage(enabled);
|
||||
CREATE INDEX idx_model_manage_deleted_at ON model_gateway_model_manage(deleted_at);
|
||||
|
||||
-- 字段与表注释
|
||||
COMMENT ON TABLE model_gateway_model_manage IS '模型管理表';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.id IS '主键ID';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.creator IS '创建人';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.updater IS '更新人';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.model_supplier IS '模型供应商';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.model_name IS '模型名称';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.model_type IS '模型类型';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.base_url IS '模型地址';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.http_method IS 'http方法';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.system_model IS '是否系统模型';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.private_model IS '是否私有模型';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.chat_model IS '是否聊天模型';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.invoke_type IS '调用类型';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.api_key IS 'api key';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.enabled IS '是否启用';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.request_mapping IS '请求映射';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.response_mapping IS '响应映射';
|
||||
COMMENT ON COLUMN model_gateway_model_manage.max_concurrency IS '最大并发数';
|
||||
--------------------pgsql创建model_gateway_model_manage表语句---------------------------
|
||||
|
||||
|
||||
-- =========================
|
||||
-- 计费规则:model_manage 新增 price_config(JSONB),model_session 新增 total_cost(NUMERIC)
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_manage
|
||||
ADD COLUMN IF NOT EXISTS price_config JSONB DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_manage.price_config
|
||||
IS '计费规则:{currency,unit,dimensions,rules,discount},未配置为NULL(费用按0处理)';
|
||||
|
||||
ALTER TABLE model_gateway_session
|
||||
ADD COLUMN IF NOT EXISTS total_cost NUMERIC DEFAULT 0;
|
||||
COMMENT ON COLUMN model_gateway_session.total_cost
|
||||
IS '本次调用总费用(元),未配置计费规则为0';
|
||||
|
||||
-- =========================
|
||||
-- 异步任务计费:task_start 快照 media_type,task_end 记录 total_cost
|
||||
-- 模型计费配置(price_config)任务完成时按 modelId 从 model_manage 现查,不在 task_start 快照
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_task_start
|
||||
ADD COLUMN IF NOT EXISTS media_type VARCHAR(32) DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_task_start.media_type
|
||||
IS '输入媒体类型快照(audio/video,空=无媒体引用;shop 计费词汇,创建任务时按请求体参考媒体字段推导)';
|
||||
|
||||
ALTER TABLE model_gateway_model_task_end
|
||||
ADD COLUMN IF NOT EXISTS total_cost NUMERIC DEFAULT 0;
|
||||
COMMENT ON COLUMN model_gateway_model_task_end.total_cost
|
||||
IS '本次调用总费用(元),未配置计费规则为0';
|
||||
|
||||
-- =========================
|
||||
-- 模型引用化:model_manage 新增 ref_system_model_id(引用行指向系统模型)
|
||||
-- 系统模型 = 配置唯一来源;引用行只存 apiKey/enabled/chatModel,配置列留空
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_manage
|
||||
ADD COLUMN IF NOT EXISTS ref_system_model_id BIGINT DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_manage.ref_system_model_id
|
||||
IS '引用的系统模型ID(NULL=非引用行;system_model=false 且该列非空=引用行)';
|
||||
CREATE INDEX IF NOT EXISTS idx_model_manage_ref_system_model_id
|
||||
ON model_gateway_model_manage(ref_system_model_id);
|
||||
|
||||
-- =========================
|
||||
-- 存量迁移:拷贝行 → 引用行(按同名系统模型匹配,一次性,幂等)
|
||||
-- 前提:存量拷贝均原封不动、无自定义(用户已确认)。若存在用户独立创建的同名自有模型会被误转,执行前复核一次:
|
||||
-- SELECT r.id, r.creator, r.model_name, r.ref_system_model_id, s.id AS sys_id
|
||||
-- FROM model_gateway_model_manage r
|
||||
-- JOIN model_gateway_model_manage s ON s.model_name = r.model_name AND s.system_model = true
|
||||
-- WHERE r.system_model = false AND r.ref_system_model_id IS NOT NULL;
|
||||
-- 列名以 entity orm 为准(update.sql 顶部旧 DDL 已过时)。
|
||||
-- =========================
|
||||
UPDATE model_gateway_model_manage r SET
|
||||
ref_system_model_id = s.id,
|
||||
base_url = NULL, http_method = NULL,
|
||||
request_head_mapping = NULL, request_body_mapping = NULL,
|
||||
request_business_field_mapping = NULL,
|
||||
response_mapping = NULL, response_body_mapping = NULL,
|
||||
response_business_field_mapping = NULL,
|
||||
max_concurrency = 0, token_mapping = NULL, async_task_mapping = NULL,
|
||||
token_predict_price = 0, token_predict_price_unit = NULL,
|
||||
max_tokens = 0, min_duration = 0, max_duration = 0,
|
||||
last_frame = NULL, error_message_mapping = NULL
|
||||
FROM model_gateway_model_manage s
|
||||
WHERE r.system_model = false AND s.system_model = true
|
||||
AND r.model_name = s.model_name
|
||||
AND r.ref_system_model_id IS NULL;
|
||||
|
||||
-- =========================
|
||||
-- 错误消息映射:model_manage 新增 error_message_mapping(JSONB,schema 树形态,解析模型错误响应用)
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_manage
|
||||
ADD COLUMN IF NOT EXISTS error_message_mapping JSONB DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_manage.error_message_mapping
|
||||
IS '错误消息映射:{code,message} 的 schema 树(type/attrs/value/defaultValue),解析模型错误响应,defaultValue 为成功码';
|
||||
|
||||
|
||||
-- =========================
|
||||
-- 错误重试记忆:LLM 分析上游模型错误是否可重试的持久知识库
|
||||
-- memory_key = SHA-256(upstream|error_code|归一化消息),唯一;命中直接复用,永久有效
|
||||
-- =========================
|
||||
CREATE TABLE IF NOT EXISTS model_gateway_model_error_memory (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT DEFAULT 0,
|
||||
creator VARCHAR(64) DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updater VARCHAR(64) DEFAULT '',
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ DEFAULT NULL,
|
||||
memory_key CHAR(64) NOT NULL,
|
||||
upstream VARCHAR(512) NOT NULL DEFAULT '',
|
||||
error_code VARCHAR(128) NOT NULL DEFAULT '',
|
||||
msg_fingerprint CHAR(32) NOT NULL DEFAULT '',
|
||||
retryable BOOLEAN NOT NULL DEFAULT false,
|
||||
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
analyzed_by VARCHAR(128) NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
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 '模型管理表';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_error_memory_memory_key
|
||||
ON model_gateway_model_error_memory (memory_key)
|
||||
WHERE deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user