44 Commits
Author SHA1 Message Date
WangLiZhao d7cf0a9380 docs(model): 更新消息结构字段注释
- 为Messages字段添加详细注释说明实际构建方式
- 注明前端表单及工作流传递构建的具体实现
- 标记该部分逻辑待后续调整优化
2026-07-07 10:01:35 +08:00
WangLiZhao 9fd71027a4 docs(readme): 添加接口文档链接
- 在 README 中新增接口文档章节
- 添加 Apifox 接口文档访问地址
- 按照原有格式组织文档结构
2026-07-07 09:55:40 +08:00
WangLiZhao 6a48edb742 docs(readme): 更新项目文档为智能模型网关
chore(database): 优化模型配置表结构和索引设计

- 重新设计 model_gateway_models 表的字段结构和约束
- 新增 billing_config 和 special_params 配置字段
- 优化索引策略,包括复合索引和条件索引的创建
- 统一表注释格式和字段说明文档
- 移除过时的配置字段和冗余索引
2026-07-07 09:47:30 +08:00
WangLiZhao d0a8d6889c feat(gateway): 修改会话历史接口路径并增加租户隔离 2026-07-06 15:12:47 +08:00
WangLiZhao d3a8043337 refactor(model): 重构模型网关结构和流式响应解析 2026-07-06 13:20:19 +08:00
WangLiZhao 1ce1913437 refactor(gateway): 提取请求头转发逻辑并优化模型管理权限控制 2026-07-03 17:33:23 +08:00
WangLiZhao 071ebe2adc refactor(task): 重构任务处理逻辑并移除文件类型检测功能 2026-07-03 15:55:43 +08:00
WangLiZhao cc23697201 Merge branch 'dev未优化' into dev优化中
# Conflicts:
#	common/util/mapping.go
#	config.yml
#	model/entity/model_gateway_model.go
#	model/entity/model_gateway_task.go
#	service/gateway/gateway_http_service.go
#	service/task/task_service.go
#	service/task/worker.go
2026-07-03 09:52:41 +08:00
WangLiZhao 4d95ff75d2 fix(task): 修复任务执行中租户余额检查的参数传递错误
- 修正了 GetTenantSurplus 函数调用中的参数,从 task.TenantId 改为 model.TenantId
- 注释掉了模型调用中重复的余额检查逻辑以避免冗余验证
2026-07-01 18:55:16 +08:00
WangLiZhao a332aa438b refactor(task): 优化任务处理中的余额检查逻辑 2026-07-01 18:47:13 +08:00
WangLiZhao d18016e698 feat(gateway): 添加租户余额管理功能 2026-07-01 18:44:31 +08:00
WangLiZhao 7e849b1c0f fix(task): 修复任务处理中的参数传递和数据解析问题 2026-07-01 18:28:00 +08:00
WangLiZhao 84421b4ee5 refactor(gateway): 调整回调载荷结构定义 2026-07-01 18:04:39 +08:00
WangLiZhao 62003c25a0 fix(billing): 修复计费数据处理逻辑并更新配置
- 在请求阶段将请求数据作为计费记录的基础字段存入数组
- 修改计费处理逻辑,使用请求阶段数据作为基础补充返回数据后进行费用计算
- 重试时使用相同的计费数据处理逻辑确保数据一致性
- 将计费结果直接替换数组第一个元素而非追加
- 更新数据库配置从 192.168.0.83 到 192.168.3.30
- 更新 Redis、Consul、Jaeger 和文件上传服务地址到新 IP
- 修改数据库端口从 15432 到 5432 并更新密码
2026-07-01 18:03:09 +08:00
WangLiZhao 2edbe54fdc fix(task): 修复任务失败时文本结果未保存的问题 2026-07-01 17:51:04 +08:00
WangLiZhao 35e955a617 feat(billing): 实现多条计费数据支持并优化模型计费逻辑 2026-07-01 17:41:01 +08:00
WangLiZhao bdd5f93e2c Merge remote-tracking branch 'origin/dev未优化' into dev未优化 2026-07-01 15:54:11 +08:00
WangLiZhao 326a8acac9 feat(gateway): 添加计费日期字段到回调请求体
- 在CallbackPayload结构体中添加BillingDate字段
- 将任务的BillingData赋值给回调请求体的BillingDate
- 保持现有回调功能的其他字段不变
2026-07-01 15:54:01 +08:00
19904408334 3ca8f93778 fix: 修复视频时长接口响应结构及URL拼写错误
移除VideoDurationResp中多余的嵌套Data字段,直接访问TotalDuration等属性;
修正API路径中缺失的字母a(medi -> media)。
2026-07-01 15:53:28 +08:00
WangLiZhao 8ef6a180f7 refactor(billing): 优化条件判断逻辑并移除未使用的常量 2026-07-01 15:39:25 +08:00
WangLiZhao 29d3f7ffb2 Merge remote-tracking branch 'origin/dev未优化' into dev未优化 2026-07-01 15:17:26 +08:00
WangLiZhao 2bc9d76e18 feat(billing): 添加计费系统功能 2026-07-01 15:17:15 +08:00
WangLiZhao c71823abdb feat(prompt): 添加提示词处理功能与文件安全过滤 2026-06-30 11:13:30 +08:00
admin b00e5d34f4 common版本更新 2026-06-25 17:05:13 +08:00
admin 85dd4e8e84 common版本更新 2026-06-25 14:49:02 +08:00
19904408334 3feb912d86 fix: 增加模型调用重试等待时间 2026-06-25 11:57:57 +08:00
19904408334 63092be679 fix: 忽略 "download failed" 错误避免误判任务失败 2026-06-25 11:48:27 +08:00
19904408334 e11155b604 fix: 忽略连接错误以避免任务误判失败 2026-06-25 11:07:07 +08:00
19904408334 c22a38da9a fix: 忽略下载错误以避免任务误失败 2026-06-24 16:17:25 +08:00
admin aee243d4eb common版本升级 2026-06-23 18:31:59 +08:00
19904408334 16f2967569 build: 升级 common 依赖至 v0.0.25 2026-06-23 17:00:05 +08:00
WangLiZhao 73f630636d feat(task): 添加构建模型名称字段支持 2026-06-23 16:18:00 +08:00
WangLiZhao eaa2942957 Merge remote-tracking branch 'origin/dev未优化' into dev未优化 2026-06-23 14:56:03 +08:00
WangLiZhao 525b391f09 feat(task): 添加构建模型名称字段支持动态校验
- 在ModelGatewayTask实体中新增BuildModelName字段
- 修改ParseAndValidate函数参数,支持传入requiredFields
- 在异步工作器中实现构建模型的动态字段校验逻辑
- 添加prompt构建服务中传递构建模型名称的功能
- 实现构建模型不存在时的兜底机制
2026-06-23 14:55:50 +08:00
WangLiZhao c33f231136 refactor(util): 重构响应解析和表单验证功能 2026-06-23 14:42:58 +08:00
WangLiZhao 47b38f221c refactor(task): 重构任务调度系统,新增 JobTask 定时任务功能,并优化配置结构 2026-06-22 16:13:39 +08:00
WangLiZhao 3ccce74465 refactor(gateway): 重构任务回调和数据结构 2026-06-22 15:08:56 +08:00
admin 610481effd 1 2026-06-22 10:57:46 +08:00
19904408334 39b61f1867 chore: 更新配置并优化任务错误处理
将数据库、Redis、Consul及Jaeger配置切换至新环境地址,并在worker中忽略"Invalid video_url"与"Invalid audio track"错误,同时补充模型调用成功日志。
2026-06-22 09:08:12 +08:00
WangLiZhao 4cc44bf57c fix(task): 修正错误信息注入逻辑 2026-06-18 16:27:11 +08:00
WangLiZhao dc06d1bb9a refactor(config): 更新数据库配置和任务处理逻辑 2026-06-18 14:48:42 +08:00
WangLiZhao ecaaa5bdbc refactor(files): 优化文件处理和任务服务逻辑 2026-06-18 13:39:40 +08:00
19904408334 b21d7a8dbf fix: 修复请求头转发与任务状态流转问题
移除 util.ForwardHeaders,改为从原始请求精确提取 Authorization 或全部请求头;
任务创建时直接设为 Running 状态,避免二次更新与查询;
模型调用使用独立超时上下文,防止外层取消影响回调;
增加 OSS 上传耗时日志,调整数据库连接池参数。
2026-06-18 10:08:36 +08:00
WangLiZhao fddaf36f48 refactor(model): 优化模型网关的数据解析和任务处理逻辑 2026-06-17 14:34:48 +08:00
94 changed files with 6077 additions and 7828 deletions
+1
View File
@@ -0,0 +1 @@
.git
+1 -3
View File
@@ -1,3 +1 @@
/.idea/*
/.superpowers/
/docs/superpowers/
/.idea/*
+396 -165
View File
@@ -1,206 +1,437 @@
# model-asynch(模型异步中间件)[2026.5.12前,暂时弃置]
# Model Gateway — 智能模型网关
一个独立的异步中间件服务:按模型配置路由调用不同模型服务,统一生成 `task_id`,后台异步执行,结果上传 OSS,并提供查询/批量领取/自动重试/自动清理能力,便于业务方“拿走结果并转移”
> 分支约定:`dev` 为开发分支;`main`(或 master)为线上主分支。
统一的 AI 模型网关服务,负责多模态(文本、图像、音频、向量、视频、全模态)模型调用的路由、执行与管理。基于 Go (GoFrame v2) 构建,使用 PostgreSQL、Redis 和 Consul 服务发现
---
## 1. 核心功能
## 架构概览
### 1.1 模型配置(asynch_models
- 增删改查模型服务配置(`model_name` 唯一标识)
- 支持配置:
- 请求地址:`base_url + route`
- 请求方式:`http_method`GET/POST
- 请求头:`head_msg`(以请求头注入,支持多个 header)
- 超时:`timeout_seconds`
- 并发:`max_concurrency`(按租户+模型的 Redis 分布式信号量限流
- 重试:`retry_times`(失败后最多再重试 N 次)
- 保留:`auto_clean_seconds`(任务被业务领取到 `state=4` 后的保留秒数,到期清理)
```
客户端 ──> HTTP API ──> Controller ──> Service ──> DAO ──> PostgreSQL
│ └─> Redis(信号量/队列)
├──> AI 模型服务商(OpenAI、阿里云、火山引擎等
├──> OSS 文件服务
├──> Admin-go(租户/余额)
├──> Prompts-core(会话回调
└──> Skill 技能服务
```
### 1.2 异步任务(asynch_task
- 创建任务:生成 `task_id`,入库排队
- 后台 Worker
- PostgreSQL `FOR UPDATE SKIP LOCKED` 抢占任务,支持多实例不重复消费
- 调用模型服务(GET/POST
- 结果上传 OSS(调用你们的 OSS 文件服务 `oss/file/uploadFile`,透传 `Authorization/X-User-Info`
- 批量领取结果:批量查询 `task_id` 列表,返回 `task_id/state/oss_file`,并把成功的任务从 `state=2` 更新为 `state=4`
- 自动重试:失败 `state=3` 会由清理器按 `retry_times` 重新入队到队尾
- 自动清理:
- `state=4``expire_at` 到期 → 硬删除任务
- 失败重试耗尽仍失败 → 硬删除任务
- `state=0/1` 超时 → 标记失败(防止卡死)
系统分为六个层次:
### 1.3 统计(asynch_model_stat
- 按天统计:`day + tenant_id + creator + model_name -> request_count`
- 统计口径:仅在 Worker 真正调用模型服务时计数(OSS 重试不计数)
- 用途:给其他服务提供全局限流/监控依据
| 层次 | 目录 | 职责 |
|---|---|---|
| **控制器层** | `controller/` | HTTP 路由注册与请求绑定 |
| **业务逻辑层** | `service/` | 模型配置 CRUD、异步任务执行、提示词构建、队列管理、统计 |
| **数据访问层** | `dao/` | PostgreSQL 数据访问(GoFrame ORM |
| **模型层** | `model/` | DTO 请求/响应结构与数据库实体定义 |
| **通用工具层** | `common/` | 计费、类型转换、文件处理、请求头、映射、提示词、流式解析等工具 |
| **常量层** | `consts/` | 公共常量和表名定义 |
---
## 2. 使用流程(业务方如何接入)
## 核心功能
### 第一步:创建模型配置
业务方(或运维)先在中间件里创建/更新模型配置(`model_name` 为唯一键),例如:
- `POST /model/createModel`(或 `/model/updateModel`
### 1. 模型配置管理 (`model_gateway_models`)
提供 AI 模型服务配置的完整增删改查:
| 字段 | 说明 |
|---|---|
| `model_name` | 模型名称(唯一标识) |
| `model_type` | 模型分类(100=推理, 200=图像, 300=音频, 400=向量, 500=全模态, 600=视频) |
| `operator_name` | 运营商标识(OpenAI、阿里云、火山引擎等) |
| `base_url` | 模型服务地址 |
| `http_method` | GET / POST |
| `head_msg` | 每次调用注入的请求头 |
| `form_json` | 动态表单定义(用于前端按模型渲染参数表单) |
| `request_mapping` / `response_mapping` | 标准格式与提供商 API 之间的参数映射 |
| `call_mode` | 0=同步, 1=异步, 2=流式 |
| `max_concurrency` | 单模型最大并发数(按租户维度限流) |
| `timeout_seconds` | 请求超时时间(秒) |
| `retry_times` | 失败重试次数 |
| `billing_config` | 计费规则(推理阶梯计价 / 视频分辨率计价) |
| `stream_config` | SSE 流式解析配置 |
| `query_config` | 异步任务轮询/查询配置 |
**支持的模型类型:**
| 类型码 | 类别 | 子类型 |
|---|---|---|
| 100 | 推理模型 | 文本生成、对话 |
| 200-205 | 图像模型 | 文生图、图生图、图片编辑、图片变体、图文生图 |
| 300-303 | 音频模型 | 文生语音、语音转文字、语音转语音 |
| 400-402 | 向量模型 | 文本嵌入、重排序 |
| 500-502 | 全模态模型 | 文图音频、视觉理解 |
| 600-604 | 视频模型 | 文生视频、图生视频、图文生视频、视频生视频 |
### 2. 异步任务执行 (`model_gateway_task`)
任务生命周期如下:
```
CreateTask ──> state=0(排队中)
Worker 抢占(state=1,执行中)
├── 同步模式:直接调用模型
├── 异步模式:提交任务,通过 QueryConfig 轮询
└── 流式模式:通过 StreamConfig 解析 SSE 事件
├── 成功(state=2)──> 上传结果到 OSS
│ └──> 触发回调(如有配置)
└── 失败(state=3)──> 重试(最多 retry_times 次)
客户端下载(state=4,已下载)
```
**关键特性:**
- **任务创建**:立即返回 `taskId`,执行在 goroutine 中异步完成
- **并发控制**:基于 Redis 的分布式信号量,按模型维度限制并发数
- **队列门控**:基于 Redis Lua 脚本的严格队列插槽机制,防止分布式创建下超限
- **自动重试**:临时性错误(超时、内部错误)自动重试;硬错误直接失败
- **OSS 上传**:结果以 JSON 格式上传到 OSS 文件服务,OSS URL 存入任务记录
- **回调通知**:任务完成时可选触发 HTTP 回调(`TriggerCallback``TriggerPromptsCallback``CallbackBuildResult`
- **阶梯计费**:支持推理 token 阶梯计价和视频分辨率计价两种模型
### 3. 提示词构建 (`/buildMessages`)
异步构建推理模型的结构化消息:
- 从配置合并系统提示词(`modelPrompts.types`
- 拉取技能 Markdown 内容(`SkillMdContent`
- 通过 `GetSessionHistory` 注入会话历史
- 根据附件数量自动拆分多轮(`SplitByAttachment`
- 支持视频模型通过对话模型编排的多轮生成
### 4. 动态调参 (`/autoTune`)
周期性自动调参(建议每小时触发),基于近期 P90 执行耗时和到达率动态调整 `max_concurrency` 和队列上限:
- 读取模型配置(数据库中的上限值 cap)
- 根据时间窗口内已完成任务统计 P90 执行耗时
- 使用 Little 定律(到达率 × P90 / 利用率)计算新的并发数
- 单次调整幅度限制在 ±50%
- 运行时参数写入 Redis(2 小时 TTL),不修改数据库中的上限值
### 5. 日统计 (`model_gateway_log_stat`)
按天、租户、创建人、模型维度的请求计数,使用原子 upsert(`ON DUPLICATE KEY UPDATE`)。
按天、租户、创建人、模型维度的请求计数,使用原子 upsert(`ON DUPLICATE KEY UPDATE`)。
### 6. 核心接口详细说明
#### 6.1 构建消息结构 `/buildMessages`
构建推理模型的提示词消息体,将系统提示词、技能知识、会话历史、用户自定义提示词合并为最终的消息结构。
**请求示例(JSON):**
请求示例(JSON):
```json
{
"modelName": "model-service",
"modelsType": "1,2,3",
"baseUrl": "http://127.0.0.1:8000",
"route": "/api/v1/chat",
"httpMethod": "POST",
"headMsg": "API_KEY:model-key,API_STATE:true,API_NUM:123",
"enabled": 1,
"maxConcurrency": 5,
"queueLimit": 20,
"timeoutSeconds": 1800,
"expectedSeconds": 600,
"retryTimes": 3,
"retryQueueMaxSeconds": 600,
"autoCleanSeconds": 3600,
"remark": "Model-Service 模型服务"
"modelName": "gpt-4o",
"buildType": 1,
"skillName": "code-review",
"callbackUrl": "http://callback.example.com/result",
"nodeId": "node_001",
"sessionId": "session_abc",
"customPrompt": "请用中文回答",
"messages": {
"model": "gpt-4o",
"max_tokens": 4096,
"messages": [
{"role": "system", "content": [{"type": "text", "text": "你是一名助手"}]},
{"role": "user", "content": [{"type": "text", "text": "写一封邮件"}]}
]
}
}
```
参数说明:
- `modelName`:模型名称(唯一标识/路由键)
- `modelsType`:模型类型ID列表(逗号分隔),示例:`1,2,3`(关联 `asynch_models_type.type_id`
| 请求字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `modelName` | string | 是 | 网关模型名称 |
| `buildType` | int | 是 | 构建类型:1=单轮构建, 2=多轮构建 |
| `skillName` | string | 否 | 技能名称,用于拉取技能 MD 知识 |
| `callbackUrl` | string | 否 | 构建完成后的回调地址 |
| `nodeId` | string | 否 | 节点 ID(用于查询会话历史) |
| `sessionId` | string | 否 | 会话 ID |
| `messages` | object | 是 | 前端构建的消息结构 |
| `customPrompt` | string | 否 | 用户自定义提示词 |
### 模型类型同步
- `POST /model/type/createModelType` 创建成功后,会同步 `POST``prompts-core``/prompt/createPrompt`
- 同步字段映射:
- `typeId` -> `modelTypeId`
- `type` -> `modelType`
- `promptInfo` -> `promptInfo`
- `responseJsonSchema` -> `responseJsonSchema`
- `version` -> `version`
-`prompts-core` 同步失败,`model-gateway` 会回滚本地新建的模型类型,避免两边数据不一致
- `form`:动态表单配置(JSON数组),用于前端按模型渲染参数表单(字段示例:field/label/type/required
- `baseUrl`:模型服务地址(Base URL
- `route`:模型服务路由(拼接到 baseUrl 后)
- `httpMethod`:请求方式(GET/POST
- `headMsg`:请求头绑定(支持多个 header,逗号分隔,格式 `Key:Value`;布尔/数字也会以字符串形式注入 header)
- `enabled`:是否启用(0禁用/1启用)
- `maxConcurrency`:单模型最大并发(按租户+模型维度限流)
- `queueLimit`:排队上限(严格控制)。创建任务时通过 Redis Lua 原子闸门校验并占位,保证分布式并发创建不会超限;任务进入成功/失败态后释放占位,失败重试重新入队时会再次占位。
- `timeoutSeconds`:调用模型服务超时(秒)
- `expectedSeconds`:模型预计执行时间(秒,用于超时判定/排队策略等)
- `retryTimes`:失败后最多再重试 N 次(不含首次)
- `retryQueueMaxSeconds`:失败重试最大排队时间(秒);0 表示重试插队到队首;>0 表示排队超过该时间后插队,否则仍到队尾
- `autoCleanSeconds`:任务被领取到 `state=4` 后的保留时间(秒),到期清理
- `remark`:备注说明
**响应:**
### 第二步:创建任务拿到 task_id
业务方发起推理请求时调用:
- `POST /task/createTask`(传 `modelName + requestPayload + bizName + callbackUrl(可选) + modelKey(可选)`
- 中间件返回 `task_id`
- 业务方将 `task_id` 落到自己的业务表,并把业务状态置为「生成中」
> `modelKey` 用于“动态覆盖/补充”模型配置中的 `head_msg`(例如每次请求携带不同的 `X-API-Key:xxx`)。
>
> `callbackUrl` 用于任务成功后的回调通知:当任务 `state=2` 成功时,中间件会发起一次 GET 请求:
> - 实际回调地址:`callbackUrl/{bizName}`
> - query 参数:`task_id/state/oss_file/file_type/text(可选,最多2000字符)`
### 第三步:同步任务进度(推荐批量)
业务方通过轮询/定时任务同步进度:
- 推荐:`POST /task/getTaskBatch`(批量传 `taskIds`,返回每个任务的 `state + oss_file`
- 或单条:`GET /task/getTaskResult?taskId=...`
业务侧拿到 `oss_file` 后自行做资源处理(直接保存或转存),并把业务状态更新为「成功/失败」。
> 说明:批量接口对 `state=2(成功)` 的任务会自动标记为 `state=4(已下载)` 并写入 `expire_at`,用于后续清理。
### 后台执行(由上层定时任务控制)
本项目不再在服务进程内常驻轮询 worker/cleaner,而是提供两个接口供上层定时任务触发:
- `POST /task/runWork`:执行一次 Worker(抢占并处理一批排队任务;适合处理 createTask 立即执行时未处理到的任务和积压队列)
- `POST /task/cleanWork`:执行一次 Cleaner(清理过期任务、失败重试、超时任务失败等)
创建任务执行策略:
- `POST /task/createTask` 成功入库后,会立即异步尝试执行当前任务。
- 若当前模型并发已满,或当前任务未成功抢占,则会按 `asynch.worker.intervalSeconds` 对当前任务做轻量级定向轮询;只要任务仍为 `state=0` 就继续尝试,一旦进入 `state=1/2/3/4` 就立即停止,不会一直轮询。
- 若任务执行成功且配置了 `callbackUrl + bizName`,会在成功落库后异步触发回调钩子。
本地调试(可选):
可在 `config.yml` 中开启自动执行,避免手工频繁调用接口:
```yml
asynch:
worker:
enabled: true
intervalSeconds: 5
batchSize: 10
goroutines: 1
cleaner:
enabled: true
intervalSeconds: 30
```
### 动态并发/队列调参(接口请求控制)
为支持根据最近一段时间的耗时与吞吐对 `max_concurrency/queue_limit` 做动态调整,本项目提供接口供上层定时任务触发(建议每小时一次):
- `POST /model/autoTune`
请求参数(JSON,可选):
```json
{
"windowSeconds": 3600
"taskId": "550e8400-e29b-41d4-a716-446655440000"
}
```
> `windowSeconds` 不传/<=0 默认 36001小时)。
动态调参口径(默认近 1 小时窗口,按 `model_name` 维度):
- 执行耗时:`finished_at - started_at`(取 P90
- 吞吐:近 1 小时完成数 / 3600
**处理流程:**
调参结果不会覆盖 `asynch_models` 中配置的最大上限(cap),而是写入 Redis 运行时参数(带 TTL,默认 2 小时):
- `asynch:runtime:max_concurrency:{model_name}`
- `asynch:runtime:queue_limit:{model_name}`
1. **查模型配置** — 根据 `modelName` 查询 `model_gateway_models` 获取模型定义
2. **创建构建记录** — 写入 `model_gateway_build_record`,状态为处理中
3. **异步执行构建**goroutine):
- **推理模型(type=100**
- 从配置读取系统提示词(`modelPrompts.types.100`
- 若有 `skillName`,通过 `GetSkillUser` 拉取技能 ZIP,提取 MD 内容
- 合并系统提示词 + 技能知识 + 用户自定义提示词到 `messages`
- 通过 `GetSessionHistory` 注入会话历史
- 按附件数量自动拆分多轮(`SplitByAttachment`,按 video/image/audio 的 `maxCount` 约束)
- 返回单轮或多轮消息结构
- **视频模型(type=600-699**
- 查找当前用户的对话模型(`is_chat_model=1`
- 获取该对话模型的协议模板(`prompts_provider_protocol`
- 构建对话模型请求体,调用对话模型生成视频分镜的 JSON rounds
- 将 rounds 上传 OSS,存入构建记录
4. **回写结果** — 更新构建记录状态(成功/失败 + 耗时)
5. **触发回调** — 若有 `callbackUrl`POST 回调通知任务完成
生效位置:
- CreateTask 入队时,严格 queue_limit 闸门会优先使用运行时 `queue_limit`(若无运行时值则回退 cap)。
- Worker 获取并发令牌时,优先使用运行时 `max_concurrency`(若无运行时值则回退 cap)。
**对应源码:**
- Controller[controller/model_gateway_task_controller.go]`ModelGatewayTask.BuildMessages`
- Service[service/task/task_service.go]`taskService.BuildMessages``executeBuild``buildResult`
- Util(提示词合并):[common/util/prompt.go]`MergePrompt``InjectHistory``SplitByAttachment`
- Util(技能知识):[service/prompt/prompt_files_handle_service.go]`SkillMdContent`
#### 6.2 创建异步任务 `/createTask`
创建异步模型调用任务,立即返回 `taskId`,后端 goroutine 异步执行模型调用、结果上传与计费。
**请求示例(JSON):**
```json
{
"modelName": "dall-e-3",
"bizName": "image-generator",
"callbackUrl": "http://callback.example.com/result",
"epicycleId": 12345,
"buildModelName": "gpt-4o",
"requestPayload": {
"model": "dall-e-3",
"prompt": "一只站在树枝上的猫头鹰,水墨风格",
"n": 1,
"size": "1024x1024"
}
}
```
| 请求字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `modelName` | string | 是 | 模型名称 |
| `bizName` | string | 否 | 业务名称,用于统计区分 |
| `callbackUrl` | string | 否 | 任务完成后的回调地址 |
| `requestPayload` | object | 是 | 透传给模型服务的请求参数 |
| `epicycleId` | int64 | 否 | 轮次 IDprompts-core 场景) |
| `buildModelName` | string | 否 | 构建阶段使用的模型名(prompts-core 场景) |
**响应:**
```json
{
"taskId": "550e8400-e29b-41d4-a716-446655440000"
}
```
**处理流程:**
1. **鉴权与参数校验** — 获取用户信息,校验模型配置是否存在且已启用
2. **队列门控(可选)** — 通过 Redis Lua 脚本做严格的分布式队列插槽校验,避免并发超限
3. **写入任务记录** — 状态为 `1(执行中)`,记录 `requestPayload``callbackUrl``bizName`
4. **操作日志** — 记录创建任务的审计日志到 `model_gateway_logs_op`
5. **计费预处理** — 若模型配置了 `billing_config`,从请求参数中提取计费维度数据
6. **异步执行**goroutine `AsyncWorker.handleOne`):
```
检查租户余额 ──> 调用模型服务 ──> 解析响应映射 ──> 计费计算并扣费
┌── 同步模式(call_mode=0):直接调用,等待 HTTP 响应
├── 异步模式(call_mode=1):提交任务后通过 QueryConfig 轮询结果
└── 流式模式(call_mode=2):通过 StreamConfig 解析 SSE 事件流
┌── prompts-core 场景:对模型输出做 ParseAndValidate + 重试机制
└── 普通场景:直接映射响应
上传 OSS ──> 更新任务为成功 ──> 触发回调
```
7. **重试机制**:超时、内部错误等可重试错误会按 `retry_times` 重试;硬错误(参数错误等)直接失败
8. **回调通知**:任务成功或失败后,若有 `callbackUrl` 则 POST 回调;prompts-core 场景额外触发 `TriggerPromptsCallback`
**调用模式对比:**
| 模式 | 值 | 行为 |
|---|---|---|
| 同步 | 0 | 直接 HTTP 调用模型,等待完整响应后返回 |
| 异步 | 1 | 提交任务后通过 `QueryConfig` 配置的轮询地址定时查询结果 |
| 流式 | 2 | 对 SSE 事件流按 `StreamConfig` 规则解析(支持 concat/base64_concat/collect/final |
**对应源码:**
- Controller[controller/model_gateway_task_controller.go]`ModelGatewayTask.CreateTask`
- Service[service/task/task_service.go]`taskService.Create`
- Worker[service/task/worker.go]`asyncWorker.handleOne``InvokeModel`
- 队列门控:[service/queue/queue_gate.go]`AcquireQueueSlot``ReleaseQueueSlot`
- 运行时调参:[service/queue/runtime_tune.go]`GetRuntimeMaxConcurrency``GetRuntimeQueueLimit`
- 异步轮询:[common/util/pull_task.go]`PullTaskResult`
- 流式解析:[common/util/streaming.go]`ParseStreamResponse`
- 计费:[common/util/billing.go]`CalculateBilling``ExtractRequestBilling`
### 7. 全量 API 接口一览
| 分类 | 接口路径 | 方法 | 说明 |
|---|---|---|---|
| **模型配置** | `/createModel` | POST | 创建模型配置 |
| | `/updateModel` | PUT | 更新模型配置 |
| | `/deleteModel` | DELETE | 删除模型配置 |
| | `/getModel` | GET | 获取模型详情 |
| | `/listModel` | GET | 模型列表(分页+筛选) |
| | `/listType` | GET | 模型类型列表 |
| | `/listOperator` | GET | 运营商列表 |
| | `/updateChatModel` | POST | 设置当前用户的对话模型 |
| | `/getIsChatModel` | GET | 获取当前对话模型 |
| | `/autoTune` | POST | 触发动态调参 |
| **任务管理** | `/createTask` | POST | 创建异步任务 |
| | `/jobTask` | POST | 定时批量任务处理器 |
| | `/getTaskResult` | GET | 获取单条任务结果 |
| | `/getTaskBatch` | POST | 批量查询任务(成功任务自动标记为已下载) |
| | `/listTask` | GET | 任务列表分页查询 |
| | `/modelCallback` | POST | 接收异步模型回调通知 |
| | `/queryPending` | GET | 轮询进行中的异步任务 |
| **提示词** | `/buildMessages` | POST | 构建结构化提示词消息(异步) |
| **统计** | `/listModelStat` | GET | 日使用量统计 |
--
## 技术栈
| 组件 | 技术选型 |
|---|---|
| 语言 | Go 1.26 |
| 框架 | GoFrame v2`github.com/gogf/gf/v2` |
| 数据库 | PostgreSQLGoFrame pgsql 驱动) |
| 缓存/队列 | Redis(分布式信号量、队列门控、运行时调参存储) |
| 服务发现 | Consul |
| 链路追踪 | JaegerOTLP HTTP |
| 模型调用 | 原生 HTTP(可定制请求头、鉴权、超时) |
| 文件存储 | OSS 文件上传服务(multipart 表单) |
## 数据库表
| 表名 | 说明 |
|---|---|
| `model_gateway_models` | 模型服务配置(动态表单、请求/响应映射、计费规则) |
| `model_gateway_task` | 异步任务记录(状态机、重试、OSS 结果文件) |
| `model_gateway_build_record` | 提示词构建记录 |
| `model_gateway_logs_op` | 操作审计日志 |
| `model_gateway_logs_stat` | 日维度请求量统计 |
| `prompts_provider_protocol` | 视频模型编排的提供商协议模板 |
## 配置说明
详见 `config.yml`。主要配置块:
- `database` — PostgreSQL 连接(双数据源:`default` + `model_gateway`
- `redis` — Redis 连接
- `consul` — Consul 地址
- `jaeger` — Jaeger OTLP HTTP 端点
- `queryPending` — 异步任务自动轮询配置(调试开关)
- `jobTask` — 批量处理器间隔、批大小、协程池大小
- `modelPrompts.types` — 各模型类型的系统提示词(100/200/300/400/500
- `nodePrompts` — 节点路由提示词模板
## 快速开始
### 环境要求
- Go 1.26+
- PostgreSQL
- Redis
- Consul(可选,用于服务发现)
### 本地开发
1. 克隆仓库
2. 在 PostgreSQL 中执行 `update.sql` 创建所有表
3. 修改 `config.yml` 中的数据库、Redis 和 Consul 配置
4. 启动服务:
```bash
go run main.go
```
5. 服务默认监听 `3004` 端口(可在 `config.yml` 中修改)
### Docker 构建
```bash
docker build -t model-gateway .
docker run -p 3004:3004 model-gateway
```
Dockerfile 使用多阶段构建,基于 `golang:alpine` 镜像,配置了国内 Go 代理,输出剥离调试信息的精简二进制。
---
## 3. 状态机说明(asynch_task.state
## 项目目录结构
| state | 含义 | 产生方 |
|---:|---|---|
| 0 | 排队中 | 创建任务/重试入队 |
| 1 | 执行中 | Worker 抢占后 |
| 2 | 成功(已上传 OSS | Worker |
| 3 | 失败 | Worker / 超时处理 |
| 4 | 已下载(已领取) | 批量领取接口(2→4) |
字段补充:
- `retry_count`:已重试次数(不含首次)
- `enqueue_at`:入队时间(用于排队顺序,重试会更新为 NOW() 放到队尾)
- `expire_at`:仅对 `state=4` 生效,表示保留到期时间
```
.
├── main.go # 入口:路由注册、自动执行器启动
├── config.yml # 应用配置
├── Dockerfile # 多阶段 Docker 构建
├── go.mod / go.sum # Go 模块依赖
├── update.sql # 数据库 DDL
├── controller/ # HTTP 接口控制器
│ ├── model_gateway_models_controller.go
│ ├── model_gateway_task_controller.go
│ └── model_gateway_logs_stat_controller.go
├── service/ # 业务逻辑层
│ ├── gateway/ # OSS 上传、回调、余额、技能/会话查询
│ ├── model/ # 模型 CRUD、对话模型管理
│ ├── prompt/ # 文件拉取、技能 Markdown、提示词构建
│ ├── queue/ # 动态调参、信号量、队列门控、运行时调参
│ ├── stat/ # 使用量统计
│ └── task/ # 任务创建、Worker 执行、异步结果处理
├── dao/ # 数据访问层
│ ├── model_gateway_models_dao.go
│ ├── model_gateway_task_dao.go
│ ├── model_gateway_build_record_dao.go
│ ├── model_gateway_logs_stat_dao.go
│ ├── model_gateway_logs_op_dao.go
│ └── provider_protocol_dao.go
├── model/ # 数据模型
│ ├── dto/ # 请求/响应结构体
│ └── entity/ # 数据库实体
├── common/ # 通用工具
│ └── util/ # 计费、类型转换、文件处理、请求头、映射、提示词、流式、异步轮询
└── consts/ # 常量定义
└── public/ # 模型类型、任务状态、运营商列表、表名
```
---
## 4. 配置说明(config.yml
## 任务状态机
关键配置:
- `database.default`: PostgreSQL 连接
- `redis.default`: Redis 连接(并发令牌、可扩展用途)
| 状态码 | 含义 | 转换来源 |
|---|---:|---|
| 0 | 排队中 | 创建任务或重试入队 |
| 1 | 执行中 | Worker 抢占成功 |
| 2 | 成功(已上传 OSS | Worker 执行完成 |
| 3 | 失败 | Worker 执行出错或超时 |
| 4 | 已下载 | 批量查询接口标记 |
---
## 5. 数据库初始化
## 接口文档
地址:https://s.apifox.cn/42764602-cb82-45fa-887f-c751311ba406
项目根目录提供 `update.sql`:首次部署执行建表 SQL。
## License
---
内部项目 — 红未来科技
## 6. 开发与发布建议(Git
- `dev`:日常开发与联调
- `main`:线上稳定分支
- 推荐流程:
1) 从 `main` 拉出 `dev`
2) 功能完成后提 MR/PR 合并回 `main`
3) 打 tag / 发布镜像
+310
View File
@@ -0,0 +1,310 @@
package util
import (
"context"
"fmt"
"math"
"model-gateway/service/gateway"
"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)
}
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 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 {
data[key] = extractValue(requestPayload, gconv.String(path))
}
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
}
}
}
// 处理 compute 字段
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
}
+14
View File
@@ -0,0 +1,14 @@
package util
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// GetModelPrompt 获取请求模型的提示词
func GetModelPrompt(ctx context.Context, modelType int) string {
key := "modelPrompts.types." + gconv.String(modelType)
return g.Cfg().MustGet(ctx, key, "").String()
}
+10
View File
@@ -0,0 +1,10 @@
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
}
+96
View File
@@ -0,0 +1,96 @@
package util
import (
"path/filepath"
"regexp"
"strings"
)
var (
// AllowedMIMEPrefixes 允许的文本类 MIME 类型前缀
AllowedMIMEPrefixes = []string{
"text/",
"application/json",
"application/xml",
"application/javascript",
"application/x-yaml",
"application/yaml",
"application/toml",
"application/x-httpd-php",
"application/x-sh",
"application/x-python",
"application/x-perl",
"application/x-ruby",
}
// BannedExtensions 禁止的文件扩展名
BannedExtensions = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".bmp": true,
".webp": true, ".svg": true, ".ico": true, ".tiff": true, ".tif": true,
".mp3": true, ".wav": true, ".ogg": true, ".flac": true, ".aac": true,
".wma": true, ".m4a": true,
".mp4": true, ".avi": true, ".mkv": true, ".mov": true, ".wmv": true,
".flv": true, ".webm": true,
".tar": true, ".gz": true, ".rar": true, ".7z": true,
".exe": true, ".dll": true, ".so": true, ".bin": true, ".dat": true,
".class": true, ".pyc": true,
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true,
".ppt": true, ".pptx": true,
}
symbolCleaner = regexp.MustCompile(`[\x00-\x08\x0B\x0C\x0E-\x1F]`)
multiNewlines = regexp.MustCompile(`\n{3,}`)
)
// SanitizeURL 清洗 URL 字符串
func SanitizeURL(raw string) string {
s := strings.TrimSpace(raw)
s = strings.Trim(s, "`\"")
return s
}
// CleanSymbols 清洗文本中的控制字符和多余空行
func CleanSymbols(text string) string {
text = symbolCleaner.ReplaceAllString(text, "")
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
text = multiNewlines.ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
// IsBannedExtension 判断是否为禁止的文件扩展名
func IsBannedExtension(url string) bool {
ext := extractExtension(url)
return BannedExtensions[ext]
}
// IsZipExtension 判断是否为 zip 文件
func IsZipExtension(url string) bool {
ext := extractExtension(url)
return ext == ".zip"
}
// IsReadableContentType 判断是否为可读的文本类型
func IsReadableContentType(contentType string) bool {
if contentType == "" {
return false
}
ct := strings.ToLower(contentType)
for _, prefix := range AllowedMIMEPrefixes {
if strings.HasPrefix(ct, prefix) {
return true
}
}
return false
}
// extractExtension 提取文件扩展名并清理查询参数
func extractExtension(url string) string {
ext := strings.ToLower(filepath.Ext(url))
if idx := strings.Index(ext, "?"); idx != -1 {
ext = ext[:idx]
}
return ext
}
+56
View File
@@ -0,0 +1,56 @@
package util
import (
"context"
"net/url"
"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
}
// ======================== 请求工具 ========================
// ParseHeadMsgHeaders 从 head_msg 中提取 HTTP 请求头
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
}
// BodyToQuery 将 body 转为 URL 查询参数
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
}
+90
View File
@@ -0,0 +1,90 @@
package util
import (
"encoding/json"
"fmt"
"model-gateway/model/entity"
"strings"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/util/gconv"
tgjson "github.com/tidwall/gjson"
)
// ======================== 响应解析 ========================
// ParseAndValidate 解析模型响应,校验必填字段,返回标准 rounds 格式
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 = cleanControlChars(contentStr)
var arr []any
if err := json.Unmarshal([]byte(contentStr), &arr); err != nil {
return raw, fmt.Errorf("JSON解析失败: %w", err)
}
if len(arr) == 0 {
return raw, fmt.Errorf("解析后数组为空")
}
for _, field := range requiredFields {
for i, r := range arr {
round, _ := r.(map[string]any)
if round != nil && gjson.New(round).Get(field).IsNil() {
return raw, fmt.Errorf("rounds[%d] 缺少必填字段: %s", i, field)
}
}
}
return map[string]any{"total_rounds": len(arr), "rounds": arr}, nil
}
// 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
}
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
}
// ======================== 内部辅助 ========================
func cleanControlChars(s string) string {
return strings.Map(func(r rune) rune {
if r < 32 && r != ' ' {
return -1
}
return r
}, s)
}
+391
View File
@@ -0,0 +1,391 @@
package util
import (
"encoding/json"
"fmt"
"model-gateway/model/entity"
"model-gateway/service/gateway"
"path/filepath"
"strings"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/util/gconv"
)
// ======================== 表单校验与构建 ========================
// ValidateAndParseForm 校验表单并转为嵌套 map
func ValidateAndParseForm(forms []entity.Form) (map[string]any, error) {
result := gjson.New("{}")
for _, form := range forms {
if form.Key == "" {
continue
}
if form.Required && (form.Value == nil || gconv.String(form.Value) == "") {
return nil, fmt.Errorf("字段 %s 为必填", form.Label)
}
if form.Value == nil {
continue
}
val, err := validateAndConvert(form)
if err != nil {
return nil, err
}
_ = result.Set(form.Key, val)
}
return result.Map(), nil
}
// validateAndConvert 验证表单字段并转为标准格式
func validateAndConvert(form entity.Form) (any, error) {
val := form.Value
fc := form.FieldConstraint
switch form.Type {
case "string":
s := gconv.String(val)
if fc.MaxLength > 0 && len(s) > fc.MaxLength {
return nil, fmt.Errorf("字段 %s 超过最大长度 %d", form.Label, fc.MaxLength)
}
if fc.MinLength > 0 && len(s) < fc.MinLength {
return nil, fmt.Errorf("字段 %s 不足最小长度 %d", form.Label, fc.MinLength)
}
return s, nil
case "number":
f := gconv.Float64(val)
if fc.Min != nil && f < gconv.Float64(fc.Min) {
return nil, fmt.Errorf("字段 %s 不能小于 %v", form.Label, fc.Min)
}
if fc.Max != nil && f > gconv.Float64(fc.Max) {
return nil, fmt.Errorf("字段 %s 不能大于 %v", form.Label, fc.Max)
}
switch fc.NumberType {
case "float", "positiveFloat", "negativeFloat":
return f, nil
default:
return int(f), nil
}
case "select", "radio":
v, ok := val.(map[string]any)
if !ok {
return nil, fmt.Errorf("字段 %s 格式错误", form.Label)
}
return v, nil
case "upload":
var urls []string
switch v := val.(type) {
case []any:
for _, u := range v {
urls = append(urls, gconv.String(u))
}
case []string:
urls = v
case string:
if v != "" {
urls = []string{v}
}
}
if fc.MaxCount > 0 && len(urls) > fc.MaxCount {
return nil, fmt.Errorf("字段 %s 上传数量超过上限 %d", form.Label, fc.MaxCount)
}
if fc.Accept != "" && len(urls) > 0 {
allowed := strings.Split(fc.Accept, ",")
for _, fileUrl := range urls {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(fileUrl), "."))
if !containsExt(allowed, ext) {
return nil, fmt.Errorf("字段 %s 不支持的文件格式: %s", form.Label, ext)
}
}
}
return urls, nil
default:
return gconv.String(val), nil
}
}
func containsExt(allowed []string, ext string) bool {
for _, a := range allowed {
if strings.TrimSpace(a) == ext {
return true
}
}
return false
}
// BuildTemplateFromForm 从 Form 构建模板
func BuildTemplateFromForm(forms []entity.Form) map[string]any {
jsonObj := gjson.New("{}")
for _, form := range forms {
if form.Key == "" {
continue
}
if form.DefaultValue != nil {
_ = jsonObj.Set(form.Key, form.DefaultValue)
} else {
switch form.Type {
case "number":
_ = jsonObj.Set(form.Key, 0)
case "boolean":
_ = jsonObj.Set(form.Key, false)
default:
_ = jsonObj.Set(form.Key, "")
}
}
}
return jsonObj.Map()
}
// ======================== 提示词拼接 ========================
// GetFormByRole 根据 role 获取单个 Form
func GetFormByRole(forms []entity.Form, role string) (entity.Form, bool) {
for _, f := range forms {
if f.Role == role {
return f, true
}
}
return entity.Form{}, false
}
// GetRoleContentPath 从 Form 数组中提取指定 role 的 Key 路径
func GetRoleContentPath(forms []entity.Form, role string) string {
for _, form := range forms {
if form.Role == role {
return form.Key
}
}
return ""
}
// MergePrompt 将多个提示词拼接到指定路径的 content 中
func MergePrompt(messages map[string]any, contentPath string, prompts ...string) map[string]any {
var parts []string
for _, p := range prompts {
if p != "" {
parts = append(parts, p)
}
}
if len(parts) == 0 {
return messages
}
appendContent := strings.Join(parts, "\n")
msgJson := gjson.New(messages)
existing := msgJson.Get(contentPath).String()
if existing != "" {
appendContent = existing + "\n" + appendContent
}
_ = msgJson.Set(contentPath, appendContent)
return msgJson.Map()
}
// ExtractUserContent 将 messages 转为自然语言文本
func ExtractUserContent(messages map[string]any) string {
return formatMapToText(messages, "")
}
func formatMapToText(v any, prefix string) string {
var b strings.Builder
switch val := v.(type) {
case map[string]any:
for k, vv := range val {
switch vv.(type) {
case map[string]any, []any:
b.WriteString(formatMapToText(vv, prefix))
default:
b.WriteString(fmt.Sprintf("%s%v", k, vv))
}
}
case []any:
for _, elem := range val {
b.WriteString(formatMapToText(elem, ""))
}
case string:
b.WriteString(fmt.Sprintf("%s", val))
}
return b.String()
}
// InjectHistory 拼接历史提示词
func InjectHistory(messages map[string]any, history []gateway.SessionHistoryItem, mergeOrder []string) map[string]any {
msgs, _ := messages["messages"].([]any)
// 按 role 分组
grouped := make(map[string][]any)
for _, m := range msgs {
if msg, ok := m.(map[string]any); ok {
role := gconv.String(msg["role"])
grouped[role] = append(grouped[role], msg)
}
}
result := make([]any, 0, len(msgs)+len(history))
for _, part := range mergeOrder {
switch part {
case "history":
for _, h := range history {
result = append(result, map[string]any{
"role": h.Role,
"content": h.Content,
})
}
default:
if msgs, ok := grouped[part]; ok {
result = append(result, msgs...)
}
}
}
messages["messages"] = result
return messages
}
// SplitByAttachment 根据附件数量拆分多轮,未超出返回空
func SplitByAttachment(messages map[string]any, forms []entity.Form) []map[string]any {
// 1) 获取约束
maxVideo := getMaxCount(forms, "video")
maxImage := getMaxCount(forms, "image")
maxAudio := getMaxCount(forms, "audio")
// 2) 提取 system 消息和 user content
msgs := gjson.New(messages).Get("messages").Array()
var systemMsg map[string]any
var userContent []any
for _, m := range msgs {
msg := m.(map[string]any)
switch msg["role"] {
case "system":
systemMsg = msg
case "user":
if c, ok := msg["content"].([]any); ok {
userContent = c
}
}
}
if systemMsg == nil || len(userContent) == 0 {
return nil
}
// 3) 分离 text 和各类附件
var texts []any
var videos, images, audios []any
for _, c := range userContent {
item := c.(map[string]any)
switch item["type"] {
case "video_url":
videos = append(videos, item)
case "image_url":
images = append(images, item)
case "input_audio":
audios = append(audios, item)
default:
texts = append(texts, item)
}
}
// 4) 判断是否需要拆分
if len(videos) <= maxVideo && len(images) <= maxImage && len(audios) <= maxAudio {
return nil
}
// 5) 按约束分片
videoChunks := chunk(videos, maxVideo)
imageChunks := chunk(images, maxImage)
audioChunks := chunk(audios, maxAudio)
maxRounds := len(videoChunks)
if len(imageChunks) > maxRounds {
maxRounds = len(imageChunks)
}
if len(audioChunks) > maxRounds {
maxRounds = len(audioChunks)
}
// 6) 构建每轮 messages
var rounds []map[string]any
for i := 0; i < maxRounds; i++ {
var newContent []any
newContent = append(newContent, texts...)
if i < len(videoChunks) {
newContent = append(newContent, videoChunks[i]...)
} else {
newContent = append(newContent, videos...) // 未超出,每轮都带
}
if i < len(imageChunks) {
newContent = append(newContent, imageChunks[i]...)
} else {
newContent = append(newContent, images...)
}
if i < len(audioChunks) {
newContent = append(newContent, audioChunks[i]...)
} else {
newContent = append(newContent, audios...)
}
rounds = append(rounds, map[string]any{
"model": gjson.New(messages).Get("model").Val(),
"max_tokens": gjson.New(messages).Get("max_tokens").Val(),
"messages": []any{
systemMsg,
map[string]any{"role": "user", "content": newContent},
},
})
}
return rounds
}
// getMaxCount 从表单获取指定 role 的最大数量
func getMaxCount(forms []entity.Form, role string) int {
for _, f := range forms {
if f.Role == role {
return f.FieldConstraint.MaxCount
}
}
return 0
}
// chunk 将数组按 size 分片
func chunk(items []any, size int) [][]any {
if size <= 0 {
return nil
}
var chunks [][]any
for i := 0; i < len(items); i += size {
end := i + size
if end > len(items) {
end = len(items)
}
chunks = append(chunks, items[i:end])
}
return chunks
}
// ======================== 请求模板 ========================
// BuildRequestBody 替换模板占位符构建请求体
func BuildRequestBody(template map[string]any, modelName, systemPrompt, userContent string) map[string]any {
escapedSystem := escapeJSON(systemPrompt)
escapedUser := escapeJSON(userContent)
str := gjson.New(template).MustToJsonString()
str = strings.ReplaceAll(str, `"{{model}}"`, `"`+modelName+`"`)
str = strings.ReplaceAll(str, `"{{system}}"`, escapedSystem)
str = strings.ReplaceAll(str, `"{{user}}"`, escapedUser)
return gjson.New(str).Map()
}
func escapeJSON(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
+163
View File
@@ -0,0 +1,163 @@
package util
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// PullTaskResult 拉取任务结果
func PullTaskResult(ctx context.Context, body map[string]any, queryConfig map[string]any, headMsg map[string]any) (map[string]any, error) {
taskID, err := extractTaskID(body, queryConfig)
if err != nil {
return nil, err
}
g.Log().Infof(ctx, "[PullTaskResult] taskID=%s", taskID)
queryUrl := buildQueryURL(queryConfig, taskID)
method := gconv.String(queryConfig["method"])
if method == "" {
method = "GET"
}
interval := gconv.Int(queryConfig["interval_seconds"])
if interval <= 0 {
interval = 2
}
statusPath := gconv.String(queryConfig["status_path"])
if statusPath == "" {
statusPath = "status"
}
statusValues, _ := queryConfig["status_values"].(map[string]any)
// 失败信息路径
errorPath := gconv.String(queryConfig["error_path"])
if errorPath == "" {
errorPath = "error.message"
}
reqBodyMap := map[string]any{"task_id": taskID}
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
result, err := doQueryRequest(ctx, method, queryUrl, reqBodyMap, headMsg)
if err != nil {
g.Log().Warningf(ctx, "[PullTaskResult] 请求失败 taskID=%s err=%v", taskID, err)
time.Sleep(time.Duration(interval) * time.Second)
continue
}
if result == nil {
time.Sleep(time.Duration(interval) * time.Second)
continue
}
statusStr := gconv.String(gjson.New(result).Get(statusPath).Val())
g.Log().Infof(ctx, "[PullTaskResult] 状态 taskID=%s status=%s", taskID, statusStr)
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(errorPath).Val())
if errMsg == "" {
errMsg = "任务失败"
}
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 extractTaskID(body, queryConfig map[string]any) (string, error) {
taskIDPath := gconv.String(queryConfig["task_id"])
taskID := gconv.String(gjson.New(body).Get(taskIDPath).Val())
if taskID == "" {
return "", fmt.Errorf("无法从路径 %s 提取 taskID", taskIDPath)
}
return taskID, nil
}
func buildQueryURL(queryConfig map[string]any, taskID string) string {
queryUrl := gconv.String(queryConfig["url"])
return replaceURLParams(queryUrl, map[string]any{"id": taskID})
}
func doQueryRequest(ctx context.Context, method, queryUrl string, reqBodyMap map[string]any, headMsg map[string]any) (map[string]any, error) {
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)
}
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 {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, nil
}
var result map[string]any
_ = json.Unmarshal(raw, &result)
return result, nil
}
func matchStatus(actual string, expected any) bool {
expectedStr := gconv.String(expected)
if actual == expectedStr {
return true
}
if arr, ok := expected.([]any); ok {
for _, item := range arr {
if actual == gconv.String(item) {
return true
}
}
}
return false
}
func replaceURLParams(rawURL string, params map[string]any) string {
re := regexp.MustCompile(`\{([^}]+)}`)
return re.ReplaceAllStringFunc(rawURL, func(s string) string {
key := strings.Trim(s, "{}")
if val, ok := params[key]; ok {
return gconv.String(val)
}
return s
})
}
+255
View File
@@ -0,0 +1,255 @@
package util
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"model-gateway/service/gateway"
"sort"
"strings"
"github.com/gogf/gf/v2/encoding/gjson"
)
// ParseStreamResponse 流式响应解析
func ParseStreamResponse(ctx context.Context, rawBytes []byte, streamConfig map[string]any) (map[string]any, error) {
enabled, _ := streamConfig["enabled"].(bool)
if !enabled {
return gjson.New(string(rawBytes)).Map(), nil
}
outputType, _ := streamConfig["output_type"].(string)
streamToClient, _ := streamConfig["stream_to_client"].(bool)
events, _ := streamConfig["events"].([]any)
if len(events) == 0 {
return gjson.New(string(rawBytes)).Map(), nil
}
// 如果业务需要流式返回,直接透传原始数据
if streamToClient {
return map[string]any{"stream_data": rawBytes}, nil
}
result := make(map[string]any)
lines := strings.Split(string(rawBytes), "\n")
for _, evt := range events {
e, _ := evt.(map[string]any)
evtType, _ := e["type"].(string)
switch evtType {
case "concat":
processConcat(lines, e, result)
case "base64_concat":
processBase64Concat(lines, e, result)
case "collect":
processCollect(lines, e, result)
case "final":
processFinal(lines, e, result)
}
}
switch outputType {
case "audio":
if audioBytes, ok := result["audio"].([]byte); ok {
oss, err := gateway.UploadByTask(ctx, audioBytes, "mp3")
if err != nil {
return nil, err
}
result["content"] = oss.FileAddressPrefix + oss.FileURL
}
case "text":
if v, ok := result["content"]; ok {
return map[string]any{"content": v, "usage": result["usage"]}, nil
}
case "image":
if v, ok := result["urls"]; ok {
return map[string]any{"content": v, "usage": result["usage"]}, nil
}
}
return result, nil
}
// processConcat 文本拼接
func processConcat(lines []string, event map[string]any, result map[string]any) {
match, _ := event["match"].(string)
aggregateTo, _ := event["aggregate_to"].(string)
fields, _ := event["fields"].(map[string]any)
var parts []string
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)
if match != "" && !strings.Contains(chunkType, match) {
continue
}
for _, chunkPath := range fields {
val := gjson.New(chunk).Get(chunkPath.(string)).String()
if val != "" {
parts = append(parts, val)
}
}
}
result[aggregateTo] = strings.Join(parts, "")
}
// processBase64Concat base64 拼接
func processBase64Concat(lines []string, event map[string]any, result map[string]any) {
aggregateTo, _ := event["aggregate_to"].(string)
fields, _ := event["fields"].(map[string]any)
var builder strings.Builder
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || line == "[DONE]" {
continue
}
var chunk map[string]any
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
continue
}
for _, chunkPath := range fields {
if data := gjson.New(chunk).Get(chunkPath.(string)).String(); data != "" {
builder.WriteString(data)
}
}
}
cleanBase64 := strings.Map(func(r rune) rune {
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
return -1
}
return r
}, builder.String())
audioBytes, err := base64.StdEncoding.DecodeString(cleanBase64)
if err != nil {
audioBytes, _ = base64.RawStdEncoding.DecodeString(cleanBase64)
}
result[aggregateTo] = audioBytes
}
// processCollect 数组收集
func processCollect(lines []string, event map[string]any, result map[string]any) {
match, _ := event["match"].(string)
aggregateTo, _ := event["aggregate_to"].(string)
orderBy, _ := event["order_by"].(string)
fields, _ := event["fields"].(map[string]any)
var items []map[string]any
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || line == "[DONE]" {
continue
}
var chunk map[string]any
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
continue
}
chunkType, _ := chunk["type"].(string)
if match != "" && !strings.Contains(chunkType, match) {
continue
}
item := make(map[string]any)
for localKey, chunkPath := range fields {
item[localKey] = gjson.New(chunk).Get(chunkPath.(string)).Val()
}
items = append(items, item)
}
if orderBy != "" {
sort.Slice(items, func(i, j int) bool {
return fmt.Sprint(items[i][orderBy]) < fmt.Sprint(items[j][orderBy])
})
}
// 如果只有一个字段,直接存值数组
if len(fields) == 1 {
var vals []any
for _, item := range items {
for _, v := range item {
vals = append(vals, v)
}
}
result[aggregateTo] = vals
} else {
result[aggregateTo] = items
}
}
// processFinal 取最后一条匹配的数据
func processFinal(lines []string, event map[string]any, result map[string]any) {
match, _ := event["match"].(string)
aggregateTo, _ := event["aggregate_to"].(string)
fields, _ := event["fields"].(map[string]any)
var lastMatch 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)
if match != "" && !strings.Contains(chunkType, match) {
continue
}
lastMatch = chunk
}
if lastMatch == nil {
return
}
data := make(map[string]any)
for localKey, chunkPath := range fields {
val := gjson.New(lastMatch).Get(chunkPath.(string)).Val()
if val != nil {
data[localKey] = val
}
}
if len(data) > 0 {
result[aggregateTo] = data
}
}
+53 -34
View File
@@ -7,10 +7,10 @@ server:
database:
default:
- type: "pgsql"
host: "192.168.0.83"
host: "116.204.74.41"
port: "15432"
user: "sql9f15b63fd203b36e"
pass: "1ec94b1acdaf57b66030242d418fad5a"
user: "postgres"
pass: "Bjang09@686^*^"
name: "model-gateway"
prefix: "" # (可选)表名前缀
role: "master" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。
@@ -28,12 +28,12 @@ database:
timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效
model_gateway:
- type: "pgsql"
host: "192.168.0.83"
port: "15432"
user: "sql9f15b63fd203b36e"
pass: "1ec94b1acdaf57b66030242d418fad5a"
host: "192.168.3.8"
port: "5432"
user: "postgres"
pass: "123456"
name: "model-gateway"
prefix: "model_gateway_"
prefix: ""
role: "master"
debug: true
dryRun: false
@@ -50,36 +50,55 @@ database:
redis:
default:
address: 192.168.0.83:6379
address: 192.168.3.30:6379
db: 0
consul:
address: 192.168.0.83:8500
address: 192.168.3.30:8500
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"
addr: 192.168.3.30:4318
# 本地调试用:可选自动执行 worker/cleaner(默认关闭)
asynch:
queryPending:
enabled: false
intervalSeconds: 10 # 每10秒轮询一次
limit: 10 # 每次查10条
worker:
enabled: false
intervalSeconds: 5
batchSize: 10
goroutines: 1
cleaner:
enabled: false
intervalSeconds: 30
queryPending:
enabled: false
intervalSeconds: 10 # 每10秒轮询一次
limit: 10 # 每次查10条
jobTask:
intervalSeconds: 10 # 轮询间隔(秒)
batchSize: 10 # 每批处理条数
poolSize: 5 # 协程池大小
modelPrompts:
types:
100: |
你是一个智能文字处理助手,专注于文本理解、文本创作、文本优化与语言表达任务,能够根据不同场景完成文章撰写、商业文案、报告总结、邮件通知、脚本创作、内容改写、信息提炼、语言翻译等多种文字处理工作,并能够理解上下文语义关系,保持内容逻辑完整、结构清晰、表达自然。
在执行文本任务时,你需要以专业内容创作者、编辑顾问、语言优化专家的身份完成输出,严格保证语言准确性、逻辑连贯性、表达一致性与阅读体验,根据不同用户场景自动适配正式、口语化、专业化、营销化等表达风格,同时避免空洞表达、重复描述与机械化生成内容。
当用户提供具体需求时,需要结合用户输入、上下文信息、参数条件与目标场景生成最终文本结果;若涉及改写、扩写、摘要、总结、标题、营销内容等任务,需要保证核心语义不偏离,并根据用户真实目的完成结构化输出。
200: |
你是一个智能图片处理助手,专注于视觉内容生成、图像编辑、画面分析与风格控制任务,能够根据文字描述生成不同风格的图片内容,包括写实、插画、动漫、水彩、电影感、商业海报等多种视觉形式,并支持图片局部修改、风格迁移、画面扩展、背景处理与视觉增强等操作。
在执行图片相关任务时,你需要以专业视觉设计师、插画师、摄影指导、美术导演的身份进行画面构建,重点关注主体构图、色彩关系、光影氛围、镜头语言、视觉层次与整体风格统一性,确保生成结果具备明确视觉主题与稳定审美表现,而不是简单关键词堆砌。
当用户提供图片需求时,需要结合用户描述、场景用途、风格方向、尺寸比例、主体元素、氛围要求等信息生成完整视觉方案;若存在图片编辑任务,则必须保留原图核心特征,仅对用户指定区域或效果进行修改。
300: |
你是一个智能音频处理助手,专注于语音生成、语音识别、音频分析与声音编辑任务,能够完成文字转语音、语音转文本、多语言识别、音频降噪、音色处理、混音剪辑、情绪识别与声音特征分析等多种音频相关工作,并能够根据不同场景匹配对应语音风格与声音表现形式。
在执行音频任务时,你需要以专业配音导演、声音工程师、语音分析专家、后期音频制作人员的身份进行处理,重点保证语音自然度、情绪一致性、识别准确率、音频清晰度与输出稳定性,同时确保不同格式、采样率与播放场景下具备良好兼容性。
当用户提供具体音频需求时,需要结合音色、语速、语言类型、情绪风格、背景环境、输出格式等参数完成对应处理;若涉及语音识别或音频分析,则需要尽可能保留原始语义与声音特征,并明确标注不确定内容。
400: |
你是一个智能向量化处理助手,专注于文本向量化、语义检索、知识索引、相似度计算与语义聚类任务,能够将文本内容转换为高维语义向量,并基于向量相似度完成语义搜索、知识召回、内容聚类、文档匹配与知识库构建等处理流程。
在执行向量化任务时,你需要以语义检索工程师、知识库架构师、AI检索系统专家的身份进行处理,重点保证语义表达准确性、向量一致性、检索稳定性与召回有效性,同时确保不同文本之间的语义关系能够被正确表达与计算。
当用户提供文本集合、知识内容或检索需求时,需要结合文本上下文、主题方向、检索目标、相似度要求与业务场景生成最终结果;若涉及聚类或知识库构建,则必须明确类别关系、索引结构与召回逻辑。
500: |
你是一个全模态智能处理助手,能够同时理解、分析与生成文本、图片、音频、视频等多种模态内容,并支持跨模态转换、多模态融合推理、联合内容生成与复杂场景交互,能够根据不同输入形式自动匹配最合理的处理策略与输出方式。
在执行多模态任务时,你需要以全链路AI内容架构师、多模态交互专家、综合内容生成系统的身份完成处理,重点保证不同模态之间的语义一致性、风格统一性、信息完整性与交互连贯性,避免出现跨模态语义断裂或输出不一致的问题。
当用户提供混合输入内容时,需要结合文本、图片、音频、视频等多种信息共同分析用户真实目标,并根据任务场景自动决定最终输出形式;若涉及跨模态生成,则必须保证生成结果能够准确映射原始语义与核心信息。
nodePrompts: |
你是流程路由助手,你的任务是根据上下文,选择一个正确的节点ID返回。
规则:
1. 只允许从下面的可选节点ID列表中选择一个返回
2. 不要返回任何多余文字、标点、解释、标题
3. 只返回纯节点ID
可选节点ID(ID: 节点描述):
%s
上下文内容:
%s
-113
View File
@@ -1,113 +0,0 @@
package model
import (
"model-gateway/consts/public"
"github.com/gogf/gf/v2/util/gconv"
)
// 供应商编码常量
const (
SupplierAliyun = 1 // 阿里云百炼
SupplierVolcengine = 2 // 火山引擎
SupplierTencent = 3 // 腾讯云
SupplierHuawei = 4 // 华为云
SupplierBaidu = 5 // 百度智能云
SupplierOpenAI = 6 // OpenAI
SupplierAzure = 7 // 微软 Azure
SupplierAWS = 8 // 亚马逊 AWS
SupplierGoogle = 9 // Google
SupplierDeepSeek = 10 // DeepSeek
SupplierMoonshot = 11 // Moonshot(月之暗面)
SupplierZhipu = 12 // 智谱AI
SupplierBaichuan = 13 // 百川智能
SupplierMinimax = 14 // MiniMax
SupplierXunfei = 15 // 科大讯飞
SupplierOthers = 16 // 其他
)
// SupplierType 供应商编码类型
type SupplierType *int8
// SupplierItem 供应商项
type SupplierItem struct {
Code SupplierType `json:"code"`
Desc string `json:"desc"`
}
// 名称映射【唯一文案维护】
var supplierNameMap = map[int]string{
SupplierAliyun: "阿里云百炼",
SupplierVolcengine: "火山引擎",
SupplierTencent: "腾讯云",
SupplierHuawei: "华为云",
SupplierBaidu: "百度智能云",
SupplierOpenAI: "OpenAI",
SupplierAzure: "微软 Azure",
SupplierAWS: "亚马逊 AWS",
SupplierGoogle: "Google",
SupplierDeepSeek: "DeepSeek",
SupplierMoonshot: "Moonshot(月之暗面)",
SupplierZhipu: "智谱AI",
SupplierBaichuan: "百川智能",
SupplierMinimax: "MiniMax",
SupplierXunfei: "科大讯飞",
SupplierOthers: "其他",
}
// 供应商展示顺序
var supplierOrder = []int{
// 国内云厂商
SupplierAliyun, SupplierVolcengine, SupplierTencent, SupplierHuawei, SupplierBaidu,
// 海外头部
SupplierOpenAI, SupplierGoogle, SupplierAWS, SupplierAzure,
// 国内AI厂商
SupplierDeepSeek, SupplierMoonshot, SupplierZhipu, SupplierBaichuan, SupplierMinimax, SupplierXunfei,
// 兜底
SupplierOthers,
}
// 全局供应商实例
var (
SupplierItemAliyun = newSupplierItem(gconv.PtrInt8(SupplierAliyun))
SupplierItemVolcengine = newSupplierItem(gconv.PtrInt8(SupplierVolcengine))
SupplierItemTencent = newSupplierItem(gconv.PtrInt8(SupplierTencent))
SupplierItemHuawei = newSupplierItem(gconv.PtrInt8(SupplierHuawei))
SupplierItemBaidu = newSupplierItem(gconv.PtrInt8(SupplierBaidu))
SupplierItemOpenAI = newSupplierItem(gconv.PtrInt8(SupplierOpenAI))
SupplierItemAzure = newSupplierItem(gconv.PtrInt8(SupplierAzure))
SupplierItemAWS = newSupplierItem(gconv.PtrInt8(SupplierAWS))
SupplierItemGoogle = newSupplierItem(gconv.PtrInt8(SupplierGoogle))
SupplierItemDeepSeek = newSupplierItem(gconv.PtrInt8(SupplierDeepSeek))
SupplierItemMoonshot = newSupplierItem(gconv.PtrInt8(SupplierMoonshot))
SupplierItemZhipu = newSupplierItem(gconv.PtrInt8(SupplierZhipu))
SupplierItemBaichuan = newSupplierItem(gconv.PtrInt8(SupplierBaichuan))
SupplierItemMinimax = newSupplierItem(gconv.PtrInt8(SupplierMinimax))
SupplierItemXunfei = newSupplierItem(gconv.PtrInt8(SupplierXunfei))
SupplierItemOthers = newSupplierItem(gconv.PtrInt8(SupplierOthers))
)
func newSupplierItem(code SupplierType) SupplierItem {
val := int(*code)
return SupplierItem{
Code: code,
Desc: supplierNameMap[val],
}
}
// GetSupplierDescByCode 根据编码获取供应商名称
func GetSupplierDescByCode(code int) string {
return supplierNameMap[code]
}
// GetSupplierOptionList 获取供应商下拉列表
func GetSupplierOptionList() []*public.Option {
var list []*public.Option
for _, code := range supplierOrder {
list = append(list, &public.Option{
Value: code,
Label: supplierNameMap[code],
})
}
return list
}
-224
View File
@@ -1,224 +0,0 @@
package model
import (
"model-gateway/consts/public"
"github.com/gogf/gf/v2/util/gconv"
)
// 模型类型编码常量
const (
TypeInference = 100 // 推理模型
TypeImage = 200 // 图片模型
TypeAudio = 300 // 音频模型
TypeVector = 400 // 向量模型
TypeOmni = 500 // 多模态模型
TypeVideo = 600 // 视频模型
TypeCode = 700 // 代码模型
//// 推理子类型
//InferenceSubChat = 101 // 对话/补全
//InferenceSubReason = 102 // 思维链/深度推理
//InferenceSubFunction = 103 // 函数调用
//
//// 图片子类型
//ImageSubTextToImage = 201 // 文生图
//ImageSubImageToImage = 202 // 图生图
//ImageSubImageEdit = 203 // 图片编辑
//ImageSubImageVariation = 204 // 图片变体
//ImageSubImageTextToImage = 205 // 图文生图
//
//// 音频子类型
//AudioSubTextToSpeech = 301 // 文生音
//AudioSubSpeechToText = 302 // 音生文
//AudioSubSpeechToSpeech = 303 // 音生音
//AudioSubVoiceClone = 304 // 声音克隆
//
//// 向量子类型
//VectorSubEmbedding = 401 // 文本嵌入
//VectorSubRerank = 402 // 重排序
//
//// 多模态子类型
//OmniSubTextImageAudio = 501 // 文图音理解
//OmniSubVision = 502 // 视觉理解
//OmniSubVideoUnderstand = 503 // 视频理解
//
//// 视频子类型
//VideoSubTextToVideo = 601 // 文生视频
//VideoSubImageToVideo = 602 // 图生视频
//VideoSubImageTextToVideo = 603 // 图文生视频
//VideoSubVideoToVideo = 604 // 视频生视频
//
//// 代码子类型
//CodeSubGeneration = 701 // 代码生成
//CodeSubCompletion = 702 // 代码补全
//CodeSubReview = 703 // 代码审查
)
// ModelType 编码类型
type ModelType *int
// ModelTypeItem 模型类型项
type ModelTypeItem struct {
Code ModelType `json:"code"`
Desc string `json:"desc"`
}
// TypeTree 树形结构
type TypeTree struct {
Value int `json:"value"`
Label string `json:"label"`
Children []*public.Option `json:"children"`
}
// 名称映射表【唯一文案维护入口】
var typeNameMap = map[int]string{
TypeInference: "推理模型",
TypeImage: "图片模型",
TypeAudio: "音频模型",
TypeVector: "向量模型",
TypeOmni: "多模态模型",
TypeVideo: "视频模型",
TypeCode: "代码模型",
//InferenceSubChat: "对话/补全",
//InferenceSubReason: "思维链/深度推理",
//InferenceSubFunction: "函数调用",
//
//ImageSubTextToImage: "文生图",
//ImageSubImageToImage: "图生图",
//ImageSubImageEdit: "图片编辑",
//ImageSubImageVariation: "图片变体",
//ImageSubImageTextToImage: "图文生图",
//
//AudioSubTextToSpeech: "文生音",
//AudioSubSpeechToText: "音生文",
//AudioSubSpeechToSpeech: "音生音",
//AudioSubVoiceClone: "声音克隆",
//
//VectorSubEmbedding: "文本嵌入",
//VectorSubRerank: "重排序",
//
//OmniSubTextImageAudio: "文图音理解",
//OmniSubVision: "视觉理解",
//OmniSubVideoUnderstand: "视频理解",
//
//VideoSubTextToVideo: "文生视频",
//VideoSubImageToVideo: "图生视频",
//VideoSubImageTextToVideo: "图文生视频",
//VideoSubVideoToVideo: "视频生视频",
//
//CodeSubGeneration: "代码生成",
//CodeSubCompletion: "代码补全",
//CodeSubReview: "代码审查",
}
// 父子级映射(仅存有子项的分类)
var parentChildMap = map[int][]int{
//TypeInference: {InferenceSubChat, InferenceSubReason, InferenceSubFunction},
//TypeImage: {ImageSubTextToImage, ImageSubImageToImage, ImageSubImageEdit, ImageSubImageVariation, ImageSubImageTextToImage},
//TypeAudio: {AudioSubTextToSpeech, AudioSubSpeechToText, AudioSubSpeechToSpeech, AudioSubVoiceClone},
//TypeVector: {VectorSubEmbedding, VectorSubRerank},
//TypeOmni: {OmniSubTextImageAudio, OmniSubVision, OmniSubVideoUnderstand},
//TypeVideo: {VideoSubTextToVideo, VideoSubImageToVideo, VideoSubImageTextToVideo, VideoSubVideoToVideo},
//TypeCode: {CodeSubGeneration, CodeSubCompletion, CodeSubReview},
}
// 一级分类展示顺序
var parentTypeOrder = []int{
TypeInference, TypeImage, TypeAudio, TypeVector, TypeOmni, TypeVideo, TypeCode,
}
// 全局实例:一级 + 全部二级子类型,统一通过 newItem 构造,文案仅维护在 typeNameMap
var (
// 一级类型
ModelTypeInference = newItem(gconv.PtrInt(TypeInference))
ModelTypeImage = newItem(gconv.PtrInt(TypeImage))
ModelTypeAudio = newItem(gconv.PtrInt(TypeAudio))
ModelTypeVector = newItem(gconv.PtrInt(TypeVector))
ModelTypeOmni = newItem(gconv.PtrInt(TypeOmni))
ModelTypeVideo = newItem(gconv.PtrInt(TypeVideo))
ModelTypeCode = newItem(gconv.PtrInt(TypeCode))
//// 推理二级子类型
//ModelInferenceSubChat = newItem(gconv.PtrInt(InferenceSubChat))
//ModelInferenceSubReason = newItem(gconv.PtrInt(InferenceSubReason))
//ModelInferenceSubFunction = newItem(gconv.PtrInt(InferenceSubFunction))
//
//// 图片二级子类型
//ModelImageSubTextToImage = newItem(gconv.PtrInt(ImageSubTextToImage))
//ModelImageSubImageToImage = newItem(gconv.PtrInt(ImageSubImageToImage))
//ModelImageSubImageEdit = newItem(gconv.PtrInt(ImageSubImageEdit))
//ModelImageSubImageVariation = newItem(gconv.PtrInt(ImageSubImageVariation))
//ModelImageSubImageTextToImage = newItem(gconv.PtrInt(ImageSubImageTextToImage))
//
//// 音频二级子类型
//ModelAudioSubTextToSpeech = newItem(gconv.PtrInt(AudioSubTextToSpeech))
//ModelAudioSubSpeechToText = newItem(gconv.PtrInt(AudioSubSpeechToText))
//ModelAudioSubSpeechToSpeech = newItem(gconv.PtrInt(AudioSubSpeechToSpeech))
//ModelAudioSubVoiceClone = newItem(gconv.PtrInt(AudioSubVoiceClone))
//
//// 向量二级子类型
//ModelVectorSubEmbedding = newItem(gconv.PtrInt(VectorSubEmbedding))
//ModelVectorSubRerank = newItem(gconv.PtrInt(VectorSubRerank))
//
//// 多模态二级子类型
//ModelOmniSubTextImageAudio = newItem(gconv.PtrInt(OmniSubTextImageAudio))
//ModelOmniSubVision = newItem(gconv.PtrInt(OmniSubVision))
//ModelOmniSubVideoUnderstand = newItem(gconv.PtrInt(OmniSubVideoUnderstand))
//
//// 视频二级子类型
//ModelVideoSubTextToVideo = newItem(gconv.PtrInt(VideoSubTextToVideo))
//ModelVideoSubImageToVideo = newItem(gconv.PtrInt(VideoSubImageToVideo))
//ModelVideoSubImageTextToVideo = newItem(gconv.PtrInt(VideoSubImageTextToVideo))
//ModelVideoSubVideoToVideo = newItem(gconv.PtrInt(VideoSubVideoToVideo))
//
//// 代码二级子类型
//ModelCodeSubGeneration = newItem(gconv.PtrInt(CodeSubGeneration))
//ModelCodeSubCompletion = newItem(gconv.PtrInt(CodeSubCompletion))
//ModelCodeSubReview = newItem(gconv.PtrInt(CodeSubReview))
)
// newItem 构造方法:自动从 typeNameMap 读取描述
func newItem(code ModelType) ModelTypeItem {
return ModelTypeItem{
Code: code,
Desc: typeNameMap[*code],
}
}
// GetDescByCode 根据编码获取名称
func GetDescByCode(code int) string {
return typeNameMap[code]
}
// GetTypeTreeList 生成树形数据
func GetTypeTreeList() []*TypeTree {
var list []*TypeTree
for _, parentCode := range parentTypeOrder {
tree := &TypeTree{
Value: parentCode,
Label: typeNameMap[parentCode],
Children: make([]*public.Option, 0),
}
if childCodes, ok := parentChildMap[parentCode]; ok {
for _, c := range childCodes {
tree.Children = append(tree.Children, &public.Option{
Value: c,
Label: typeNameMap[c],
})
}
}
list = append(list, tree)
}
return list
}
// GetAllTypeOption 全量平铺选项
func GetAllTypeOption() []*public.Option {
var list []*public.Option
for code, label := range typeNameMap {
list = append(list, &public.Option{Value: code, Label: label})
}
return list
}
-27
View File
@@ -1,27 +0,0 @@
package model
import "github.com/gogf/gf/v2/util/gconv"
var (
ResponseTypeSync = newResponseType(gconv.PtrInt8(1), "sync") // 同步
ResponseTypeAsync = newResponseType(gconv.PtrInt8(2), "async") // 异步
ResponseTypeStream = newResponseType(gconv.PtrInt8(3), "stream") // 流
)
type ResponseType *int8
type responseType struct {
code ResponseType
desc string
}
func (s responseType) Code() ResponseType {
return s.code
}
func (s responseType) Desc() string {
return s.desc
}
func newResponseType(code ResponseType, desc string) responseType {
return responseType{code: code, desc: desc}
}
+2 -16
View File
@@ -1,18 +1,5 @@
package public
const GmqMsgPluginsName = "gmq_model_msg"
const KnowledgeLockEsKey = "knowledge:lock:knowledgeIdEs-%v"
const KnowledgeLockSqlKey = "knowledge:lock:knowledgeIdSql-%v"
const KnowledgeContentHashEsKey = "knowledge:knowledgeId:contentHashEs-%v"
const KnowledgeContentHashSqlKey = "knowledge:knowledgeId:contentHashSql-%v"
// Option 通用下拉选项
type Option struct {
Value int `json:"value"`
Label string `json:"label"`
}
const (
CallModeSync = 0 // 同步调用
CallModeAsync = 1 // 异步调用
@@ -28,9 +15,8 @@ const (
)
const (
BuildTypePrompt = 1 //提示词构建
BuildTypeNode = 2 //节点构建
BuildTypeStruct = 3 //结构构建
BuildTypeSingle = 1 // 单轮构建
BuildTypeMulti = 2 // 多轮构建
)
// ModelType 模型类型常量
+6 -5
View File
@@ -5,9 +5,10 @@ const (
)
const (
TableNameModelManage = "model_manage"
TableNameModelSession = "model_session"
TableNameModelTaskStart = "model_task_start"
TableNameModelTaskEnd = "model_task_end"
TableNameModelErrorMemory = "model_error_memory"
TableNameModel = "model_gateway_models" // 模型表
TableNameTask = "model_gateway_task" // 任务表
TableNameBuildRecord = "model_gateway_build_record" // 构建记录表
TableNameOpLog = "model_gateway_logs_op" // 操作日志表
TableNameStat = "model_gateway_logs_stat" // 按天统计表
TableNameProtocol = "prompts_provider_protocol" // 模型协议表
)
-40
View File
@@ -1,40 +0,0 @@
package controller
import (
"context"
"encoding/json"
"model-gateway/model/dto"
"model-gateway/service"
"net/http"
"gitea.redpowerfuture.com/red-future/common/beans"
"github.com/gogf/gf/v2/frame/g"
)
// ModelCall 模型调用控制器
var ModelCall = new(modelCall)
type modelCall struct{}
// ModelCall 模型调用
func (c *modelCall) ModelCall(ctx context.Context, req *dto.ModelCallReq) (res *dto.ModelCallRes, err error) {
return service.ModelCall.ModelCall(ctx, req)
}
// CreateSessionStream 创建模型会话(流式)
func (c *modelCall) CreateSessionStream(ctx context.Context, req *dto.ModelCallStreamReq) (res *beans.ResponseEmpty, err error) {
r := g.RequestFromCtx(ctx)
w := r.Response.RawWriter()
err = service.ModelCall.ModelCallStream(ctx, w, req)
if err != nil {
h := w.Header()
h.Set("Content-Type", "application/json; charset=utf-8")
errResp, _ := json.Marshal(map[string]interface{}{
"code": http.StatusInternalServerError,
"message": err.Error(),
})
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(errResp)
}
return
}
@@ -1,25 +0,0 @@
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
}
@@ -0,0 +1,18 @@
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)
}
@@ -0,0 +1,66 @@
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)
}
// GetModel 获取配置详情
func (c *model) GetModel(ctx context.Context, req *dto.GetModelReq) (res *dto.GetModelRes, err error) {
return modelService.ModelGatewayModels.Get(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
}
// 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)
}
@@ -0,0 +1,53 @@
package controller
import (
"context"
taskService "model-gateway/service/task"
"model-gateway/model/dto"
)
// ModelGatewayTask 任务控制器
var ModelGatewayTask = new(task)
type task struct{}
// BuildMessages 构建请求模型的数据结构
func (c *task) BuildMessages(ctx context.Context, req *dto.BuildMessagesReq) (res *dto.BuildMessagesRes, err error) {
return taskService.ModelGatewayTask.BuildMessages(ctx, req)
}
// CreateTask 根据 modelName 创建异步任务,返回 taskId
func (c *task) CreateTask(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) {
return taskService.ModelGatewayTask.Create(ctx, req)
}
// JobTask 定时任务:循环执行待处理任务
func (c *task) JobTask(ctx context.Context, req *dto.JobTaskReq) (res *dto.JobTaskRes, err error) {
return taskService.ModelGatewayTask.JobTask(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)
}
-65
View File
@@ -1,65 +0,0 @@
package controller
import (
"context"
"model-gateway/model/dto"
"model-gateway/service"
"gitea.redpowerfuture.com/red-future/common/beans"
)
// ModelManage 模型配置控制器
var ModelManage = new(modelManage)
type modelManage struct{}
// CreateModel 添加配置
func (c *modelManage) CreateModel(ctx context.Context, req *dto.CreateModelManageReq) (res *dto.CreateModelManageRes, err error) {
return service.ModelManage.Create(ctx, req)
}
// UpdateModel 更改配置
func (c *modelManage) UpdateModel(ctx context.Context, req *dto.UpdateModelManageReq) (res *dto.GetModelManageRes, err error) {
return service.ModelManage.Update(ctx, req)
}
// DeleteModel 删除配置
func (c *modelManage) DeleteModel(ctx context.Context, req *dto.DeleteModelManageReq) (res *beans.ResponseEmpty, err error) {
err = service.ModelManage.Delete(ctx, req)
return
}
// GetModel 获取配置
func (c *modelManage) GetModel(ctx context.Context, req *dto.GetModelManageReq) (res *dto.GetModelManageRes, err error) {
return service.ModelManage.Get(ctx, req)
}
// GetChatModel 获取聊天模型
func (c *modelManage) GetChatModel(ctx context.Context, req *dto.GetChatModelReq) (res *dto.GetChatModelRes, err error) {
return service.ModelManage.GetChatModel(ctx, req)
}
// ListModel 配置列表
func (c *modelManage) ListModel(ctx context.Context, req *dto.ListModelManageReq) (res *dto.ListModelManageRes, err error) {
return service.ModelManage.List(ctx, req)
}
// CheckChatModel 检查是否为聊天模型
func (c *modelManage) CheckChatModel(ctx context.Context, req *dto.CheckChatModelReq) (res *dto.CheckChatModelRes, err error) {
return service.ModelManage.CheckChatModel(ctx, req)
}
// ListType 模型类型列表
func (c *modelManage) ListType(ctx context.Context, req *dto.ModelTypeReq) (res *dto.ModelTypeRes, err error) {
return service.ModelManage.GetModelType(ctx, req)
}
// ListOperator 运营商列表
func (c *modelManage) ListOperator(ctx context.Context, req *dto.ModelSupplierReq) (res *dto.ModelSupplierRes, err error) {
return service.ModelManage.GetModelSupplier(ctx, req)
}
// BuildSchemaMapping 自动构建 Schema 映射
func (c *modelManage) BuildSchemaMapping(ctx context.Context, req *dto.BuildSchemaMappingReq) (res *dto.BuildSchemaMappingRes, err error) {
return service.SchemaMapping.BuildSchemaMapping(ctx, req)
}
-70
View File
@@ -1,70 +0,0 @@
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
}
+88
View File
@@ -0,0 +1,88 @@
package dao
import (
"context"
"model-gateway/consts/public"
"model-gateway/model/entity"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/util/gconv"
)
var ModelGatewayBuildRecord = &buildRecordDao{}
type buildRecordDao struct{}
// Insert 插入构建记录
func (d *buildRecordDao) Insert(ctx context.Context, req *entity.ModelGatewayBuildRecord) (id int64, err error) {
m := new(entity.ModelGatewayBuildRecord)
err = gconv.Struct(req, &m)
if err != nil {
return
}
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameBuildRecord).Insert(m)
if err != nil {
return
}
return r.LastInsertId()
}
// Update 更新构建记录
func (d *buildRecordDao) Update(ctx context.Context, req *entity.ModelGatewayBuildRecord) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameBuildRecord).
OmitEmpty().
Data(req).
Where(entity.ModelGatewayBuildRecordCol.Id, req.Id).
Where(entity.ModelGatewayBuildRecordCol.TaskID, req.TaskID).
Update()
if err != nil {
return
}
return r.RowsAffected()
}
// Get 获取构建记录
func (d *buildRecordDao) Get(ctx context.Context, req *entity.ModelGatewayBuildRecord) (m *entity.ModelGatewayBuildRecord, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameBuildRecord).
OmitEmpty().
Where(entity.ModelGatewayBuildRecordCol.TaskID, req.TaskID).
Where(entity.ModelGatewayBuildRecordCol.Id, req.Id).
One()
if err != nil {
return
}
err = r.Struct(&m)
return
}
// List 分页查询
func (d *buildRecordDao) List(ctx context.Context, pageNum, pageSize int, req *entity.ModelGatewayBuildRecord) (list []*entity.ModelGatewayBuildRecord, total int64, err error) {
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameBuildRecord).
OmitEmpty().
Where(entity.ModelGatewayBuildRecordCol.Creator, req.Creator).
Where(entity.ModelGatewayBuildRecordCol.ModelName, req.ModelName).
Where(entity.ModelGatewayBuildRecordCol.Status, req.Status).
Where(entity.ModelGatewayBuildRecordCol.TaskID, req.TaskID).
OrderDesc(entity.ModelGatewayBuildRecordCol.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 删除构建记录
func (d *buildRecordDao) Delete(ctx context.Context, req *entity.ModelGatewayBuildRecord) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameBuildRecord).
Where(entity.ModelGatewayBuildRecordCol.Id, req.Id).
Delete()
if err != nil {
return
}
return r.RowsAffected()
}
+23
View File
@@ -0,0 +1,23 @@
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()
}
+52
View File
@@ -0,0 +1,52 @@
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
}
+196
View File
@@ -0,0 +1,196 @@
package dao
import (
"context"
"model-gateway/consts/public"
"model-gateway/model/dto"
"model-gateway/model/entity"
"model-gateway/service/gateway"
"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) {
// 判断是否管理员
isAdmin, _ := gateway.IsSuperAdmin(ctx)
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 + "%",
}
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 !isAdmin {
sql += ` AND ((creator = ?) OR (is_private = 1 AND enabled = 1)) `
args = append(args, req.Creator)
}
if req.Enabled != nil {
sql += ` AND enabled = ? `
args = append(args, *req.Enabled)
}
sql += ` ORDER BY model_name, 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
}
+175
View File
@@ -0,0 +1,175 @@
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
}
// ListPending 查询待处理任务
func (d *modelGatewayTaskDao) ListPending(ctx context.Context, limit int) (list []*entity.ModelGatewayTask, err error) {
if limit <= 0 {
limit = 10
}
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameTask).
Where(entity.ModelGatewayTaskCol.State, 0).
OrderAsc(entity.ModelGatewayTaskCol.CreatedAt).
Limit(limit)
r, err := model.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
}
-229
View File
@@ -1,229 +0,0 @@
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/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var ModelManage = &modelManageDao{}
type modelManageDao struct{}
// Insert 插入
func (d *modelManageDao) Insert(ctx context.Context, req *dto.CreateModelManageReq) (id int64, err error) {
var e = new(entity.ModelManage)
err = gconv.Struct(req, &e)
if err != nil {
return
}
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Insert(e)
if err != nil {
return
}
return r.LastInsertId()
}
// Update 更新
func (d *modelManageDao) Update(ctx context.Context, req *dto.UpdateModelManageReq) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).OmitEmpty().Data(req).Where(entity.ModelManageCol.Id, req.Id).Update()
if err != nil {
return
}
return r.RowsAffected()
}
// Delete 删除
func (d *modelManageDao) Delete(ctx context.Context, req *dto.DeleteModelManageReq) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Where(entity.ModelManageCol.Id, req.Id).Delete()
if err != nil {
return
}
return r.RowsAffected()
}
func (d *modelManageDao) Get(ctx context.Context, req *dto.GetModelManage, fields ...string) (res *entity.ModelManage, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Cache(ctx).
OmitEmpty().
Where(entity.ModelManageCol.ModelName, req.ModelName).
Where(entity.ModelManageCol.ChatModel, req.ChatModel).
Where(entity.ModelManageCol.Creator, req.Creator).
Fields(fields).One()
if err != nil {
return
}
err = r.Struct(&res)
return
}
// GetByCreatorAndName 按创建人+模型名精确查询(无缓存),用于同一用户下的同名唯一性校验。
// 走 Model 链(自动过滤软删除),与 Get 一致但不带 Cache,避免缓存过期导致重复放行。
func (d *modelManageDao) GetByCreatorAndName(ctx context.Context, creator, modelName string) (res *entity.ModelManage, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).
OmitEmpty().
Where(entity.ModelManageCol.ModelName, modelName).
Where(entity.ModelManageCol.Creator, creator).
One()
if err != nil {
return
}
err = r.Struct(&res)
return
}
// 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()
table := prefix + public.TableNameModelManage
// 动态拼接 SELECT 列
var field string
if !g.IsEmpty(fields) {
for k, v := range fields {
if k == len(fields)-1 {
field = field + v
} else {
field = field + v + ","
}
}
} else {
field = "*"
}
// 动态拼接 WHERE 条件
var whereCondition string
var queryParams []interface{}
if !g.IsEmpty(req.Id) {
whereCondition = fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.Id)
queryParams = append(queryParams, req.Id)
}
whereCondition = whereCondition + " AND " + entity.ModelManageCol.DeletedAt + " IS NULL "
sql := `SELECT ` + field + ` FROM ` + table + ` WHERE 1=1 ` + whereCondition + ``
// 执行查询
result, err := gfdb.DB(ctx, public.DbNameModelGateway).GetOne(ctx, sql, queryParams...)
if err != nil {
return nil, err
}
err = result.Struct(&res)
return
}
func (d *modelManageDao) ListNotTenantId(ctx context.Context, req *dto.ListModelManageReq, fields ...string) (res []*entity.ModelManage, total int, err error) {
// 获取表前缀
prefix := g.Cfg().MustGet(ctx, fmt.Sprintf("database.%s.0.prefix", public.DbNameModelGateway)).String()
table := prefix + public.TableNameModelManage
// 动态拼接 SELECT 列
var field string
if !g.IsEmpty(fields) {
for k, v := range fields {
if k == len(fields)-1 {
field = field + v
} else {
field = field + v + ","
}
}
} else {
field = "*"
}
// 动态拼接 WHERE 条件
var whereCondition string
var queryParams []interface{}
if !g.IsEmpty(req.ModelName) {
whereCondition += fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.ModelName)
queryParams = append(queryParams, req.ModelName)
}
if !g.IsEmpty(req.ModelType) {
whereCondition += fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.ModelType)
queryParams = append(queryParams, req.ModelType)
}
if !g.IsEmpty(req.Creator) {
whereCondition += fmt.Sprintf(" AND (%s=(?) OR %s=true) ", entity.ModelManageCol.Creator, entity.ModelManageCol.SystemModel)
queryParams = append(queryParams, req.Creator)
}
whereCondition = whereCondition + " AND " + entity.ModelManageCol.DeletedAt + " IS NULL "
// 1. 统计去重后总条数
countSql := fmt.Sprintf(
`SELECT COUNT(DISTINCT %s) FROM %s WHERE 1=1 %s`,
entity.ModelManageCol.ModelName,
table,
whereCondition,
)
countResult, err := gfdb.DB(ctx, public.DbNameModelGateway).GetOne(ctx, countSql, queryParams...)
if err != nil {
return nil, 0, err
}
type crr struct {
Count int64 `db:"count"`
}
var cr crr
if err = countResult.Struct(&cr); err != nil {
return nil, 0, err
}
total = int(cr.Count)
// 2. 分页处理
limitSql := ""
if req.Page != nil {
pageNum := int(req.Page.PageNum)
pageSize := int(req.Page.PageSize)
offset := (pageNum - 1) * pageSize
limitSql = fmt.Sprintf(" LIMIT ? OFFSET ? ")
// PG 语法 LIMIT 条数 OFFSET 偏移量
queryParams = append(queryParams, pageSize, offset)
}
// 排序优先级:1.分组字段ModelName 2.SystemModel升序(false在前,保留用户数据) 3.创建时间倒序
orderSql := fmt.Sprintf(
" ORDER BY %s, %s ASC, %s DESC ",
entity.ModelManageCol.ModelName,
entity.ModelManageCol.SystemModel,
entity.ModelManageCol.CreatedAt,
)
// PG DISTINCT ON 按模型名去重,同名只取第一条(用户数据)
sql := fmt.Sprintf(
`SELECT DISTINCT ON (%s) %s FROM %s WHERE 1=1 %s %s %s`,
entity.ModelManageCol.ModelName,
field,
table,
whereCondition,
orderSql,
limitSql,
)
// 执行查询
result, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql, queryParams...)
if err != nil {
return nil, 0, err
}
err = result.Structs(&res)
return
}
-42
View File
@@ -1,42 +0,0 @@
package dao
import (
"context"
"model-gateway/consts/public"
"model-gateway/model/dto"
"model-gateway/model/entity"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/util/gconv"
)
var ModelSession = &modelSessionDao{}
type modelSessionDao struct{}
// Insert 插入
func (d *modelSessionDao) Insert(ctx context.Context, req *dto.CreateModelSessionReq) (id int64, err error) {
m := new(entity.ModelSession)
err = gconv.Struct(req, &m)
if err != nil {
return
}
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelSession).Insert(m)
if err != nil {
return
}
return r.LastInsertId()
}
// Update 更新(按ID
func (d *modelSessionDao) Update(ctx context.Context, req *dto.UpdateModelSessionReq) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelSession).
OmitEmpty().
Data(req).
Where(entity.ModelSessionCol.Id, req.Id).
Update()
if err != nil {
return
}
return r.RowsAffected()
}
-29
View File
@@ -1,29 +0,0 @@
package dao
import (
"context"
"model-gateway/consts/public"
"model-gateway/model/dto"
"model-gateway/model/entity"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/util/gconv"
)
var ModelTaskEnd = &modelTaskEndDao{}
type modelTaskEndDao struct{}
// Insert 插入
func (d *modelTaskEndDao) Insert(ctx context.Context, req *dto.CreateModelTaskEndReq) (id int64, err error) {
m := new(entity.ModelTaskEnd)
err = gconv.Struct(req, &m)
if err != nil {
return
}
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelTaskEnd).Insert(m)
if err != nil {
return
}
return r.LastInsertId()
}
-97
View File
@@ -1,97 +0,0 @@
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
}
+91
View File
@@ -0,0 +1,91 @@
package dao
import (
"context"
"model-gateway/consts/public"
"model-gateway/model/entity"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
)
var ProviderProtocol = &providerProtocolDao{}
type providerProtocolDao struct{}
// Insert 新增协议配置
func (d *providerProtocolDao) Insert(ctx context.Context, req *entity.ProviderProtocol) (id int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameProtocol).Insert(req)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
// Get 获取协议配置
func (d *providerProtocolDao) Get(ctx context.Context, req *entity.ProviderProtocol) (res *entity.ProviderProtocol, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameProtocol).
NoTenantId(ctx).
OmitEmpty().
Where(entity.ProviderProtocolCol.Id, req.Id).
Where(entity.ProviderProtocolCol.ProviderName, req.ProviderName).
Where(entity.ProviderProtocolCol.Status, 1).
One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, nil
}
err = r.Struct(&res)
return
}
// List 列表查询
func (d *providerProtocolDao) List(ctx context.Context, req *entity.ProviderProtocol, page, size int) (list []*entity.ProviderProtocol, total int, err error) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = 10
}
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameProtocol).OmitEmpty()
if req.ProviderName != "" {
model = model.Where(entity.ProviderProtocolCol.ProviderName, req.ProviderName)
}
if req.Status > 0 {
model = model.Where(entity.ProviderProtocolCol.Status, req.Status)
}
model = model.OrderDesc(entity.ProviderProtocolCol.CreatedAt).Page(page, size)
r, totalInt, err := model.AllAndCount(false)
if err != nil {
return nil, 0, err
}
err = r.Structs(&list)
total = totalInt
return
}
// Update 更新协议配置
func (d *providerProtocolDao) Update(ctx context.Context, req *entity.ProviderProtocol) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameProtocol).
OmitEmpty().
Where(entity.ProviderProtocolCol.Id, req.Id).
Data(req).
Update()
if err != nil {
return 0, err
}
return r.RowsAffected()
}
// Delete 软删除协议配置
func (d *providerProtocolDao) Delete(ctx context.Context, id int64) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameProtocol).
Where(entity.ProviderProtocolCol.Id, id).
Data(map[string]any{"deleted_at": "NOW()"}).
Update()
if err != nil {
return 0, err
}
return r.RowsAffected()
}
+44 -51
View File
@@ -3,35 +3,34 @@ module model-gateway
go 1.26.1
require (
gitea.redpowerfuture.com/red-future/common v0.0.33
github.com/bjang03/gmq v0.0.3
gitea.redpowerfuture.com/red-future/common v0.0.29
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
golang.org/x/sync v0.19.0
github.com/google/uuid v1.6.0
github.com/tidwall/gjson v1.19.0
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dgraph-io/badger/v4 v4.2.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-ego/gse v1.0.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-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/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@@ -40,68 +39,62 @@ require (
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/flatbuffers v1.12.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/hashicorp/consul/api v1.26.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/consul/api v1.33.5 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.5.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/serf v0.10.1 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/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/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.3.0 // indirect
github.com/olekukonko/ll v0.1.8 // indirect
github.com/olekukonko/tablewriter v1.1.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/r3labs/diff/v2 v2.15.1 // indirect
github.com/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/redis/go-redis/v9 v9.21.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tiger1103/gfast-token v1.0.10 // indirect
github.com/vcaesar/cedar v0.30.0 // indirect
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
go.mongodb.org/mongo-driver/v2 v2.4.0 // indirect
go.opencensus.io v0.23.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/grpc v1.75.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+104 -130
View File
@@ -1,9 +1,9 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
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=
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=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -19,8 +19,6 @@ 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,6 +36,10 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -49,8 +51,6 @@ github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWa
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
@@ -63,14 +63,10 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/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/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4=
github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@@ -82,15 +78,11 @@ 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=
@@ -128,10 +120,10 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -148,12 +140,12 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM=
github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A=
github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU=
github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/consul/api v1.33.5 h1:Nn6q87zudRU1rLBTJEgaWxz9STCNadilLCD7B8OA5aI=
github.com/hashicorp/consul/api v1.33.5/go.mod h1:pa6fJOSHKLOzNHpUVeqLDtxA5+J1D7NNzLasuk8eRXA=
github.com/hashicorp/consul/sdk v0.17.3 h1:oZMMxzQGSsiT+ToOH50y3Qcs0nc9Ud+7L5lRx+EmMU0=
github.com/hashicorp/consul/sdk v0.17.3/go.mod h1:jnOmYjiNfVRpBaujQ1DFFVs0N6g3S1y6wygSjLTzYfc=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -183,8 +175,8 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
@@ -199,10 +191,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.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/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -212,10 +204,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -223,45 +213,39 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/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/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
@@ -288,44 +272,37 @@ 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/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/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.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/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/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=
@@ -337,28 +314,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.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=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -370,18 +347,16 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -397,8 +372,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -407,8 +382,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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -432,16 +407,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -451,8 +425,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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -467,17 +441,17 @@ google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -487,8 +461,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+39 -35
View File
@@ -2,9 +2,8 @@ package main
import (
"context"
"model-gateway/consts/public"
"model-gateway/service"
"model-gateway/service/utils"
"model-gateway/model/dto"
"model-gateway/service/task"
"os"
"os/signal"
"syscall"
@@ -15,54 +14,59 @@ 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"
"github.com/gogf/gf/v2/os/grpool"
)
func main() {
ctx := context.Background()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
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)
// 初始化全局协程池
poolSize := g.Cfg().MustGet(ctx, "jobTask.poolSize", 5).Int()
task.JobPool = grpool.New(poolSize)
defer task.JobPool.Close()
// 注册路由
http.RouteRegister([]interface{}{
controller.ModelCall,
controller.ModelManage,
controller.ModelErrorMemory,
controller.ModelGatewayModels,
controller.ModelGatewayTask,
controller.ModelGatewayLogsStat,
})
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(),
},
})
// 本地调试:可选自动触发 worker/cleaner
startAutoRunner(ctx)
gtimer.AddSingleton(ctx, 10*time.Second, func(ctx context.Context) {
err := service.ModelTaskEndService.GetTaskStartList(ctx)
if err != nil {
g.Log().Error(ctx, "模型视频任务处理失败 err: %v", err)
}
})
// 监听退出信号,确保 Ctrl+C 能完整退出(停掉定时器与协程池,等任务执行完成再关闭)
// 监听退出信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
g.Log().Infof(ctx, "[main] 收到退出信号,开始优雅退出...")
cancel()
_ = http.Httpserver.Shutdown()
}
utils.Shutdown()
g.Log().Infof(ctx, "[main] 全局协程池已关闭")
// 收到退出信号后,关闭全局协程池,等待所有已提交的任务执行完成
g.Log().Info(ctx, "服务正在关闭...")
func startAutoRunner(ctx context.Context) {
// queryPending
if g.Cfg().MustGet(ctx, "queryPending.enabled").Bool() {
interval := g.Cfg().MustGet(ctx, "queryPending.intervalSeconds", 10).Int()
limit := g.Cfg().MustGet(ctx, "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)
}
}
}
}()
}
}
-52
View File
@@ -1,52 +0,0 @@
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:"视频时长(秒)"`
}
-149
View File
@@ -1,149 +0,0 @@
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:"枚举值列表"`
}
-34
View File
@@ -1,34 +0,0 @@
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"`
}
+20
View File
@@ -0,0 +1,20 @@
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:"总数"`
}
+173
View File
@@ -0,0 +1,173 @@
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:"模型名称(唯一标识)"`
OperatorName string `p:"operatorName" json:"operatorName" 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-是"`
CallMode *int `p:"callMode" json:"callMode" dc:"调用模式:0-同步 1-异步 2-流式"`
ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥"`
Form []entity.Form `p:"form" json:"form" dc:"动态表单配置"`
RequestMapping map[string]any `p:"requestMapping" json:"requestMapping" dc:"请求映射"`
ResponseMapping map[string]any `p:"responseMapping" json:"responseMapping" dc:"返回映射"`
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:"流式输出配置"`
SpecialParams map[string]any `p:"specialParams" json:"specialParams" dc:"请求特殊参数(首尾帧等)"`
RequiredFields []string `p:"requiredFields" json:"requiredFields" 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"`
}
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" v:"required#模型名称不能为空" dc:"模型名称(唯一标识)"`
OperatorName string `p:"operatorName" json:"operatorName" 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-是"`
CallMode *int `p:"callMode" json:"callMode" dc:"调用模式:0-同步 1-异步 2-流式"`
ApiKey string `p:"apiKey" json:"apiKey" dc:"调用凭证/密钥"`
Form []entity.Form `p:"form" json:"form" dc:"动态表单配置"`
RequestMapping map[string]any `p:"requestMapping" json:"requestMapping" dc:"请求映射"`
ResponseMapping map[string]any `p:"responseMapping" json:"responseMapping" dc:"返回映射"`
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:"流式输出配置"`
SpecialParams map[string]any `p:"specialParams" json:"specialParams" dc:"请求特殊参数(首尾帧等)"`
RequiredFields []string `p:"requiredFields" json:"requiredFields" 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"`
}
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"`
}
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 默认 36001小时)"`
}
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:"选项值"`
}
+144
View File
@@ -0,0 +1,144 @@
package dto
import (
"github.com/gogf/gf/v2/frame/g"
)
type BuildMessagesReq struct {
g.Meta `path:"/buildMessages" method:"post" tags:"提示词处理" summary:"拼接提示词"`
ModelName string `p:"modelName" json:"modelName" v:"required#modelName不能为空" dc:"实际请求的网关模型名称"`
BuildType int64 `p:"buildType" json:"buildType" v:"required#buildType不能为空" dc:"构建类型:1单轮 2多轮"`
SkillName string `p:"skillName" json:"skillName" dc:"技能名称"`
CallbackUrl string `p:"callbackUrl" json:"callbackUrl" dc:"回调地址"`
NodeId string `p:"nodeId" json:"nodeId" dc:"节点ID"`
SessionId string `p:"sessionId" json:"sessionId" dc:"会话ID"`
Messages map[string]any `json:"messages" dc:"前端构建好的消息结构"` //<- 实际构建根据前端表单及工作流传递所构建。待调整
CustomPrompt string `p:"customPrompt" json:"customPrompt" dc:"用户提示词"`
Cause string `p:"cause" json:"cause" dc:"原因"`
}
type BuildMessagesRes struct {
TaskId string `json:"taskId" dc:"任务ID"`
}
type CallbackReq struct {
g.Meta `path:"/callback" method:"post" tags:"提示词处理" summary:"model-gateway 回调" dc:"model-gateway 成功后 POST 回调:callbackUrl/{bizName}"`
TaskId string `json:"task_id" v:"required#task_id不能为空" dc:"网关任务ID"`
State int `json:"state" dc:"网关任务状态"`
OssFile string `json:"oss_file" dc:"结果文件地址"`
FileType string `json:"file_type" dc:"结果文件类型"`
ErrorMsg string `json:"error_msg" dc:"错误信息"`
}
// 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"`
BuildModelName string `json:"buildModelName" json:"buildModelName" dc:"构建模型名称"`
}
type CreateTaskRes struct {
TaskID string `json:"taskId" dc:"任务ID"`
}
type JobTaskReq struct {
g.Meta `path:"/jobTask" method:"post" tags:"任务管理" summary:"定时任务" dc:"循环执行待处理任务,按间隔时间和批次大小处理"`
Interval int `json:"interval" dc:"循环间隔(秒)"`
BatchSize int `json:"batchSize" dc:"每批执行条数"`
PoolSize int `json:"poolSize" dc:"协程池大小"`
}
type JobTaskRes struct {
TotalProcessed int `json:"totalProcessed" dc:"总处理数"`
SuccessCount int `json:"successCount" dc:"成功数"`
FailCount int `json:"failCount" dc:"失败数"`
}
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地址"`
}
// 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:"本次抢占并处理的任务数"`
}
-159
View File
@@ -1,159 +0,0 @@
package dto
import (
"model-gateway/consts/model"
"model-gateway/consts/public"
"model-gateway/model/entity"
"gitea.redpowerfuture.com/red-future/common/beans"
"github.com/gogf/gf/v2/frame/g"
)
// CreateModelManageReq 添加模型配置
type CreateModelManageReq struct {
g.Meta `path:"/createModelManage" method:"post" tags:"模型管理" summary:"创建模型配置" dc:"添加新的模型配置"`
ModelSupplier model.SupplierType `json:"modelSupplier" v:"required#模型供应商不能为空" dc:"模型供应商"`
ModelName string `json:"modelName" v:"required#模型名称不能为空" dc:"模型名称"`
ModelType model.ModelType `json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"`
BaseURL string `json:"baseUrl" v:"required#模型服务地址不能为空" dc:"模型服务地址"`
SystemModel *bool `json:"systemModel" dc:"系统模型"`
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 {
Id int64 `json:"id,string" dc:"配置ID"`
}
type UpdateModelManageReq struct {
g.Meta `path:"/updateModelManage" method:"put" tags:"模型管理" summary:"更新模型配置" dc:"更新指定ID的模型配置"`
Id int64 `json:"id" v:"required#id不能为空" dc:"配置ID"`
ModelSupplier model.SupplierType `json:"modelSupplier" dc:"模型供应商"`
ModelName string `json:"modelName" dc:"模型名称"`
ModelType model.ModelType `json:"modelType" dc:"模型类型"`
BaseURL string `json:"baseUrl" dc:"模型服务地址"`
SystemModel *bool `json:"systemModel" dc:"系统模型"`
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:"模型管理" summary:"删除模型配置" dc:"删除指定ID的模型配置"`
Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"`
}
type GetModelManage struct {
ChatModel *bool `json:"chatModel" dc:"对话模型"`
Creator string `json:"creator" dc:"创建人"`
ModelName string `json:"modelName" dc:"模型名称"`
}
type GetModelManageReq struct {
g.Meta `path:"/getModelManage" method:"get" tags:"模型管理" summary:"获取模型配置" dc:"获取指定ID的模型配置"`
Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"`
}
type GetModelManageRes struct {
ModelManage *entity.ModelManage `json:"modelManage"`
}
type GetChatModelReq struct {
g.Meta `path:"/getChatModel" method:"get" tags:"模型管理" summary:"获取聊天模型" dc:"获取聊天模型"`
}
type GetChatModelRes struct {
ModelManage *entity.ModelManage `json:"modelManage"`
}
// ListModelManageReq 配置列表
type ListModelManageReq struct {
g.Meta `path:"/listModelManage" method:"get" tags:"模型管理" summary:"模型配置列表" dc:"分页获取模型配置列表"`
*beans.Page `json:"page"`
Id int64 `p:"id" json:"id,string" dc:"配置ID"`
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"`
ModelType model.ModelType `p:"modelType" json:"modelType" dc:"模型类型"`
IsSameType bool `p:"isSameType" json:"isSameType" dc:"是否相同类型"`
Creator string `json:"creator" dc:"创建人"`
}
type ListModelManageRes struct {
List []*entity.ModelManage `json:"list" dc:"列表数据"`
Total int `json:"total" dc:"总数"`
}
type CheckChatModelReq struct {
g.Meta `path:"/checkChatModel" method:"get" tags:"模型管理" summary:"检查是否为聊天模型" dc:"检查是否为聊天模型"`
}
type CheckChatModelRes struct {
IsChatModel bool `json:"isChatModel" dc:"是否为聊天模型"`
}
// ModelTypeReq 模型类型列表(分页)
type ModelTypeReq struct {
g.Meta `path:"/modelType" method:"get" tags:"模型管理" summary:"模型类型列表" dc:"分页获取模型类型列表"`
}
type ModelTypeRes struct {
List []*model.TypeTree `json:"list" dc:"模型类型ID到名称的映射"`
}
type ModelSupplierReq struct {
g.Meta `path:"/modelSupplier" method:"get" tags:"模型管理" summary:"获取运营商列表" dc:"获取运营商列表"`
}
type ModelSupplierRes struct {
List []*public.Option `json:"list" dc:"运营商名称到ID的映射"`
}
// BuildSchemaMappingReq 构建 Schema 映射请求
type BuildSchemaMappingReq struct {
g.Meta `path:"/buildSchemaMapping" method:"post" tags:"模型管理" summary:"自动构建 Schema 映射" dc:"根据模型类型和 Schema JSON,自动生成业务字段映射"`
ModelType int `json:"modelType" v:"required#模型类型不能为空" dc:"模型类型编码"`
Schema map[string]any `json:"schema" v:"required#Schema不能为空" dc:"模型的完整 Schema JSON"`
}
type BuildSchemaMappingRes struct {
SchemaMapping map[string]any `json:"schemaMapping" dc:"生成的 Schema 映射 JSON"`
}
-32
View File
@@ -1,32 +0,0 @@
package dto
import "model-gateway/model/entity"
type CallModelSessionReq struct {
Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"`
ModelInfo *entity.ModelManage `json:"modelInfo" dc:"模型信息"`
RequestParams map[string]any `json:"requestParams" dc:"新请求参数"`
}
// CreateModelSessionReq 创建会话
type CreateModelSessionReq struct {
ModelId int64 `json:"modelId" dc:"模型ID"`
BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"`
RequestPath string `json:"requestPath" dc:"请求参数保存路径"`
OriginalRequestPath string `json:"originalRequestPath" dc:"原始请求参数保存路径"`
}
// UpdateModelSessionReq 修改会话
type UpdateModelSessionReq struct {
Id int64 `json:"id" v:"required#id不能为空" dc:"任务ID"`
RetryCount int `json:"retryCount" dc:"重试"`
ResponsePath string `json:"responsePath" dc:"响应结果保存路径"`
OriginalResponsePath string `json:"originalResponsePath" dc:"原始响应结果保存路径"`
DurationSeconds int64 `json:"durationSeconds" dc:"耗时(秒)"`
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
CompletionTokens int64 `json:"completionTokens" dc:"输出token"`
TotalTokens int64 `json:"totalTokens" dc:"总token"`
TotalCost float64 `json:"totalCost" dc:"总费用(元)"`
ErrorMsg string `json:"errorMsg" dc:"错误消息"`
}
-16
View File
@@ -1,16 +0,0 @@
package dto
type CreateModelTaskEndReq struct {
ModelId int64 `json:"modelId" dc:"模型ID"`
BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
MsgTopic string `json:"msgTopic" dc:"消息主题(可选,用于后续业务通知)"`
TaskId string `json:"taskId" dc:"任务ID"`
ResponseParams map[string]any `json:"responseParams" dc:"响应结果"`
OriginalResponseParams map[string]any `json:"originalResponseParams" dc:"原始响应结果"`
DurationSeconds int64 `json:"durationSeconds" dc:"耗时(秒)"`
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
CompletionTokens int64 `json:"completionTokens" dc:"输出token"`
TotalTokens int64 `json:"totalTokens" dc:"总token"`
TotalCost float64 `json:"totalCost" dc:"本次调用总费用(元),未配置计费规则为0"`
ErrorMsg string `json:"errorMsg" dc:"错误消息"`
}
-46
View File
@@ -1,46 +0,0 @@
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"`
}
-37
View File
@@ -1,37 +0,0 @@
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:"分析模型名"`
}
@@ -0,0 +1,54 @@
package entity
import (
"gitea.redpowerfuture.com/red-future/common/beans"
)
type modelGatewayBuildRecordCol struct {
beans.SQLBaseCol
TaskID string
BuildType string
ModelName string
SkillName string
SessionID string
NodeID string
RequestMessages string
ResultMessages string
Status string
ErrorMsg string
DurationSeconds string
CallbackURL string
}
var ModelGatewayBuildRecordCol = modelGatewayBuildRecordCol{
SQLBaseCol: beans.DefSQLBaseCol,
TaskID: "task_id",
BuildType: "build_type",
ModelName: "model_name",
SkillName: "skill_name",
SessionID: "session_id",
NodeID: "node_id",
RequestMessages: "request_messages",
ResultMessages: "result_messages",
Status: "status",
ErrorMsg: "error_msg",
DurationSeconds: "duration_seconds",
CallbackURL: "callback_url",
}
// ModelGatewayBuildRecord 构建记录
type ModelGatewayBuildRecord struct {
beans.SQLBaseDO `orm:",inline"`
TaskID string `orm:"task_id" json:"taskId"`
BuildType int64 `orm:"build_type" json:"buildType"`
ModelName string `orm:"model_name" json:"modelName"`
SkillName string `orm:"skill_name" json:"skillName"`
SessionID string `orm:"session_id" json:"sessionId"`
NodeID string `orm:"node_id" json:"nodeId"`
RequestMessages map[string]any `orm:"request_messages" json:"requestMessages"`
ResultMessages []map[string]any `orm:"result_messages" json:"resultMessages"`
Status int `orm:"status" json:"status"`
ErrorMsg string `orm:"error_msg" json:"errorMsg"`
DurationSeconds int `orm:"duration_seconds" json:"durationSeconds"`
CallbackURL string `orm:"callback_url" json:"callbackUrl"`
}
+56
View File
@@ -0,0 +1,56 @@
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 map[string]any `orm:"request_payload" json:"requestPayload"`
ResponsePayload map[string]any `orm:"response_payload" json:"responsePayload"`
}
+35
View File
@@ -0,0 +1,35 @@
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"`
}
+112
View File
@@ -0,0 +1,112 @@
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
OperatorName string
ExtendMapping string
QueryConfig string
StreamConfig string
SpecialParams 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",
OperatorName: "operator_name",
ExtendMapping: "extend_mapping",
QueryConfig: "query_config",
StreamConfig: "stream_config",
SpecialParams: "special_params",
BillingConfig: "billing_config",
}
type ModelGatewayModel struct {
beans.SQLBaseDO `orm:",inline"`
ModelName string `orm:"model_name" json:"modelName"`
OperatorName string `orm:"operator_name" json:"operatorName"`
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"`
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"`
Form []Form `orm:"form_json" json:"form"`
RequestMapping map[string]any `orm:"request_mapping" json:"requestMapping"`
ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping"`
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"`
SpecialParams map[string]any `orm:"special_params" json:"specialParams"`
BillingConfig map[string]any `orm:"billing_config" json:"billingConfig"`
RequiredFields []string `orm:"required_fields" json:"requiredFields"`
MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency"`
TimeoutSeconds int `orm:"timeout_seconds" json:"timeoutSeconds"`
RetryTimes int `orm:"retry_times" json:"retryTimes"`
}
type Form struct {
Key string `json:"key"`
Value any `json:"value"`
Label string `json:"label"`
Type string `json:"type"`
DefaultValue any `json:"defaultValue"`
Required bool `json:"required"`
IsForm bool `json:"isForm"`
Options []map[string]any `json:"options"`
Role string `json:"role"`
FieldConstraint FieldConstraint `json:"fieldConstraint"`
}
type FieldConstraint struct {
MaxLength int `json:"maxLength"`
MinLength int `json:"minLength"`
NumberType string `json:"numberType"`
Min any `json:"min"`
Max any `json:"max"`
MaxSize int `json:"maxSize"`
MaxCount int `json:"maxCount"`
Accept string `json:"accept"`
}
const (
ResponseBody = "content"
)
+64
View File
@@ -0,0 +1,64 @@
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
RetryCount string
ErrorMsg string
ResultFile string
RequestPayload string
DurationSeconds string
EpicycleId string
BillingData string
BuildModelName string
}
var ModelGatewayTaskCol = modelGatewayTaskCol{
SQLBaseCol: beans.DefSQLBaseCol,
ModelName: "model_name",
TaskID: "task_id",
BizName: "biz_name",
CallbackURL: "callback_url",
State: "state",
RetryCount: "retry_count",
ErrorMsg: "error_msg",
ResultFile: "result_file",
RequestPayload: "request_payload",
DurationSeconds: "duration_seconds",
EpicycleId: "epicycle_id",
BillingData: "billing_data",
BuildModelName: "build_model_name",
}
// 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"`
RetryCount int `orm:"retry_count" json:"retryCount"`
ErrorMsg string `orm:"error_msg" json:"errorMsg"`
ResultFile *ResultFile `orm:"result_file" json:"resultFile"`
RequestPayload map[string]any `orm:"request_payload" json:"requestPayload"`
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds"`
EpicycleId int64 `orm:"epicycle_id" json:"epicycleId"`
BillingData []map[string]any `orm:"billing_data" json:"billingData"`
BuildModelName string `orm:"build_model_name" json:"buildModelName"`
}
// ResultFile OSS 结果文件
type ResultFile struct {
OssFile string `json:"ossFile"`
FileType string `json:"fileType"`
FileSize int64 `json:"fileSize"`
}
-116
View File
@@ -1,116 +0,0 @@
package entity
import (
"model-gateway/consts/model"
"gitea.redpowerfuture.com/red-future/common/beans"
)
type modelManageCol struct {
beans.SQLBaseCol
ModelSupplier string
ModelName string
ModelType string
BaseURL string
SystemModel string
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",
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:"系统模型"`
RefSystemModelId int64 `orm:"ref_system_model_id" json:"refSystemModelId" description:"引用的系统模型IDNULL=非引用行)"`
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 {
PromptTokens string `json:"promptTokens" dc:"输入token"`
CompletionTokens string `json:"completionTokens" dc:"输出token"`
TotalTokens string `json:"totalTokens" dc:"总token"`
}
type AsyncTaskMapping struct {
Url string `json:"url" dc:"url"`
HttpMethod string `json:"httpMethod" dc:"http方法" d:"POST"`
RequestBodyMapping map[string]any `json:"requestBodyMapping" description:"请求体映射"`
RequestHeadMapping map[string]string `json:"requestHeadMapping" description:"请求头映射"`
ResponseMapping map[string]any `json:"responseMapping" description:"响应映射"`
TaskId string `json:"taskId" dc:"任务id"`
TaskStatus string `json:"taskStatus" dc:"任务状态"`
TaskStatusPending string `json:"taskStatusPending" dc:"任务状态-待处理"`
TaskStatusRunning string `json:"taskStatusRunning" dc:"任务状态-运行中"`
TaskStatusSuccess string `json:"taskStatusSuccess" dc:"任务状态-成功"`
TaskStatusFailed string `json:"taskStatusFailed" dc:"任务状态-失败"`
TaskStatusCancel string `json:"taskStatusCancel" dc:"任务状态-取消"`
TaskStatusUnknown string `json:"taskStatusUnknown" dc:"任务状态-未知"`
}
-60
View File
@@ -1,60 +0,0 @@
package entity
import (
"gitea.redpowerfuture.com/red-future/common/beans"
)
type modelSessionCol struct {
beans.SQLBaseCol
ModelId string
BizName string
SessionId string
RetryCount string
RequestPath string
ResponsePath string
OriginalRequestPath string
OriginalResponsePath string
DurationSeconds string
PromptTokens string
CompletionTokens string
TotalTokens string
TotalCost string
ErrorMsg string
}
var ModelSessionCol = modelSessionCol{
SQLBaseCol: beans.DefSQLBaseCol,
ModelId: "model_id",
BizName: "biz_name",
SessionId: "session_id",
RetryCount: "retry_count",
RequestPath: "request_path",
ResponsePath: "response_path",
OriginalRequestPath: "original_request_path",
OriginalResponsePath: "original_response_path",
DurationSeconds: "duration_seconds",
PromptTokens: "prompt_tokens",
CompletionTokens: "completion_tokens",
TotalTokens: "total_tokens",
TotalCost: "total_cost",
ErrorMsg: "error_msg",
}
// ModelSession 模型网关任务
type ModelSession struct {
beans.SQLBaseDO `orm:",inline"`
ModelId int64 `orm:"model_id" json:"modelId" dc:"模型ID"`
BizName string `orm:"biz_name" json:"bizName" dc:"业务名称"`
SessionId string `orm:"session_id" json:"sessionId" dc:"会话ID"`
RetryCount int `orm:"retry_count" json:"retryCount" dc:"重试"`
RequestPath string `orm:"request_path" json:"requestPath" dc:"请求参数保存路径"`
ResponsePath string `orm:"response_path" json:"responsePath" dc:"响应结果保存路径"`
OriginalRequestPath string `orm:"original_request_path" json:"originalRequestPath" dc:"原始请求参数保存路径"`
OriginalResponsePath string `orm:"original_response_path" json:"originalResponsePath" dc:"原始响应结果保存路径"`
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"`
PromptTokens int64 `orm:"prompt_tokens" json:"promptTokens" dc:"输入token"`
CompletionTokens int64 `orm:"completion_tokens" json:"completionTokens" dc:"输出token"`
TotalTokens int64 `orm:"total_tokens" json:"totalTokens" dc:"总token"`
TotalCost float64 `orm:"total_cost" json:"totalCost" dc:"总费用(元)"`
ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"`
}
-57
View File
@@ -1,57 +0,0 @@
package entity
import (
"gitea.redpowerfuture.com/red-future/common/beans"
)
type modelTaskEndCol struct {
beans.SQLBaseCol
ModelId string
BizName string
MsgTopic string
RetryCount string
ResponseParams string
OriginalResponseParams string
DurationSeconds string
TaskId string
PromptTokens string
CompletionTokens string
TotalTokens string
TotalCost string
ErrorMsg string
}
var ModelTaskEndCol = modelTaskEndCol{
SQLBaseCol: beans.DefSQLBaseCol,
ModelId: "model_id",
BizName: "biz_name",
MsgTopic: "msg_topic",
RetryCount: "retry_count",
ResponseParams: "response_params",
OriginalResponseParams: "original_response_params",
DurationSeconds: "duration_seconds",
TaskId: "task_id",
PromptTokens: "prompt_tokens",
CompletionTokens: "completion_tokens",
TotalTokens: "total_tokens",
TotalCost: "total_cost",
ErrorMsg: "error_msg",
}
// ModelTaskEnd 模型网关任务
type ModelTaskEnd struct {
beans.SQLBaseDO `orm:",inline"`
ModelId int64 `orm:"model_id" json:"modelId" dc:"模型ID"`
BizName string `orm:"biz_name" json:"bizName" dc:"业务名称"`
MsgTopic string `orm:"msg_topic" json:"msgTopic" dc:"消息主题"`
RetryCount int `orm:"retry_count" json:"retryCount" dc:"重试"`
ResponseParams string `orm:"response_params" json:"responseParams" dc:"响应结果"`
OriginalResponseParams string `orm:"original_response_params" json:"originalResponseParams" dc:"原始响应结果"`
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"`
TaskId string `orm:"task_id" json:"taskId" dc:"任务ID"`
PromptTokens int64 `orm:"prompt_tokens" json:"promptTokens" dc:"输入token"`
CompletionTokens int64 `orm:"completion_tokens" json:"completionTokens" dc:"输出token"`
TotalTokens int64 `orm:"total_tokens" json:"totalTokens" dc:"总token"`
TotalCost float64 `orm:"total_cost" json:"totalCost" dc:"本次调用总费用(元),未配置计费规则为0"`
ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"`
}
-54
View File
@@ -1,54 +0,0 @@
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:"错误消息"`
}
+30
View File
@@ -0,0 +1,30 @@
package entity
import "gitea.redpowerfuture.com/red-future/common/beans"
// ProviderProtocol 模型协议映射配置
type ProviderProtocol struct {
beans.SQLBaseDO `orm:",inherit"`
ProviderName string `orm:"provider_name" json:"providerName"`
RequestTemplate map[string]any `orm:"request_template" json:"requestTemplate"`
SystemPromptTemplate string `orm:"system_prompt_template" json:"systemPromptTemplate"`
Status int `orm:"status" json:"status"`
}
// providerProtocolCol 列名
type providerProtocolCol struct {
beans.SQLBaseCol
ProviderName string
RequestTemplate string
SystemPromptTemplate string
Status string
}
// ProviderProtocolCol 列名常量
var ProviderProtocolCol = providerProtocolCol{
SQLBaseCol: beans.DefSQLBaseCol,
ProviderName: "provider_name",
RequestTemplate: "request_template",
SystemPromptTemplate: "system_prompt_template",
Status: "status",
}
-153
View File
@@ -1,153 +0,0 @@
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)
}
-43
View File
@@ -1,43 +0,0 @@
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[:])
}
+320
View File
@@ -0,0 +1,320 @@
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/os/gtime"
"github.com/gogf/gf/v2/util/guid"
)
// ForwardHeaders 获取转发请求头
func ForwardHeaders(ctx context.Context) map[string]string {
headers := make(map[string]string)
if r := g.RequestFromCtx(ctx); r != nil {
for k, v := range r.Request.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
}
return headers
}
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 := ForwardHeaders(ctx)
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"`
TaskId string `json:"task_id"`
State int `json:"state"`
ErrorMsg string `json:"error_msg"`
OssFile string `json:"oss_file"`
FileType string `json:"file_type"`
}
// TriggerPromptsCallback 任务成功后的提示词回调
func TriggerPromptsCallback(ctx context.Context, t *entity.ModelGatewayTask) {
callbackURL := "prompts-core/session/callback"
headers := ForwardHeaders(ctx)
var resp struct{}
payload := PromptsCallbackPayload{
EpicycleId: t.EpicycleId,
}
jsonData, err := json.Marshal(payload)
if err != nil {
g.Log().Warningf(ctx, "[提示词回调] JSON序列化失败 epicycleId=%d 错误=%v", t.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))
}
// BuildCallbackPayload 构建回调请求体
type BuildCallbackPayload struct {
TaskId string `json:"taskId"`
Status int `json:"status"`
Messages any `json:"messages"`
ErrorMsg string `json:"errorMsg"`
}
// CallbackBuildResult 回调构建结果
func CallbackBuildResult(ctx context.Context, record *entity.ModelGatewayBuildRecord) {
headers := ForwardHeaders(ctx)
payload := BuildCallbackPayload{
TaskId: record.TaskID,
Status: record.Status,
Messages: record.ResultMessages,
ErrorMsg: record.ErrorMsg,
}
jsonData, _ := json.Marshal(payload)
var resp struct{}
if err := commonHttp.Post(ctx, record.CallbackURL, headers, &resp, jsonData); err != nil {
g.Log().Warningf(ctx, "[构建回调] 发送失败 taskId=%s err=%v", record.TaskID, err)
return
}
g.Log().Infof(ctx, "[构建回调] 发送成功 taskId=%s", record.TaskID)
}
// IsSuperAdmin 调用admin-go服务检查是否是超级管理员
func IsSuperAdmin(ctx context.Context) (res bool, err error) {
headers := ForwardHeaders(ctx)
var r = make(map[string]bool)
if err = commonHttp.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headers, &r); err != nil {
return false, err
}
return r["isSuperAdmin"], err
}
// SkillUserVO 技能用户视图对象
type SkillUserVO struct {
Id int64 `json:"id,string"`
Name string `json:"name"`
Description string `json:"description"`
FileName string `json:"fileName"`
FileUrl string `json:"fileUrl"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
ImgAddressPrefix string `json:"imgAddressPrefix"`
}
// GetSkillUser 获取技能用户信息
func GetSkillUser(ctx context.Context, name string) (*SkillUserVO, error) {
fullURL := fmt.Sprintf("ai-agent/skill/user/getUserOrTemplate?name=%s", name)
headers := ForwardHeaders(ctx)
var resp SkillUserVO
var req struct{}
if err := commonHttp.Get(ctx, fullURL, headers, &resp, req); err != nil {
return nil, err
}
return &resp, nil
}
// SessionHistoryItem 会话历史条目
type SessionHistoryItem struct {
Role string `json:"role"`
Content string `json:"content"`
}
// GetSessionHistory 获取会话历史
func GetSessionHistory(ctx context.Context, nodeId, sessionId string) ([]SessionHistoryItem, error) {
fullURL := fmt.Sprintf("model-session/session/sessionHistory?nodeId=%s&sessionId=%s", nodeId, sessionId)
headers := ForwardHeaders(ctx)
var req struct{}
var resp []SessionHistoryItem
if err := commonHttp.Get(ctx, fullURL, headers, &resp, req); err != nil {
return nil, err
}
return resp, nil
}
// 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 := ForwardHeaders(ctx)
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 := ForwardHeaders(ctx)
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 {
Surplus float64 `json:"surplus"`
}
// GetTenantSurplus 获取租户余额
func GetTenantSurplus(ctx context.Context, tenantId uint64) (float64, error) {
apiURL := fmt.Sprintf("admin-go/api/v1/system/tenant/getTenantDetails?tenantId=%d", tenantId)
headers := ForwardHeaders(ctx)
var resp TenantSurplusResp
err := commonHttp.Get(ctx, apiURL, headers, &resp, nil)
if err != nil {
g.Log().Warningf(ctx, "[获取余额] 失败 tenantId=%d err=%v", tenantId, err)
return 0, err
}
return resp.Surplus, nil
}
-167
View File
@@ -1,167 +0,0 @@
// 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 头并验证 Flusherstream=false 时只返回 Reader,不设置响应头
func ModelHttpStreamRequest(ctx context.Context, w http.ResponseWriter, url string, headers map[string]string, httpMethod string, body map[string]any) (io.Reader, error) {
// 1) 先发起上游请求(此时还没写任何 SSE 头,失败可以正常返回 error)
response, err := modelDoRaw(ctx, httpMethod, url, headers, body)
if err != nil {
g.Log().Errorf(ctx, "[HttpModel] 模型流式请求失败 [Error]: %v", err)
return nil, fmt.Errorf("模型流式请求失败: %w", err)
}
// 检查 HTTP 状态码
if response.StatusCode < 200 || response.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(response.Body)
response.Close()
return nil, fmt.Errorf("[HTTP][Stream] 状态码异常: %d, body=%s", response.StatusCode, string(bodyBytes))
}
if w != nil {
// 2) 上游连接成功,再设置 SSE 头
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
if _, ok := w.(http.Flusher); !ok {
response.Close()
return nil, errors.New("response writer not support flush")
}
}
// 下层统一托管关闭:用包装器保证流最终关闭
return &autoCloseReader{r: response.Body}, nil
}
// autoCloseReader 包装 io.ReadCloser,读取结束/销毁时自动 Close
type autoCloseReader struct {
r io.ReadCloser
}
func (a *autoCloseReader) Read(p []byte) (int, error) {
n, err := a.r.Read(p)
// 读取完毕 / 读出错,主动关闭流
if err != nil {
_ = a.r.Close()
}
return n, err
}
-85
View File
@@ -1,85 +0,0 @@
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] 流式读取正常结束")
}
+244
View File
@@ -0,0 +1,244 @@
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)入库
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)超管创建/普通用户更新
if isAdmin, _ := gateway.IsSuperAdmin(ctx); isAdmin {
_, 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
}
model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
SQLBaseDO: beans.SQLBaseDO{
Id: req.ID,
Creator: user.UserName,
},
})
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
}
-364
View File
@@ -1,364 +0,0 @@
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) 执行业务期间周期续期名额KeySemaphoreAcquire 仅在首次占用(计数从 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 可能混有扁平路径 keymessages.enumValues...)与已是对象/数组的值(stream)。
// IsFlatMap 遇 map/slice 值即整体返回 false 会跳过 unflattensjson.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
}
-107
View File
@@ -1,107 +0,0 @@
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)
}
-35
View File
@@ -1,35 +0,0 @@
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/ossmultipart 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
}
-386
View File
@@ -1,386 +0,0 @@
package service
import (
"context"
"fmt"
"model-gateway/consts/model"
"model-gateway/consts/public"
"model-gateway/dao"
"model-gateway/model/dto"
"model-gateway/model/entity"
modelUtils "model-gateway/service/utils"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var ModelManage = &modelManageService{}
type modelManageService struct{}
// Create 创建模型
func (s *modelManageService) Create(ctx context.Context, req *dto.CreateModelManageReq) (res *dto.CreateModelManageRes, err error) {
err = gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
// 1)检查是否是超管
var isSuperAdmin bool
isSuperAdmin, err = IsSuperAdmin(ctx)
if err != nil {
return
}
req.SystemModel = &isSuperAdmin
// 1)如果设为会话模型,先把该用户旧会话模型取消
err = s.CancelChatModel(ctx, req.ModelType, req.ChatModel, isSuperAdmin)
if err != nil {
return
}
// 2)模型名称唯一性:同一用户下不允许同名模型
var user *beans.User
user, err = utils.GetUserInfo(ctx)
if err != nil {
return
}
if !g.IsEmpty(req.ModelName) {
var exist *entity.ModelManage
exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, req.ModelName)
if err != nil {
return
}
if exist != nil {
return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", req.ModelName)
}
}
// 3)插入数据
id, err := dao.ModelManage.Insert(ctx, req)
if err != nil {
return
}
res = &dto.CreateModelManageRes{Id: id}
return
})
return
}
// Update 更新模型配置
// 引用行:只改 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{
Id: req.Id,
})
if err != nil {
return
}
if get == nil {
return fmt.Errorf("模型不存在")
}
var user *beans.User
user, err = utils.GetUserInfo(ctx)
if err != nil {
return
}
// 引用行:只改 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
}
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 = IsSuperAdmin(ctx)
if err != nil {
return
}
if err = s.CancelChatModel(ctx, get.ModelType, req.ChatModel, isSuperAdmin); err != nil {
return err
}
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
})
return
}
func (s *modelManageService) CancelChatModel(ctx context.Context, modelType model.ModelType, chatModel *bool, isSuperAdmin bool) (err error) {
if !g.IsEmpty(chatModel) && *chatModel {
if !g.IsEmpty(modelType) && *modelType == *model.ModelTypeInference.Code {
if isSuperAdmin {
return fmt.Errorf("超级管理员不能设置会话模型")
}
// 2)获取该用户信息
var user *beans.User
user, err = utils.GetUserInfo(ctx)
if err != nil {
return
}
// 3)取消该用户之前的会话模型
var get *entity.ModelManage
get, err = dao.ModelManage.Get(ctx, &dto.GetModelManage{
Creator: user.UserName,
ChatModel: chatModel,
})
if err != nil {
return
}
if g.IsEmpty(get) {
return
}
_, err = dao.ModelManage.Update(ctx, &dto.UpdateModelManageReq{
Id: get.Id,
ChatModel: gconv.PtrBool(false),
})
if err != nil {
return
}
} else {
return fmt.Errorf("只有推理模型可以设置成会话模型")
}
}
return
}
// Delete 删除模型:系统模型被引用 → 拒绝;引用行/自有行仅创建者可删
func (s *modelManageService) Delete(ctx context.Context, req *dto.DeleteModelManageReq) error {
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
}
func (s *modelManageService) Get(ctx context.Context, req *dto.GetModelManageReq) (res *dto.GetModelManageRes, err error) {
get, err := dao.ModelManage.GetNotTenantId(ctx, req)
if err != nil {
return
}
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 {
return
}
req.Creator = user.UserName
list, total, err := dao.ModelManage.ListNotTenantId(ctx, req)
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,
}
err = gconv.Struct(list, &res.List)
return
}
func (s *modelManageService) CheckChatModel(ctx context.Context, req *dto.CheckChatModelReq) (res *dto.CheckChatModelRes, err error) {
get, err := s.GetChatModel(ctx, &dto.GetChatModelReq{})
if err != nil {
return
}
res = &dto.CheckChatModelRes{
IsChatModel: !g.IsEmpty(get),
}
return
}
// GetModelType 获取模型类型
func (s *modelManageService) GetModelType(ctx context.Context, req *dto.ModelTypeReq) (res *dto.ModelTypeRes, err error) {
res = &dto.ModelTypeRes{
List: model.GetTypeTreeList(),
}
return res, nil
}
// GetModelSupplier 获取运营商列表
func (s *modelManageService) GetModelSupplier(ctx context.Context, req *dto.ModelSupplierReq) (res *dto.ModelSupplierRes, err error) {
return &dto.ModelSupplierRes{
List: model.GetSupplierOptionList(),
}, nil
}
-376
View File
@@ -1,376 +0,0 @@
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
}
-90
View File
@@ -1,90 +0,0 @@
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
}
-194
View File
@@ -1,194 +0,0 @@
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
}
-110
View File
@@ -1,110 +0,0 @@
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 已配置且启用计价,否则阻塞调用。
// 替换原 CheckTenantBalanceadmin-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 模型把输出字数映射到 completionTokensTokenMapping),随该字段传给 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
}
@@ -0,0 +1,282 @@
package prompt
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"model-gateway/common/util"
"model-gateway/service/gateway"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
const (
bytesPerKB = 1024
bytesPerMB = 1024 * 1024
)
// FetchFileTextsAsString 从 URL 列表获取文件内容,拼接为字符串
func FetchFileTextsAsString(ctx context.Context, urls []string) string {
if len(urls) == 0 {
return ""
}
client := createHTTPClient(ctx, "userFiles.httpTimeoutSec", 8)
var builder strings.Builder
for _, rawURL := range urls {
url := util.SanitizeURL(rawURL)
if url == "" || util.IsBannedExtension(url) {
continue
}
if util.IsZipExtension(url) {
for _, text := range fetchZipFileTexts(ctx, client, url) {
builder.WriteString(text)
builder.WriteString("\n")
}
continue
}
if text := fetchAndCleanFileContent(ctx, client, url); text != "" {
builder.WriteString(fmt.Sprintf("【文件:%s】\n%s\n", url, text))
}
}
return builder.String()
}
// fetchAndCleanFileContent 获取并清理文件内容
func fetchAndCleanFileContent(ctx context.Context, client *http.Client, url string) string {
text, err := fetchFileContent(ctx, client, url)
if err != nil || text == "" {
return ""
}
return util.CleanSymbols(text)
}
// fetchZipFileTexts 下载并解压 zip 文件,提取可读文本内容
func fetchZipFileTexts(ctx context.Context, client *http.Client, url string) map[string]string {
result := make(map[string]string)
maxSize := int64(g.Cfg().MustGet(ctx, "userFiles.zipMaxSizeMB", 10).Int()) * bytesPerMB
zipBytes, err := downloadFile(client, url, maxSize)
if err != nil {
return result
}
reader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
return result
}
entryMaxSize := int64(g.Cfg().MustGet(ctx, "userFiles.zipEntryMaxSizeKB", 500).Int()) * bytesPerKB
for _, file := range reader.File {
if shouldSkipZipEntry(file.Name) {
continue
}
if text := extractZipEntryContent(file, entryMaxSize); text != "" {
result[url+"::"+file.Name] = text
}
}
return result
}
// shouldSkipZipEntry 判断是否应该跳过 zip 条目
func shouldSkipZipEntry(fileName string) bool {
return util.IsBannedExtension(fileName) || util.IsZipExtension(fileName)
}
// extractZipEntryContent 提取 zip 条目内容
func extractZipEntryContent(file *zip.File, maxSize int64) string {
rc, err := file.Open()
if err != nil {
return ""
}
defer rc.Close()
content, err := io.ReadAll(io.LimitReader(rc, maxSize))
if err != nil {
return ""
}
if !util.IsReadableContentType(http.DetectContentType(content)) {
return ""
}
text := util.CleanSymbols(string(content))
if text == "" {
return ""
}
return text
}
// downloadFile 下载文件,限制最大大小
func downloadFile(client *http.Client, url string, maxSize int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("执行请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
return body, nil
}
// fetchFileContent 获取单个文本文件内容
func fetchFileContent(ctx context.Context, client *http.Client, url string) (string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("创建请求失败: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("执行请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !util.IsReadableContentType(contentType) {
return "", fmt.Errorf("不可读的内容类型: %s", contentType)
}
maxSize := int64(g.Cfg().MustGet(ctx, "userFiles.textFileMaxSizeKB", 500).Int()) * bytesPerKB
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
if err != nil {
return "", fmt.Errorf("读取响应失败: %w", err)
}
return strings.TrimSpace(string(body)), nil
}
func SkillMdContent(ctx context.Context, skillName string) string {
if skillName == "" {
return ""
}
skillResp, err := gateway.GetSkillUser(ctx, skillName)
if err != nil {
g.Log().Warningf(ctx, "[SkillMd] GetSkillUser 失败: %v", err)
return ""
}
fullUrl := skillResp.ImgAddressPrefix + skillResp.FileUrl
client := createHTTPClient(ctx, "skillFiles.httpTimeoutSec", 30)
maxSize := int64(g.Cfg().MustGet(ctx, "skillFiles.zipMaxSizeMB", 10).Int()) * bytesPerMB
zipBytes, err := downloadFile(client, fullUrl, maxSize)
if err != nil {
g.Log().Warningf(ctx, "[SkillMd] 下载失败 url=%s err=%v", fullUrl, err)
return ""
}
mdContents, err := extractMdFiles(ctx, zipBytes)
if err != nil || len(mdContents) == 0 {
g.Log().Warningf(ctx, "[SkillMd] 提取md失败 count=%d err=%v", len(mdContents), err)
return ""
}
return buildSkillMarkdown(skillResp, mdContents)
}
// buildSkillMarkdown 构建技能 Markdown 内容
func buildSkillMarkdown(skillResp *gateway.SkillUserVO, mdContents map[string]string) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("# Skill: %s\n\n", skillResp.Name))
if skillResp.Description != "" {
builder.WriteString(fmt.Sprintf("> %s\n\n", skillResp.Description))
}
for fileName, content := range mdContents {
builder.WriteString(fmt.Sprintf("## %s\n\n", fileName))
builder.WriteString(content)
builder.WriteString("\n\n---\n\n")
}
return strings.TrimSpace(builder.String())
}
// extractMdFiles 解压 zip 并提取所有 .md 文件内容
func extractMdFiles(ctx context.Context, zipBytes []byte) (map[string]string, error) {
result := make(map[string]string)
reader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
return nil, fmt.Errorf("创建 zip 阅读器失败: %w", err)
}
entryMaxSize := int64(g.Cfg().MustGet(ctx, "skillFiles.mdMaxSizeKB", 500).Int()) * bytesPerKB
for _, file := range reader.File {
if file.FileInfo().IsDir() || !isMarkdownFile(file.Name) {
continue
}
if content := readMarkdownFileContent(file, entryMaxSize); content != "" {
result[file.Name] = content
}
}
return result, nil
}
// isMarkdownFile 判断是否为 Markdown 文件
func isMarkdownFile(fileName string) bool {
return strings.HasSuffix(strings.ToLower(fileName), ".md")
}
// readMarkdownFileContent 读取 Markdown 文件内容
func readMarkdownFileContent(file *zip.File, maxSize int64) string {
rc, err := file.Open()
if err != nil {
return ""
}
defer rc.Close()
content, err := io.ReadAll(io.LimitReader(rc, maxSize))
if err != nil {
return ""
}
if len(content) == 0 {
return ""
}
return strings.TrimSpace(string(content))
}
// createHTTPClient 创建 HTTP 客户端
func createHTTPClient(ctx context.Context, configKey string, defaultSeconds int) *http.Client {
timeout := time.Duration(g.Cfg().MustGet(ctx, configKey, defaultSeconds).Int()) * time.Second
return &http.Client{
Timeout: timeout,
}
}
+198
View File
@@ -0,0 +1,198 @@
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_concurrencycap,不会被动态调参覆盖)
OldMaxConcurrency int `json:"oldMaxConcurrency"` // 调参前运行时值(Redis),若无则等于 cap
NewMaxConcurrency int `json:"newMaxConcurrency"` // 本次计算出的运行时值(将写入 Redis),受 ±50% 约束且不超过 cap
CapQueueLimit int `json:"capQueueLimit"` // 配置上限:asynch_models.queue_limitcap,不会被动态调参覆盖)
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_limitW_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
}
+107
View File
@@ -0,0 +1,107 @@
package queue
import (
"context"
"fmt"
"math"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// ===== 严格 queue_limitRedis 原子闸门 =====
//
// 背景:原来的 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)
}
+82
View File
@@ -0,0 +1,82 @@
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
}
+57
View File
@@ -0,0 +1,57 @@
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
}
-43
View File
@@ -1,43 +0,0 @@
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)))
}
-260
View File
@@ -1,260 +0,0 @@
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
}
-362
View File
@@ -1,362 +0,0 @@
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:
// 获取上游流式 readerstream=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/DurationSecondsOmitEmpty 不会影响已落库字段);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 "", ""
}
-135
View File
@@ -1,135 +0,0 @@
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
}
+34
View File
@@ -0,0 +1,34 @@
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
}
-101
View File
@@ -1,101 +0,0 @@
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
}
+558
View File
@@ -0,0 +1,558 @@
package task
import (
"context"
"errors"
"fmt"
"model-gateway/common/util"
"model-gateway/consts/public"
"model-gateway/service/gateway"
"model-gateway/service/prompt"
"sync"
"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/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/grpool"
"github.com/gogf/gf/v2/util/gconv"
"github.com/google/uuid"
)
var ModelGatewayTask = &taskService{}
type taskService struct{}
// BuildMessages 构建消息(异步)
func (s *taskService) BuildMessages(ctx context.Context, req *dto.BuildMessagesReq) (*dto.BuildMessagesRes, error) {
user, err := utils.GetUserInfo(ctx)
if err != nil {
return nil, err
}
model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
SQLBaseDO: beans.SQLBaseDO{TenantId: user.TenantId, Creator: user.UserName},
ModelName: req.ModelName,
})
if err != nil || model == nil {
return nil, err
}
// 1) 创建构建记录
taskId := uuid.NewString()
record := &entity.ModelGatewayBuildRecord{
TaskID: taskId,
BuildType: req.BuildType,
ModelName: req.ModelName,
SkillName: req.SkillName,
SessionID: req.SessionId,
NodeID: req.NodeId,
RequestMessages: req.Messages,
CallbackURL: req.CallbackUrl,
Status: 0,
}
_, err = dao.ModelGatewayBuildRecord.Insert(ctx, record)
if err != nil {
return nil, err
}
// 2) 异步执行构建
go s.executeBuild(util.AsyncCtx(ctx), record, req, model)
return &dto.BuildMessagesRes{TaskId: taskId}, nil
}
// executeBuild 异步执行构建逻辑
func (s *taskService) executeBuild(ctx context.Context, record *entity.ModelGatewayBuildRecord, req *dto.BuildMessagesReq, model *entity.ModelGatewayModel) {
var (
startTime = time.Now()
result []map[string]any
err error
)
result, err = s.buildResult(ctx, req, model, record)
record.DurationSeconds = int(time.Since(startTime).Seconds())
if err != nil {
record.Status = 2
record.ErrorMsg = err.Error()
} else {
record.Status = 1
record.ResultMessages = result
}
_, _ = dao.ModelGatewayBuildRecord.Update(ctx, record)
gateway.CallbackBuildResult(ctx, record)
}
// buildResult 构建结果:推理模型手动拼接,视频模型调模型生成多轮
func (s *taskService) buildResult(ctx context.Context, req *dto.BuildMessagesReq, model *entity.ModelGatewayModel, record *entity.ModelGatewayBuildRecord) ([]map[string]any, error) {
messages := req.Messages
switch {
case model.ModelType == public.ModelTypeInference:
// 推理模型:拼接提示词 + 历史
systemPrompt := util.GetModelPrompt(ctx, model.ModelType)
skillContent := prompt.SkillMdContent(ctx, req.SkillName)
systemKey := util.GetRoleContentPath(model.Form, "system")
if systemKey != "" {
messages = util.MergePrompt(messages, systemKey, systemPrompt, skillContent, req.CustomPrompt)
}
history, _ := gateway.GetSessionHistory(ctx, req.NodeId, req.SessionId)
if len(history) > 0 {
messages = util.InjectHistory(messages, history, []string{"system", "history", "user"})
}
// 检查附件是否需要拆分多轮
rounds := util.SplitByAttachment(messages, model.Form)
if len(rounds) > 0 {
return rounds, nil
}
return []map[string]any{messages}, nil
case model.ModelType >= 600 && model.ModelType < 700:
// 视频模型:调推理模型生成多轮
chatModel, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
SQLBaseDO: beans.SQLBaseDO{TenantId: model.TenantId, Creator: model.Creator},
IsChatModel: gconv.PtrInt(1),
})
if err != nil || chatModel == nil {
return nil, fmt.Errorf("未找到对话模型")
}
protocol, err := dao.ProviderProtocol.Get(ctx, &entity.ProviderProtocol{
ProviderName: chatModel.OperatorName,
Status: 1,
})
if err != nil || protocol == nil {
return nil, fmt.Errorf("未找到协议配置: %s", chatModel.OperatorName)
}
template := util.BuildTemplateFromForm(model.Form)
outputStruct := gjson.New(template).MustToJsonString()
durationForm, _ := util.GetFormByRole(model.Form, "duration")
totalDur := gconv.Int(gjson.New(req.Messages).Get(durationForm.Key).Val())
minDur := gconv.Int(durationForm.FieldConstraint.Min)
maxDur := gconv.Int(durationForm.FieldConstraint.Max)
systemPrompt := fmt.Sprintf(protocol.SystemPromptTemplate, outputStruct, totalDur, minDur, maxDur)
userContent := util.ExtractUserContent(req.Messages)
reqBody := util.BuildRequestBody(protocol.RequestTemplate, chatModel.ModelName, systemPrompt, userContent)
task := &entity.ModelGatewayTask{
ModelName: chatModel.ModelName,
TaskID: record.TaskID,
State: public.TaskStatusRunning,
BizName: "model-gateway",
RequestPayload: reqBody,
}
id, err := dao.ModelGatewayTask.Insert(ctx, task)
if err != nil {
return nil, err
}
task.Id = id
rawData, err := InvokeModel(ctx, chatModel, reqBody)
if err != nil {
task.State = public.TaskStatusFailed
task.ErrorMsg = err.Error()
_, _ = dao.ModelGatewayTask.Update(ctx, task)
return nil, err
}
mapped, err := util.MapResponsePayload(chatModel.ResponseMapping, gjson.New(string(rawData)).Map())
if err != nil {
return nil, err
}
var rounds []map[string]any
contentStr := gjson.New(mapped).Get(entity.ResponseBody).String()
if contentStr != "" {
if err = gjson.DecodeTo(contentStr, &rounds); err != nil {
task.State = public.TaskStatusFailed
task.ErrorMsg = err.Error()
_, _ = dao.ModelGatewayTask.Update(ctx, task)
return nil, err
}
}
oss, err := gateway.UploadByTask(ctx, gjson.New(rounds).MustToJson(), "json")
if err != nil {
task.State = public.TaskStatusFailed
task.ErrorMsg = err.Error()
_, _ = dao.ModelGatewayTask.Update(ctx, task)
return nil, err
}
task.State = public.TaskStatusSuccess
task.ResultFile = &entity.ResultFile{
OssFile: oss.FileAddressPrefix + oss.FileURL,
FileType: oss.FileFormat,
FileSize: int64(oss.FileSize),
}
_, _ = dao.ModelGatewayTask.Update(ctx, task)
return rounds, nil
default:
return nil, errors.New("不支持的模型类型")
}
}
// Create 创建任务
func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) {
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("模型不存在或未启用")
}
// TODO: 排队控制暂时关闭,后续需要时取消注释
// limit := queue.GetRuntimeQueueLimit(ctx, req.ModelName, model.MaxConcurrency*2)
// if limit > 0 {
// ok, err := queue.AcquireQueueSlot(ctx, req.ModelName, taskID, limit, model.TimeoutSeconds)
// if err != nil {
// return nil, err
// }
// if !ok {
// return nil, errors.New("任务排队已满,请稍后再试")
// }
// }
// 3) 构建任务实体
task := &entity.ModelGatewayTask{
ModelName: model.ModelName,
TaskID: taskID,
State: public.TaskStatusRunning,
BizName: req.BizName,
CallbackURL: req.CallbackUrl,
RequestPayload: req.RequestPayload,
EpicycleId: req.EpicycleId,
BuildModelName: req.BuildModelName,
}
// 4) 插入任务记录
id, err := dao.ModelGatewayTask.Insert(ctx, task)
if err != nil {
// TODO: 恢复排队逻辑后,此处需要回滚排队占位
// queue.ReleaseQueueSlot(ctx, req.ModelName, taskID)
return nil, err
}
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)
return &dto.CreateTaskRes{TaskID: taskID}, nil
}
var JobPool *grpool.Pool
// JobTask 定时任务:循环执行待处理任务
func (s *taskService) JobTask(ctx context.Context, req *dto.JobTaskReq) (res *dto.JobTaskRes, err error) {
// 1) 参数默认值从配置取
if req.Interval <= 0 {
req.Interval = g.Cfg().MustGet(ctx, "jobTask.intervalSeconds", 5).Int()
}
if req.BatchSize <= 0 {
req.BatchSize = g.Cfg().MustGet(ctx, "jobTask.batchSize", 10).Int()
}
var (
totalProcessed int
successCount int
failCount int
mu sync.Mutex
wg sync.WaitGroup
)
// 2) 循环查询待处理任务
for {
select {
case <-ctx.Done():
wg.Wait()
return &dto.JobTaskRes{
TotalProcessed: totalProcessed,
SuccessCount: successCount,
FailCount: failCount,
}, nil
default:
}
// 3) 查询 state=0 的任务列表
tasks, err := dao.ModelGatewayTask.ListPending(ctx, req.BatchSize)
if err != nil {
g.Log().Warningf(ctx, "[定时任务] 查询任务失败: %v", err)
time.Sleep(time.Second)
continue
}
if len(tasks) == 0 {
time.Sleep(time.Duration(req.Interval) * time.Second)
continue
}
// 4) 提交到全局协程池执行
for _, task := range tasks {
wg.Add(1)
t := task
err = JobPool.Add(ctx, func(ctx context.Context) {
defer wg.Done()
mu.Lock()
totalProcessed++
mu.Unlock()
if execErr := s.executeTask(ctx, t); execErr != nil {
mu.Lock()
failCount++
mu.Unlock()
g.Log().Errorf(ctx, "[定时任务] 执行失败 taskId=%s err=%v", t.TaskID, execErr)
} else {
mu.Lock()
successCount++
mu.Unlock()
}
})
if err != nil {
return nil, err
}
}
}
}
// executeTask 执行单个任务
func (s *taskService) executeTask(ctx context.Context, task *entity.ModelGatewayTask) error {
// 1) 查询模型配置
model, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
SQLBaseDO: beans.SQLBaseDO{
TenantId: task.TenantId,
Creator: task.Creator,
},
ModelName: task.ModelName,
})
if err != nil {
return fmt.Errorf("查询模型配置失败: %w", err)
}
if model == nil || (model.Enabled != nil && *model.Enabled != 1) {
return fmt.Errorf("模型不存在或未启用: %s", task.ModelName)
}
// 3) 调用 handleOne
AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model)
return nil
}
// 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 != 2 {
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,
})
}
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
}
result, err := util.PullTaskResult(ctx, nil, model.QueryConfig, model.HeadMsg)
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
}
+530
View File
@@ -0,0 +1,530 @@
package task
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"model-gateway/common/util"
"model-gateway/consts/public"
"model-gateway/dao"
"model-gateway/model/entity"
"model-gateway/service/gateway"
"net/http"
"strings"
"sync"
"time"
"gitea.redpowerfuture.com/red-future/common/beans"
"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) {
var (
body = task.RequestPayload
maxRetry = model.RetryTimes
startTime = time.Now()
rawData []byte
result map[string]any
err error
)
g.Log().Infof(ctx, "[handleOne] 开始 taskId=%s model=%s", task.TaskID, task.ModelName)
// ============================================
// 1) 查询余额
// ============================================
surplus, _ := gateway.GetTenantSurplus(ctx, model.TenantId)
if surplus <= 0 {
w.failTask(ctx, task, startTime, "租户余额不足")
return
}
g.Log().Infof(ctx, "[handleOne] 当前余额 tenantId=%d surplus=%.2f", task.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(ctx, 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(), "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 {
_ = gateway.DeductBalance(util.AsyncCtx(ctx), task.TenantId, -totalFee)
}
}
}
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 task.BizName == "prompts-core" {
mapped, err = w.parseAndRetry(ctx, mapped, model, task, maxRetry)
if err != nil {
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),
}
if _, err = dao.ModelGatewayTask.Update(ctx, task); err != nil {
g.Log().Errorf(ctx, "[handleOne] 更新DB失败 taskId=%s err=%v", task.TaskID, err)
return
}
go gateway.TriggerCallback(util.AsyncCtx(ctx), task)
if task.EpicycleId != 0 {
go gateway.TriggerPromptsCallback(util.AsyncCtx(ctx), task)
}
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) {
buildModel, err := dao.ModelGatewayModels.Get(ctx, &entity.ModelGatewayModel{
SQLBaseDO: beans.SQLBaseDO{TenantId: model.TenantId, Creator: model.Creator},
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, 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 {
_ = gateway.DeductBalance(util.AsyncCtx(ctx), task.TenantId, -totalFee)
}
}
_, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{
SQLBaseDO: beans.SQLBaseDO{Id: task.Id},
BillingData: task.BillingData,
})
}
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())
_, _ = dao.ModelGatewayTask.Update(ctx, t) // 更新任务状态
go gateway.TriggerCallback(util.AsyncCtx(ctx), t) // 触发回调
}
-628
View File
@@ -1,628 +0,0 @@
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
}
-30
View File
@@ -1,30 +0,0 @@
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
}
-150
View File
@@ -1,150 +0,0 @@
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
}
-58
View File
@@ -1,58 +0,0 @@
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()
}
-260
View File
@@ -1,260 +0,0 @@
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 统一为 numberresolveField 只分发 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 值是否可写入节点 value0/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
}
}
-605
View File
@@ -1,605 +0,0 @@
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{} {
// 实际数据在 valueschema-editor 数据存放处),直接返回
if v, has := field["value"]; has {
if arr, ok := v.([]interface{}); ok && len(arr) > 0 {
return arr
}
}
enumValues, ok := field["enumValues"].([]interface{})
if ok {
var result []interface{}
for _, ev := range enumValues {
evMap, ok := ev.(map[string]interface{})
if !ok {
continue
}
items := resolveEnumObject(evMap)
result = append(result, items...)
}
if len(result) > 0 {
return result
}
}
// enumValues 取不到或为空时,尝试从 attrs(数组)中取
attrs, ok := field["attrs"].([]interface{})
if !ok {
return nil
}
var result []interface{}
for _, item := range attrs {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if v := resolveField(itemMap); v != nil {
result = append(result, v)
}
}
return result
}
// resolveEnumObject 解析 enumValue 对象,支持数组展开
func resolveEnumObject(ev map[string]interface{}) []interface{} {
attrs, ok := ev["attrs"].(map[string]interface{})
if !ok {
return nil
}
// 将 enumValue 级别的 value 注入 attrs.type.value(如果 type.value 为空)
if evVal, has := ev["value"]; has && evVal != nil {
if s, ok := evVal.(string); ok && s != "" {
if typeField, has := attrs["type"]; has {
if typeMap, ok := typeField.(map[string]interface{}); ok {
if existing, has := typeMap["value"]; !has || existing == nil || existing == "" {
typeMap["value"] = s
}
}
}
}
}
return resolveAttrs(attrs)
}
// resolveAttrs 递归解析 attrs map,支持字段级数组展开
func resolveAttrs(attrs map[string]interface{}) []interface{} {
currentItems := []map[string]interface{}{{}}
hasValue := false
for key, val := range attrs {
subField, isMap := val.(map[string]interface{})
var subType string
if isMap {
subType, _ = subField["type"].(string)
}
var nextItems []map[string]interface{}
// 非包裹字段(纯值/纯对象,无 type 键):原样保留,数组值仍参与展开
if !isMap || subType == "" {
raw := val
if raw == nil {
nextItems = currentItems
currentItems = nextItems
continue
}
hasValue = true
if arr, ok := raw.([]interface{}); ok && len(arr) > 0 {
for _, item := range currentItems {
for _, elem := range arr {
cp := copyMap(item)
cp[key] = elem
nextItems = append(nextItems, cp)
}
}
} else {
for _, item := range currentItems {
cp := copyMap(item)
cp[key] = raw
nextItems = append(nextItems, cp)
}
}
currentItems = nextItems
continue
}
switch subType {
case TypeString, TypeBool, TypeNumber:
raw := extractRawValue(subField)
if raw == nil {
nextItems = currentItems
continue
}
hasValue = true
if arr, ok := raw.([]interface{}); ok && len(arr) > 0 {
for _, item := range currentItems {
for _, elem := range arr {
cp := copyMap(item)
cp[key] = elem
nextItems = append(nextItems, cp)
}
}
} else {
for _, item := range currentItems {
cp := copyMap(item)
cp[key] = raw
nextItems = append(nextItems, cp)
}
}
case TypeObject:
subAttrs, ok := subField["attrs"].(map[string]interface{})
if !ok {
nextItems = currentItems
continue
}
subItems := resolveAttrs(subAttrs)
if len(subItems) == 0 {
nextItems = currentItems
continue
}
hasValue = true
for _, item := range currentItems {
for _, subI := range subItems {
cp := copyMap(item)
cp[key] = subI
nextItems = append(nextItems, cp)
}
}
case TypeArray:
items := resolveArray(subField)
if len(items) == 0 {
nextItems = currentItems
continue
}
hasValue = true
for _, item := range currentItems {
cp := copyMap(item)
cp[key] = items
nextItems = append(nextItems, cp)
}
default:
nextItems = currentItems
}
currentItems = nextItems
}
if !hasValue {
return nil
}
result := make([]interface{}, len(currentItems))
for i, item := range currentItems {
result[i] = item
}
return result
}
// extractRawValue 提取原始值(保留数组值供上层展开)
func extractRawValue(field map[string]interface{}) interface{} {
if v, has := field["value"]; has && v != nil {
switch vv := v.(type) {
case string:
if vv != "" {
return vv
}
case float64:
if vv != 0 {
return vv
}
case bool:
return vv
case []interface{}:
if len(vv) > 0 {
return vv
}
default:
return vv
}
}
return nil
}
// extractRawValueKeepZero 同 extractRawValue,但不过滤零值
// 在参数定义场景下,default 可能是 false/0/"",需要保留
func extractRawValueKeepZero(field map[string]interface{}) interface{} {
if v, has := field["value"]; has && v != nil {
switch vv := v.(type) {
case string:
return vv
case float64:
return vv
case bool:
return vv
case []interface{}:
if len(vv) > 0 {
return vv
}
return vv
default:
return vv
}
}
if dv, has := field["defaultValue"]; has && dv != nil {
switch dvv := dv.(type) {
case string:
if field["type"] == TypeBool {
if dvv == "true" {
return true
}
if dvv == "false" {
return false
}
}
return dvv
case float64:
return dvv
case bool:
return dvv
case []interface{}:
if len(dvv) > 0 {
return dvv
}
return dvv
default:
return dvv
}
}
return nil
}
// copyMap 浅拷贝 map
func copyMap(src map[string]interface{}) map[string]interface{} {
dst := make(map[string]interface{}, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
// CoerceBodyTypes 按模板声明的 type 递归归一请求体字段值类型:
// - string → gconv.Stringnumber → gconv.Float64boolean → gconv.Bool
// - object → 按模板 attrs 递归子字段;array → 按元素模板逐个递归
// - 模板未声明的字段(业务字段写入且超出模板的部分)保持原样
//
// 用于构建请求体后统一修正:模板字段 value 与业务字段写入的值都可能携带与声明
// 类型不一致的 Go 类型(如 number 字段 value 为字符串 "0.7"),在此统一转成模型
// API 期望的 JSON 类型。仅做类型归一,不增删字段。
func CoerceBodyTypes(out map[string]interface{}, templateParams map[string]interface{}) map[string]interface{} {
if len(templateParams) == 0 {
return out
}
for key, raw := range out {
if tmplNode, has := templateParams[key]; has {
out[key] = coerceNode(raw, tmplNode)
}
}
return out
}
// coerceNode 按单个模板节点归一值类型
func coerceNode(value interface{}, tmplNode interface{}) interface{} {
tmplMap, ok := tmplNode.(map[string]interface{})
if !ok {
return value
}
fieldType, _ := tmplMap["type"].(string)
switch fieldType {
case TypeString:
return gconv.String(value)
case TypeNumber, TypeNumberInt, TypeNumberFloat:
return gconv.Float64(value)
case TypeBool:
return gconv.Bool(value)
case TypeObject:
sub, ok := value.(map[string]interface{})
if !ok {
return value
}
if attrs, ok := tmplMap["attrs"].(map[string]interface{}); ok {
return coerceObject(sub, attrs)
}
return value
case TypeArray:
arr, ok := value.([]interface{})
if !ok {
return value
}
proto := arrayElementTemplate(tmplMap)
if proto == nil {
return value
}
out := make([]interface{}, len(arr))
for i, elem := range arr {
out[i] = coerceNode(elem, proto)
}
return out
default:
return value
}
}
// coerceObject 按对象模板 attrs 归一对象子字段类型
func coerceObject(sub, attrs map[string]interface{}) map[string]interface{} {
for key, raw := range sub {
if tmplNode, has := attrs[key]; has {
sub[key] = coerceNode(raw, tmplNode)
}
}
return sub
}
// arrayElementTemplate 从数组模板节点提取元素模板(attrs 优先,其次 enumValues)。
// 与 arrayElementPrototype 语义一致,但直接工作在原始模板 map 上,供类型归一使用。
func arrayElementTemplate(field map[string]interface{}) map[string]interface{} {
if attrs, ok := field["attrs"].([]interface{}); ok && len(attrs) > 0 {
if m, ok := attrs[0].(map[string]interface{}); ok {
return m
}
}
if evs, ok := field["enumValues"].([]interface{}); ok && len(evs) > 0 {
if m, ok := evs[0].(map[string]interface{}); ok {
return m
}
}
return nil
}
-503
View File
@@ -1,503 +0,0 @@
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
}
+184 -371
View File
@@ -1,389 +1,202 @@
-- =========================
-- model_gateway_models
-- =========================
CREATE TABLE IF NOT EXISTS model_gateway_models (
id int8 PRIMARY KEY,
tenant_id int8 NOT NULL DEFAULT 0,
creator varchar(64) NOT NULL,
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater varchar(64) NOT NULL,
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at timestamp(6),
model_name varchar(128) NOT NULL,
model_type int2 NOT NULL DEFAULT 0,
operator_name varchar(64) NOT NULL DEFAULT '',
base_url varchar(256) NOT NULL,
http_method varchar(8) NOT NULL DEFAULT 'POST',
head_msg jsonb NOT NULL DEFAULT '{}',
api_key varchar(256) NOT NULL DEFAULT '',
is_private int2 NOT NULL DEFAULT 0,
enabled int2 NOT NULL DEFAULT 1,
is_chat_model int2 NOT NULL DEFAULT 0,
is_owner int2 NOT NULL DEFAULT 99,
form_json jsonb NOT NULL DEFAULT '{}',
request_mapping jsonb NOT NULL DEFAULT '{}',
response_mapping jsonb NOT NULL DEFAULT '{}',
response_body varchar(128) NOT NULL DEFAULT '',
token_config jsonb NOT NULL DEFAULT '{}',
extend_mapping jsonb NOT NULL DEFAULT '{}',
query_config jsonb NOT NULL DEFAULT '{}',
stream_config jsonb NOT NULL DEFAULT '{}',
first_frame varchar(128) NOT NULL DEFAULT '',
last_frame varchar(128) NOT NULL DEFAULT '',
max_concurrency int4 NOT NULL DEFAULT 10,
timeout_seconds int4 NOT NULL DEFAULT 600,
retry_times int2 NOT NULL DEFAULT 3,
response_token_field varchar(128) NOT NULL DEFAULT '',
call_mode int2 NOT NULL DEFAULT 0,
required_fields jsonb NOT NULL DEFAULT '[]',
max_tokens int4 DEFAULT 0
);
-- ============================================
-- 模型网关 (model-gateway) 建表语句
-- ============================================
CREATE UNIQUE INDEX IF NOT EXISTS uk_model_gateway_models_tenant_creator_model ON model_gateway_models (tenant_id, creator, model_name);
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_model_name ON model_gateway_models (model_name);
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_model_type ON model_gateway_models (model_type);
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_tenant_id ON model_gateway_models (tenant_id);
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_deleted_at ON model_gateway_models (deleted_at);
CREATE INDEX IF NOT EXISTS idx_model_gateway_models_enabled ON model_gateway_models (enabled);
-- 1. 模型配置表
CREATE TABLE "public"."model_gateway_models" (
"id" int8 NOT NULL,
"tenant_id" int8 NOT NULL DEFAULT 0,
"creator" varchar(64) NOT NULL,
"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) NOT NULL,
"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" timestamp(6),
"model_name" varchar(128) NOT NULL,
"model_type" int2 NOT NULL DEFAULT 0,
"operator_name" varchar(64) NOT NULL DEFAULT '',
"base_url" varchar(256) NOT NULL,
"http_method" varchar(8) NOT NULL DEFAULT 'POST',
"head_msg" jsonb NOT NULL DEFAULT '{}',
"api_key" varchar(256) NOT NULL DEFAULT '',
"is_private" int2 NOT NULL DEFAULT 0,
"enabled" int2 NOT NULL DEFAULT 1,
"is_chat_model" int2 NOT NULL DEFAULT 0,
"form_json" jsonb NOT NULL DEFAULT '{}',
"request_mapping" jsonb NOT NULL DEFAULT '{}',
"response_mapping" jsonb NOT NULL DEFAULT '{}',
"extend_mapping" jsonb NOT NULL DEFAULT '{}',
"query_config" jsonb NOT NULL DEFAULT '{}',
"stream_config" jsonb NOT NULL DEFAULT '{}',
"max_concurrency" int4 NOT NULL DEFAULT 10,
"timeout_seconds" int4 NOT NULL DEFAULT 600,
"retry_times" int2 NOT NULL DEFAULT 3,
"call_mode" int2 NOT NULL DEFAULT 0,
"required_fields" jsonb NOT NULL DEFAULT '[]',
"billing_config" jsonb NOT NULL DEFAULT '{}',
"special_params" jsonb NOT NULL DEFAULT '{}',
CONSTRAINT "model_gateway_models_pkey" PRIMARY KEY ("id")
);
COMMENT ON TABLE model_gateway_models IS '模型配置表';
COMMENT ON COLUMN model_gateway_models.id IS '主键ID(非自增)';
COMMENT ON COLUMN model_gateway_models.tenant_id IS '租户ID';
COMMENT ON COLUMN model_gateway_models.creator IS '创建人';
COMMENT ON COLUMN model_gateway_models.created_at IS '创建时间';
COMMENT ON COLUMN model_gateway_models.updater IS '更新人';
COMMENT ON COLUMN model_gateway_models.updated_at IS '更新时间';
COMMENT ON COLUMN model_gateway_models.deleted_at IS '删除时间(软删)';
ALTER TABLE "public"."model_gateway_models" OWNER TO "postgres";
COMMENT ON COLUMN model_gateway_models.model_name IS '模型名称';
COMMENT ON COLUMN model_gateway_models.model_type IS '模型类型';
COMMENT ON COLUMN model_gateway_models.operator_name IS '运营商名称';
COMMENT ON COLUMN model_gateway_models.base_url IS '模型地址';
COMMENT ON COLUMN model_gateway_models.http_method IS '请求方式 GET/POST';
COMMENT ON COLUMN model_gateway_models.head_msg IS '请求头信息';
COMMENT ON COLUMN model_gateway_models.api_key IS '调用凭证/密钥';
COMMENT ON COLUMN model_gateway_models.is_private IS '是否私有化:0-私有 1-公共';
COMMENT ON COLUMN model_gateway_models.enabled IS '是否启用:0-停用 1-启用';
COMMENT ON COLUMN model_gateway_models.is_chat_model IS '是否为对话模型:0-否 1-是';
COMMENT ON COLUMN model_gateway_models.is_owner IS '1=当前用户创建 0=超级管理员';
COMMENT ON COLUMN model_gateway_models.call_mode IS '调用模式:0-同步 1-异步 2-流式';
COMMENT ON COLUMN model_gateway_models.form_json IS '动态表单结构';
COMMENT ON COLUMN model_gateway_models.request_mapping IS '请求映射';
COMMENT ON COLUMN model_gateway_models.response_mapping IS '返回映射';
COMMENT ON COLUMN model_gateway_models.response_body IS '返回主体';
COMMENT ON COLUMN model_gateway_models.token_config IS 'Token计算配置';
COMMENT ON COLUMN model_gateway_models.extend_mapping IS '附加映射';
COMMENT ON COLUMN model_gateway_models.query_config IS '查询/回调配置';
COMMENT ON COLUMN model_gateway_models.stream_config IS '流式输出配置';
COMMENT ON COLUMN model_gateway_models.first_frame IS '首帧图片参数';
COMMENT ON COLUMN model_gateway_models.last_frame IS '尾帧图片参数';
COMMENT ON COLUMN model_gateway_models.max_concurrency IS '最大并发数';
COMMENT ON COLUMN model_gateway_models.timeout_seconds IS '调用模型超时(秒)';
COMMENT ON COLUMN model_gateway_models.retry_times IS '失败重试次数';
COMMENT ON COLUMN model_gateway_models.response_token_field IS '响应中消耗token的字段映射';
COMMENT ON COLUMN model_gateway_models.required_fields IS '必选字段列表';
COMMENT ON COLUMN model_gateway_models.max_tokens IS '最大 token 数,0 表示不传';
CREATE INDEX "idx_models_deleted_at" ON "public"."model_gateway_models" ("deleted_at");
CREATE INDEX "idx_models_enabled" ON "public"."model_gateway_models" ("enabled");
CREATE INDEX "idx_models_model_name" ON "public"."model_gateway_models" ("model_name");
CREATE INDEX "idx_models_model_type" ON "public"."model_gateway_models" ("model_type");
CREATE INDEX "idx_models_tenant_id" ON "public"."model_gateway_models" ("tenant_id");
CREATE INDEX "idx_models_tenant_creator" ON "public"."model_gateway_models" ("tenant_id", "creator");
CREATE INDEX "idx_models_tenant_creator_model_deleted" ON "public"."model_gateway_models" ("tenant_id", "creator", "model_name") WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX "uk_models_tenant_creator_model" ON "public"."model_gateway_models" ("tenant_id", "creator", "model_name");
COMMENT ON TABLE "public"."model_gateway_models" IS '模型配置表';
COMMENT ON COLUMN "public"."model_gateway_models"."model_name" IS '模型名称';
COMMENT ON COLUMN "public"."model_gateway_models"."model_type" IS '模型类型';
COMMENT ON COLUMN "public"."model_gateway_models"."operator_name" IS '运营商名称';
COMMENT ON COLUMN "public"."model_gateway_models"."base_url" IS '模型地址';
COMMENT ON COLUMN "public"."model_gateway_models"."http_method" IS '请求方式 GET/POST';
COMMENT ON COLUMN "public"."model_gateway_models"."head_msg" IS '请求头信息';
COMMENT ON COLUMN "public"."model_gateway_models"."api_key" IS '调用凭证/密钥';
COMMENT ON COLUMN "public"."model_gateway_models"."is_private" IS '是否私有化:0-私有 1-公共';
COMMENT ON COLUMN "public"."model_gateway_models"."enabled" IS '是否启用:0-停用 1-启用';
COMMENT ON COLUMN "public"."model_gateway_models"."is_chat_model" IS '是否为对话模型:0-否 1-是';
COMMENT ON COLUMN "public"."model_gateway_models"."form_json" IS '动态表单结构';
COMMENT ON COLUMN "public"."model_gateway_models"."request_mapping" IS '请求映射';
COMMENT ON COLUMN "public"."model_gateway_models"."response_mapping" IS '返回映射';
COMMENT ON COLUMN "public"."model_gateway_models"."extend_mapping" IS '附加映射';
COMMENT ON COLUMN "public"."model_gateway_models"."query_config" IS '查询/回调配置';
COMMENT ON COLUMN "public"."model_gateway_models"."stream_config" IS '流式输出配置';
COMMENT ON COLUMN "public"."model_gateway_models"."max_concurrency" IS '最大并发数';
COMMENT ON COLUMN "public"."model_gateway_models"."timeout_seconds" IS '调用模型超时(秒)';
COMMENT ON COLUMN "public"."model_gateway_models"."retry_times" IS '失败重试次数';
COMMENT ON COLUMN "public"."model_gateway_models"."call_mode" IS '调用模式:0-同步 1-异步 2-流式';
COMMENT ON COLUMN "public"."model_gateway_models"."required_fields" IS '必选字段列表';
COMMENT ON COLUMN "public"."model_gateway_models"."billing_config" IS '计费配置';
COMMENT ON COLUMN "public"."model_gateway_models"."special_params" IS '请求特殊参数';
-- 2. 模型网关任务表
CREATE TABLE "public"."model_gateway_task" (
"id" int8 NOT NULL,
"tenant_id" int8 NOT NULL DEFAULT 0,
"creator" varchar(64) NOT NULL,
"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) NOT NULL,
"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" timestamp(6),
"model_name" varchar(128) NOT NULL,
"task_id" varchar(64) NOT NULL,
"biz_name" varchar(128) NOT NULL DEFAULT '',
"callback_url" varchar(512) DEFAULT '',
"state" int2 NOT NULL DEFAULT 0,
"retry_count" int4 NOT NULL DEFAULT 0,
"error_msg" text DEFAULT '',
"result_file" jsonb NOT NULL DEFAULT '{}',
"request_payload" jsonb NOT NULL DEFAULT '{}',
"duration_seconds" int8 NOT NULL DEFAULT 0,
"epicycle_id" varchar(64) NOT NULL DEFAULT '',
"billing_data" jsonb NOT NULL DEFAULT '[]',
"build_model_name" varchar(128) NOT NULL DEFAULT '',
CONSTRAINT "model_gateway_task_pkey" PRIMARY KEY ("id")
);
-- =========================
-- model_gateway_task
-- =========================
CREATE TABLE IF NOT EXISTS model_gateway_task (
id int8 PRIMARY KEY,
tenant_id int8 NOT NULL DEFAULT 0,
creator varchar(64) NOT NULL,
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater varchar(64) NOT NULL,
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at timestamp(6),
model_name varchar(128) NOT NULL,
task_id varchar(64) NOT NULL,
biz_name varchar(128) NOT NULL DEFAULT '',
callback_url varchar(512) DEFAULT '',
state int2 NOT NULL DEFAULT 0,
retry_count int4 NOT NULL DEFAULT 0,
phase int2 NOT NULL DEFAULT 0,
tmp_file text DEFAULT '',
error_msg text DEFAULT '',
result_file jsonb NOT NULL DEFAULT '{}',
request_payload jsonb NOT NULL DEFAULT '{}',
text_result jsonb NOT NULL DEFAULT '{}',
expend_tokens int8 NOT NULL DEFAULT 0,
duration_seconds int8 NOT NULL DEFAULT 0,
epicycle_id varchar(64) NOT NULL DEFAULT ''
);
ALTER TABLE "public"."model_gateway_task" OWNER TO "postgres";
CREATE UNIQUE INDEX IF NOT EXISTS uk_model_gateway_task_tenant_creator_task_id ON model_gateway_task (tenant_id, creator, task_id);
CREATE INDEX IF NOT EXISTS idx_model_gateway_task_task_id ON model_gateway_task (task_id);
CREATE INDEX IF NOT EXISTS idx_model_gateway_task_state ON model_gateway_task (state);
CREATE INDEX IF NOT EXISTS idx_model_gateway_task_deleted_at ON model_gateway_task (deleted_at);
CREATE INDEX "idx_task_creator_created" ON "public"."model_gateway_task" ("creator", "created_at" DESC);
CREATE INDEX "idx_task_model_state" ON "public"."model_gateway_task" ("model_name", "state");
CREATE INDEX "idx_task_state" ON "public"."model_gateway_task" ("state");
CREATE INDEX "idx_task_state_created" ON "public"."model_gateway_task" ("state", "created_at");
CREATE INDEX "idx_task_task_id" ON "public"."model_gateway_task" ("task_id");
CREATE INDEX "idx_task_tenant_model" ON "public"."model_gateway_task" ("tenant_id", "model_name");
CREATE UNIQUE INDEX "uk_task_task_id" ON "public"."model_gateway_task" ("task_id");
COMMENT ON TABLE model_gateway_task IS '模型网关任务表';
COMMENT ON COLUMN model_gateway_task.id IS '主键ID';
COMMENT ON COLUMN model_gateway_task.tenant_id IS '租户ID';
COMMENT ON COLUMN model_gateway_task.creator IS '创建人';
COMMENT ON COLUMN model_gateway_task.created_at IS '创建时间';
COMMENT ON COLUMN model_gateway_task.updater IS '更新人';
COMMENT ON COLUMN model_gateway_task.updated_at IS '更新时间';
COMMENT ON COLUMN model_gateway_task.deleted_at IS '删除时间(软删)';
COMMENT ON COLUMN model_gateway_task.model_name IS '模型名称';
COMMENT ON COLUMN model_gateway_task.task_id IS '任务ID(对外返回';
COMMENT ON COLUMN model_gateway_task.biz_name IS '业务名称(调用方模块/系统';
COMMENT ON COLUMN model_gateway_task.callback_url IS '回调地址';
COMMENT ON COLUMN model_gateway_task.state IS '0排队中/1执行中/2成功/3失败/4已下载';
COMMENT ON COLUMN model_gateway_task.retry_count IS '已重试次数';
COMMENT ON COLUMN model_gateway_task.phase IS '执行阶段:0模型阶段/1OSS阶段';
COMMENT ON COLUMN model_gateway_task.tmp_file IS '临时结果文件路径';
COMMENT ON COLUMN model_gateway_task.error_msg IS '错误信息';
COMMENT ON COLUMN model_gateway_task.result_file IS '结果文件:{oss_file, file_type, file_size}';
COMMENT ON COLUMN model_gateway_task.request_payload IS '请求参数(JSON';
COMMENT ON COLUMN model_gateway_task.text_result IS '文本类结果';
COMMENT ON COLUMN model_gateway_task.expend_tokens IS '消耗token数';
COMMENT ON COLUMN model_gateway_task.duration_seconds IS '耗时(秒)';
COMMENT ON COLUMN model_gateway_task.epicycle_id IS '轮次ID';
COMMENT ON TABLE "public"."model_gateway_task" IS '模型网关任务表';
COMMENT ON COLUMN "public"."model_gateway_task"."model_name" IS '模型名称';
COMMENT ON COLUMN "public"."model_gateway_task"."task_id" IS '任务ID(对外返回)';
COMMENT ON COLUMN "public"."model_gateway_task"."biz_name" IS '业务名称';
COMMENT ON COLUMN "public"."model_gateway_task"."callback_url" IS '回调地址';
COMMENT ON COLUMN "public"."model_gateway_task"."state" IS '0排队中/1执行中/2成功/3失败/4已下载';
COMMENT ON COLUMN "public"."model_gateway_task"."retry_count" IS '已重试次数';
COMMENT ON COLUMN "public"."model_gateway_task"."error_msg" IS '错误信息';
COMMENT ON COLUMN "public"."model_gateway_task"."result_file" IS '结果文件:{oss_file, file_type, file_size}';
COMMENT ON COLUMN "public"."model_gateway_task"."request_payload" IS '请求参数(JSON';
COMMENT ON COLUMN "public"."model_gateway_task"."duration_seconds" IS '耗时(秒';
COMMENT ON COLUMN "public"."model_gateway_task"."epicycle_id" IS '轮次ID';
COMMENT ON COLUMN "public"."model_gateway_task"."billing_data" IS '计费数据';
COMMENT ON COLUMN "public"."model_gateway_task"."build_model_name" IS '构建模型名称';
-- 3. 操作日志表
CREATE TABLE "public"."model_gateway_logs_op" (
"id" int8 NOT NULL,
"tenant_id" int8 NOT NULL DEFAULT 0,
"creator" varchar(64) NOT NULL,
"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) NOT NULL,
"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" timestamp(6),
"ip" varchar(64) DEFAULT '',
"user_agent" varchar(256) DEFAULT '',
"api_path" varchar(256) DEFAULT '',
"http_method" varchar(16) DEFAULT '',
"biz_name" varchar(128) NOT NULL DEFAULT '',
"model_name" varchar(128) NOT NULL DEFAULT '',
"task_id" varchar(64) NOT NULL DEFAULT '',
"op_type" varchar(64) NOT NULL DEFAULT 'createTask',
"success" int2 NOT NULL DEFAULT 1,
"error_msg" text DEFAULT '',
"cost_ms" int8 NOT NULL DEFAULT 0,
"request_payload" jsonb,
"response_payload" jsonb,
CONSTRAINT "model_gateway_logs_op_pkey" PRIMARY KEY ("id")
);
-- =========================
-- model_gateway_log_stat
-- =========================
CREATE TABLE IF NOT EXISTS model_gateway_log_stat (
day date NOT NULL,
tenant_id int8 NOT NULL DEFAULT 0,
creator varchar(64) NOT NULL DEFAULT '',
model_name varchar(128) NOT NULL DEFAULT '',
request_count int8 NOT NULL DEFAULT 0,
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (day, tenant_id, creator, model_name)
);
ALTER TABLE "public"."model_gateway_logs_op" OWNER TO "postgres";
CREATE INDEX IF NOT EXISTS idx_model_gateway_log_stat_day ON model_gateway_log_stat (day);
CREATE INDEX IF NOT EXISTS idx_model_gateway_log_stat_creator ON model_gateway_log_stat (creator);
CREATE INDEX IF NOT EXISTS idx_model_gateway_log_stat_model_name ON model_gateway_log_stat (model_name);
CREATE INDEX IF NOT EXISTS idx_model_gateway_log_stat_tenant_day ON model_gateway_log_stat (tenant_id, day);
CREATE INDEX "idx_op_log_biz_name" ON "public"."model_gateway_logs_op" ("biz_name");
CREATE INDEX "idx_op_log_deleted_at" ON "public"."model_gateway_logs_op" ("deleted_at");
CREATE INDEX "idx_op_log_model_name" ON "public"."model_gateway_logs_op" ("model_name");
CREATE INDEX "idx_op_log_op_type" ON "public"."model_gateway_logs_op" ("op_type");
CREATE INDEX "idx_op_log_task_id" ON "public"."model_gateway_logs_op" ("task_id");
CREATE INDEX "idx_op_log_tenant_time" ON "public"."model_gateway_logs_op" ("tenant_id", "created_at");
CREATE INDEX "idx_op_log_model_time" ON "public"."model_gateway_logs_op" ("model_name", "created_at");
COMMENT ON TABLE model_gateway_log_stat IS '按天统计';
COMMENT ON COLUMN model_gateway_log_stat.day IS '天(YYYY-MM-DD';
COMMENT ON COLUMN model_gateway_log_stat.tenant_id IS '租户ID';
COMMENT ON COLUMN model_gateway_log_stat.creator IS '创建人';
COMMENT ON COLUMN model_gateway_log_stat.model_name IS '模型名称';
COMMENT ON COLUMN model_gateway_log_stat.request_count IS '请求次数';
COMMENT ON COLUMN model_gateway_log_stat.created_at IS '创建时间';
COMMENT ON COLUMN model_gateway_log_stat.updated_at IS '更新时间';
COMMENT ON TABLE "public"."model_gateway_logs_op" IS '操作日志';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."ip" IS '客户端IP';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."user_agent" IS 'User-Agent';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."api_path" IS '接口路径';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."http_method" IS 'HTTP方法';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."biz_name" IS '业务名称';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."model_name" IS '模型名称';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."task_id" IS '任务ID';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."op_type" IS '操作类型';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."success" IS '是否成功:1成功/0失败';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."error_msg" IS '错误信息';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."cost_ms" IS '耗时(毫秒)';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."request_payload" IS '请求 JSON';
COMMENT ON COLUMN "public"."model_gateway_logs_op"."response_payload" IS '响应 JSON';
-- =========================
-- model_gateway_logs_op
-- =========================
CREATE TABLE IF NOT EXISTS model_gateway_logs_op (
id int8 PRIMARY KEY,
tenant_id int8 NOT NULL DEFAULT 0,
creator varchar(64) NOT NULL,
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater varchar(64) NOT NULL,
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at timestamp(6),
ip varchar(64) DEFAULT '',
user_agent varchar(256) DEFAULT '',
api_path varchar(256) DEFAULT '',
http_method varchar(16) DEFAULT '',
biz_name varchar(128) NOT NULL DEFAULT '',
model_name varchar(128) NOT NULL DEFAULT '',
task_id varchar(64) NOT NULL DEFAULT '',
op_type varchar(64) NOT NULL DEFAULT 'createTask',
success int2 NOT NULL DEFAULT 1,
error_msg text DEFAULT '',
cost_ms int8 NOT NULL DEFAULT 0,
request_payload jsonb,
response_payload jsonb
);
-- 4. 按天统计表
CREATE TABLE "public"."model_gateway_logs_stat" (
"day" date NOT NULL,
"tenant_id" int8 NOT NULL DEFAULT 0,
"creator" varchar(64) NOT NULL DEFAULT '',
"model_name" varchar(128) NOT NULL DEFAULT '',
"request_count" int8 NOT NULL DEFAULT 0,
"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_task_id ON model_gateway_logs_op (task_id);
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_biz_name ON model_gateway_logs_op (biz_name);
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_model_name ON model_gateway_logs_op (model_name);
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_op_type ON model_gateway_logs_op (op_type);
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_deleted_at ON model_gateway_logs_op (deleted_at);
CREATE INDEX IF NOT EXISTS idx_model_gateway_logs_op_tenant_time ON model_gateway_logs_op (tenant_id, created_at);
ALTER TABLE "public"."model_gateway_logs_stat" OWNER TO "postgres";
COMMENT ON TABLE model_gateway_logs_op IS '操作日志表';
COMMENT ON COLUMN model_gateway_logs_op.id IS '主键ID(非自增)';
COMMENT ON COLUMN model_gateway_logs_op.tenant_id IS '租户ID';
COMMENT ON COLUMN model_gateway_logs_op.creator IS '创建人';
COMMENT ON COLUMN model_gateway_logs_op.created_at IS '创建时间';
COMMENT ON COLUMN model_gateway_logs_op.updater IS '更新人';
COMMENT ON COLUMN model_gateway_logs_op.updated_at IS '更新时间';
COMMENT ON COLUMN model_gateway_logs_op.deleted_at IS '删除时间(软删)';
COMMENT ON COLUMN model_gateway_logs_op.ip IS '客户端IP';
COMMENT ON COLUMN model_gateway_logs_op.user_agent IS 'User-Agent';
COMMENT ON COLUMN model_gateway_logs_op.api_path IS '接口路径';
COMMENT ON COLUMN model_gateway_logs_op.http_method IS 'HTTP方法';
COMMENT ON COLUMN model_gateway_logs_op.biz_name IS '业务名称(调用方模块/系统)';
COMMENT ON COLUMN model_gateway_logs_op.model_name IS '模型名称';
COMMENT ON COLUMN model_gateway_logs_op.task_id IS '任务ID';
COMMENT ON COLUMN model_gateway_logs_op.op_type IS '操作类型';
COMMENT ON COLUMN model_gateway_logs_op.success IS '是否成功:1成功/0失败';
COMMENT ON COLUMN model_gateway_logs_op.error_msg IS '错误信息(失败时)';
COMMENT ON COLUMN model_gateway_logs_op.cost_ms IS '耗时(毫秒)';
COMMENT ON COLUMN model_gateway_logs_op.request_payload IS '请求 JSON';
COMMENT ON COLUMN model_gateway_logs_op.response_payload IS '响应 JSON';
CREATE INDEX "idx_stat_creator" ON "public"."model_gateway_logs_stat" ("creator");
CREATE INDEX "idx_stat_day" ON "public"."model_gateway_logs_stat" ("day");
CREATE INDEX "idx_stat_model_name" ON "public"."model_gateway_logs_stat" ("model_name");
CREATE INDEX "idx_stat_tenant_day" ON "public"."model_gateway_logs_stat" ("tenant_id", "day");
-- =====================================================================================
--------------------pgsql创建model_gateway_model_manage表语句---------------------------
-- 模型管理表
CREATE TABLE IF NOT EXISTS model_gateway_model_manage (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
creator VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at timestamp(6),
model_supplier VARCHAR(64) NOT NULL DEFAULT '',
model_name VARCHAR(128) NOT NULL DEFAULT '',
model_type INT NOT NULL DEFAULT 0,
base_url VARCHAR(512) NOT NULL DEFAULT '',
http_method VARCHAR(32) NOT NULL DEFAULT '',
system_model BOOLEAN NOT NULL DEFAULT false,
private_model BOOLEAN NOT NULL DEFAULT false,
chat_model BOOLEAN NOT NULL DEFAULT false,
invoke_type VARCHAR(32) NOT NULL DEFAULT '',
api_key VARCHAR(255) NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT false,
request_mapping JSONB DEFAULT '{}',
response_mapping JSONB DEFAULT '{}',
max_concurrency INT NOT NULL DEFAULT 0
);
-- 索引
CREATE INDEX idx_model_manage_tenant_id ON model_gateway_model_manage(tenant_id);
CREATE INDEX idx_model_manage_supplier ON model_gateway_model_manage(model_supplier);
CREATE INDEX idx_model_manage_type ON model_gateway_model_manage(model_type);
CREATE INDEX idx_model_manage_enabled ON model_gateway_model_manage(enabled);
CREATE INDEX idx_model_manage_deleted_at ON model_gateway_model_manage(deleted_at);
-- 字段与表注释
COMMENT ON TABLE model_gateway_model_manage IS '模型管理表';
COMMENT ON COLUMN model_gateway_model_manage.id IS '主键ID';
COMMENT ON COLUMN model_gateway_model_manage.tenant_id IS '租户ID';
COMMENT ON COLUMN model_gateway_model_manage.creator IS '创建人';
COMMENT ON COLUMN model_gateway_model_manage.created_at IS '创建时间';
COMMENT ON COLUMN model_gateway_model_manage.updater IS '更新人';
COMMENT ON COLUMN model_gateway_model_manage.updated_at IS '更新时间';
COMMENT ON COLUMN model_gateway_model_manage.deleted_at IS '删除时间(软删)';
COMMENT ON COLUMN model_gateway_model_manage.model_supplier IS '模型供应商';
COMMENT ON COLUMN model_gateway_model_manage.model_name IS '模型名称';
COMMENT ON COLUMN model_gateway_model_manage.model_type IS '模型类型';
COMMENT ON COLUMN model_gateway_model_manage.base_url IS '模型地址';
COMMENT ON COLUMN model_gateway_model_manage.http_method IS 'http方法';
COMMENT ON COLUMN model_gateway_model_manage.system_model IS '是否系统模型';
COMMENT ON COLUMN model_gateway_model_manage.private_model IS '是否私有模型';
COMMENT ON COLUMN model_gateway_model_manage.chat_model IS '是否聊天模型';
COMMENT ON COLUMN model_gateway_model_manage.invoke_type IS '调用类型';
COMMENT ON COLUMN model_gateway_model_manage.api_key IS 'api key';
COMMENT ON COLUMN model_gateway_model_manage.enabled IS '是否启用';
COMMENT ON COLUMN model_gateway_model_manage.request_mapping IS '请求映射';
COMMENT ON COLUMN model_gateway_model_manage.response_mapping IS '响应映射';
COMMENT ON COLUMN model_gateway_model_manage.max_concurrency IS '最大并发数';
--------------------pgsql创建model_gateway_model_manage表语句---------------------------
-- =========================
-- 计费规则:model_manage 新增 price_configJSONB),model_session 新增 total_costNUMERIC
-- =========================
ALTER TABLE model_gateway_model_manage
ADD COLUMN IF NOT EXISTS price_config JSONB DEFAULT NULL;
COMMENT ON COLUMN model_gateway_model_manage.price_config
IS '计费规则:{currency,unit,dimensions,rules,discount},未配置为NULL(费用按0处理)';
ALTER TABLE model_gateway_session
ADD COLUMN IF NOT EXISTS total_cost NUMERIC DEFAULT 0;
COMMENT ON COLUMN model_gateway_session.total_cost
IS '本次调用总费用(元),未配置计费规则为0';
-- =========================
-- 异步任务计费:task_start 快照 media_typetask_end 记录 total_cost
-- 模型计费配置(price_config)任务完成时按 modelId 从 model_manage 现查,不在 task_start 快照
-- =========================
ALTER TABLE model_gateway_model_task_start
ADD COLUMN IF NOT EXISTS media_type VARCHAR(32) DEFAULT NULL;
COMMENT ON COLUMN model_gateway_model_task_start.media_type
IS '输入媒体类型快照(audio/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 '引用的系统模型IDNULL=非引用行;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_mappingJSONBschema 树形态,解析模型错误响应用)
-- =========================
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 UNIQUE INDEX IF NOT EXISTS uk_error_memory_memory_key
ON model_gateway_model_error_memory (memory_key)
WHERE deleted_at IS NULL;
COMMENT ON TABLE "public"."model_gateway_logs_stat" IS '按天统计表';
COMMENT ON COLUMN "public"."model_gateway_logs_stat"."day" IS '天(YYYY-MM-DD';
COMMENT ON COLUMN "public"."model_gateway_logs_stat"."tenant_id" IS '租户ID';
COMMENT ON COLUMN "public"."model_gateway_logs_stat"."creator" IS '创建人';
COMMENT ON COLUMN "public"."model_gateway_logs_stat"."model_name" IS '模型名称';
COMMENT ON COLUMN "public"."model_gateway_logs_stat"."request_count" IS '请求次数';