Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ee3722a6e | ||
|
|
5d2c63ced1 |
+2
-1
@@ -27,4 +27,5 @@ bak/
|
|||||||
# *.pyc
|
# *.pyc
|
||||||
# !dir/*.pycnode_modules/
|
# !dir/*.pycnode_modules/
|
||||||
*.tmp.*
|
*.tmp.*
|
||||||
*_上线.py
|
*_上线.py
|
||||||
|
.env*
|
||||||
-953
@@ -1,953 +0,0 @@
|
|||||||
# 对外开放模型 API v3 接口文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本文档描述视频/图片生成平台的对外开放 API v3 接口。外部调用方通过 API Key 认证,调用 AI 视频和图片生成能力。
|
|
||||||
|
|
||||||
- **Base URL**: `http://your-domain.com/api/v3`
|
|
||||||
- **认证方式**: `Authorization: Bearer {api-key}`
|
|
||||||
- **数据格式**: JSON
|
|
||||||
- **字符编码**: UTF-8
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 认证
|
|
||||||
|
|
||||||
所有接口均需在请求头中携带 API Key:
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer vk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 错误响应
|
|
||||||
|
|
||||||
认证失败时返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
// 401 API Key 无效或过期
|
|
||||||
{
|
|
||||||
"error": {
|
|
||||||
"code": "invalid_api_key",
|
|
||||||
"message": "无效的 API Key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 429 配额不足
|
|
||||||
{
|
|
||||||
"error": {
|
|
||||||
"code": "quota_exceeded",
|
|
||||||
"message": "配额不足 (需要 1.00 元, 剩余 0.50 元)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 接口列表
|
|
||||||
|
|
||||||
### 1. 获取可用模型列表
|
|
||||||
|
|
||||||
获取当前 API Key 可调用的所有视频和图片模型(仅返回已配置价格的模型)。
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/models
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Authorization | 是 | `Bearer {api-key}` |
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"models": [
|
|
||||||
{
|
|
||||||
"model": "doubao-seedance-2-0-260128",
|
|
||||||
"engine_type": "video",
|
|
||||||
"engine_id": "eng_xxxx",
|
|
||||||
"supported_ratios": ["16:9", "9:16", "1:1", "4:3"],
|
|
||||||
"supported_resolutions": ["480p", "720p", "1080p"],
|
|
||||||
"supported_durations": [3, 4, 5, 6, 7, 8, 9, 10, 15]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "doubao-seedream-5-0-260128",
|
|
||||||
"engine_type": "image",
|
|
||||||
"engine_id": "eng_yyyy",
|
|
||||||
"supported_sizes": ["2K", "4K"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| models | array | 可用模型列表 |
|
|
||||||
| models[].model | string | 模型名称 |
|
|
||||||
| models[].engine_type | string | 引擎类型: `video` / `image` |
|
|
||||||
| models[].engine_id | string | 引擎 ID |
|
|
||||||
| models[].supported_ratios | string[] | 视频支持的比例列表 |
|
|
||||||
| models[].supported_resolutions | string[] | 视频支持的分辨率列表 |
|
|
||||||
| models[].supported_durations | int[] | 视频支持的时长列表(秒) |
|
|
||||||
| models[].supported_sizes | string[] | 图片支持的尺寸列表 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 创建视频生成任务(异步)
|
|
||||||
|
|
||||||
创建视频生成任务,接口立即返回 `task_id`,调用方通过轮询查询任务状态和结果。
|
|
||||||
|
|
||||||
**并发排队机制:**
|
|
||||||
- 每个 API Key 可配置最大并发视频任务数(`max_concurrent_video_tasks`)
|
|
||||||
- 未超并发:任务立即执行,`status="queued"`
|
|
||||||
- 超过并发:任务排队等待,`status="pending_queue"`
|
|
||||||
- 当有任务完成/失败时,自动从队列中启动下一个任务
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v3/videos
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Authorization | 是 | `Bearer {api-key}` |
|
|
||||||
| Content-Type | 是 | `application/json` |
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "doubao-seedance-2-0-260128",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"text": "一只猫在草地上奔跑"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {"url": "https://..."},
|
|
||||||
"role": "reference_image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "video_url",
|
|
||||||
"video_url": {"url": "https://..."},
|
|
||||||
"role": "reference_video"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "audio_url",
|
|
||||||
"audio_url": {"url": "https://..."},
|
|
||||||
"role": "reference_audio"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ratio": "16:9",
|
|
||||||
"duration": 5,
|
|
||||||
"resolution": "1080p",
|
|
||||||
"generate_audio": true,
|
|
||||||
"watermark": false,
|
|
||||||
"idempotency_key": "unique-key-123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
|
||||||
|------|------|------|--------|------|
|
|
||||||
| model | string | 是 | - | 模型名称 |
|
|
||||||
| content | array | 是 | - | 生成内容数组(见下方) |
|
|
||||||
| ratio | string | 否 | `16:9` | 视频比例: `16:9` / `9:16` / `1:1` / `4:3` / `3:4` / `21:9` |
|
|
||||||
| duration | int | 否 | `5` | 视频时长(秒): 3-15 |
|
|
||||||
| resolution | string | 否 | `480p` | 分辨率: `480p` / `720p` / `1080p` |
|
|
||||||
| generate_audio | bool | 否 | `true` | 是否生成音频 |
|
|
||||||
| watermark | bool | 否 | `false` | 是否添加水印 |
|
|
||||||
| idempotency_key | string | 否 | - | 幂等键,防止重复创建 |
|
|
||||||
|
|
||||||
**content 数组元素 (ApiVideoContentPart):**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| type | string | 是 | 内容类型: `text` / `image_url` / `video_url` / `audio_url` |
|
|
||||||
| text | string | 否 | 文本内容(type=text 时必填) |
|
|
||||||
| image_url | object | 否 | 图片URL对象: `{"url": "..."}`(type=image_url 时必填) |
|
|
||||||
| video_url | object | 否 | 视频URL对象: `{"url": "..."}`(type=video_url 时必填) |
|
|
||||||
| audio_url | object | 否 | 音频URL对象: `{"url": "..."}`(type=audio_url 时必填) |
|
|
||||||
| role | string | 否 | 参考角色: `first_frame` / `last_frame` / `reference_image` / `reference_video` / `reference_audio` |
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "cgt-20260730183334-wdgfl"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | string | 任务 ID,用于查询状态 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 查询视频任务状态
|
|
||||||
|
|
||||||
根据 `task_id` 查询视频生成任务的状态和结果。
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/videos/{task_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Authorization | 是 | `Bearer {api-key}` |
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
| 参数 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| task_id | string | 创建任务时返回的 task_id |
|
|
||||||
|
|
||||||
**响应示例(排队中):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "cgt-20260730183334-wdgfl",
|
|
||||||
"model": "doubao-seedance-2-0-mini-260615",
|
|
||||||
"status": "queued",
|
|
||||||
"created_at": 1785407620,
|
|
||||||
"updated_at": 1785407620,
|
|
||||||
"content": null,
|
|
||||||
"duration": null,
|
|
||||||
"ratio": null,
|
|
||||||
"resolution": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例(运行中):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "cgt-20260730183334-wdgfl",
|
|
||||||
"model": "doubao-seedance-2-0-mini-260615",
|
|
||||||
"status": "running",
|
|
||||||
"created_at": 1785407620,
|
|
||||||
"updated_at": 1785407650,
|
|
||||||
"content": null,
|
|
||||||
"duration": null,
|
|
||||||
"ratio": null,
|
|
||||||
"resolution": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例(成功):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "cgt-20260730183334-wdgfl",
|
|
||||||
"model": "doubao-seedance-2-0-mini-260615",
|
|
||||||
"status": "succeeded",
|
|
||||||
"created_at": 1785407620,
|
|
||||||
"updated_at": 1785407723,
|
|
||||||
"content": {
|
|
||||||
"video_url": "https://..."
|
|
||||||
},
|
|
||||||
"duration": 4,
|
|
||||||
"ratio": "9:16",
|
|
||||||
"resolution": "480p"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例(失败):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "cgt-20260730183334-wdgfl",
|
|
||||||
"model": "doubao-seedance-2-0-mini-260615",
|
|
||||||
"status": "failed",
|
|
||||||
"created_at": 1785407620,
|
|
||||||
"updated_at": 1785407650,
|
|
||||||
"content": null,
|
|
||||||
"duration": null,
|
|
||||||
"ratio": null,
|
|
||||||
"resolution": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | string | 任务 ID |
|
|
||||||
| model | string | 模型名称 |
|
|
||||||
| status | string | 任务状态(见下方状态说明) |
|
|
||||||
| created_at | int | 创建时间戳(Unix) |
|
|
||||||
| updated_at | int | 更新时间戳(Unix) |
|
|
||||||
| content | object | 视频内容(成功时返回,包含 video_url) |
|
|
||||||
| duration | int | 视频时长(秒) |
|
|
||||||
| ratio | string | 视频比例 |
|
|
||||||
| resolution | string | 分辨率 |
|
|
||||||
|
|
||||||
**任务状态说明:**
|
|
||||||
|
|
||||||
| 状态 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `queued` | 排队中 |
|
|
||||||
| `running` | 任务运行中 |
|
|
||||||
| `succeeded` | 任务成功 |
|
|
||||||
| `failed` | 任务失败 |
|
|
||||||
| `expired` | 任务超时 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 生成图片(同步)
|
|
||||||
|
|
||||||
同步生成图片,接口阻塞等待完成后直接返回结果。
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v3/images
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Authorization | 是 | `Bearer {api-key}` |
|
|
||||||
| Content-Type | 是 | `application/json` |
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "doubao-seedream-5-0-260128",
|
|
||||||
"prompt": "一只可爱的猫咪",
|
|
||||||
"size": "2K",
|
|
||||||
"response_format": "url",
|
|
||||||
"watermark": false,
|
|
||||||
"image": ["https://example.com/ref1.jpg"],
|
|
||||||
"output_format": "png",
|
|
||||||
"sequential_image_generation": "auto",
|
|
||||||
"generation_count": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
|
||||||
|------|------|------|--------|------|
|
|
||||||
| model | string | 是 | - | 模型名称 |
|
|
||||||
| prompt | string | 是 | - | 图片描述提示词 |
|
|
||||||
| size | string | 否 | `2K` | 图片尺寸: `2K` / `4K` 或 `2048x2048` |
|
|
||||||
| response_format | string | 否 | `url` | 返回格式: `url` / `b64_json` |
|
|
||||||
| watermark | bool | 否 | `false` | 是否添加水印 |
|
|
||||||
| image | string[] | 否 | - | 参考图片 URL 列表 |
|
|
||||||
| output_format | string | 否 | - | 输出格式: `jpeg` / `png` / `webp` |
|
|
||||||
| sequential_image_generation | string | 否 | - | 组图模式: `auto` 开启 |
|
|
||||||
| generation_count | int | 否 | `1` | 生成数量: 1-5 |
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"created": 1721000000,
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"url": "https://volc.example.com/image/xxx.png",
|
|
||||||
"size": "2K",
|
|
||||||
"output_format": "png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"model": "doubao-seedream-5-0-260128"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| created | int | 创建时间戳(Unix) |
|
|
||||||
| data | array | 图片结果列表 |
|
|
||||||
| data[].url | string | 图片 URL |
|
|
||||||
| data[].b64_json | string | Base64 编码图片(response_format=b64_json 时) |
|
|
||||||
| data[].size | string | 图片尺寸 |
|
|
||||||
| data[].output_format | string | 输出格式 |
|
|
||||||
| model | string | 使用的模型名称 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 虚拟素材库接口(中转)
|
|
||||||
|
|
||||||
虚拟素材库用于在**火山方舟私域可信素材服务**中管理专属的图片/视频素材(如客户专属虚拟人)。数据与前台用户私域素材库完全隔离,归属按 API Key 管理。
|
|
||||||
|
|
||||||
**启用前置条件:**
|
|
||||||
1. 管理员在后台「API Key 管理 → 详情 → 虚拟素材库配额」中配置项目数/素材数/存储 MB 上限(默认 0=不可用)
|
|
||||||
2. 任一上限大于 0 即表示该 API Key 启用了虚拟素材库功能
|
|
||||||
3. 所有操作占用对应配额,超出上限返回 403 `quota_exceeded`
|
|
||||||
|
|
||||||
**生命周期流程(新,一步到位):**
|
|
||||||
1. 创建虚拟项目(CreateAssetGroup 建远端组)
|
|
||||||
2. 直接调用「项目下创建素材」接口,**仅传一个公网可访问的 URL**(http/https 图片/视频直链)
|
|
||||||
- 服务端先将该 URL 的文件**下载保存到本地存储系统**(路径见下),占用存储配额
|
|
||||||
- 保存成功后,再将**本地公网 URL** 同步提交给火山平台 CreateAsset 做异步审核
|
|
||||||
3. 素材状态 Creating → 轮询 `/assets/{id}` 或 `/assets/{id}/sync` 主动刷新 → 状态 Active(可使用)
|
|
||||||
4. AI 创作时通过 `/selectable-assets` 选择器拿到已就绪素材
|
|
||||||
5. 素材/项目删除(软删本地 + 同步清理本地落盘文件 → 异步删火山远端,返回 `remote_delete_status=pending`)
|
|
||||||
|
|
||||||
**素材文件本地保存路径(服务端自动处理,调用方无需关心):**
|
|
||||||
- 图片:`/uploads/images/vp_v3_virtual/{api_key_id_short}/yyyy/mm/dd/vp_v3_{uuid}.{ext}`
|
|
||||||
- 视频:`/uploads/videos/vp_v3_virtual/{api_key_id_short}/yyyy/mm/dd/vp_v3_{uuid}.{ext}`
|
|
||||||
|
|
||||||
**技术细节 & 错误处理(保证数据一致性):**
|
|
||||||
1. URL 下载阶段失败(网络/超时/4xx/5xx/大文件/非法 MIME):立即清理临时文件,不占用任何配额,返回对应错误码
|
|
||||||
2. 下载成功但**配额不足**:立即删除已下载的本地文件,释放磁盘,再抛 403 `quota_exceeded`
|
|
||||||
3. 视频时长:若请求未传 `video_duration`,服务端自动 ffprobe 探测;两者都失败则删本地文件并 400 要求显式传时长
|
|
||||||
4. 本地写库成功但**火山 CreateAsset 失败**:保留本地文件(已占配额和素材数),素材状态标记为 `Failed`,错误信息记录在 `error_message` / `moderation_json`。调用方可选择:
|
|
||||||
- 保留并排查(后续可调用 DELETE 删除 → 自动清理本地文件 + 返还配额)
|
|
||||||
- 直接 DELETE 重试
|
|
||||||
5. 删除素材:**本地 commit 时同步删除本地落盘文件**(目录穿越防御,仅允许删 `/uploads` 目录内),返还存储配额和素材数配额;火山远端异步删除
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 获取配额配置
|
|
||||||
|
|
||||||
获取当前 API Key 的虚拟素材库配额上限和已使用量,判断是否可用。
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/virtual-portrait/config
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"project_limit": 5,
|
|
||||||
"asset_limit": 50,
|
|
||||||
"storage_mb_limit": 500,
|
|
||||||
"project_used": 2,
|
|
||||||
"asset_used": 18,
|
|
||||||
"storage_mb_used": 128.43,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| project_limit | int | 虚拟项目上限,0=不可创建 |
|
|
||||||
| asset_limit | int | 虚拟素材总数上限(图片+视频),0=不可上传 |
|
|
||||||
| storage_mb_limit | int | 上传存储上限 MB,0=不可上传文件 |
|
|
||||||
| project_used | int | 已创建项目数(未删除) |
|
|
||||||
| asset_used | int | 已上传素材数(未删除) |
|
|
||||||
| storage_mb_used | float | 已占用存储 MB |
|
|
||||||
| enabled | bool | 该 Key 是否可使用虚拟素材库功能(任一上限>0即可) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. 获取枚举元数据
|
|
||||||
|
|
||||||
返回素材/项目所有枚举值及其说明,便于前端展示筛选选项。
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/virtual-portrait/enums
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"asset_type": { "Image": "图片素材", "Video": "视频素材" },
|
|
||||||
"asset_status": {
|
|
||||||
"creating": "创建中/审核中",
|
|
||||||
"active": "已就绪/可用",
|
|
||||||
"failed": "失败",
|
|
||||||
"deleting": "删除中"
|
|
||||||
},
|
|
||||||
"project_status": {
|
|
||||||
"creating_remote_group": "远端组创建中",
|
|
||||||
"active": "就绪",
|
|
||||||
"create_group_failed": "远端组创建失败",
|
|
||||||
"deleting": "删除中"
|
|
||||||
},
|
|
||||||
"remote_delete_status": {
|
|
||||||
"none": "未删除",
|
|
||||||
"pending": "待异步删除",
|
|
||||||
"processing": "远端删除中",
|
|
||||||
"deleted": "远端已删除",
|
|
||||||
"failed": "远端删除失败"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. 虚拟项目 CRUD
|
|
||||||
|
|
||||||
#### 7.1 创建项目
|
|
||||||
|
|
||||||
创建一个虚拟素材项目(同步调火山 CreateAssetGroup 创建远端素材组)。
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v3/virtual-portrait/projects
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "客户A的虚拟人素材",
|
|
||||||
"description": "用于客户A的电商视频生成(可选)"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**字段说明:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| name | string | 是 | 项目名称,1-100 字符 |
|
|
||||||
| description | string | 否 | 项目描述,最多 500 字符 |
|
|
||||||
|
|
||||||
**响应(VpV3ProjectOut):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Id": "0019xxxxxxxxxxxxxxxx"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7.2 查询项目列表
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/virtual-portrait/projects
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query 参数:**
|
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| page | int | 否 | 页码,默认 1 |
|
|
||||||
| page_size | int | 否 | 每页数量 1-100,默认 20 |
|
|
||||||
| keyword | string | 否 | 项目名称模糊搜索 |
|
|
||||||
| status | string | 否 | 项目状态筛选(不传查全部) |
|
|
||||||
|
|
||||||
**响应:** `{ "items": [...], "total": N, "page": X, "page_size": Y }`
|
|
||||||
|
|
||||||
#### 7.3 项目详情
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/virtual-portrait/projects/{project_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7.4 更新项目
|
|
||||||
|
|
||||||
修改展示信息(名称/描述),不会重新创建火山远端 Group。
|
|
||||||
|
|
||||||
```
|
|
||||||
PUT /api/v3/virtual-portrait/projects/{project_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7.5 删除项目
|
|
||||||
|
|
||||||
软删项目和其下所有素材。**本地 commit 后会投递 Celery 异步任务去删除火山远端 AssetGroup/Asset**,接口返回 `remote_delete_status=pending` 表示远端删除处理中,可通过项目详情接口轮询最终状态。
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /api/v3/virtual-portrait/projects/{project_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "success": true, "remote_delete_status": "pending" }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. 虚拟素材 CRUD
|
|
||||||
|
|
||||||
#### 8.1 在项目下创建素材
|
|
||||||
|
|
||||||
创建素材(一步到位:**仅传 URL**:
|
|
||||||
1. 服务端先将 `source_url`(公网 http(s))下载保存到本地存储系统(自动校验 URL/网络/MIME/大小)
|
|
||||||
2. 下载成功后占用 **本地存储配额 & 素材数配额
|
|
||||||
3. 再将本地公网 URL 同步提交给火山方舟 CreateAsset 进行异步审核
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v3/virtual-portrait/projects/{project_id}/assets
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source_url": "https://cdn.example.com/avatars/portrait_01.png",
|
|
||||||
"name": "虚拟人正面照片",
|
|
||||||
"asset_type": "Image"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求字段(新):**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| source_url | string | 是 | **公网可访问的 http(s) 图片/视频直链**,最多 2000 字符。<br/>⚠️ 不允许指向内网/本机地址(SSRF 防御) |
|
|
||||||
| name | string | 否 | 素材展示名,1-100 字符;不传自动从 URL 文件名或响应头 Content-Disposition 推断 |
|
|
||||||
| asset_type | string | 是 | `Image` / `Video` |
|
|
||||||
| video_duration | float | Video 可选 | 视频时长秒(1-60)。Video 不传会自动 ffprobe 探测,两者都失败则返回 400 需显式传入 |
|
|
||||||
| video_cover_url | string | 否(Video) | 视频封面图 URL(可选,仅 Video 用) |
|
|
||||||
|
|
||||||
> 💡 **不再需要**:`upload_resource_id` / `file_size_bytes` / `mime_type` —— 服务端自动探测并写入。
|
|
||||||
|
|
||||||
**创建流程时序(服务端内部处理步骤):**
|
|
||||||
1. 校验 URL 格式(http/https + 非内网) → 400
|
|
||||||
2. 下载 URL 文件到临时目录
|
|
||||||
- HTTP 4xx/5xx → 502(含前 200B 响应片段);连接/读取超时 → 502
|
|
||||||
- Content-Type 非法 → 415(application/octet-stream 除外);大小超限 → 413
|
|
||||||
- 任一步失败:立即清理临时文件,不占配额
|
|
||||||
3. 配额校验(素材数 + 存储 MB,按真实大小)→ 不足则删除刚下载的本地文件,403
|
|
||||||
4. Video 时长合并校验(payload 优先,否则 ffprobe 探测) → 非法删本地文件并 400
|
|
||||||
5. 写 VpV3Asset(status=Creating)+ 刷新 next_poll_at
|
|
||||||
6. 调火山 CreateAsset(url=本地公网 URL) → 成功返回 Creating + remote_asset_id;失败则 status=Failed, error_message=错误
|
|
||||||
|
|
||||||
**响应:VpV3AssetOut**(字段见素材详情)
|
|
||||||
|
|
||||||
创建成功后 `status=Creating`,火山审核 3-30 秒,建议:
|
|
||||||
- 轮询 GET /assets/{id} 或 POST /assets/{id}/sync 主动刷新
|
|
||||||
- 直到 status=Active 才能在 AI 创作中使用
|
|
||||||
- 如果 status=Failed,读取 `error_message` / `moderation_json` 查看原因;可选择 DELETE 后重试
|
|
||||||
|
|
||||||
#### 8.2 查询项目下素材列表
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/virtual-portrait/projects/{project_id}/assets
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query 参数:**
|
|
||||||
|
|
||||||
| 参数 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| page / page_size | int | 分页,默认 1/20 |
|
|
||||||
| status | string | 素材状态筛选:Creating/Active/Failed/Deleting |
|
|
||||||
| keyword | string | 素材名称模糊搜索 |
|
|
||||||
| asset_type | string | Image / Video |
|
|
||||||
|
|
||||||
#### 8.3 素材详情
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/virtual-portrait/assets/{asset_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 8.4 主动同步素材状态
|
|
||||||
|
|
||||||
主动调火山 GetAsset 刷新素材状态、URL、审核结果(轮询中断或前端主动刷新时使用)。
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v3/virtual-portrait/assets/{asset_id}/sync
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 8.5 删除素材
|
|
||||||
|
|
||||||
软删素材。本地 commit 时**同步删除本地落盘文件**(自动清理 `/uploads/...` 目录下的文件,带目录穿越防御),再投递 Celery 异步任务删除火山远端 Asset,返回 `remote_delete_status=pending` 表示处理中。
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /api/v3/virtual-portrait/assets/{asset_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:** `{ "success": true, "remote_delete_status": "pending" }`
|
|
||||||
|
|
||||||
> 说明:
|
|
||||||
> - 本地文件删除失败只打日志,不会影响素材状态置为 deleting + soft-delete(避免远端删除也回滚)
|
|
||||||
> - 删除成功后存储/素材数配额会自动返还(`/config` 接口再次查询可见 used 降低)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. AI 创作选择器素材
|
|
||||||
|
|
||||||
只返回当前 API Key 虚拟素材库中 **status=Active** 的图片/视频素材,提供给 AI 创作参考素材选择器使用。
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v3/virtual-portrait/selectable-assets
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query 参数:**
|
|
||||||
|
|
||||||
| 参数 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| page / page_size | int | 分页,默认 1/20(1-100) |
|
|
||||||
| project_id | string | 可选,按项目筛选 |
|
|
||||||
| keyword | string | 可选,素材名称模糊搜索 |
|
|
||||||
| asset_type | string | 可选,Image / Video |
|
|
||||||
|
|
||||||
**响应字段(VpV3SelectableAssetOut):**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| asset_id | string | 素材 ID,供后续带入生成(预留使用:`source=vp_v3_asset, asset_id`) |
|
|
||||||
| project_id | string | 所属项目 ID |
|
|
||||||
| name | string | 素材名称 |
|
|
||||||
| asset_type | string | Image/Video |
|
|
||||||
| status | string | Active |
|
|
||||||
| source_url | string | 原始上传 URL |
|
|
||||||
| preview_url | string | 显示用预览 URL(直接绑定 img/video src) |
|
|
||||||
| video_duration | float | 视频时长秒(Video 时有值) |
|
|
||||||
| video_cover_url | string | 视频封面 |
|
|
||||||
| file_size_bytes | int | 文件大小字节 |
|
|
||||||
| created_at | datetime | 创建时间 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 虚拟素材库配额限制错误
|
|
||||||
|
|
||||||
当 API Key 的虚拟素材库配额不足时,会返回 403:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": {
|
|
||||||
"code": "quota_exceeded",
|
|
||||||
"message": "虚拟素材库配额不足:素材总数 上限 50,已使用 50,本次需要 1,超出上限"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
如果管理员完全没有配置配额(全 0),任何虚拟素材库操作都返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": {
|
|
||||||
"code": "forbidden",
|
|
||||||
"message": "当前 API Key 未开启虚拟素材库功能,请联系管理员配置配额"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 价格计算规则
|
|
||||||
|
|
||||||
API 采用**先扣后退回**策略:任务创建/生成前预扣配额,失败时自动退回。
|
|
||||||
|
|
||||||
### 视频价格公式
|
|
||||||
|
|
||||||
```
|
|
||||||
基础费用 = (base_price + per_second_price × duration) × price_ratio
|
|
||||||
|
|
||||||
传入视频附加 = (input_video_base_price + input_video_per_second_price × 视频时长) × input_video_ratio
|
|
||||||
传入图片附加 = (input_image_base_price + input_image_per_image_price × 图片数量) × input_image_ratio
|
|
||||||
|
|
||||||
总价格 = 基础费用 + 传入视频附加 + 传入图片附加
|
|
||||||
```
|
|
||||||
|
|
||||||
### 图片价格公式
|
|
||||||
|
|
||||||
```
|
|
||||||
基础费用 = base_price × price_ratio
|
|
||||||
|
|
||||||
传入图片附加 = (input_image_base_price + input_image_per_image_price × 图片数量) × input_image_ratio
|
|
||||||
|
|
||||||
总价格 = 基础费用 + 传入图片附加
|
|
||||||
```
|
|
||||||
|
|
||||||
### 价格查找优先级
|
|
||||||
|
|
||||||
1. 引擎专属规则: `gen_type + engine_id + resolution`
|
|
||||||
2. 降级: `gen_type + resolution`(取 base_price 最高的)
|
|
||||||
3. 未配置价格的模型不会出现在可用列表中
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 状态码说明
|
|
||||||
|
|
||||||
| 状态码 | 说明 |
|
|
||||||
|--------|------|
|
|
||||||
| 200 | 请求成功 |
|
|
||||||
| 202 | 任务已创建(视频接口) |
|
|
||||||
| 401 | API Key 无效或缺失 |
|
|
||||||
| 403 | API Key 过期或无权限 |
|
|
||||||
| 404 | 资源不存在 |
|
|
||||||
| 422 | 请求参数校验失败 |
|
|
||||||
| 429 | 配额不足或并发超限 |
|
|
||||||
| 500 | 服务器内部错误 |
|
|
||||||
| 504 | 生成超时(图片接口) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 使用示例
|
|
||||||
|
|
||||||
### cURL 示例
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 获取可用模型
|
|
||||||
curl -X GET http://localhost:8000/api/v3/models \
|
|
||||||
-H "Authorization: Bearer vk_xxxxxxxxxxxx"
|
|
||||||
|
|
||||||
# 2. 创建视频任务
|
|
||||||
curl -X POST http://localhost:8000/api/v3/videos \
|
|
||||||
-H "Authorization: Bearer vk_xxxxxxxxxxxx" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"model": "doubao-seedance-2-0-260128",
|
|
||||||
"content": [{"type": "text", "text": "一只猫在草地上奔跑"}],
|
|
||||||
"ratio": "16:9",
|
|
||||||
"duration": 5,
|
|
||||||
"resolution": "1080p"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 3. 查询视频状态
|
|
||||||
curl -X GET http://localhost:8000/api/v3/videos/{task_id} \
|
|
||||||
-H "Authorization: Bearer vk_xxxxxxxxxxxx"
|
|
||||||
|
|
||||||
# 4. 生成图片
|
|
||||||
curl -X POST http://localhost:8000/api/v3/images \
|
|
||||||
-H "Authorization: Bearer vk_xxxxxxxxxxxx" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"model": "doubao-seedream-5-0-260128",
|
|
||||||
"prompt": "一只可爱的猫咪",
|
|
||||||
"size": "2K"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python 示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
import requests
|
|
||||||
|
|
||||||
BASE_URL = "http://localhost:8000/api/v3"
|
|
||||||
API_KEY = "vk_xxxxxxxxxxxx"
|
|
||||||
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
|
|
||||||
|
|
||||||
# 获取模型列表
|
|
||||||
resp = requests.get(f"{BASE_URL}/models", headers=HEADERS)
|
|
||||||
models = resp.json()["models"]
|
|
||||||
|
|
||||||
# 创建视频任务
|
|
||||||
resp = requests.post(f"{BASE_URL}/videos", headers=HEADERS, json={
|
|
||||||
"model": "doubao-seedance-2-0-260128",
|
|
||||||
"content": [{"type": "text", "text": "一只猫在草地上奔跑"}],
|
|
||||||
"ratio": "16:9",
|
|
||||||
"duration": 5,
|
|
||||||
"resolution": "1080p",
|
|
||||||
})
|
|
||||||
task_id = resp.json()["task_id"]
|
|
||||||
|
|
||||||
# 轮询视频状态
|
|
||||||
import time
|
|
||||||
while True:
|
|
||||||
resp = requests.get(f"{BASE_URL}/videos/{task_id}", headers=HEADERS)
|
|
||||||
data = resp.json()
|
|
||||||
if data["status"] == "completed":
|
|
||||||
print(f"视频URL: {data['video_url']}")
|
|
||||||
break
|
|
||||||
elif data["status"] == "failed":
|
|
||||||
print(f"失败: {data['error']}")
|
|
||||||
break
|
|
||||||
time.sleep(30)
|
|
||||||
|
|
||||||
# 生成图片
|
|
||||||
resp = requests.post(f"{BASE_URL}/images", headers=HEADERS, json={
|
|
||||||
"model": "doubao-seedream-5-0-260128",
|
|
||||||
"prompt": "一只可爱的猫咪",
|
|
||||||
"size": "2K",
|
|
||||||
})
|
|
||||||
images = resp.json()["data"]
|
|
||||||
for img in images:
|
|
||||||
print(f"图片URL: {img['url']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## HTTP 状态码
|
|
||||||
|
|
||||||
所有接口 HTTP 状态码固定返回 **200**,业务结果通过响应体中的 `code` 字段判断:
|
|
||||||
|
|
||||||
| code | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 0 | 成功 |
|
|
||||||
| 40000 | 请求参数错误 |
|
|
||||||
| 40001 | 模型+分辨率未配置价格 |
|
|
||||||
| 40100 | API Key 无效或缺失 |
|
|
||||||
| 40300 | API Key 过期或无权限 |
|
|
||||||
| 40400 | 资源不存在 |
|
|
||||||
| 42200 | 参数校验失败 |
|
|
||||||
| 42900 | 配额不足或并发超限 |
|
|
||||||
| 50000 | 服务器内部错误 |
|
|
||||||
| 50400 | 生成超时(图片接口) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 统一响应格式
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": { ... },
|
|
||||||
"message": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 40001,
|
|
||||||
"data": null,
|
|
||||||
"message": "模型 'eng_xxxx' 在分辨率 '1080p' 下未配置,无法生成"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **配额预扣**: 视频任务创建时预扣配额,失败时自动退回
|
|
||||||
2. **并发限制**: 每个 API Key 有最大并发视频任务数限制
|
|
||||||
3. **轮询间隔**: 视频任务建议轮询间隔 30 秒(前 10 分钟可缩短至 30 秒,之后逐步增加)
|
|
||||||
4. **超时时间**: 视频任务最长 24 小时,超时自动失败并退回配额
|
|
||||||
5. **幂等键**: 视频接口支持 `idempotency_key`,相同键重复请求返回同一任务
|
|
||||||
6. **图片同步**: 图片接口为同步阻塞调用,建议设置 300 秒超时
|
|
||||||
7. **统一格式**: 所有接口 HTTP 状态码固定 200,通过 `code` 字段判断业务结果
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署与运维
|
|
||||||
|
|
||||||
### Celery Worker 启动命令
|
|
||||||
|
|
||||||
API v3 视频异步生成依赖以下 3 个 Celery 队列:
|
|
||||||
|
|
||||||
| 队列 | 用途 | 推荐并发 |
|
|
||||||
|------|------|---------|
|
|
||||||
| `gen_api_create` | 视频任务创建(调用 Volcano Ark SDK) | 2-4 |
|
|
||||||
| `gen_api_poll` | 视频状态轮询 | 2-4 |
|
|
||||||
| `gen_api_download` | 视频下载与超分 | 2-4 |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动 API v3 专用 Worker
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_api_create,gen_api_poll,gen_api_download \
|
|
||||||
--concurrency=4 -n worker_api@%h
|
|
||||||
|
|
||||||
# 启动 Beat 调度器(定时恢复任务)
|
|
||||||
celery -A app.tasks.celery_app beat -l info
|
|
||||||
```
|
|
||||||
|
|
||||||
### 容灾恢复
|
|
||||||
|
|
||||||
服务重启后,Worker 会自动触发恢复扫描:
|
|
||||||
- 扫描 48 小时内未完成的 `ApiGenerationTask`
|
|
||||||
- 重新入队中断的 Celery 任务
|
|
||||||
- 每分钟定时扫描(Beat 调度)
|
|
||||||
|
|
||||||
### 日志目录
|
|
||||||
|
|
||||||
```
|
|
||||||
storage/logs/external/
|
|
||||||
├── requests/ # 外部 API 请求/响应日志
|
|
||||||
├── models/ # 模型调用日志
|
|
||||||
├── upscale/ # 超分轮询日志
|
|
||||||
└── errors/ # 错误日志
|
|
||||||
```
|
|
||||||
-69
@@ -383,75 +383,6 @@ async def _cleanup_urls(): # 实际逻辑写在 async 函数
|
|||||||
- 队列路由在 `celery_app.py` 的 `task_routes` 配置
|
- 队列路由在 `celery_app.py` 的 `task_routes` 配置
|
||||||
- 必需参数通过 `apply_async(args=[...], queue="xxx", priority=0)` 传递
|
- 必需参数通过 `apply_async(args=[...], queue="xxx", priority=0)` 传递
|
||||||
|
|
||||||
#### Celery 队列清单(12 个)
|
|
||||||
|
|
||||||
| 队列 | 用途 | 推荐并发 |
|
|
||||||
|------|------|---------|
|
|
||||||
| `gen_chatapi_create` | ChatAPI 生成任务创建 | 2-4 |
|
|
||||||
| `gen_provider_poll` | 轮询火山引擎生成状态 | 2-4 |
|
|
||||||
| `gen_result_download` | 下载生成的视频/图片结果 | 2-4 |
|
|
||||||
| `gen_video_upscale_local` | 本地视频超分(FFmpeg) | 1-2 |
|
|
||||||
| `gen_video_upscale_remote` | 远程视频超分(火山 MediaKit) | 1-2 |
|
|
||||||
| `gen_recovery` | 容灾恢复任务 | 1 |
|
|
||||||
| `gen_private_portrait` | 真人素材认证与同步 | 1-2 |
|
|
||||||
| `gen_shot_analysis` | 拆镜分析 | 1-2 |
|
|
||||||
| `gen_shot_split` | 拆镜切片 | 1-2 |
|
|
||||||
| `gen_api_create` | API v3 视频任务创建 | 2-4 |
|
|
||||||
| `gen_api_poll` | API v3 视频状态轮询 | 2-4 |
|
|
||||||
| `gen_api_download` | API v3 视频下载 | 2-4 |
|
|
||||||
| `gen_api_upscale` | API v3 超分 | 2-4 |
|
|
||||||
| `default` | 默认队列(用户 OAuth、清理任务等) | 1-2 |
|
|
||||||
|
|
||||||
#### 完整启动命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ── 单机部署(所有队列一个 Worker)──
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_video_upscale_local,gen_video_upscale_remote,gen_recovery,gen_private_portrait,gen_shot_analysis,gen_shot_split,gen_api_create,gen_api_poll,gen_api_download,gen_api_upscale,default \
|
|
||||||
--concurrency=4
|
|
||||||
|
|
||||||
# ── 生产环境(按功能分离 Worker)──
|
|
||||||
|
|
||||||
# 业务 Worker
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download \
|
|
||||||
--concurrency=4 -n worker_busy@%h
|
|
||||||
|
|
||||||
# 超分 Worker
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale \
|
|
||||||
--concurrency=2 -n worker_upscale@%h
|
|
||||||
|
|
||||||
# 恢复 Worker
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_recovery --concurrency=1 -n worker_recovery@%h
|
|
||||||
|
|
||||||
# 其他 Worker
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_private_portrait,gen_shot_analysis,gen_shot_split,default \
|
|
||||||
--concurrency=2 -n worker_other@%h
|
|
||||||
|
|
||||||
# Beat 调度器(定时任务)
|
|
||||||
celery -A app.tasks.celery_app beat -l info
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Beat 定时任务清单
|
|
||||||
|
|
||||||
| 任务 | 频率 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `dispatch-due-poll-tasks` | 每分钟 | 调度到期的轮询任务 |
|
|
||||||
| `generation-create-recovery` | 每分钟 | 恢复未完成的创建任务 |
|
|
||||||
| `module-async-recovery` | 每分钟 | 恢复模块异步任务 |
|
|
||||||
| `video-upscale-recovery` | 每分钟 | 恢复未完成的超分任务 |
|
|
||||||
| `generation-download-recovery` | 每分钟 | 恢复未完成的下载任务 |
|
|
||||||
| `shot-split-recovery` | 每分钟 | 恢复拆镜切片任务 |
|
|
||||||
| `shot-analysis-recovery` | 每分钟 | 恢复拆镜分析任务 |
|
|
||||||
| `api-generation-recovery` | 分钟 | 恢复 API v3 未完成任务 |
|
|
||||||
| `celery-runtime-reconcile` | 每 5 分钟 | Worker 实例协调 |
|
|
||||||
| `celery-runtime-registry-gc` | 每 10 分钟 | Worker 注册表 GC |
|
|
||||||
| `private-portrait-sync-due-assets` | 每分钟 | 同步到期素材 |
|
|
||||||
| `private-portrait-recover-remote-deletes` | 每 5 分钟 | 恢复远程删除任务 |
|
|
||||||
|
|
||||||
### 2.14 配置约定
|
### 2.14 配置约定
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
+7
-117
@@ -234,72 +234,29 @@ sudo systemctl start videogen-api
|
|||||||
|
|
||||||
### 6. Celery Worker(可选)
|
### 6. Celery Worker(可选)
|
||||||
|
|
||||||
异步任务流水线需要 Celery Worker,依赖 Redis 作为 Broker。
|
ChatAPI 异步生成流水线需要 Celery Worker,依赖 Redis 作为 Broker。
|
||||||
|
|
||||||
Celery 使用 **12 个队列**,按功能分离:
|
Celery 使用 **6 个队列**,按功能分离:
|
||||||
|
|
||||||
| 队列 | 用途 |
|
| 队列 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) |
|
| `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) |
|
||||||
| `gen_provider_poll` | 轮询火山引擎生成状态 |
|
| `gen_provider_poll` | 轮询火山引擎生成状态 |
|
||||||
| `gen_result_download` | 下载生成的视频/图片结果 |
|
| `gen_result_download` | 下载生成的视频/图片结果 |
|
||||||
| `gen_video_upscale_local` | 本地视频超分(FFmpeg) |
|
|
||||||
| `gen_video_upscale_remote` | 远程视频超分(火山 MediaKit) |
|
|
||||||
| `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) |
|
| `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) |
|
||||||
| `gen_private_portrait` | 真人素材认证与同步 |
|
| `gen_private_portrait` | 真人素材认证与同步 |
|
||||||
| `gen_shot_analysis` | 拆镜分析 |
|
|
||||||
| `gen_shot_split` | 拆镜切片 |
|
|
||||||
| `gen_api_create` | **API v3 视频任务创建** |
|
|
||||||
| `gen_api_poll` | **API v3 视频状态轮询** |
|
|
||||||
| `gen_api_download` | **API v3 视频下载** |
|
|
||||||
| `gen_api_upscale` | **API v3 超分处理(本地/远程)** |
|
|
||||||
| `default` | 默认队列(用户 OAuth、清理任务等) |
|
| `default` | 默认队列(用户 OAuth、清理任务等) |
|
||||||
|
|
||||||
#### 启动命令
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ── 启动 Worker(消费所有队列,单机部署)──
|
# 启动 Worker(消费所有队列)
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
|
||||||
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_video_upscale_local,gen_video_upscale_remote,gen_recovery,gen_private_portrait,gen_shot_analysis,gen_shot_split,gen_api_create,gen_api_poll,gen_api_download,gen_api_upscale,default \
|
|
||||||
--concurrency=4
|
|
||||||
|
|
||||||
# ── 按功能分离 Worker(生产环境推荐)──
|
|
||||||
|
|
||||||
# 业务 Worker:处理生成创建、轮询、下载
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download \
|
|
||||||
--concurrency=4 \
|
|
||||||
-n worker_busy@%h
|
|
||||||
|
|
||||||
# 超分 Worker:处理本地和远程超分(含 API v3 超分)
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale \
|
|
||||||
--concurrency=2 \
|
|
||||||
-n worker_upscale@%h
|
|
||||||
|
|
||||||
# 恢复 Worker:处理容灾恢复(低频任务)
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_recovery \
|
|
||||||
--concurrency=1 \
|
|
||||||
-n worker_recovery@%h
|
|
||||||
|
|
||||||
# 其他 Worker:真人素材、拆镜、默认
|
|
||||||
celery -A app.tasks.celery_app worker -l info \
|
|
||||||
-Q gen_private_portrait,gen_shot_analysis,gen_shot_split,default \
|
|
||||||
--concurrency=2 \
|
|
||||||
-n worker_other@%h
|
|
||||||
|
|
||||||
# ── 启动 Celery Beat(定时任务调度器)──
|
|
||||||
celery -A app.tasks.celery_app beat -l info
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### systemd 服务文件
|
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`:
|
||||||
|
|
||||||
**业务 Worker** `/etc/systemd/system/videogen-worker-busy.service`:
|
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=VideoGen Celery Worker (Busy)
|
Description=VideoGen Celery Worker
|
||||||
After=network.target redis.service
|
After=network.target redis.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -307,7 +264,7 @@ Type=simple
|
|||||||
User=www-data
|
User=www-data
|
||||||
WorkingDirectory=/opt/video-gen-api
|
WorkingDirectory=/opt/video-gen-api
|
||||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
Environment=PATH=/opt/video-gen-api/.venv/bin
|
||||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download --concurrency=4 -n worker_busy@%h
|
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -315,73 +272,6 @@ RestartSec=5
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
**超分 Worker** `/etc/systemd/system/videogen-worker-upscale.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=VideoGen Celery Worker (Upscale)
|
|
||||||
After=network.target redis.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=www-data
|
|
||||||
WorkingDirectory=/opt/video-gen-api
|
|
||||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
|
||||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale --concurrency=2 -n worker_upscale@%h
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
**恢复 Worker** `/etc/systemd/system/videogen-worker-recovery.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=VideoGen Celery Worker (Recovery)
|
|
||||||
After=network.target redis.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=www-data
|
|
||||||
WorkingDirectory=/opt/video-gen-api
|
|
||||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
|
||||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_recovery --concurrency=1 -n worker_recovery@%h
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beat 调度器** `/etc/systemd/system/videogen-beat.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=VideoGen Celery Beat
|
|
||||||
After=network.target redis.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=www-data
|
|
||||||
WorkingDirectory=/opt/video-gen-api
|
|
||||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
|
||||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app beat -l info
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启用并启动所有服务
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable videogen-worker-busy videogen-worker-upscale videogen-worker-recovery videogen-beat
|
|
||||||
sudo systemctl start videogen-worker-busy videogen-worker-upscale videogen-worker-recovery videogen-beat
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Docker 部署(可选)
|
### 7. Docker 部署(可选)
|
||||||
|
|
||||||
项目提供 `Dockerfile` 和 `docker-compose.yml`。
|
项目提供 `Dockerfile` 和 `docker-compose.yml`。
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# VITE_API_BASE=http://192.168.120.17:8000
|
# VITE_API_BASE=http://192.168.120.17:8000
|
||||||
#VITE_API_BASE=https://apiforeign.minzhongzc.com
|
#VITE_API_BASE=https://apiforeign.minzhongzc.com
|
||||||
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
|
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
|
||||||
#VITE_API_BASE=http://localhost:8000
|
|
||||||
VITE_USE_MOCK=false
|
VITE_USE_MOCK=false
|
||||||
# Encryption disabled for dev — enable in production
|
# Encryption disabled for dev — enable in production
|
||||||
VITE_ENCRYPTION_KEY=
|
VITE_ENCRYPTION_KEY=
|
||||||
+591
File diff suppressed because one or more lines are too long
-592
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>后台管理</title>
|
<title>后台管理</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName + ' - 管理后台';
|
document.title = info.siteName + ' - 管理后台';
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-CfFryG8z.js"></script>
|
<script type="module" crossorigin src="/assets/index-BZDhy9nW.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>␍
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
|
|||||||
import AdminOAuthList from './pages/AdminOAuthList';
|
import AdminOAuthList from './pages/AdminOAuthList';
|
||||||
import AdminMaterialList from './pages/AdminMaterialList';
|
import AdminMaterialList from './pages/AdminMaterialList';
|
||||||
import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
|
import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
|
||||||
import AdminApiKeys from './pages/AdminApiKeys';
|
|
||||||
import AdminApiModelPricings from './pages/AdminApiModelPricings';
|
|
||||||
import AdminApiUsage from './pages/AdminApiUsage';
|
|
||||||
|
|
||||||
import { useAdminStore } from './store';
|
import { useAdminStore } from './store';
|
||||||
|
|
||||||
@@ -103,9 +100,6 @@ const App = () => {
|
|||||||
<Route path="settings" element={<AdminSettings />} />
|
<Route path="settings" element={<AdminSettings />} />
|
||||||
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
|
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
|
||||||
<Route path="video-upscale" element={<AdminVideoUpscale />} />
|
<Route path="video-upscale" element={<AdminVideoUpscale />} />
|
||||||
<Route path="api-keys" element={<AdminApiKeys />} />
|
|
||||||
<Route path="api-model-pricings" element={<AdminApiModelPricings />} />
|
|
||||||
<Route path="api-usage" element={<AdminApiUsage />} />
|
|
||||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||||
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
||||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||||
|
|||||||
@@ -241,10 +241,6 @@ export async function createSystemConfig(key: string, value: string, description
|
|||||||
return api.post('/admin/system-configs', { key, value, description });
|
return api.post('/admin/system-configs', { key, value, description });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetActivityBanner(): Promise<{ siteBannerVersion: number }> {
|
|
||||||
return api.post('/admin/system-configs/banner/reset');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
||||||
return api.get('/admin/resource-capacity/global');
|
return api.get('/admin/resource-capacity/global');
|
||||||
}
|
}
|
||||||
@@ -394,131 +390,6 @@ export async function deleteCreditRatio(id: string): Promise<void> {
|
|||||||
await api.delete(`/admin/credit-ratios/${id}`);
|
await api.delete(`/admin/credit-ratios/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === API 模型价格管理 ===
|
|
||||||
|
|
||||||
export async function getApiModelPricings(): Promise<any[]> {
|
|
||||||
return api.get('/admin/api-model-pricings');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveApiModelPricing(pricing: any): Promise<any> {
|
|
||||||
if (pricing.id) return api.put(`/admin/api-model-pricings/${pricing.id}`, pricing);
|
|
||||||
return api.post('/admin/api-model-pricings', pricing);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteApiModelPricing(id: string): Promise<void> {
|
|
||||||
await api.delete(`/admin/api-model-pricings/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === API Key 管理 ===
|
|
||||||
|
|
||||||
export async function getApiKeys(params?: {
|
|
||||||
skip?: number;
|
|
||||||
limit?: number;
|
|
||||||
companyName?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
}): Promise<any> {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.skip !== undefined) query.set('skip', String(params.skip));
|
|
||||||
if (params?.limit !== undefined) query.set('limit', String(params.limit));
|
|
||||||
if (params?.companyName) query.set('company_name', params.companyName);
|
|
||||||
if (params?.isActive !== undefined) query.set('is_active', String(params.isActive));
|
|
||||||
const qs = query.toString();
|
|
||||||
return api.get(`/admin/api-keys${qs ? '?' + qs : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getApiKeyDetail(id: string): Promise<any> {
|
|
||||||
return api.get(`/admin/api-keys/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createApiKey(data: any): Promise<any> {
|
|
||||||
return api.post('/admin/api-keys', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateApiKey(id: string, data: any): Promise<any> {
|
|
||||||
return api.put(`/admin/api-keys/${id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteApiKey(id: string): Promise<void> {
|
|
||||||
await api.delete(`/admin/api-keys/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getApiKeyUsage(id: string, days?: number, page?: number, pageSize?: number): Promise<any> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (days) params.set('days', String(days));
|
|
||||||
if (page) params.set('page', String(page));
|
|
||||||
if (pageSize) params.set('page_size', String(pageSize));
|
|
||||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
||||||
return api.get(`/admin/api-keys/${id}/usage${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function adjustApiKeyQuota(
|
|
||||||
id: string,
|
|
||||||
data: {
|
|
||||||
action: 'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle';
|
|
||||||
quotaLimitDelta?: number;
|
|
||||||
quotaLimit?: number | null;
|
|
||||||
quotaCycle?: string | null;
|
|
||||||
reason?: string | null;
|
|
||||||
},
|
|
||||||
): Promise<any> {
|
|
||||||
return api.post(`/admin/api-keys/${id}/quota-adjust`, {
|
|
||||||
action: data.action,
|
|
||||||
quota_limit_delta: data.quotaLimitDelta,
|
|
||||||
quota_limit: data.quotaLimit,
|
|
||||||
quota_cycle: data.quotaCycle,
|
|
||||||
reason: data.reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getApiKeyUpscaleConfig(id: string): Promise<any> {
|
|
||||||
return api.get(`/admin/api-keys/${id}/upscale`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveApiKeyUpscaleConfig(id: string, data: any): Promise<any> {
|
|
||||||
return api.put(`/admin/api-keys/${id}/upscale`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === V3 虚拟素材库配额 ===
|
|
||||||
export async function getApiKeyVpV3Quota(id: string): Promise<any> {
|
|
||||||
return api.get(`/admin/api-keys/${id}/vp-v3-quota`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveApiKeyVpV3Quota(id: string, data: { projectLimit: number; assetLimit: number; storageMbLimit: number; remark?: string | null }): Promise<any> {
|
|
||||||
return api.post(`/admin/api-keys/${id}/vp-v3-quota`, {
|
|
||||||
project_limit: data.projectLimit,
|
|
||||||
asset_limit: data.assetLimit,
|
|
||||||
storage_mb_limit: data.storageMbLimit,
|
|
||||||
remark: data.remark,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revealApiKey(id: string): Promise<any> {
|
|
||||||
return api.get(`/admin/api-keys/${id}/reveal`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 整体消耗列表 ===
|
|
||||||
|
|
||||||
export async function getApiUsageAll(params?: {
|
|
||||||
skip?: number;
|
|
||||||
limit?: number;
|
|
||||||
apiKeyId?: string;
|
|
||||||
genType?: string;
|
|
||||||
status?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}): Promise<any> {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.skip !== undefined) query.set('skip', String(params.skip));
|
|
||||||
if (params?.limit !== undefined) query.set('limit', String(params.limit));
|
|
||||||
if (params?.apiKeyId) query.set('api_key_id', params.apiKeyId);
|
|
||||||
if (params?.genType) query.set('gen_type', params.genType);
|
|
||||||
if (params?.status) query.set('status', params.status);
|
|
||||||
if (params?.startDate) query.set('start_date', params.startDate);
|
|
||||||
if (params?.endDate) query.set('end_date', params.endDate);
|
|
||||||
const qs = query.toString();
|
|
||||||
return api.get(`/admin/api-keys/usage/all${qs ? '?' + qs : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPaymentConfigs(): Promise<any[]> {
|
export async function getPaymentConfigs(): Promise<any[]> {
|
||||||
return api.get('/admin/payment-configs');
|
return api.get('/admin/payment-configs');
|
||||||
}
|
}
|
||||||
@@ -645,16 +516,8 @@ export async function deleteRechargePackage(id: string): Promise<void> {
|
|||||||
|
|
||||||
// ── Operation Logs ──────────────────────────────────────
|
// ── Operation Logs ──────────────────────────────────────
|
||||||
|
|
||||||
export async function getOperationLogs(params?: {
|
export async function getOperationLogs(page?: number): Promise<{ total: number; items: any[] }> {
|
||||||
page?: number;
|
const q = page ? `?page=${page}` : '';
|
||||||
pageSize?: number;
|
|
||||||
action?: string;
|
|
||||||
}): Promise<{ total: number; items: any[] }> {
|
|
||||||
const sp = new URLSearchParams();
|
|
||||||
if (params?.page) sp.set('page', String(params.page));
|
|
||||||
if (params?.pageSize) sp.set('page_size', String(params.pageSize));
|
|
||||||
if (params?.action) sp.set('action', params.action);
|
|
||||||
const q = sp.toString() ? `?${sp.toString()}` : '';
|
|
||||||
return api.get(`/admin/operation-logs${q}`);
|
return api.get(`/admin/operation-logs${q}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Modal, Radio, InputNumber, Input, Select, Space, Typography, Tag, Divider, message,
|
|
||||||
} from 'antd';
|
|
||||||
import { adjustApiKeyQuota } from '../api';
|
|
||||||
|
|
||||||
interface QuotaAdjustModalProps {
|
|
||||||
open: boolean;
|
|
||||||
keyId: string;
|
|
||||||
companyName: string;
|
|
||||||
quotaLimit: number | null;
|
|
||||||
quotaUsed: number;
|
|
||||||
quotaCycle: string | null;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QuotaAdjustModal: React.FC<QuotaAdjustModalProps> = ({
|
|
||||||
open, keyId, companyName, quotaLimit, quotaUsed, quotaCycle, onCancel, onSuccess,
|
|
||||||
}) => {
|
|
||||||
const [action, setAction] = useState<'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle'>('adjust');
|
|
||||||
const [delta, setDelta] = useState<number>(0);
|
|
||||||
const [newLimit, setNewLimit] = useState<number | null>(quotaLimit);
|
|
||||||
const [newCycle, setNewCycle] = useState<string | null>(quotaCycle);
|
|
||||||
const [reason, setReason] = useState<string>('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const cycleLabel = (cycle: string | null) => {
|
|
||||||
const map: Record<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
|
|
||||||
return cycle ? map[cycle] || cycle : '无限';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOk = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const payload: any = { action, reason: reason || undefined };
|
|
||||||
if (action === 'adjust') payload.quotaLimitDelta = delta;
|
|
||||||
if (action === 'set_limit') payload.quotaLimit = newLimit;
|
|
||||||
if (action === 'change_cycle') payload.quotaCycle = newCycle;
|
|
||||||
|
|
||||||
await adjustApiKeyQuota(keyId, payload);
|
|
||||||
message.success('配额调整成功');
|
|
||||||
onSuccess();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.response?.data?.detail || '调整失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setAction('adjust');
|
|
||||||
setDelta(0);
|
|
||||||
setNewLimit(quotaLimit);
|
|
||||||
setNewCycle(quotaCycle);
|
|
||||||
setReason('');
|
|
||||||
onCancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 预览计算
|
|
||||||
const previewLimit = action === 'adjust'
|
|
||||||
? round((quotaLimit || 0) + delta)
|
|
||||||
: action === 'set_limit'
|
|
||||||
? newLimit
|
|
||||||
: quotaLimit;
|
|
||||||
|
|
||||||
function round(n: number) {
|
|
||||||
return Math.round(n * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="调整配额"
|
|
||||||
open={open}
|
|
||||||
onOk={handleOk}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
okText="确认调整"
|
|
||||||
cancelText="取消"
|
|
||||||
confirmLoading={loading}
|
|
||||||
width={480}
|
|
||||||
>
|
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
||||||
<div>
|
|
||||||
<Typography.Text type="secondary">公司:</Typography.Text>
|
|
||||||
<Typography.Text strong>{companyName}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text type="secondary">当前:</Typography.Text>
|
|
||||||
<Tag color="blue">已用 {quotaUsed.toFixed(2)} 元</Tag>
|
|
||||||
<Tag color="green">限额 {quotaLimit != null ? `${quotaLimit.toFixed(2)} 元` : '无限'}</Tag>
|
|
||||||
<Tag color="purple">{cycleLabel(quotaCycle)}</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
|
||||||
|
|
||||||
<Radio.Group value={action} onChange={e => setAction(e.target.value)} style={{ width: '100%' }}>
|
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
||||||
<Radio value="adjust">
|
|
||||||
<Space>
|
|
||||||
<Typography.Text>增加总额</Typography.Text>
|
|
||||||
{action === 'adjust' && (
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
step={10}
|
|
||||||
value={delta}
|
|
||||||
onChange={v => setDelta(v || 0)}
|
|
||||||
addonAfter="元"
|
|
||||||
style={{ width: 160 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Radio>
|
|
||||||
|
|
||||||
<Radio value="reset_usage">
|
|
||||||
<Space>
|
|
||||||
<Typography.Text>重置已用</Typography.Text>
|
|
||||||
{action === 'reset_usage' && (
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
({quotaUsed.toFixed(2)} → 0.00 元)
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Radio>
|
|
||||||
|
|
||||||
<Radio value="set_limit">
|
|
||||||
<Space>
|
|
||||||
<Typography.Text>设置限额</Typography.Text>
|
|
||||||
{action === 'set_limit' && (
|
|
||||||
<>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
step={10}
|
|
||||||
value={newLimit}
|
|
||||||
onChange={setNewLimit}
|
|
||||||
addonAfter="元"
|
|
||||||
placeholder="留空=无限"
|
|
||||||
style={{ width: 160 }}
|
|
||||||
/>
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
(当前:{quotaLimit != null ? `${quotaLimit.toFixed(2)} 元` : '无限'})
|
|
||||||
</Typography.Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Radio>
|
|
||||||
|
|
||||||
<Radio value="change_cycle">
|
|
||||||
<Space>
|
|
||||||
<Typography.Text>修改周期</Typography.Text>
|
|
||||||
{action === 'change_cycle' && (
|
|
||||||
<Select
|
|
||||||
value={newCycle}
|
|
||||||
onChange={setNewCycle}
|
|
||||||
allowClear
|
|
||||||
placeholder="选择周期"
|
|
||||||
style={{ width: 140 }}
|
|
||||||
options={[
|
|
||||||
{ label: '每日', value: 'daily' },
|
|
||||||
{ label: '每月', value: 'monthly' },
|
|
||||||
{ label: '一次性', value: 'one_time' },
|
|
||||||
{ label: '无限', value: null },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Radio>
|
|
||||||
</Space>
|
|
||||||
</Radio.Group>
|
|
||||||
|
|
||||||
{action === 'adjust' && delta > 0 && (
|
|
||||||
<div style={{ padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 13 }}>
|
|
||||||
调整后总额:<strong style={{ color: '#1677ff' }}>{previewLimit != null ? `${previewLimit.toFixed(2)} 元` : '无限'}</strong>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整原因(可选)</Typography.Text>
|
|
||||||
<Input.TextArea
|
|
||||||
value={reason}
|
|
||||||
onChange={e => setReason(e.target.value)}
|
|
||||||
placeholder="请输入调整原因..."
|
|
||||||
rows={2}
|
|
||||||
maxLength={500}
|
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QuotaAdjustModal;
|
|
||||||
@@ -1,760 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import {
|
|
||||||
Button, Card, DatePicker, Divider, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import {
|
|
||||||
PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, EyeOutlined, KeyOutlined, CopyOutlined, DollarOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import QuotaAdjustModal from '../components/QuotaAdjustModal';
|
|
||||||
import {
|
|
||||||
getApiKeys, createApiKey, updateApiKey, deleteApiKey, getApiKeyUsage, getGenerationAiEngines, revealApiKey, getApiKeyUpscaleConfig, saveApiKeyUpscaleConfig,
|
|
||||||
getApiKeyVpV3Quota, saveApiKeyVpV3Quota, getOperationLogs,
|
|
||||||
} from '../api';
|
|
||||||
import type { GenerationAiEngineOption } from '../types';
|
|
||||||
|
|
||||||
interface EngineOption {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
modelName: string;
|
|
||||||
genType: 'video' | 'image';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiKey {
|
|
||||||
id: string;
|
|
||||||
companyName: string;
|
|
||||||
apiKeyPrefix: string;
|
|
||||||
description: string | null;
|
|
||||||
callableModels?: Array<{ engineId: string; engineType: string; modelName: string }>;
|
|
||||||
quotaLimit: number | null;
|
|
||||||
quotaCycle: string | null;
|
|
||||||
quotaUsed: number;
|
|
||||||
validFrom: string | null;
|
|
||||||
validUntil: string | null;
|
|
||||||
maxConcurrentVideoTasks: number | null;
|
|
||||||
isActive: boolean;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpscaleRule {
|
|
||||||
targetResolution: string;
|
|
||||||
providerGenerationResolution: string;
|
|
||||||
processorKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminApiKeys: React.FC = () => {
|
|
||||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [modal, setModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null });
|
|
||||||
const [usageModal, setUsageModal] = useState<{ open: boolean; key: ApiKey | null; usage: any }>({ open: false, key: null, usage: null });
|
|
||||||
const [quotaModal, setQuotaModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null });
|
|
||||||
const [quotaLogs, setQuotaLogs] = useState<{ items: any[]; total: number; page: number; loading: boolean }>({ items: [], total: 0, page: 1, loading: false });
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [engines, setEngines] = useState<EngineOption[]>([]);
|
|
||||||
const [upscaleRules, setUpscaleRules] = useState<UpscaleRule[]>([]);
|
|
||||||
const [upscaleEnabled, setUpscaleEnabled] = useState(false);
|
|
||||||
const [deleteSource, setDeleteSource] = useState(false);
|
|
||||||
|
|
||||||
// V3 虚拟素材库配额(编辑时加载)
|
|
||||||
const [vpV3Quota, setVpV3Quota] = useState<{
|
|
||||||
projectLimit: number; assetLimit: number; storageMbLimit: number;
|
|
||||||
projectUsed: number; assetUsed: number; storageMbUsed: number;
|
|
||||||
enabled: boolean; remark?: string | null;
|
|
||||||
}>({
|
|
||||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
|
||||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
|
||||||
enabled: false, remark: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [keysData, enginesData] = await Promise.all([
|
|
||||||
getApiKeys({ limit: 100 }),
|
|
||||||
getGenerationAiEngines(),
|
|
||||||
]);
|
|
||||||
setKeys(keysData?.items || keysData || []);
|
|
||||||
setTotal(keysData?.total || (keysData?.length || 0));
|
|
||||||
const allEngines: EngineOption[] = [
|
|
||||||
...(enginesData?.engine?.image || []).map((e: any) => ({
|
|
||||||
id: e.id,
|
|
||||||
name: e.name || e.modelName,
|
|
||||||
modelName: e.modelName,
|
|
||||||
genType: 'image' as const,
|
|
||||||
})),
|
|
||||||
...(enginesData?.engine?.video || []).map((e: any) => ({
|
|
||||||
id: e.id,
|
|
||||||
name: e.name || e.modelName,
|
|
||||||
modelName: e.modelName,
|
|
||||||
genType: 'video' as const,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
setEngines(allEngines);
|
|
||||||
} catch {
|
|
||||||
message.error('加载失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
|
||||||
|
|
||||||
const openEdit = async (key: ApiKey | null = null) => {
|
|
||||||
if (key) {
|
|
||||||
// 将 callableModels 转换为引擎 ID 数组用于 Select
|
|
||||||
const selectedEngineIds = (key.callableModels || []).map((m: any) => m.engineId || m.engine_id);
|
|
||||||
form.setFieldsValue({
|
|
||||||
companyName: key.companyName || '',
|
|
||||||
description: key.description || '',
|
|
||||||
quotaLimit: key.quotaLimit || null,
|
|
||||||
quotaCycle: key.quotaCycle || 'monthly',
|
|
||||||
validUntil: key.validUntil ? dayjs(key.validUntil) : null,
|
|
||||||
maxConcurrentVideoTasks: key.maxConcurrentVideoTasks || null,
|
|
||||||
engineIds: selectedEngineIds,
|
|
||||||
});
|
|
||||||
// 并行加载:超分配置 + 虚拟素材库配额
|
|
||||||
await Promise.all([
|
|
||||||
loadUpscaleConfig(key.id),
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const quota = await getApiKeyVpV3Quota(key.id);
|
|
||||||
setVpV3Quota({
|
|
||||||
projectLimit: quota?.projectLimit ?? quota?.project_limit ?? 0,
|
|
||||||
assetLimit: quota?.assetLimit ?? quota?.asset_limit ?? 0,
|
|
||||||
storageMbLimit: quota?.storageMbLimit ?? quota?.storage_mb_limit ?? 0,
|
|
||||||
projectUsed: quota?.projectUsed ?? quota?.project_used ?? 0,
|
|
||||||
assetUsed: quota?.assetUsed ?? quota?.asset_used ?? 0,
|
|
||||||
storageMbUsed: quota?.storageMbUsed ?? quota?.storage_mb_used ?? 0,
|
|
||||||
enabled: !!quota?.enabled,
|
|
||||||
remark: quota?.remark ?? null,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setVpV3Quota({
|
|
||||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
|
||||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
|
||||||
enabled: false, remark: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({ quotaCycle: 'monthly', quotaLimit: 100, engineIds: [] });
|
|
||||||
setUpscaleEnabled(false);
|
|
||||||
setDeleteSource(false);
|
|
||||||
setUpscaleRules([]);
|
|
||||||
setVpV3Quota({
|
|
||||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
|
||||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
|
||||||
enabled: false, remark: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setModal({ open: true, key });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
// 将选中的引擎 ID 转换为 callableModels 格式
|
|
||||||
const callableModels = (values.engineIds || []).map((id: string) => {
|
|
||||||
const engine = engines.find(e => e.id === id);
|
|
||||||
return {
|
|
||||||
engineId: id,
|
|
||||||
engineType: engine?.genType || 'video',
|
|
||||||
modelName: engine?.modelName || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const payload = {
|
|
||||||
companyName: values.companyName,
|
|
||||||
description: values.description || null,
|
|
||||||
quotaLimit: values.quotaLimit || null,
|
|
||||||
quotaCycle: values.quotaCycle || null,
|
|
||||||
validUntil: values.validUntil ? (values.validUntil.toISOString ? values.validUntil.toISOString() : values.validUntil) : null,
|
|
||||||
maxConcurrentVideoTasks: values.maxConcurrentVideoTasks || null,
|
|
||||||
callableModels,
|
|
||||||
};
|
|
||||||
console.log('API Key payload:', JSON.stringify(payload, null, 2));
|
|
||||||
if (modal.key?.id) {
|
|
||||||
await updateApiKey(modal.key.id, payload);
|
|
||||||
} else {
|
|
||||||
const result = await createApiKey(payload);
|
|
||||||
if (result?.apiKey) {
|
|
||||||
Modal.success({
|
|
||||||
title: 'API Key 创建成功',
|
|
||||||
content: (
|
|
||||||
<div>
|
|
||||||
<p>请妥善保存以下 API Key,此信息仅显示一次:</p>
|
|
||||||
<Typography.Paragraph copyable style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, fontFamily: 'monospace' }}>
|
|
||||||
{result.apiKey}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 保存超分配置
|
|
||||||
if (modal.key?.id) {
|
|
||||||
await saveUpscaleConfig(modal.key.id);
|
|
||||||
// 保存 V3 虚拟素材库配额(编辑模式才需要,因为新建时还没有 id)
|
|
||||||
try {
|
|
||||||
await saveApiKeyVpV3Quota(modal.key.id, {
|
|
||||||
projectLimit: vpV3Quota.projectLimit || 0,
|
|
||||||
assetLimit: vpV3Quota.assetLimit || 0,
|
|
||||||
storageMbLimit: vpV3Quota.storageMbLimit || 0,
|
|
||||||
remark: vpV3Quota.remark ?? null,
|
|
||||||
});
|
|
||||||
} catch (qErr: any) {
|
|
||||||
message.warning(qErr?.response?.data?.detail || '虚拟素材库配额保存失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
message.success('保存成功');
|
|
||||||
setModal({ open: false, key: null });
|
|
||||||
form.resetFields();
|
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.errorFields) return;
|
|
||||||
message.error('保存失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteApiKey(id);
|
|
||||||
message.success('已删除');
|
|
||||||
load();
|
|
||||||
} catch {
|
|
||||||
message.error('删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyKey = async (key: ApiKey) => {
|
|
||||||
try {
|
|
||||||
const result = await revealApiKey(key.id);
|
|
||||||
const plainKey: string | undefined = result?.apiKey || result?.data?.apiKey;
|
|
||||||
if (!plainKey) {
|
|
||||||
message.error('获取 API Key 失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 优先用 Clipboard API,不支持时回退到 execCommand
|
|
||||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(plainKey);
|
|
||||||
} catch {
|
|
||||||
fallbackCopy(plainKey);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fallbackCopy(plainKey);
|
|
||||||
}
|
|
||||||
message.success('API Key 已复制到剪贴板');
|
|
||||||
} catch (e: any) {
|
|
||||||
const msg = e?.response?.data?.detail || '复制失败';
|
|
||||||
message.error(msg);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fallbackCopy = (text: string) => {
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadUsageDetail = async (keyId: string, page = 1, pageSize = 20) => {
|
|
||||||
try {
|
|
||||||
const usage = await getApiKeyUsage(keyId, 30, page, pageSize);
|
|
||||||
setUsageModal(prev => ({ ...prev, usage }));
|
|
||||||
} catch {
|
|
||||||
message.error('加载使用统计失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadQuotaLogs = async (keyId: string, page = 1) => {
|
|
||||||
setQuotaLogs(prev => ({ ...prev, loading: true }));
|
|
||||||
try {
|
|
||||||
// 从 operation_logs 中筛选 quota_adjust:* 且 path 包含该 keyId 的记录
|
|
||||||
const data = await getOperationLogs({ page, pageSize: 20, action: 'quota_adjust' });
|
|
||||||
const filtered = (data?.items || []).filter((item: any) => item.path?.includes(keyId));
|
|
||||||
setQuotaLogs({ items: filtered, total: filtered.length, page, loading: false });
|
|
||||||
} catch {
|
|
||||||
message.error('加载配额变更记录失败');
|
|
||||||
setQuotaLogs(prev => ({ ...prev, loading: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewUsage = async (key: ApiKey) => {
|
|
||||||
try {
|
|
||||||
const usage = await getApiKeyUsage(key.id, 30, 1, 20);
|
|
||||||
setUsageModal({ open: true, key, usage });
|
|
||||||
loadQuotaLogs(key.id, 1);
|
|
||||||
} catch {
|
|
||||||
message.error('加载使用统计失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 超分配置处理 ──
|
|
||||||
const handleAddUpscaleRule = () => {
|
|
||||||
setUpscaleRules([...upscaleRules, { targetResolution: '1080p', providerGenerationResolution: '720p', processorKey: 'volc_large_model_v1' }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveUpscaleRule = (idx: number) => {
|
|
||||||
setUpscaleRules(upscaleRules.filter((_, i) => i !== idx));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpscaleRuleChange = (idx: number, field: keyof UpscaleRule, value: string) => {
|
|
||||||
const newRules = [...upscaleRules];
|
|
||||||
newRules[idx] = { ...newRules[idx], [field]: value };
|
|
||||||
setUpscaleRules(newRules);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadUpscaleConfig = async (keyId: string) => {
|
|
||||||
try {
|
|
||||||
const config = await getApiKeyUpscaleConfig(keyId);
|
|
||||||
setUpscaleEnabled(config?.data?.enabled || false);
|
|
||||||
setDeleteSource(config?.data?.deleteSourceAfterSuccess || false);
|
|
||||||
setUpscaleRules(config?.data?.rules || []);
|
|
||||||
} catch {
|
|
||||||
setUpscaleEnabled(false);
|
|
||||||
setDeleteSource(false);
|
|
||||||
setUpscaleRules([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveUpscaleConfig = async (keyId: string) => {
|
|
||||||
try {
|
|
||||||
await saveApiKeyUpscaleConfig(keyId, {
|
|
||||||
data: {
|
|
||||||
enabled: upscaleEnabled,
|
|
||||||
deleteSourceAfterSuccess: deleteSource,
|
|
||||||
rules: upscaleRules,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
message.success('超分配置已保存');
|
|
||||||
} catch {
|
|
||||||
message.error('保存超分配置失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleLabel = (cycle: string | null) => {
|
|
||||||
const map: Record<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
|
|
||||||
return cycle ? map[cycle] || cycle : '无限';
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnsType<ApiKey> = [
|
|
||||||
{ title: '公司', dataIndex: 'companyName', width: 120, ellipsis: true },
|
|
||||||
{
|
|
||||||
title: 'api-key',
|
|
||||||
dataIndex: 'apiKeyPrefix',
|
|
||||||
width: 180,
|
|
||||||
render: (v: string, r: ApiKey) => (
|
|
||||||
<Space size={4}>
|
|
||||||
<code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}****</code>
|
|
||||||
<Button type="link" size="small" icon={<CopyOutlined />} onClick={() => handleCopyKey(r)}></Button>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '配额(元)',
|
|
||||||
dataIndex: 'quotaLimit',
|
|
||||||
width: 130,
|
|
||||||
render: (_v: number, r: ApiKey) => {
|
|
||||||
if (!r.quotaLimit) return <Tag>无限</Tag>;
|
|
||||||
const used = r.quotaUsed || 0;
|
|
||||||
const limit = r.quotaLimit || 1;
|
|
||||||
const pct = Math.min(100, Math.round((used / limit) * 100));
|
|
||||||
return (
|
|
||||||
<div style={{ width: 110 }}>
|
|
||||||
<Progress percent={pct} size="small" format={() => `${used.toFixed(1)}/${limit}`} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '周期',
|
|
||||||
dataIndex: 'quotaCycle',
|
|
||||||
width: 70,
|
|
||||||
render: (v: string | null) => <Tag>{cycleLabel(v)}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'isActive',
|
|
||||||
width: 70,
|
|
||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '有效期',
|
|
||||||
dataIndex: 'validUntil',
|
|
||||||
width: 100,
|
|
||||||
render: (v: string | null) => v ? new Date(v).toLocaleDateString() : '永久',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最后使用',
|
|
||||||
dataIndex: 'lastUsedAt',
|
|
||||||
width: 150,
|
|
||||||
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
fixed: 'right',
|
|
||||||
width: 260,
|
|
||||||
render: (_: any, r: ApiKey) => (
|
|
||||||
<Space size={0}>
|
|
||||||
<Button type="link" size="small" icon={<DollarOutlined />} onClick={() => setQuotaModal({ open: true, key: r })}>配额</Button>
|
|
||||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => viewUsage(r)}>统计</Button>
|
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
|
||||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
|
||||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Space>
|
|
||||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<ApiOutlined style={{ color: '#fff', fontSize: 18 }} />
|
|
||||||
</div>
|
|
||||||
<Typography.Text strong style={{ fontSize: 16 }}>API Key 管理</Typography.Text>
|
|
||||||
<Tag color="purple">{total} 个</Tag>
|
|
||||||
</Space>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>创建 Key</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
|
||||||
<Table columns={columns} dataSource={keys} rowKey="id" loading={loading} pagination={false} scroll={{ x: 1100 }} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 创建/编辑弹窗 */}
|
|
||||||
<Modal
|
|
||||||
title={modal.key ? '编辑 API Key' : '创建 API Key'}
|
|
||||||
open={modal.open}
|
|
||||||
onOk={handleSave}
|
|
||||||
onCancel={() => { setModal({ open: false, key: null }); form.resetFields(); }}
|
|
||||||
okText="保存" cancelText="取消" width={760}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
|
||||||
<Form.Item name="companyName" label="公司名称" rules={[{ required: true, message: '请输入公司名称' }]}>
|
|
||||||
<Input placeholder="公司名称" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="description" label="备注">
|
|
||||||
<Input.TextArea placeholder="备注信息" rows={2} />
|
|
||||||
</Form.Item>
|
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
|
||||||
<Form.Item name="quotaLimit" label="配额总额(元)" style={{ flex: 1 }}>
|
|
||||||
<InputNumber min={0} step={10} style={{ width: '100%' }} placeholder="留空=无限" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="quotaCycle" label="配额周期" style={{ flex: 1 }}>
|
|
||||||
<Select>
|
|
||||||
<Select.Option value="daily">每日</Select.Option>
|
|
||||||
<Select.Option value="monthly">每月</Select.Option>
|
|
||||||
<Select.Option value="one_time">一次性</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
|
||||||
<Form.Item name="validUntil" label="有效期至" style={{ flex: 1 }}>
|
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="留空=永久" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="maxConcurrentVideoTasks" label="最大并发视频任务" style={{ flex: 1 }}>
|
|
||||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="留空=无限" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
<Form.Item name="engineIds" label="可调用模型">
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
placeholder="选择该 Key 可调用的模型(留空=允许所有已定价模型)"
|
|
||||||
allowClear
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={(engines || []).map(e => ({
|
|
||||||
label: `[${e.genType === 'video' ? '视频' : '图片'}] ${e.name || e.modelName || e.id}`,
|
|
||||||
value: e.id,
|
|
||||||
}))}
|
|
||||||
notFoundContent={engines.length === 0 ? '暂无可用引擎' : null}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
{/* 超分配置(仅编辑模式显示,新建时没ID) */}
|
|
||||||
{modal.key?.id && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<Typography.Text strong style={{ fontSize: 14 }}>🎬 超分配置</Typography.Text>
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
||||||
<Typography.Text>启用超分:</Typography.Text>
|
|
||||||
<Switch checked={upscaleEnabled} onChange={setUpscaleEnabled} checkedChildren="启用" unCheckedChildren="关闭" />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
||||||
<Typography.Text>成功后删除源文件:</Typography.Text>
|
|
||||||
<Switch checked={deleteSource} onChange={setDeleteSource} checkedChildren="是" unCheckedChildren="否" />
|
|
||||||
</div>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>超分规则:</Typography.Text>
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{(upscaleRules || []).map((rule, idx) => (
|
|
||||||
<Card key={idx} size="small" style={{ background: '#f8f9fc' }}>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<Select
|
|
||||||
value={rule.targetResolution}
|
|
||||||
onChange={v => handleUpscaleRuleChange(idx, 'targetResolution', v)}
|
|
||||||
style={{ width: 100 }}
|
|
||||||
options={['480p', '720p', '1080p', '2K', '4K'].map(r => ({ label: r, value: r }))}
|
|
||||||
/>
|
|
||||||
<span>→</span>
|
|
||||||
<Select
|
|
||||||
value={rule.providerGenerationResolution}
|
|
||||||
onChange={v => handleUpscaleRuleChange(idx, 'providerGenerationResolution', v)}
|
|
||||||
style={{ width: 100 }}
|
|
||||||
options={['480p', '720p', '1080p'].map(r => ({ label: r, value: r }))}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={rule.processorKey}
|
|
||||||
onChange={v => handleUpscaleRuleChange(idx, 'processorKey', v)}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
options={[
|
|
||||||
{ label: '本地FFmpeg', value: 'local_ffmpeg_crop_v1' },
|
|
||||||
{ label: '火山标准版', value: 'volc_standard_v1' },
|
|
||||||
{ label: '火山专业版', value: 'volc_professional_v1' },
|
|
||||||
{ label: '火山大模型', value: 'volc_large_model_v1' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Popconfirm title="确定删除此规则?" onConfirm={() => handleRemoveUpscaleRule(idx)}>
|
|
||||||
<Button type="link" danger size="small" icon={<DeleteOutlined />} />
|
|
||||||
</Popconfirm>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={handleAddUpscaleRule}>
|
|
||||||
添加规则
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 虚拟素材库配额(仅编辑模式显示,新建时没ID) */}
|
|
||||||
{modal.key?.id && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
||||||
<Typography.Text strong style={{ fontSize: 14 }}>🧩 V3 虚拟素材库配额</Typography.Text>
|
|
||||||
<Tag color={vpV3Quota.enabled ? 'green' : 'default'}>
|
|
||||||
{vpV3Quota.enabled ? '已启用' : '未启用(全0=不可用)'}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '12px 16px', backgroundColor: '#f6ffed', borderRadius: 8, border: '1px solid #b7eb8f' }}>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
|
||||||
默认 0 = 该 API Key 不可使用虚拟素材库功能。项目数或素材数任一上限 > 0 即启用(存储空间不再设置上限)。
|
|
||||||
</Typography.Text>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography.Text strong>项目数上限</Typography.Text>
|
|
||||||
<Tag color="blue">已使用 {vpV3Quota.projectUsed || 0} / {vpV3Quota.projectLimit || 0}</Tag>
|
|
||||||
</div>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
max={10000}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={vpV3Quota.projectLimit}
|
|
||||||
onChange={(v) => setVpV3Quota(q => ({ ...q, projectLimit: Number(v) || 0 }))}
|
|
||||||
addonBefore="上限" addonAfter="个"
|
|
||||||
/>
|
|
||||||
<Progress
|
|
||||||
percent={vpV3Quota.projectLimit > 0 ? Math.min(100, Math.round((vpV3Quota.projectUsed || 0) * 100 / (vpV3Quota.projectLimit || 1))) : 0}
|
|
||||||
size="small"
|
|
||||||
style={{ marginTop: 6 }}
|
|
||||||
strokeColor={vpV3Quota.projectLimit > 0 && (vpV3Quota.projectUsed || 0) >= vpV3Quota.projectLimit ? '#ff4d4f' : '#1677ff'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography.Text strong>素材数上限</Typography.Text>
|
|
||||||
<Tag color="blue">已使用 {vpV3Quota.assetUsed || 0} / {vpV3Quota.assetLimit || 0}</Tag>
|
|
||||||
</div>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
max={1000000}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={vpV3Quota.assetLimit}
|
|
||||||
onChange={(v) => setVpV3Quota(q => ({ ...q, assetLimit: Number(v) || 0 }))}
|
|
||||||
addonBefore="上限" addonAfter="张"
|
|
||||||
/>
|
|
||||||
<Progress
|
|
||||||
percent={vpV3Quota.assetLimit > 0 ? Math.min(100, Math.round((vpV3Quota.assetUsed || 0) * 100 / (vpV3Quota.assetLimit || 1))) : 0}
|
|
||||||
size="small"
|
|
||||||
style={{ marginTop: 6 }}
|
|
||||||
strokeColor={vpV3Quota.assetLimit > 0 && (vpV3Quota.assetUsed || 0) >= vpV3Quota.assetLimit ? '#ff4d4f' : '#1677ff'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{vpV3Quota.storageMbUsed > 0 && (
|
|
||||||
<div style={{ marginTop: 12, padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 12, color: '#475569' }}>
|
|
||||||
已使用存储空间:<strong style={{ color: '#1e40af' }}>{Number(vpV3Quota.storageMbUsed || 0).toFixed(2)} MB</strong>(无上限限制,仅供参考)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>备注(仅后台可见):</Typography.Text>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
maxLength={500}
|
|
||||||
placeholder="可选:配额配置说明"
|
|
||||||
value={vpV3Quota.remark ?? ''}
|
|
||||||
onChange={(e) => setVpV3Quota(q => ({ ...q, remark: e.target.value || null }))}
|
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 使用统计 + 配额变更弹窗 */}
|
|
||||||
<Modal
|
|
||||||
title={`API Key 详情 - ${usageModal.key?.companyName || ''}`}
|
|
||||||
open={usageModal.open}
|
|
||||||
onCancel={() => setUsageModal({ open: false, key: null, usage: null })}
|
|
||||||
footer={null} width={720}
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
defaultActiveKey="usage"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'usage',
|
|
||||||
label: '使用统计',
|
|
||||||
children: usageModal.usage && (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 24 }}>
|
|
||||||
<Card><Typography.Text type="secondary">总请求数</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests}</Typography.Title></Card>
|
|
||||||
<Card><Typography.Text type="secondary">总消耗(元)</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalCreditsCost?.toFixed(2)}</Typography.Title></Card>
|
|
||||||
<Card><Typography.Text type="secondary">成功率</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests ? ((usageModal.usage.successCount / usageModal.usage.totalRequests) * 100).toFixed(1) : 0}%</Typography.Title></Card>
|
|
||||||
</div>
|
|
||||||
<Table
|
|
||||||
columns={[
|
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
|
|
||||||
{
|
|
||||||
title: '类型', dataIndex: 'genType', width: 70,
|
|
||||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
|
||||||
},
|
|
||||||
{ title: '模型', dataIndex: 'modelName', width: 140, ellipsis: true },
|
|
||||||
{ title: '消耗(元)', dataIndex: 'creditsCost', width: 90, render: (v: number) => v?.toFixed(2) || '0.00' },
|
|
||||||
{ title: '状态', dataIndex: 'status', width: 70, render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag> },
|
|
||||||
]}
|
|
||||||
dataSource={usageModal.usage.items || []}
|
|
||||||
rowKey="id"
|
|
||||||
pagination={{
|
|
||||||
current: usageModal.usage.page || 1,
|
|
||||||
pageSize: usageModal.usage.pageSize || 20,
|
|
||||||
total: usageModal.usage.total || 0,
|
|
||||||
onChange: (p, ps) => loadUsageDetail(usageModal.key?.id || '', p, ps || 20),
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
|
||||||
size: 'small',
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'quota',
|
|
||||||
label: '配额变更',
|
|
||||||
children: (
|
|
||||||
<div>
|
|
||||||
<Table
|
|
||||||
columns={[
|
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
|
|
||||||
{ title: '管理员', dataIndex: 'username', width: 100, ellipsis: true },
|
|
||||||
{
|
|
||||||
title: '操作', dataIndex: 'action', width: 110,
|
|
||||||
render: (v: string) => {
|
|
||||||
const sub = v?.split(':')[1] || v;
|
|
||||||
const label: Record<string, string> = { adjust: '增加总额', reset_usage: '重置已用', set_limit: '设置限额', change_cycle: '修改周期' };
|
|
||||||
return <Tag color="blue">{label[sub] || v}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '变更详情', dataIndex: 'detail', width: 220,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string, row: any) => {
|
|
||||||
let detail: any = v;
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
try { detail = JSON.parse(v); } catch { return v || '-'; }
|
|
||||||
}
|
|
||||||
if (!detail || typeof detail !== 'object') return '-';
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (detail.old_limit != null || detail.new_limit != null) {
|
|
||||||
parts.push(`限额: ${detail.old_limit != null ? detail.old_limit.toFixed(2) : '-'} → ${detail.new_limit != null ? detail.new_limit.toFixed(2) : '无限'}`);
|
|
||||||
}
|
|
||||||
if (detail.old_used != null && detail.new_used != null && detail.old_used !== detail.new_used) {
|
|
||||||
parts.push(`已用: ${detail.old_used.toFixed(2)} → ${detail.new_used.toFixed(2)}`);
|
|
||||||
}
|
|
||||||
if (detail.old_cycle != null || detail.new_cycle != null) {
|
|
||||||
if (detail.old_cycle !== detail.new_cycle) {
|
|
||||||
parts.push(`周期: ${cycleLabel(detail.old_cycle)} → ${cycleLabel(detail.new_cycle)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts.length > 0 ? <span style={{ fontSize: 12 }}>{parts.join(' | ')}</span> : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '原因', dataIndex: 'detail', width: 120,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string) => {
|
|
||||||
let detail: any = v;
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
try { detail = JSON.parse(v); } catch { /* */ }
|
|
||||||
}
|
|
||||||
return detail?.reason || '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
dataSource={quotaLogs.items}
|
|
||||||
rowKey={(r, i) => r.id || r.createdAt || i}
|
|
||||||
loading={quotaLogs.loading}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 700 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 配额调整弹窗 */}
|
|
||||||
<QuotaAdjustModal
|
|
||||||
open={quotaModal.open}
|
|
||||||
keyId={quotaModal.key?.id || ''}
|
|
||||||
companyName={quotaModal.key?.companyName || ''}
|
|
||||||
quotaLimit={quotaModal.key?.quotaLimit ?? null}
|
|
||||||
quotaUsed={quotaModal.key?.quotaUsed || 0}
|
|
||||||
quotaCycle={quotaModal.key?.quotaCycle || null}
|
|
||||||
onCancel={() => setQuotaModal({ open: false, key: null })}
|
|
||||||
onSuccess={() => {
|
|
||||||
setQuotaModal({ open: false, key: null });
|
|
||||||
load();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminApiKeys;
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import {
|
|
||||||
PlusOutlined, EditOutlined, DeleteOutlined, DollarOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import {
|
|
||||||
getApiModelPricings, saveApiModelPricing, deleteApiModelPricing, getGenerationAiEngines,
|
|
||||||
} from '../api';
|
|
||||||
import type { GenerationAiEngineOption } from '../types';
|
|
||||||
|
|
||||||
type PricingGenType = 'image' | 'video';
|
|
||||||
|
|
||||||
interface ApiModelPricing {
|
|
||||||
id: string;
|
|
||||||
modelConfigId: string;
|
|
||||||
genType: PricingGenType | string;
|
|
||||||
resolution: string;
|
|
||||||
priceRatio: number;
|
|
||||||
basePrice: number;
|
|
||||||
perSecondPrice: number;
|
|
||||||
inputVideoRatio: number;
|
|
||||||
inputVideoBasePrice: number;
|
|
||||||
inputVideoPerSecondPrice: number;
|
|
||||||
inputImageRatio: number;
|
|
||||||
inputImageBasePrice: number;
|
|
||||||
inputImagePerImagePrice: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
|
||||||
const DEFAULT_VIDEO_RESOLUTIONS = ['480p', '720p', '1080p'];
|
|
||||||
|
|
||||||
const AdminApiModelPricings: React.FC = () => {
|
|
||||||
const [pricings, setPricings] = useState<ApiModelPricing[]>([]);
|
|
||||||
const [engines, setEngines] = useState<GenerationAiEngineOption[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [modal, setModal] = useState<{ open: boolean; pricing: ApiModelPricing | null }>({ open: false, pricing: null });
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const genType = Form.useWatch('genType', form) || 'video';
|
|
||||||
const selectedEngineId = Form.useWatch('modelConfigId', form);
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [pricingData, enginesData] = await Promise.all([
|
|
||||||
getApiModelPricings(),
|
|
||||||
getGenerationAiEngines(),
|
|
||||||
]);
|
|
||||||
setPricings(pricingData);
|
|
||||||
|
|
||||||
const imageEngines: GenerationAiEngineOption[] = (enginesData?.engine?.image || []).map(engine => ({
|
|
||||||
...engine,
|
|
||||||
genType: 'image' as const,
|
|
||||||
}));
|
|
||||||
const videoEngines: GenerationAiEngineOption[] = (enginesData?.engine?.video || []).map(engine => ({
|
|
||||||
...engine,
|
|
||||||
genType: 'video' as const,
|
|
||||||
}));
|
|
||||||
setEngines([...imageEngines, ...videoEngines]);
|
|
||||||
} catch {
|
|
||||||
message.error('加载失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
|
||||||
|
|
||||||
const filteredEngines = engines.filter(e => e.genType === genType);
|
|
||||||
const selectedEngine = engines.find(e => e.id === selectedEngineId);
|
|
||||||
const resolutions: string[] = genType === 'video'
|
|
||||||
? (selectedEngine?.supportedResolutions?.length ? selectedEngine.supportedResolutions : DEFAULT_VIDEO_RESOLUTIONS)
|
|
||||||
: (selectedEngine?.supportedSizes?.length ? Object.keys(selectedEngine.supportedSizes) : DEFAULT_IMAGE_SIZES);
|
|
||||||
|
|
||||||
const openEdit = (pricing: ApiModelPricing | null = null) => {
|
|
||||||
if (pricing) {
|
|
||||||
form.setFieldsValue({
|
|
||||||
modelConfigId: pricing.modelConfigId,
|
|
||||||
genType: pricing.genType,
|
|
||||||
resolution: pricing.resolution,
|
|
||||||
priceRatio: pricing.priceRatio,
|
|
||||||
basePrice: pricing.basePrice,
|
|
||||||
perSecondPrice: pricing.perSecondPrice,
|
|
||||||
inputVideoRatio: pricing.inputVideoRatio,
|
|
||||||
inputVideoBasePrice: pricing.inputVideoBasePrice,
|
|
||||||
inputVideoPerSecondPrice: pricing.inputVideoPerSecondPrice,
|
|
||||||
inputImageRatio: pricing.inputImageRatio,
|
|
||||||
inputImageBasePrice: pricing.inputImageBasePrice,
|
|
||||||
inputImagePerImagePrice: pricing.inputImagePerImagePrice,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({
|
|
||||||
genType: 'video',
|
|
||||||
priceRatio: 1.0,
|
|
||||||
basePrice: 0.0,
|
|
||||||
perSecondPrice: 0.00,
|
|
||||||
inputVideoRatio: 1.0,
|
|
||||||
inputVideoBasePrice: 0,
|
|
||||||
inputVideoPerSecondPrice: 0,
|
|
||||||
inputImageRatio: 1.0,
|
|
||||||
inputImageBasePrice: 0,
|
|
||||||
inputImagePerImagePrice: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setModal({ open: true, pricing });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
const payload = {
|
|
||||||
...(modal.pricing?.id ? { id: modal.pricing.id } : {}),
|
|
||||||
modelConfigId: values.modelConfigId,
|
|
||||||
genType: values.genType,
|
|
||||||
resolution: values.resolution,
|
|
||||||
priceRatio: values.priceRatio,
|
|
||||||
basePrice: values.basePrice,
|
|
||||||
perSecondPrice: values.perSecondPrice || 0,
|
|
||||||
inputVideoRatio: values.inputVideoRatio || 1.0,
|
|
||||||
inputVideoBasePrice: values.inputVideoBasePrice || 0,
|
|
||||||
inputVideoPerSecondPrice: values.inputVideoPerSecondPrice || 0,
|
|
||||||
inputImageRatio: values.inputImageRatio || 1.0,
|
|
||||||
inputImageBasePrice: values.inputImageBasePrice || 0,
|
|
||||||
inputImagePerImagePrice: values.inputImagePerImagePrice || 0,
|
|
||||||
};
|
|
||||||
await saveApiModelPricing(payload);
|
|
||||||
message.success('保存成功');
|
|
||||||
setModal({ open: false, pricing: null });
|
|
||||||
form.resetFields();
|
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.errorFields) return;
|
|
||||||
message.error('保存失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteApiModelPricing(id);
|
|
||||||
message.success('已删除');
|
|
||||||
load();
|
|
||||||
} catch {
|
|
||||||
message.error('删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnsType<ApiModelPricing> = [
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
dataIndex: 'genType',
|
|
||||||
width: 80,
|
|
||||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '引擎',
|
|
||||||
dataIndex: 'modelConfigId',
|
|
||||||
width: 160,
|
|
||||||
render: (v: string) => {
|
|
||||||
const engine = engines.find(e => e.id === v);
|
|
||||||
return engine?.name || v;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '分辨率',
|
|
||||||
dataIndex: 'resolution',
|
|
||||||
width: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '价格系数',
|
|
||||||
dataIndex: 'priceRatio',
|
|
||||||
width: 90,
|
|
||||||
render: (v: number) => <span style={{ color: v >= 2 ? '#f5222d' : v >= 1.5 ? '#faad14' : '#52c41a' }}>{v}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '基础价格(元)',
|
|
||||||
dataIndex: 'basePrice',
|
|
||||||
width: 110,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '每秒价格(元)',
|
|
||||||
dataIndex: 'perSecondPrice',
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '传入视频(元)/每秒',
|
|
||||||
dataIndex: 'inputVideoBasePrice',
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '传入图片(元)/每张',
|
|
||||||
dataIndex: 'inputImageBasePrice',
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
fixed: 'right',
|
|
||||||
width: 150,
|
|
||||||
render: (_: any, r: ApiModelPricing) => (
|
|
||||||
<Space>
|
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
|
||||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
|
||||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Space>
|
|
||||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<DollarOutlined style={{ color: '#fff', fontSize: 18 }} />
|
|
||||||
</div>
|
|
||||||
<Typography.Text strong style={{ fontSize: 16 }}>API 模型价格配置</Typography.Text>
|
|
||||||
<Tag color="purple">{pricings.length} 条</Tag>
|
|
||||||
</Space>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>添加价格</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={pricings}
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
|
||||||
scroll={{ x: 1100 }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={modal.pricing ? '编辑价格' : '添加价格'}
|
|
||||||
open={modal.open}
|
|
||||||
onOk={handleSave}
|
|
||||||
onCancel={() => { setModal({ open: false, pricing: null }); form.resetFields(); }}
|
|
||||||
okText="保存" cancelText="取消" width={560}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
|
||||||
<Form.Item name="genType" label="引擎类型" rules={[{ required: true }]} style={{ flex: 1 }}>
|
|
||||||
<Select onChange={() => { form.setFieldsValue({ modelConfigId: undefined, resolution: undefined }); }}>
|
|
||||||
<Select.Option value="video">视频</Select.Option>
|
|
||||||
<Select.Option value="image">图片</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="modelConfigId" label="引擎" rules={[{ required: true }]} style={{ flex: 1 }}>
|
|
||||||
<Select placeholder="选择引擎" showSearch optionFilterProp="label">
|
|
||||||
{filteredEngines.map(e => (
|
|
||||||
<Select.Option key={e.id} value={e.id} label={e.name}>{e.name}</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
<Form.Item name="resolution" label="分辨率" rules={[{ required: true }]}>
|
|
||||||
<Select placeholder="选择分辨率">
|
|
||||||
{resolutions.map(r => (
|
|
||||||
<Select.Option key={r} value={r}>{r}</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
|
||||||
<Form.Item name="priceRatio" label="价格系数" rules={[{ required: true }]} style={{ flex: 1 }}>
|
|
||||||
<InputNumber min={0.01} step={0.1} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="basePrice" label="基础价格(元)" rules={[{ required: true }]} style={{ flex: 1 }}>
|
|
||||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
{genType === 'video' && (
|
|
||||||
<Form.Item name="perSecondPrice" label="每秒价格(元)">
|
|
||||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>传入媒体附加费</Typography.Text>
|
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
|
|
||||||
<Form.Item name="inputVideoBasePrice" label="传入视频(元)/每秒" style={{ flex: 1 }}>
|
|
||||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="inputImageBasePrice" label="传入图片(元)/每张" style={{ flex: 1 }}>
|
|
||||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminApiModelPricings;
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
TableOutlined, ReloadOutlined, SearchOutlined, ExportOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { getApiUsageAll } from '../api';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
interface UsageItem {
|
|
||||||
id: string;
|
|
||||||
apiKeyId: string;
|
|
||||||
companyName: string;
|
|
||||||
apiKeyPrefix: string | null;
|
|
||||||
taskId: string | null;
|
|
||||||
requestType: string;
|
|
||||||
modelName: string;
|
|
||||||
genType: string;
|
|
||||||
creditsCost: number;
|
|
||||||
tokensUsed: number;
|
|
||||||
requestDurationMs: number;
|
|
||||||
duration: number | null;
|
|
||||||
resolution: string | null;
|
|
||||||
status: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
errorCode: string | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminApiUsage: React.FC = () => {
|
|
||||||
const [items, setItems] = useState<UsageItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(50);
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
const [filterGenType, setFilterGenType] = useState<string | undefined>(undefined);
|
|
||||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
|
||||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params: any = {
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
limit: pageSize,
|
|
||||||
};
|
|
||||||
if (filterGenType) params.genType = filterGenType;
|
|
||||||
if (filterStatus) params.status = filterStatus;
|
|
||||||
if (dateRange[0]) params.startDate = dateRange[0].startOf('day').toISOString();
|
|
||||||
if (dateRange[1]) params.endDate = dateRange[1].endOf('day').toISOString();
|
|
||||||
if (searchText.trim()) params.search = searchText.trim();
|
|
||||||
|
|
||||||
const data = await getApiUsageAll(params);
|
|
||||||
setItems(data?.items || []);
|
|
||||||
setTotal(data?.total || 0);
|
|
||||||
} catch {
|
|
||||||
message.error('加载失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [page, pageSize, filterGenType, filterStatus, dateRange, searchText]);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
setPage(1);
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 导出 CSV
|
|
||||||
const handleExport = () => {
|
|
||||||
const headers = ['时间', '公司', 'api-key', '类型', '模型', '时长(秒)', '分辨率', '消耗(元)', 'Token', '耗时(ms)', '状态', '错误信息'];
|
|
||||||
const rows = items.map(item => [
|
|
||||||
item.createdAt ? new Date(item.createdAt).toLocaleString() : '',
|
|
||||||
item.companyName || '',
|
|
||||||
item.apiKeyPrefix || '',
|
|
||||||
item.genType === 'video' ? '视频' : '图片',
|
|
||||||
item.modelName || '',
|
|
||||||
item.duration || '',
|
|
||||||
item.resolution || '',
|
|
||||||
(item.creditsCost || 0).toFixed(2),
|
|
||||||
item.tokensUsed || '',
|
|
||||||
item.requestDurationMs || '',
|
|
||||||
item.status === 'success' ? '成功' : '失败',
|
|
||||||
item.errorMessage || '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const csvContent = [headers, ...rows]
|
|
||||||
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const BOM = '';
|
|
||||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `api_usage_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
message.success('导出成功');
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '时间',
|
|
||||||
dataIndex: 'createdAt',
|
|
||||||
width: 160,
|
|
||||||
render: (v: string) => v ? new Date(v).toLocaleString() : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '公司',
|
|
||||||
dataIndex: 'companyName',
|
|
||||||
width: 120,
|
|
||||||
render: (v: string) => v || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'api-key',
|
|
||||||
dataIndex: 'apiKeyPrefix',
|
|
||||||
width: 110,
|
|
||||||
render: (v: string) => v ? <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}</code> : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
dataIndex: 'genType',
|
|
||||||
width: 70,
|
|
||||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '模型',
|
|
||||||
dataIndex: 'modelName',
|
|
||||||
width: 160,
|
|
||||||
ellipsis: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '消耗(元)',
|
|
||||||
dataIndex: 'creditsCost',
|
|
||||||
width: 90,
|
|
||||||
render: (v: number) => <span style={{ color: v > 0 ? '#f5222d' : '#52c41a', fontWeight: 500 }}>{v?.toFixed(2) || '0.00'}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Token',
|
|
||||||
dataIndex: 'tokensUsed',
|
|
||||||
width: 80,
|
|
||||||
render: (v: number) => v || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '耗时(ms)',
|
|
||||||
dataIndex: 'requestDurationMs',
|
|
||||||
width: 90,
|
|
||||||
render: (v: number) => v || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 80,
|
|
||||||
render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '错误信息',
|
|
||||||
dataIndex: 'errorMessage',
|
|
||||||
width: 200,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string) => v ? <span style={{ color: '#f5222d' }}>{v}</span> : '-',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 统计
|
|
||||||
const totalCost = items.reduce((sum, item) => sum + (item.creditsCost || 0), 0);
|
|
||||||
const successCount = items.filter(i => i.status === 'success').length;
|
|
||||||
const failedCount = items.filter(i => i.status === 'failed').length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Space>
|
|
||||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<TableOutlined style={{ color: '#fff', fontSize: 18 }} />
|
|
||||||
</div>
|
|
||||||
<Typography.Text strong style={{ fontSize: 16 }}>API 消耗列表</Typography.Text>
|
|
||||||
<Tag color="purple">{total} 条</Tag>
|
|
||||||
</Space>
|
|
||||||
<Space>
|
|
||||||
<Tag color="blue">本页消耗: {totalCost.toFixed(2)} 元</Tag>
|
|
||||||
<Tag color="green">成功: {successCount}</Tag>
|
|
||||||
<Tag color="red">失败: {failedCount}</Tag>
|
|
||||||
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={handleSearch}>刷新</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 筛选栏 */}
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
<Input
|
|
||||||
placeholder="搜索公司名或 Key 前缀"
|
|
||||||
prefix={<SearchOutlined />}
|
|
||||||
value={searchText}
|
|
||||||
onChange={e => setSearchText(e.target.value)}
|
|
||||||
onPressEnter={handleSearch}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 220 }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="类型"
|
|
||||||
value={filterGenType}
|
|
||||||
onChange={v => { setFilterGenType(v); setPage(1); }}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 100 }}
|
|
||||||
>
|
|
||||||
<Select.Option value="video">视频</Select.Option>
|
|
||||||
<Select.Option value="image">图片</Select.Option>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
placeholder="状态"
|
|
||||||
value={filterStatus}
|
|
||||||
onChange={v => { setFilterStatus(v); setPage(1); }}
|
|
||||||
allowClear
|
|
||||||
style={{ width: 100 }}
|
|
||||||
>
|
|
||||||
<Select.Option value="success">成功</Select.Option>
|
|
||||||
<Select.Option value="failed">失败</Select.Option>
|
|
||||||
</Select>
|
|
||||||
<DatePicker.RangePicker
|
|
||||||
value={dateRange}
|
|
||||||
onChange={(dates) => { setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]); setPage(1); }}
|
|
||||||
/>
|
|
||||||
<Button type="primary" onClick={handleSearch}>筛选</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={items}
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
pagination={{
|
|
||||||
current: page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
onChange: (p, ps) => { setPage(p); setPageSize(ps || 50); },
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
|
||||||
}}
|
|
||||||
scroll={{ x: 1300 }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminApiUsage;
|
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -17,11 +17,6 @@ const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
|||||||
totalRecharge: 0,
|
totalRecharge: 0,
|
||||||
totalConsume: 0,
|
totalConsume: 0,
|
||||||
totalRefund: 0,
|
totalRefund: 0,
|
||||||
totalCharge: 0,
|
|
||||||
totalHold: 0,
|
|
||||||
totalRefundReal: 0,
|
|
||||||
totalHoldRelease: 0,
|
|
||||||
netConsume: 0,
|
|
||||||
transactionCount: 0,
|
transactionCount: 0,
|
||||||
generationCount: 0,
|
generationCount: 0,
|
||||||
generationAttemptCount: 0,
|
generationAttemptCount: 0,
|
||||||
@@ -315,22 +310,17 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
],
|
],
|
||||||
summaryRows: [
|
summaryRows: [
|
||||||
['总充值', exportSummary.totalRecharge],
|
['总充值', exportSummary.totalRecharge],
|
||||||
['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume],
|
['总消费', exportSummary.totalConsume],
|
||||||
[' · 真实扣费(独立统计:type=消费 & action=charge/NULL)', exportSummary.totalCharge],
|
['总回退', exportSummary.totalRefund],
|
||||||
[' · 预扣占用(独立统计:type=消费 & action=hold)', exportSummary.totalHold],
|
|
||||||
['总回退(真实退款 + 预扣释放)', exportSummary.totalRefund],
|
|
||||||
[' · 真实退款(独立统计:type=回退 & action=refund/NULL)', exportSummary.totalRefundReal],
|
|
||||||
[' · 预扣释放(独立统计:type=回退 & action=hold_release)', exportSummary.totalHoldRelease],
|
|
||||||
['净消耗(总消费 − 总回退,≥ 0)', exportSummary.netConsume],
|
|
||||||
['交易笔数', exportSummary.transactionCount],
|
['交易笔数', exportSummary.transactionCount],
|
||||||
['生成条数', exportSummary.generationCount],
|
['生成条数', exportSummary.generationCount],
|
||||||
['生成尝试次数', exportSummary.generationAttemptCount],
|
['生成尝试次数', exportSummary.generationAttemptCount],
|
||||||
['图片生成条数', exportSummary.imageGenerationCount],
|
['图片生成条数', exportSummary.imageGenerationCount],
|
||||||
['视频生成条数', exportSummary.videoGenerationCount],
|
['视频生成条数', exportSummary.videoGenerationCount],
|
||||||
['图片消费积分(仅真实扣费)', exportSummary.imageConsume],
|
['图片消费积分', exportSummary.imageConsume],
|
||||||
['视频消费积分(仅真实扣费)', exportSummary.videoConsume],
|
['视频消费积分', exportSummary.videoConsume],
|
||||||
['提词消费积分(仅真实扣费)', exportSummary.textConsume],
|
['提词消费积分', exportSummary.textConsume],
|
||||||
['视频分析积分(仅真实扣费)', exportSummary.analysisConsume],
|
['视频分析积分', exportSummary.analysisConsume],
|
||||||
['总 Token', exportSummary.totalTokens],
|
['总 Token', exportSummary.totalTokens],
|
||||||
['输入 Token', exportSummary.inputTokens],
|
['输入 Token', exportSummary.inputTokens],
|
||||||
['输出 Token', exportSummary.outputTokens],
|
['输出 Token', exportSummary.outputTokens],
|
||||||
@@ -368,64 +358,11 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总充值</div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
|
||||||
<Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} />
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总消费</div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
|
||||||
<div>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总回退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
|
||||||
<div style={{ color: '#94a3b8' }}>总充值</div>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>交易 / 生成</div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
||||||
<Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} />
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ color: '#94a3b8' }}>
|
|
||||||
总消费
|
|
||||||
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>(真实扣费 + 预扣占用)</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
|
|
||||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
|
|
||||||
真实 <span style={{ color: '#b91c1c', fontWeight: 600 }}>{n(summary.totalCharge)}</span>
|
|
||||||
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
|
|
||||||
预扣 <span style={{ color: '#d97706', fontWeight: 600 }}>{n(summary.totalHold)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
||||||
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ color: '#94a3b8' }}>
|
|
||||||
总回退
|
|
||||||
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>(真实退款 + 预扣释放)</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
|
|
||||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
|
|
||||||
退款 <span style={{ color: '#1d4ed8', fontWeight: 600 }}>{n(summary.totalRefundReal)}</span>
|
|
||||||
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
|
|
||||||
释放 <span style={{ color: '#047857', fontWeight: 600 }}>{n(summary.totalHoldRelease)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: 'linear-gradient(135deg, #faf5ff 0%, #eef2ff 100%)' }}>
|
|
||||||
<Space><DollarOutlined style={{ color: '#6366f1', fontSize: 22 }} />
|
|
||||||
<div>
|
|
||||||
<div style={{ color: '#6366f1' }}>净消耗(实际用掉)</div>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div>
|
|
||||||
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}>总消费 − 总回退(≥ 0)</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
||||||
<Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} />
|
|
||||||
<div>
|
|
||||||
<div style={{ color: '#94a3b8' }}>交易 / 生成</div>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty, Tabs,
|
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined, NotificationOutlined, SaveOutlined,
|
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import ReactQuill from 'react-quill-new';
|
import ReactQuill from 'react-quill-new';
|
||||||
import 'react-quill-new/dist/quill.snow.css';
|
import 'react-quill-new/dist/quill.snow.css';
|
||||||
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers, getSystemConfigs, updateSystemConfig, createSystemConfig, resetActivityBanner } from '../api';
|
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
interface NotificationRecord {
|
interface NotificationRecord {
|
||||||
@@ -31,19 +31,6 @@ interface ReadUser {
|
|||||||
readAt: string;
|
readAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 富文本编辑器工具栏配置(含颜色选择)
|
|
||||||
const editorModules = {
|
|
||||||
toolbar: [
|
|
||||||
[{ header: [1, 2, 3, false] }],
|
|
||||||
[{ color: [] }, { background: [] }],
|
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
|
||||||
[{ align: [] }],
|
|
||||||
['link', 'image'],
|
|
||||||
['clean'],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminNotificationManager: React.FC = () => {
|
const AdminNotificationManager: React.FC = () => {
|
||||||
const [notifications, setNotifications] = useState<NotificationRecord[]>([]);
|
const [notifications, setNotifications] = useState<NotificationRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -56,11 +43,6 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
// Banner state
|
|
||||||
const [bannerContent, setBannerContent] = useState('');
|
|
||||||
const [bannerConfigId, setBannerConfigId] = useState<string | null>(null);
|
|
||||||
const [bannerSaving, setBannerSaving] = useState(false);
|
|
||||||
const [bannerLoading, setBannerLoading] = useState(false);
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -89,29 +71,8 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadBanner = async () => {
|
|
||||||
setBannerLoading(true);
|
|
||||||
try {
|
|
||||||
const configs = await getSystemConfigs();
|
|
||||||
const banner = configs.find((c: any) => c.key === 'site_banner');
|
|
||||||
if (banner) {
|
|
||||||
setBannerContent(banner.value || '');
|
|
||||||
setBannerConfigId(banner.id);
|
|
||||||
} else {
|
|
||||||
setBannerContent('');
|
|
||||||
setBannerConfigId(null);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setBannerLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [page, pageSize]);
|
useEffect(() => { load(); }, [page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => { loadBanner(); }, []);
|
|
||||||
|
|
||||||
const handlePageChange = (p: number, ps: number) => {
|
const handlePageChange = (p: number, ps: number) => {
|
||||||
setPage(p);
|
setPage(p);
|
||||||
setPageSize(ps);
|
setPageSize(ps);
|
||||||
@@ -156,39 +117,6 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveBanner = async () => {
|
|
||||||
const content = bannerContent.trim();
|
|
||||||
if (!content) {
|
|
||||||
message.error('请输入横幅内容');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBannerSaving(true);
|
|
||||||
try {
|
|
||||||
if (bannerConfigId) {
|
|
||||||
await updateSystemConfig(bannerConfigId, content);
|
|
||||||
} else {
|
|
||||||
const res = await createSystemConfig('site_banner', content, '全局活动通知横幅内容');
|
|
||||||
setBannerConfigId(res.id);
|
|
||||||
}
|
|
||||||
// 内容变更后自动递增版本号,让所有用户重新看到横幅
|
|
||||||
try { await resetActivityBanner(); } catch { /* ignore */ }
|
|
||||||
message.success('活动横幅已保存,所有用户将重新看到该横幅');
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '保存失败');
|
|
||||||
} finally {
|
|
||||||
setBannerSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetBanner = async () => {
|
|
||||||
try {
|
|
||||||
const res = await resetActivityBanner();
|
|
||||||
message.success(`横幅已重新展示(版本 → ${res.siteBannerVersion})`);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '操作失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
const getTypeColor = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'system': return 'blue';
|
case 'system': return 'blue';
|
||||||
@@ -239,148 +167,37 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const tabItems = [
|
|
||||||
{
|
|
||||||
key: 'notifications',
|
|
||||||
label: (
|
|
||||||
<span><BellOutlined style={{ marginRight: 6 }} />消息推送</span>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
||||||
<Space>
|
|
||||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
|
||||||
<Typography.Text strong style={{ fontSize: 16 }}>消息推送管理</Typography.Text>
|
|
||||||
<Tag color="purple">共 {total} 条消息</Tag>
|
|
||||||
</Space>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
|
|
||||||
style={{ borderRadius: 8 }}>
|
|
||||||
发送新消息
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={notifications}
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
pagination={{
|
|
||||||
current: page,
|
|
||||||
pageSize: pageSize,
|
|
||||||
total: total,
|
|
||||||
onChange: handlePageChange,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (t) => `共 ${t} 条消息`,
|
|
||||||
}}
|
|
||||||
scroll={{ x: 900 }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'banner',
|
|
||||||
label: (
|
|
||||||
<span><NotificationOutlined style={{ marginRight: 6 }} />活动横幅</span>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 40, height: 40, borderRadius: 10,
|
|
||||||
background: 'rgba(99,102,241,0.08)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 18, color: '#6366f1',
|
|
||||||
}}>
|
|
||||||
<NotificationOutlined />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text strong style={{ fontSize: 16 }}>全局活动横幅设置</Typography.Text>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 13, display: 'block' }}>
|
|
||||||
设置后将在用户前台页面顶部显示活动通知横幅,支持富文本格式
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>横幅内容</Typography.Text>
|
|
||||||
{bannerLoading ? (
|
|
||||||
<div style={{ padding: '40px 0', textAlign: 'center', color: '#94a3b8' }}>加载中...</div>
|
|
||||||
) : (
|
|
||||||
<ReactQuill
|
|
||||||
theme="snow"
|
|
||||||
value={bannerContent}
|
|
||||||
onChange={setBannerContent}
|
|
||||||
modules={editorModules}
|
|
||||||
placeholder="请输入横幅内容(支持富文本:加粗、变色、链接等)"
|
|
||||||
style={{ height: 200, marginBottom: 48 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
|
||||||
<Button
|
|
||||||
icon={<NotificationOutlined />}
|
|
||||||
onClick={handleResetBanner}
|
|
||||||
size="large"
|
|
||||||
style={{ borderRadius: 8, minWidth: 160 }}
|
|
||||||
>
|
|
||||||
重新展示横幅
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
onClick={handleSaveBanner}
|
|
||||||
loading={bannerSaving}
|
|
||||||
size="large"
|
|
||||||
style={{ borderRadius: 8, minWidth: 140 }}
|
|
||||||
>
|
|
||||||
保存横幅
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
|
|
||||||
💡 点击「重新展示横幅」将强制所有已关闭横幅的用户再次看到;修改内容并保存也会自动重新展示。
|
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
{/* 预览区域 */}
|
|
||||||
{bannerContent && (
|
|
||||||
<div style={{ marginTop: 24 }}>
|
|
||||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>前台预览</Typography.Text>
|
|
||||||
<div style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 50%, #e0e7ff 100%)',
|
|
||||||
border: '1px solid rgba(139, 92, 246, 0.15)',
|
|
||||||
padding: '10px 16px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 28, height: 28, borderRadius: 8,
|
|
||||||
background: 'rgba(139, 92, 246, 0.12)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<NotificationOutlined style={{ color: '#7c3aed', fontSize: 14 }} />
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
color: '#5b21b6',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 500,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
flex: 1,
|
|
||||||
}} dangerouslySetInnerHTML={{ __html: bannerContent }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Tabs items={tabItems} defaultActiveKey="notifications" />
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
|
<Typography.Text strong style={{ fontSize: 16 }}>消息推送管理</Typography.Text>
|
||||||
|
<Tag color="purple">共 {total} 条消息</Tag>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
|
||||||
|
style={{ borderRadius: 8 }}>
|
||||||
|
发送新消息
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={notifications}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
total: total,
|
||||||
|
onChange: handlePageChange,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条消息`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Send Notification Modal */}
|
{/* Send Notification Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -397,7 +214,7 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="content" label="消息内容"
|
<Form.Item name="content" label="消息内容"
|
||||||
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
|
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
|
||||||
<ReactQuill theme="snow" modules={editorModules} placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
|
<ReactQuill theme="snow" placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
|
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
|
||||||
@@ -450,4 +267,4 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminNotificationManager;
|
export default AdminNotificationManager;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const AdminOperationLogs: React.FC = () => {
|
|||||||
const load = async (p?: number) => {
|
const load = async (p?: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getOperationLogs({ page: p || page });
|
const res = await getOperationLogs(p || page);
|
||||||
setLogs(res.items || []);
|
setLogs(res.items || []);
|
||||||
setTotal(res.total || 0);
|
setTotal(res.total || 0);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -70,12 +70,9 @@ const AdminSettings: React.FC = () => {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
// 仅当 llm_billing_enabled 字段在当前标签页渲染时,才校验预扣积分
|
const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes(
|
||||||
const llmBillingEnabled = values.llm_billing_enabled !== undefined && values.llm_billing_enabled !== null
|
String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(),
|
||||||
? !['0', 'false', 'no', 'off', 'disabled'].includes(
|
);
|
||||||
String(values.llm_billing_enabled).trim().toLowerCase(),
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
if (llmBillingEnabled) {
|
if (llmBillingEnabled) {
|
||||||
const holdKeys = [
|
const holdKeys = [
|
||||||
'optimize_hold_credits',
|
'optimize_hold_credits',
|
||||||
@@ -252,7 +249,7 @@ const AdminSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const groupedConfigs: Record<string, SystemConfig[]> = {
|
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||||
'站点信息': configs.filter(c => c.key.startsWith('site_') && c.key !== 'site_banner'),
|
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
||||||
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
||||||
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
||||||
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
isActive: true, priority: 0,
|
isActive: true, priority: 0,
|
||||||
multiGenerationEnabled: false, maxGenerationCount: 1,
|
multiGenerationEnabled: false, maxGenerationCount: 1,
|
||||||
maxDuration: 15,
|
maxDuration: 30,
|
||||||
maxImageCount: 2,
|
maxImageCount: 2,
|
||||||
maxVideoCount: 0,
|
maxVideoCount: 0,
|
||||||
maxAudioCount: 0,
|
maxAudioCount: 0,
|
||||||
@@ -286,7 +286,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="supportedDurations" label="支持时长(秒)" style={{ flex: 1 }}>
|
<Form.Item name="supportedDurations" label="支持时长(秒)" style={{ flex: 1 }}>
|
||||||
<Select mode="multiple" size="large" options={
|
<Select mode="multiple" size="large" options={
|
||||||
Array.from({ length: 27 }, (_, i) => ({ value: i + 4, label: `${i + 4}秒` }))
|
Array.from({ length: 12 }, (_, i) => ({ value: i + 4, label: `${i + 4}秒` }))
|
||||||
} />
|
} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,14 +306,14 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
name="maxAudioCount"
|
name="maxAudioCount"
|
||||||
label="最大参考音频数"
|
label="最大参考音频数"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
extra="0 表示不支持音频参考"
|
extra="0 表示不支持音频参考,最大 3 段"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
validator: (_, value) => {
|
validator: (_, value) => {
|
||||||
const n = Number(value ?? 0);
|
const n = Number(value ?? 0);
|
||||||
// if (!Number.isInteger(n) || n < 0 || n > 3) {
|
if (!Number.isInteger(n) || n < 0 || n > 3) {
|
||||||
// return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
|
return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
|
||||||
// }
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Col, ColorPicker, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
|
import { Col, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
|
||||||
import type { HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
import type { HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||||
import WatermarkPreview from './WatermarkPreview';
|
import WatermarkPreview from './WatermarkPreview';
|
||||||
|
|
||||||
@@ -87,15 +87,7 @@ const WatermarkEditor: React.FC<WatermarkEditorProps> = ({ value, onChange, wate
|
|||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item label="文字颜色" required>
|
<Form.Item label="文字颜色" required>
|
||||||
<ColorPicker
|
<Input value={textWatermark.color} onChange={(e) => patchText({ color: e.target.value || '#ffffff' })} placeholder="#ffffff" />
|
||||||
value={textWatermark.color}
|
|
||||||
onChange={(_, hex) => patchText({ color: hex || '#ffffff' })}
|
|
||||||
showText
|
|
||||||
presets={[{
|
|
||||||
label: '推荐',
|
|
||||||
colors: ['#ffffff', '#000000', '#ff4d4f', '#1677ff', '#52c41a', '#faad14', '#722ed1', '#eb2f96'],
|
|
||||||
}]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
|
|||||||
@@ -868,32 +868,16 @@ export interface VideoPromptSchemaPreviewOut {
|
|||||||
|
|
||||||
export interface AdminCreditRecordSummary {
|
export interface AdminCreditRecordSummary {
|
||||||
totalRecharge: number;
|
totalRecharge: number;
|
||||||
/** 总消费(仅 type=consume,不含团队内部转账)= 真实扣费 + 预扣占用 */
|
|
||||||
totalConsume: number;
|
totalConsume: number;
|
||||||
/** 总回退(仅 type=refund)= 真实退款 + 预扣释放 */
|
|
||||||
totalRefund: number;
|
totalRefund: number;
|
||||||
/** 独立统计列:真实扣费 charge(含历史 NULL),对应"筛选类型=消费 & action=charge/NULL"求和 */
|
|
||||||
totalCharge: number;
|
|
||||||
/** 独立统计列:预扣占用 hold */
|
|
||||||
totalHold: number;
|
|
||||||
/** 独立统计列:真实退款 refund(含历史 NULL) */
|
|
||||||
totalRefundReal: number;
|
|
||||||
/** 独立统计列:预扣释放 hold_release(type=refund, action=hold_release) */
|
|
||||||
totalHoldRelease: number;
|
|
||||||
/** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */
|
|
||||||
netConsume: number;
|
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
generationCount: number;
|
generationCount: number;
|
||||||
generationAttemptCount: number;
|
generationAttemptCount: number;
|
||||||
imageGenerationCount: number;
|
imageGenerationCount: number;
|
||||||
videoGenerationCount: number;
|
videoGenerationCount: number;
|
||||||
/** 子分类消费(图片)仅真实扣费 charge 口径 */
|
|
||||||
imageConsume: number;
|
imageConsume: number;
|
||||||
/** 子分类消费(视频)仅真实扣费 charge 口径 */
|
|
||||||
videoConsume: number;
|
videoConsume: number;
|
||||||
/** 子分类消费(提词)仅真实扣费 charge 口径 */
|
|
||||||
textConsume: number;
|
textConsume: number;
|
||||||
/** 子分类消费(分析)仅真实扣费 charge 口径 */
|
|
||||||
analysisConsume: number;
|
analysisConsume: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||||
+1
-2
@@ -5,8 +5,8 @@ DEBUG=false
|
|||||||
SECRET_KEY=local-dev-secret-key-not-for-production
|
SECRET_KEY=local-dev-secret-key-not-for-production
|
||||||
|
|
||||||
# Database (PostgreSQL)
|
# Database (PostgreSQL)
|
||||||
|
#DATABASE_URL=postgresql+asyncpg://videogen_test:Yr7kM7kDj75izCiA@180.184.42.66:5432/videogen_test
|
||||||
DATABASE_URL=postgresql+asyncpg://videogen:7k33pnXdPL62Yyb4@180.184.42.66:5432/videogen
|
DATABASE_URL=postgresql+asyncpg://videogen:7k33pnXdPL62Yyb4@180.184.42.66:5432/videogen
|
||||||
#DATABASE_URL=postgresql+asyncpg://postgres:123456@localhost:5432/videogen_cs
|
|
||||||
|
|
||||||
# Redis (leave empty to disable - rate limiting and captcha will use in-memory fallback)
|
# Redis (leave empty to disable - rate limiting and captcha will use in-memory fallback)
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
@@ -44,7 +44,6 @@ CAPTCHA_ENABLED=true
|
|||||||
CORS_ORIGINS=["*"]
|
CORS_ORIGINS=["*"]
|
||||||
|
|
||||||
# Base URL (用于 favicon、回调地址等)
|
# Base URL (用于 favicon、回调地址等)
|
||||||
#BASE_URL=http://localhost:8000
|
|
||||||
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
|
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
|
||||||
|
|
||||||
# RESOURCE
|
# RESOURCE
|
||||||
|
|||||||
@@ -1,654 +0,0 @@
|
|||||||
"""2026073101_add_column_comments
|
|
||||||
|
|
||||||
Revision ID: 2026073101
|
|
||||||
Revises: f7g8h9i0j1k2
|
|
||||||
Create Date: 2026-07-31 00:00:00.000000
|
|
||||||
|
|
||||||
该文件包含 2026-07-31 的数据库迁移内容:
|
|
||||||
给所有表字段添加 COMMENT 注释,便于数据库维护与排查。
|
|
||||||
仅使用 COMMENT ON COLUMN/COMMENT ON TABLE 语句,不修改列类型与约束。
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
|
|
||||||
revision: str = '2026073101'
|
|
||||||
down_revision: Union[str, None] = 'f7g8h9i0j1k2'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _comment_table(table_name: str, comment: str) -> None:
|
|
||||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
|
||||||
|
|
||||||
|
|
||||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
|
||||||
escaped = comment.replace("'", "''")
|
|
||||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ============================================================
|
|
||||||
# users 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("users", "用户表")
|
|
||||||
_comment_column("users", "id", "主键ID")
|
|
||||||
_comment_column("users", "username", "用户名,唯一")
|
|
||||||
_comment_column("users", "email", "邮箱,唯一")
|
|
||||||
_comment_column("users", "phone", "手机号,唯一")
|
|
||||||
_comment_column("users", "hashed_password", "加密后的密码")
|
|
||||||
_comment_column("users", "avatar", "头像URL")
|
|
||||||
_comment_column("users", "credits", "账户积分余额")
|
|
||||||
_comment_column("users", "is_active", "是否启用,True启用")
|
|
||||||
_comment_column("users", "is_admin", "是否管理员,True管理员")
|
|
||||||
_comment_column("users", "user_type", "用户类型:frontend前台用户,admin后台管理员")
|
|
||||||
_comment_column("users", "frontend_user_kind", "前台用户类型:internal内部用户,external外部用户")
|
|
||||||
_comment_column("users", "team_id", "当前归属团队ID,仅前台用户有意义")
|
|
||||||
_comment_column("users", "last_login_at", "最后登录时间")
|
|
||||||
_comment_column("users", "password_set_at", "密码设置时间,NULL表示未设置密码")
|
|
||||||
_comment_column("users", "allowed_menus", "允许访问的菜单列表(JSON),NULL表示继承默认")
|
|
||||||
_comment_column("users", "private_portrait_asset_limit", "私域人像素材总量上限,0表示关闭模块")
|
|
||||||
_comment_column("users", "created_at", "创建时间")
|
|
||||||
_comment_column("users", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# projects 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("projects", "项目表")
|
|
||||||
_comment_column("projects", "id", "主键ID")
|
|
||||||
_comment_column("projects", "user_id", "所属用户ID")
|
|
||||||
_comment_column("projects", "name", "项目名称")
|
|
||||||
_comment_column("projects", "industry", "所属行业")
|
|
||||||
_comment_column("projects", "created_at", "创建时间")
|
|
||||||
_comment_column("projects", "updated_at", "更新时间")
|
|
||||||
_comment_column("projects", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# credit_ratios 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("credit_ratios", "积分计费规则表")
|
|
||||||
_comment_column("credit_ratios", "id", "主键ID")
|
|
||||||
_comment_column("credit_ratios", "model_config_id", "引擎ID:图片对应image_engines.id,视频对应video_engines.id")
|
|
||||||
_comment_column("credit_ratios", "gen_type", "生成类型:image图片,video视频")
|
|
||||||
_comment_column("credit_ratios", "resolution", "分辨率档位:图片(2K/4K) / 视频(480p/720p/1080p)")
|
|
||||||
_comment_column("credit_ratios", "ratio", "生成倍率,最终积分 = (基础积分+单位积分×时长/张数) × 倍率")
|
|
||||||
_comment_column("credit_ratios", "base_credits", "生成基础积分")
|
|
||||||
_comment_column("credit_ratios", "per_second_credits", "视频每秒积分 / 图片每张积分")
|
|
||||||
_comment_column("credit_ratios", "input_video_ratio", "传入视频积分倍率")
|
|
||||||
_comment_column("credit_ratios", "input_video_base_credits", "传入视频基础积分")
|
|
||||||
_comment_column("credit_ratios", "input_video_per_second_credits", "传入视频每秒积分")
|
|
||||||
_comment_column("credit_ratios", "input_image_ratio", "传入图片积分倍率")
|
|
||||||
_comment_column("credit_ratios", "input_image_base_credits", "传入图片基础积分")
|
|
||||||
_comment_column("credit_ratios", "input_image_per_image_credits", "传入图片每张积分")
|
|
||||||
_comment_column("credit_ratios", "created_at", "创建时间")
|
|
||||||
_comment_column("credit_ratios", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# credit_records 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("credit_records", "积分流水表")
|
|
||||||
_comment_column("credit_records", "id", "主键ID")
|
|
||||||
_comment_column("credit_records", "user_id", "所属用户ID")
|
|
||||||
_comment_column("credit_records", "type", "流水类型:charge扣费,recharge充值,refund退款,gift赠送")
|
|
||||||
_comment_column("credit_records", "amount", "流水金额,扣费为负数,充值/退款/赠送为正数")
|
|
||||||
_comment_column("credit_records", "balance_after", "流水后账户余额")
|
|
||||||
_comment_column("credit_records", "description", "流水描述")
|
|
||||||
_comment_column("credit_records", "related_id", "关联业务ID,如生成任务ID/订单ID")
|
|
||||||
_comment_column("credit_records", "biz_key", "业务幂等键,格式如 owner_type:owner_id:attempt_no:charge_kind:action")
|
|
||||||
_comment_column("credit_records", "refund_for_biz_key", "退款时,对应的扣费biz_key")
|
|
||||||
_comment_column("credit_records", "owner_type", "归属类型:chat_generation_task/ generation_record等")
|
|
||||||
_comment_column("credit_records", "owner_id", "归属业务记录ID")
|
|
||||||
_comment_column("credit_records", "attempt_no", "计费尝试次数,重试时递增")
|
|
||||||
_comment_column("credit_records", "charge_kind", "扣费大类:media媒体生成,prompt提示词等")
|
|
||||||
_comment_column("credit_records", "charge_action", "扣费动作:charge扣费,refund退款")
|
|
||||||
_comment_column("credit_records", "credit_subject", "计费科目:image/video/text")
|
|
||||||
_comment_column("credit_records", "media_type", "媒体类型:与credit_subject配合细分")
|
|
||||||
_comment_column("credit_records", "billing_scene", "计费场景:如chat_creation、project等")
|
|
||||||
_comment_column("credit_records", "source_module", "来源模块:generation_record/module_generation等")
|
|
||||||
_comment_column("credit_records", "source_project_id", "来源项目ID")
|
|
||||||
_comment_column("credit_records", "source_step_id", "来源步骤ID")
|
|
||||||
_comment_column("credit_records", "source_step_code", "来源步骤编码")
|
|
||||||
_comment_column("credit_records", "token_usage_id", "关联Token消耗记录ID")
|
|
||||||
_comment_column("credit_records", "input_tokens", "输入Token数量快照")
|
|
||||||
_comment_column("credit_records", "output_tokens", "输出Token数量快照")
|
|
||||||
_comment_column("credit_records", "total_tokens", "总Token数量快照")
|
|
||||||
_comment_column("credit_records", "engine_type", "引擎类型:image/video/text")
|
|
||||||
_comment_column("credit_records", "engine_id", "使用的引擎ID")
|
|
||||||
_comment_column("credit_records", "engine_name", "引擎名称快照")
|
|
||||||
_comment_column("credit_records", "engine_provider", "引擎供应商快照:ark/其他")
|
|
||||||
_comment_column("credit_records", "engine_model_name", "引擎模型名快照")
|
|
||||||
_comment_column("credit_records", "user_type_snapshot", "用户类型快照:frontend/admin")
|
|
||||||
_comment_column("credit_records", "frontend_user_kind_snapshot", "前台用户类型快照:internal/external")
|
|
||||||
_comment_column("credit_records", "team_id_snapshot", "团队ID快照,流水发生时的归属团队")
|
|
||||||
_comment_column("credit_records", "team_name_snapshot", "团队名称快照")
|
|
||||||
_comment_column("credit_records", "created_at", "创建时间")
|
|
||||||
_comment_column("credit_records", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# chat_generation_tasks 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("chat_generation_tasks", "AI创作任务表(不绑定项目的聊天式生成)")
|
|
||||||
_comment_column("chat_generation_tasks", "id", "主键ID,顶层/子任务ID")
|
|
||||||
_comment_column("chat_generation_tasks", "user_id", "所属用户ID")
|
|
||||||
_comment_column("chat_generation_tasks", "original_prompt", "原始用户提示词")
|
|
||||||
_comment_column("chat_generation_tasks", "optimized_prompt", "优化后的提示词")
|
|
||||||
_comment_column("chat_generation_tasks", "gen_type", "生成类型:image图片,video视频")
|
|
||||||
_comment_column("chat_generation_tasks", "duration", "视频时长(秒)")
|
|
||||||
_comment_column("chat_generation_tasks", "aspect_ratio", "视频比例:16:9/9:16等")
|
|
||||||
_comment_column("chat_generation_tasks", "resolution", "用户选择的分辨率")
|
|
||||||
_comment_column("chat_generation_tasks", "provider_generation_resolution", "供应商实际生成分辨率")
|
|
||||||
_comment_column("chat_generation_tasks", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
|
||||||
_comment_column("chat_generation_tasks", "video_upscale_snapshot_json", "视频超分参数快照JSON")
|
|
||||||
_comment_column("chat_generation_tasks", "image_size", "图片分辨率档位:2K/4K")
|
|
||||||
_comment_column("chat_generation_tasks", "image_proportion", "图片比例:1:1/16:9等")
|
|
||||||
_comment_column("chat_generation_tasks", "image_px", "图片像素,如2048×2048")
|
|
||||||
_comment_column("chat_generation_tasks", "status", "任务状态:generating/success/failed等")
|
|
||||||
_comment_column("chat_generation_tasks", "pipeline_stage", "流水线阶段:prompt_optimized/resource_generated等")
|
|
||||||
_comment_column("chat_generation_tasks", "generation_mode", "生成模式:chatapi_async单份异步/chatapi_main多份主任务")
|
|
||||||
_comment_column("chat_generation_tasks", "parent_task_id", "父任务ID,多份生成时子任务关联主任务")
|
|
||||||
_comment_column("chat_generation_tasks", "generation_count", "生成份数,主任务表示总共多少份")
|
|
||||||
_comment_column("chat_generation_tasks", "generation_index", "第N份子任务,主任务为NULL")
|
|
||||||
_comment_column("chat_generation_tasks", "generation_attempt_no", "生成尝试次数,重试时递增")
|
|
||||||
_comment_column("chat_generation_tasks", "resource_generation_started_at", "资源生成开始时间")
|
|
||||||
_comment_column("chat_generation_tasks", "provider_create_claim_token", "供应商创建任务分布式租约token")
|
|
||||||
_comment_column("chat_generation_tasks", "provider_create_lease_until", "供应商创建租约过期时间")
|
|
||||||
_comment_column("chat_generation_tasks", "provider_create_started_at", "供应商创建任务开始时间")
|
|
||||||
_comment_column("chat_generation_tasks", "media_references", "参考素材JSON数组")
|
|
||||||
_comment_column("chat_generation_tasks", "provider_task_id", "供应商任务ID")
|
|
||||||
_comment_column("chat_generation_tasks", "seedance_task_id", "Seedance任务ID(兼容字段)")
|
|
||||||
_comment_column("chat_generation_tasks", "remote_result_url", "供应商返回的远程资源URL")
|
|
||||||
_comment_column("chat_generation_tasks", "image_url", "图片结果URL")
|
|
||||||
_comment_column("chat_generation_tasks", "video_url", "视频结果URL")
|
|
||||||
_comment_column("chat_generation_tasks", "video_cover_url", "视频封面URL")
|
|
||||||
_comment_column("chat_generation_tasks", "engine_id", "使用的引擎ID")
|
|
||||||
_comment_column("chat_generation_tasks", "engine_snapshot_json", "引擎参数快照JSON")
|
|
||||||
_comment_column("chat_generation_tasks", "provider_response_json", "供应商完整响应JSON")
|
|
||||||
_comment_column("chat_generation_tasks", "credits_cost", "媒体生成消耗的总积分")
|
|
||||||
_comment_column("chat_generation_tasks", "text_credits_cost", "提示词优化消耗积分")
|
|
||||||
_comment_column("chat_generation_tasks", "text_tokens_used", "提示词优化Token消耗")
|
|
||||||
_comment_column("chat_generation_tasks", "video_tokens_used", "视频生成Token消耗")
|
|
||||||
_comment_column("chat_generation_tasks", "image_tokens_used", "图片生成Token消耗")
|
|
||||||
_comment_column("chat_generation_tasks", "retry_count", "重试次数(兼容旧字段)")
|
|
||||||
_comment_column("chat_generation_tasks", "manual_retry_count", "用户手动重试次数")
|
|
||||||
_comment_column("chat_generation_tasks", "poll_error_count", "轮询错误次数")
|
|
||||||
_comment_column("chat_generation_tasks", "poll_count", "轮询总次数")
|
|
||||||
_comment_column("chat_generation_tasks", "last_poll_at", "最后一次轮询时间")
|
|
||||||
_comment_column("chat_generation_tasks", "poll_started_at", "本次轮询开始时间")
|
|
||||||
_comment_column("chat_generation_tasks", "next_poll_at", "下一次轮询触发时间")
|
|
||||||
_comment_column("chat_generation_tasks", "poll_interval_seconds", "轮询间隔秒数")
|
|
||||||
_comment_column("chat_generation_tasks", "poll_claim_token", "轮询分布式租约token")
|
|
||||||
_comment_column("chat_generation_tasks", "poll_lease_until", "轮询租约过期时间")
|
|
||||||
_comment_column("chat_generation_tasks", "deadline_at", "任务截止时间,超时自动失败")
|
|
||||||
_comment_column("chat_generation_tasks", "generated_at", "资源生成完成时间")
|
|
||||||
_comment_column("chat_generation_tasks", "error_message", "错误信息")
|
|
||||||
_comment_column("chat_generation_tasks", "idempotency_key", "幂等键,防重复创建")
|
|
||||||
_comment_column("chat_generation_tasks", "download_celery_task_id", "下载步骤Celery任务ID")
|
|
||||||
_comment_column("chat_generation_tasks", "download_enqueued_at", "下载入队时间")
|
|
||||||
_comment_column("chat_generation_tasks", "download_started_at", "下载开始时间")
|
|
||||||
_comment_column("chat_generation_tasks", "download_claim_token", "下载租约token")
|
|
||||||
_comment_column("chat_generation_tasks", "download_lease_until", "下载租约过期时间")
|
|
||||||
_comment_column("chat_generation_tasks", "download_next_retry_at", "下载下次重试时间")
|
|
||||||
_comment_column("chat_generation_tasks", "download_attempt_count", "下载重试次数")
|
|
||||||
_comment_column("chat_generation_tasks", "download_last_error", "下载最后一次错误信息")
|
|
||||||
_comment_column("chat_generation_tasks", "download_storage_date_dir", "下载存储日期目录")
|
|
||||||
_comment_column("chat_generation_tasks", "created_at", "创建时间")
|
|
||||||
_comment_column("chat_generation_tasks", "updated_at", "更新时间")
|
|
||||||
_comment_column("chat_generation_tasks", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# generation_records 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("generation_records", "项目生成记录表(绑定项目的旧版生成)")
|
|
||||||
_comment_column("generation_records", "id", "主键ID")
|
|
||||||
_comment_column("generation_records", "user_id", "所属用户ID")
|
|
||||||
_comment_column("generation_records", "project_id", "所属项目ID")
|
|
||||||
_comment_column("generation_records", "original_prompt", "原始提示词")
|
|
||||||
_comment_column("generation_records", "optimized_prompt", "优化后的提示词")
|
|
||||||
_comment_column("generation_records", "prompt_usage_snapshot_json", "提示词消耗快照JSON")
|
|
||||||
_comment_column("generation_records", "gen_type", "生成类型:image/video")
|
|
||||||
_comment_column("generation_records", "duration", "视频时长秒数")
|
|
||||||
_comment_column("generation_records", "aspect_ratio", "视频比例")
|
|
||||||
_comment_column("generation_records", "resolution", "分辨率档位")
|
|
||||||
_comment_column("generation_records", "provider_generation_resolution", "供应商实际分辨率")
|
|
||||||
_comment_column("generation_records", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
|
||||||
_comment_column("generation_records", "video_upscale_snapshot_json", "视频超分快照JSON")
|
|
||||||
_comment_column("generation_records", "image_size", "图片分辨率档位")
|
|
||||||
_comment_column("generation_records", "image_proportion", "图片比例")
|
|
||||||
_comment_column("generation_records", "image_px", "图片像素尺寸")
|
|
||||||
_comment_column("generation_records", "status", "任务状态")
|
|
||||||
_comment_column("generation_records", "pipeline_stage", "流水线阶段")
|
|
||||||
_comment_column("generation_records", "video_url", "视频结果URL")
|
|
||||||
_comment_column("generation_records", "video_cover_url", "视频封面URL")
|
|
||||||
_comment_column("generation_records", "image_url", "图片结果URL")
|
|
||||||
_comment_column("generation_records", "media_references", "参考素材JSON数组")
|
|
||||||
_comment_column("generation_records", "include_media_references", "是否包含参考素材")
|
|
||||||
_comment_column("generation_records", "video_url_expires_at", "视频URL过期时间")
|
|
||||||
_comment_column("generation_records", "seedance_task_id", "Seedance任务ID")
|
|
||||||
_comment_column("generation_records", "credits_cost", "媒体生成消耗积分")
|
|
||||||
_comment_column("generation_records", "text_credits_cost", "提示词消耗积分")
|
|
||||||
_comment_column("generation_records", "text_tokens_used", "提示词Token数")
|
|
||||||
_comment_column("generation_records", "video_tokens_used", "视频Token数")
|
|
||||||
_comment_column("generation_records", "image_tokens_used", "图片Token数")
|
|
||||||
_comment_column("generation_records", "generated_at", "生成完成时间")
|
|
||||||
_comment_column("generation_records", "error_message", "错误信息")
|
|
||||||
_comment_column("generation_records", "idempotency_key", "幂等键")
|
|
||||||
_comment_column("generation_records", "generation_attempt_no", "生成尝试次数")
|
|
||||||
_comment_column("generation_records", "resource_generation_started_at", "资源生成开始时间")
|
|
||||||
_comment_column("generation_records", "deadline_at", "任务截止时间")
|
|
||||||
_comment_column("generation_records", "engine_id", "使用引擎ID")
|
|
||||||
_comment_column("generation_records", "engine_snapshot_json", "引擎参数快照JSON")
|
|
||||||
_comment_column("generation_records", "provider_response_json", "供应商响应JSON")
|
|
||||||
_comment_column("generation_records", "remote_result_url", "远程资源URL")
|
|
||||||
_comment_column("generation_records", "provider_create_claim_token", "供应商创建租约token")
|
|
||||||
_comment_column("generation_records", "provider_create_lease_until", "供应商创建租约过期")
|
|
||||||
_comment_column("generation_records", "provider_create_started_at", "供应商创建开始时间")
|
|
||||||
_comment_column("generation_records", "retry_count", "重试次数(兼容)")
|
|
||||||
_comment_column("generation_records", "manual_retry_count", "手动重试次数")
|
|
||||||
_comment_column("generation_records", "poll_error_count", "轮询错误次数")
|
|
||||||
_comment_column("generation_records", "poll_count", "轮询次数")
|
|
||||||
_comment_column("generation_records", "last_poll_at", "最后轮询时间")
|
|
||||||
_comment_column("generation_records", "poll_started_at", "轮询开始时间")
|
|
||||||
_comment_column("generation_records", "next_poll_at", "下次轮询时间")
|
|
||||||
_comment_column("generation_records", "poll_interval_seconds", "轮询间隔秒")
|
|
||||||
_comment_column("generation_records", "poll_claim_token", "轮询租约token")
|
|
||||||
_comment_column("generation_records", "poll_lease_until", "轮询租约过期")
|
|
||||||
_comment_column("generation_records", "download_celery_task_id", "下载Celery任务ID")
|
|
||||||
_comment_column("generation_records", "download_enqueued_at", "下载开始入队时间")
|
|
||||||
_comment_column("generation_records", "download_started_at", "下载开始时间")
|
|
||||||
_comment_column("generation_records", "download_claim_token", "下载租约token")
|
|
||||||
_comment_column("generation_records", "download_lease_until", "下载租约过期")
|
|
||||||
_comment_column("generation_records", "download_next_retry_at", "下载下次重试")
|
|
||||||
_comment_column("generation_records", "download_attempt_count", "下载重试次数")
|
|
||||||
_comment_column("generation_records", "download_last_error", "下载最后错误")
|
|
||||||
_comment_column("generation_records", "download_storage_date_dir", "下载存储日期目录")
|
|
||||||
_comment_column("generation_records", "created_at", "创建时间")
|
|
||||||
_comment_column("generation_records", "updated_at", "更新时间")
|
|
||||||
_comment_column("generation_records", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# generated_resources 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("generated_resources", "生成资源账本表(统一记录所有生成的图片/视频)")
|
|
||||||
_comment_column("generated_resources", "id", "主键ID")
|
|
||||||
_comment_column("generated_resources", "user_id", "所属用户ID")
|
|
||||||
_comment_column("generated_resources", "resource_type", "资源类型:image/video")
|
|
||||||
_comment_column("generated_resources", "resource_url", "资源访问URL")
|
|
||||||
_comment_column("generated_resources", "remote_url", "供应商原始远程URL")
|
|
||||||
_comment_column("generated_resources", "storage_type", "存储类型:local本地/oss对象存储")
|
|
||||||
_comment_column("generated_resources", "storage_path", "存储路径")
|
|
||||||
_comment_column("generated_resources", "file_name", "文件名,平台素材名称")
|
|
||||||
_comment_column("generated_resources", "file_size_bytes", "文件大小(字节)")
|
|
||||||
_comment_column("generated_resources", "source_model", "来源模型:chat_generation_task/generation_record")
|
|
||||||
_comment_column("generated_resources", "source_model_module", "来源模块描述")
|
|
||||||
_comment_column("generated_resources", "source_id", "来源记录ID")
|
|
||||||
_comment_column("generated_resources", "engine_id", "使用引擎ID")
|
|
||||||
_comment_column("generated_resources", "engine_type", "引擎类型:image/video")
|
|
||||||
_comment_column("generated_resources", "provider", "供应商:ark/其他")
|
|
||||||
_comment_column("generated_resources", "model_name", "模型名称")
|
|
||||||
_comment_column("generated_resources", "generated_at", "资源生成完成时间")
|
|
||||||
_comment_column("generated_resources", "resource_month", "资源归属月份,按月统计")
|
|
||||||
_comment_column("generated_resources", "extra_json", "扩展字段JSON")
|
|
||||||
_comment_column("generated_resources", "created_at", "创建时间")
|
|
||||||
_comment_column("generated_resources", "updated_at", "更新时间")
|
|
||||||
_comment_column("generated_resources", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# upload_resources 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("upload_resources", "用户上传资源账本表(用户上传/模块上传/切片文件)")
|
|
||||||
_comment_column("upload_resources", "id", "主键ID")
|
|
||||||
_comment_column("upload_resources", "user_id", "所属用户ID")
|
|
||||||
_comment_column("upload_resources", "module", "所属模块:conversation/generation_record等")
|
|
||||||
_comment_column("upload_resources", "resource_type", "资源类型:image/video/audio/file")
|
|
||||||
_comment_column("upload_resources", "resource_url", "资源访问URL")
|
|
||||||
_comment_column("upload_resources", "storage_path", "存储路径,唯一")
|
|
||||||
_comment_column("upload_resources", "file_name", "原始文件名")
|
|
||||||
_comment_column("upload_resources", "file_ext", "文件扩展名")
|
|
||||||
_comment_column("upload_resources", "mime_type", "MIME类型")
|
|
||||||
_comment_column("upload_resources", "file_size_bytes", "文件大小(字节)")
|
|
||||||
_comment_column("upload_resources", "duration_seconds", "音视频时长(秒)")
|
|
||||||
_comment_column("upload_resources", "duration_source", "时长来源:probe探测/用户设置")
|
|
||||||
_comment_column("upload_resources", "width", "图片/视频宽度(像素)")
|
|
||||||
_comment_column("upload_resources", "height", "图片/视频高度(像素)")
|
|
||||||
_comment_column("upload_resources", "source_model", "关联业务模型")
|
|
||||||
_comment_column("upload_resources", "source_id", "关联业务记录ID")
|
|
||||||
_comment_column("upload_resources", "source_module", "关联业务模块")
|
|
||||||
_comment_column("upload_resources", "bind_status", "绑定状态:pending待绑定/bound已绑定/unbound已解绑")
|
|
||||||
_comment_column("upload_resources", "delete_policy", "删除策略:user_deletable用户可删/keep_forever永久保留")
|
|
||||||
_comment_column("upload_resources", "created_by", "创建来源:api用户上传/worker系统生成")
|
|
||||||
_comment_column("upload_resources", "metadata_json", "媒体元数据JSON")
|
|
||||||
_comment_column("upload_resources", "capacity_released_at", "容量统计中已释放时间")
|
|
||||||
_comment_column("upload_resources", "physical_deleted_at", "物理文件删除时间")
|
|
||||||
_comment_column("upload_resources", "file_delete_status", "文件删除状态:active待删/deleting删除中/deleted已删除/error失败")
|
|
||||||
_comment_column("upload_resources", "file_delete_error", "文件删除失败信息")
|
|
||||||
_comment_column("upload_resources", "created_at", "创建时间")
|
|
||||||
_comment_column("upload_resources", "updated_at", "更新时间")
|
|
||||||
_comment_column("upload_resources", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# image_engines 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("image_engines", "图片生成引擎配置表")
|
|
||||||
_comment_column("image_engines", "id", "主键ID")
|
|
||||||
_comment_column("image_engines", "name", "引擎显示名称")
|
|
||||||
_comment_column("image_engines", "provider", "供应商:ark/其他")
|
|
||||||
_comment_column("image_engines", "api_base", "API基础地址")
|
|
||||||
_comment_column("image_engines", "api_key", "API密钥")
|
|
||||||
_comment_column("image_engines", "model_name", "模型名")
|
|
||||||
_comment_column("image_engines", "supported_models", "支持的模型列表JSON")
|
|
||||||
_comment_column("image_engines", "supported_sizes", "支持尺寸JSON:{分辨率:{比例:像素}}")
|
|
||||||
_comment_column("image_engines", "default_size", "默认分辨率档位")
|
|
||||||
_comment_column("image_engines", "max_image_count", "允许生成图片数量上限")
|
|
||||||
_comment_column("image_engines", "multi_generation_enabled", "是否允许多份生成")
|
|
||||||
_comment_column("image_engines", "max_generation_count", "多份生成最大份数")
|
|
||||||
_comment_column("image_engines", "multi_image_max_images", "组图接口参考图+生成图数量上限")
|
|
||||||
_comment_column("image_engines", "max_reference_image_count", "最多参考图片张数")
|
|
||||||
_comment_column("image_engines", "output_format", "输出格式,空表示使用默认")
|
|
||||||
_comment_column("image_engines", "generate_url", "生成接口URL,留空使用SDK默认")
|
|
||||||
_comment_column("image_engines", "is_active", "是否启用")
|
|
||||||
_comment_column("image_engines", "priority", "排序优先级,越大越优先")
|
|
||||||
_comment_column("image_engines", "created_at", "创建时间")
|
|
||||||
_comment_column("image_engines", "updated_at", "更新时间")
|
|
||||||
_comment_column("image_engines", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# video_engines 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("video_engines", "视频生成引擎配置表")
|
|
||||||
_comment_column("video_engines", "id", "主键ID")
|
|
||||||
_comment_column("video_engines", "name", "引擎显示名称")
|
|
||||||
_comment_column("video_engines", "provider", "供应商:ark/其他")
|
|
||||||
_comment_column("video_engines", "api_base", "API基础地址")
|
|
||||||
_comment_column("video_engines", "api_key", "API密钥")
|
|
||||||
_comment_column("video_engines", "model_name", "模型名")
|
|
||||||
_comment_column("video_engines", "supported_ratios", "支持比例JSON数组")
|
|
||||||
_comment_column("video_engines", "supported_resolutions", "支持分辨率JSON数组")
|
|
||||||
_comment_column("video_engines", "supported_durations", "支持时长JSON数组")
|
|
||||||
_comment_column("video_engines", "max_duration", "最大时长秒数")
|
|
||||||
_comment_column("video_engines", "max_image_count", "最多参考图片张数,0表示不支持")
|
|
||||||
_comment_column("video_engines", "max_video_count", "最多参考视频段数,0表示不支持")
|
|
||||||
_comment_column("video_engines", "max_audio_count", "最多参考音频段数,0表示不支持")
|
|
||||||
_comment_column("video_engines", "multi_generation_enabled", "是否允许多份生成")
|
|
||||||
_comment_column("video_engines", "max_generation_count", "多份生成最大份数")
|
|
||||||
_comment_column("video_engines", "supports_first_last_frame", "是否支持首尾帧参考")
|
|
||||||
_comment_column("video_engines", "supports_universal_reference", "是否支持通用参考素材")
|
|
||||||
_comment_column("video_engines", "generate_url", "生成接口URL")
|
|
||||||
_comment_column("video_engines", "query_url", "查询接口URL")
|
|
||||||
_comment_column("video_engines", "is_active", "是否启用")
|
|
||||||
_comment_column("video_engines", "priority", "排序优先级")
|
|
||||||
_comment_column("video_engines", "created_at", "创建时间")
|
|
||||||
_comment_column("video_engines", "updated_at", "更新时间")
|
|
||||||
_comment_column("video_engines", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# model_configs 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("model_configs", "文本模型配置表(提示词优化等文本模型)")
|
|
||||||
_comment_column("model_configs", "id", "主键ID")
|
|
||||||
_comment_column("model_configs", "name", "模型显示名称")
|
|
||||||
_comment_column("model_configs", "provider", "供应商")
|
|
||||||
_comment_column("model_configs", "api_base", "API基础地址")
|
|
||||||
_comment_column("model_configs", "api_key", "API密钥")
|
|
||||||
_comment_column("model_configs", "model_name", "模型名")
|
|
||||||
_comment_column("model_configs", "weight", "权重,权重选择时使用")
|
|
||||||
_comment_column("model_configs", "max_tokens", "最大输出Token数")
|
|
||||||
_comment_column("model_configs", "temperature", "采样温度")
|
|
||||||
_comment_column("model_configs", "is_active", "是否启用")
|
|
||||||
_comment_column("model_configs", "priority", "排序优先级")
|
|
||||||
_comment_column("model_configs", "created_at", "创建时间")
|
|
||||||
_comment_column("model_configs", "updated_at", "更新时间")
|
|
||||||
_comment_column("model_configs", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# system_configs 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("system_configs", "系统配置表")
|
|
||||||
_comment_column("system_configs", "id", "主键ID")
|
|
||||||
_comment_column("system_configs", "key", "配置键名,唯一")
|
|
||||||
_comment_column("system_configs", "value", "配置值")
|
|
||||||
_comment_column("system_configs", "description", "配置说明")
|
|
||||||
_comment_column("system_configs", "created_at", "创建时间")
|
|
||||||
_comment_column("system_configs", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# operation_logs 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("operation_logs", "操作日志表")
|
|
||||||
_comment_column("operation_logs", "id", "主键ID")
|
|
||||||
_comment_column("operation_logs", "user_id", "操作用户ID")
|
|
||||||
_comment_column("operation_logs", "username", "操作用户名")
|
|
||||||
_comment_column("operation_logs", "action", "操作动作:CREATE/UPDATE/DELETE等")
|
|
||||||
_comment_column("operation_logs", "method", "HTTP方法:GET/POST/PUT/DELETE")
|
|
||||||
_comment_column("operation_logs", "path", "请求路径")
|
|
||||||
_comment_column("operation_logs", "detail", "操作详情JSON")
|
|
||||||
_comment_column("operation_logs", "ip", "客户端IP")
|
|
||||||
_comment_column("operation_logs", "created_at", "创建时间")
|
|
||||||
_comment_column("operation_logs", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# notifications 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("notifications", "通知消息表")
|
|
||||||
_comment_column("notifications", "id", "主键ID")
|
|
||||||
_comment_column("notifications", "user_id", "接收用户ID,NULL表示全体广播")
|
|
||||||
_comment_column("notifications", "title", "通知标题")
|
|
||||||
_comment_column("notifications", "content", "通知内容")
|
|
||||||
_comment_column("notifications", "type", "通知类型:system系统公告/billing账单通知等")
|
|
||||||
_comment_column("notifications", "is_read", "是否已读")
|
|
||||||
_comment_column("notifications", "related_id", "关联业务ID")
|
|
||||||
_comment_column("notifications", "created_at", "创建时间")
|
|
||||||
_comment_column("notifications", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# recharge_packages 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("recharge_packages", "积分充值套餐表")
|
|
||||||
_comment_column("recharge_packages", "id", "主键ID")
|
|
||||||
_comment_column("recharge_packages", "name", "套餐名称")
|
|
||||||
_comment_column("recharge_packages", "credits", "套餐包含积分")
|
|
||||||
_comment_column("recharge_packages", "price", "套餐价格(元)")
|
|
||||||
_comment_column("recharge_packages", "bonus_credits", "赠送积分")
|
|
||||||
_comment_column("recharge_packages", "description", "套餐描述")
|
|
||||||
_comment_column("recharge_packages", "package_type", "套餐类型:normal普通/gift赠送首充等")
|
|
||||||
_comment_column("recharge_packages", "is_gift", "是否赠送套餐")
|
|
||||||
_comment_column("recharge_packages", "is_active", "是否启用")
|
|
||||||
_comment_column("recharge_packages", "sort_order", "排序值,越小越靠前")
|
|
||||||
_comment_column("recharge_packages", "created_at", "创建时间")
|
|
||||||
_comment_column("recharge_packages", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# payment_orders 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("payment_orders", "支付订单表")
|
|
||||||
_comment_column("payment_orders", "id", "主键ID")
|
|
||||||
_comment_column("payment_orders", "user_id", "下单用户ID")
|
|
||||||
_comment_column("payment_orders", "order_no", "订单号,唯一")
|
|
||||||
_comment_column("payment_orders", "amount", "支付金额(元)")
|
|
||||||
_comment_column("payment_orders", "credits", "获得积分总数(含赠送)")
|
|
||||||
_comment_column("payment_orders", "payment_method", "支付方式:wxpay/alipay等")
|
|
||||||
_comment_column("payment_orders", "status", "订单状态:pending待支付/paid已支付/refunded已退款/failed失败")
|
|
||||||
_comment_column("payment_orders", "paid_at", "支付成功时间")
|
|
||||||
_comment_column("payment_orders", "trade_no", "第三方支付流水号")
|
|
||||||
_comment_column("payment_orders", "refund_trade_no", "退款流水号")
|
|
||||||
_comment_column("payment_orders", "refunded_at", "退款完成时间")
|
|
||||||
_comment_column("payment_orders", "refund_amount", "退款金额")
|
|
||||||
_comment_column("payment_orders", "created_at", "创建时间")
|
|
||||||
_comment_column("payment_orders", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# video_upscale_tasks 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("video_upscale_tasks", "视频超分任务表")
|
|
||||||
_comment_column("video_upscale_tasks", "id", "主键ID")
|
|
||||||
_comment_column("video_upscale_tasks", "chat_generation_task_id", "关联AI创作任务ID,与generation_record_id二选一")
|
|
||||||
_comment_column("video_upscale_tasks", "generation_record_id", "关联项目生成记录ID,与chat_generation_task_id二选一")
|
|
||||||
_comment_column("video_upscale_tasks", "api_generation_task_id", "关联API生成任务ID")
|
|
||||||
_comment_column("video_upscale_tasks", "status", "任务状态:pending/processing/success/failed")
|
|
||||||
_comment_column("video_upscale_tasks", "stage", "阶段:upscale_queued/upscale_processing等")
|
|
||||||
_comment_column("video_upscale_tasks", "processor_key", "处理节点标识")
|
|
||||||
_comment_column("video_upscale_tasks", "attempt_count", "执行尝试次数")
|
|
||||||
_comment_column("video_upscale_tasks", "failure_count", "失败次数")
|
|
||||||
_comment_column("video_upscale_tasks", "manual_retry_count", "手动重试次数")
|
|
||||||
_comment_column("video_upscale_tasks", "next_retry_at", "下次重试时间")
|
|
||||||
_comment_column("video_upscale_tasks", "last_error", "最后错误信息")
|
|
||||||
_comment_column("video_upscale_tasks", "source_local_path", "源视频本地路径")
|
|
||||||
_comment_column("video_upscale_tasks", "source_file_size_bytes", "源文件大小(字节)")
|
|
||||||
_comment_column("video_upscale_tasks", "source_width", "源视频宽度")
|
|
||||||
_comment_column("video_upscale_tasks", "source_height", "源视频高度")
|
|
||||||
_comment_column("video_upscale_tasks", "source_duration_seconds", "源视频时长秒数")
|
|
||||||
_comment_column("video_upscale_tasks", "source_deleted_at", "源文件删除时间")
|
|
||||||
_comment_column("video_upscale_tasks", "source_delete_error", "源文件删除错误")
|
|
||||||
_comment_column("video_upscale_tasks", "source_remote_url", "源文件远程URL")
|
|
||||||
_comment_column("video_upscale_tasks", "source_remote_url_signed_at", "远程URL签名时间")
|
|
||||||
_comment_column("video_upscale_tasks", "source_remote_url_expires_at", "远程URL过期时间")
|
|
||||||
_comment_column("video_upscale_tasks", "source_remote_url_last_probe_at", "远程URL最后探测时间")
|
|
||||||
_comment_column("video_upscale_tasks", "source_remote_url_probe_status", "远程URL探测状态")
|
|
||||||
_comment_column("video_upscale_tasks", "input_source_type", "输入源类型:local/remote")
|
|
||||||
_comment_column("video_upscale_tasks", "input_source_fallback_count", "输入源回退次数")
|
|
||||||
_comment_column("video_upscale_tasks", "target_width", "目标宽度像素")
|
|
||||||
_comment_column("video_upscale_tasks", "target_height", "目标高度像素")
|
|
||||||
_comment_column("video_upscale_tasks", "effective_target_width", "实际生效目标宽度")
|
|
||||||
_comment_column("video_upscale_tasks", "effective_target_height", "实际生效目标高度")
|
|
||||||
_comment_column("video_upscale_tasks", "provider_task_id", "供应商超分任务ID")
|
|
||||||
_comment_column("video_upscale_tasks", "provider_request_json", "供应商请求JSON")
|
|
||||||
_comment_column("video_upscale_tasks", "provider_response_json", "供应商响应JSON")
|
|
||||||
_comment_column("video_upscale_tasks", "provider_output_url", "供应商输出URL")
|
|
||||||
_comment_column("video_upscale_tasks", "provider_output_url_expires_at", "供应商输出URL过期")
|
|
||||||
_comment_column("video_upscale_tasks", "provider_submitted_at", "提交供应商时间")
|
|
||||||
_comment_column("video_upscale_tasks", "final_local_path", "最终本地文件路径")
|
|
||||||
_comment_column("video_upscale_tasks", "final_resource_url", "最终资源访问URL")
|
|
||||||
_comment_column("video_upscale_tasks", "final_file_size_bytes", "最终文件大小(字节)")
|
|
||||||
_comment_column("video_upscale_tasks", "celery_task_id", "Celery任务ID")
|
|
||||||
_comment_column("video_upscale_tasks", "lease_token", "分布式租约token")
|
|
||||||
_comment_column("video_upscale_tasks", "lease_until", "租约过期时间")
|
|
||||||
_comment_column("video_upscale_tasks", "started_at", "开始处理时间")
|
|
||||||
_comment_column("video_upscale_tasks", "completed_at", "完成时间")
|
|
||||||
_comment_column("video_upscale_tasks", "failed_at", "失败时间")
|
|
||||||
_comment_column("video_upscale_tasks", "created_at", "创建时间")
|
|
||||||
_comment_column("video_upscale_tasks", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# shot_replicate_task_sets 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("shot_replicate_task_sets", "拆镜复刻总任务集")
|
|
||||||
_comment_column("shot_replicate_task_sets", "id", "主键ID")
|
|
||||||
_comment_column("shot_replicate_task_sets", "user_id", "所属用户ID")
|
|
||||||
_comment_column("shot_replicate_task_sets", "title", "任务集标题")
|
|
||||||
_comment_column("shot_replicate_task_sets", "video_url", "上传视频访问URL")
|
|
||||||
_comment_column("shot_replicate_task_sets", "video_path", "上传视频存储路径")
|
|
||||||
_comment_column("shot_replicate_task_sets", "video_duration_seconds", "上传视频总时长秒数")
|
|
||||||
_comment_column("shot_replicate_task_sets", "status", "总任务状态:pending_analysis/analyzing/analysis_done等")
|
|
||||||
_comment_column("shot_replicate_task_sets", "analysis_status", "AI分析状态:pending/processing/success/failed")
|
|
||||||
_comment_column("shot_replicate_task_sets", "split_status", "切片状态:none/slicing/sliced")
|
|
||||||
_comment_column("shot_replicate_task_sets", "original_video_content", "原视频内容描述")
|
|
||||||
_comment_column("shot_replicate_task_sets", "original_video_category", "原视频行业分类")
|
|
||||||
_comment_column("shot_replicate_task_sets", "original_video_audience", "原视频目标受众")
|
|
||||||
_comment_column("shot_replicate_task_sets", "ai_suggestion_json", "AI复刻建议JSON")
|
|
||||||
_comment_column("shot_replicate_task_sets", "analysis_raw_json", "AI分析原始JSON")
|
|
||||||
_comment_column("shot_replicate_task_sets", "analysis_result_json", "AI分析结果JSON")
|
|
||||||
_comment_column("shot_replicate_task_sets", "segment_count", "总拆镜头数")
|
|
||||||
_comment_column("shot_replicate_task_sets", "completed_segment_count", "已完成镜头数")
|
|
||||||
_comment_column("shot_replicate_task_sets", "failed_segment_count", "失败镜头数")
|
|
||||||
_comment_column("shot_replicate_task_sets", "analysis_attempt_no", "AI分析尝试次数")
|
|
||||||
_comment_column("shot_replicate_task_sets", "analysis_claim_token", "AI分析租约token")
|
|
||||||
_comment_column("shot_replicate_task_sets", "analysis_started_at", "AI分析开始时间")
|
|
||||||
_comment_column("shot_replicate_task_sets", "analysis_lease_until", "AI分析租约过期")
|
|
||||||
_comment_column("shot_replicate_task_sets", "analysis_error_message", "AI分析错误信息")
|
|
||||||
_comment_column("shot_replicate_task_sets", "split_error_message", "切片错误信息")
|
|
||||||
_comment_column("shot_replicate_task_sets", "idempotency_key", "幂等键")
|
|
||||||
_comment_column("shot_replicate_task_sets", "created_at", "创建时间")
|
|
||||||
_comment_column("shot_replicate_task_sets", "updated_at", "更新时间")
|
|
||||||
_comment_column("shot_replicate_task_sets", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# teams 表(已经有部分comment,补齐未加的)
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("teams", "团队表")
|
|
||||||
_comment_column("teams", "id", "主键ID")
|
|
||||||
_comment_column("teams", "name", "团队名称")
|
|
||||||
_comment_column("teams", "code", "团队编码")
|
|
||||||
_comment_column("teams", "description", "团队备注")
|
|
||||||
_comment_column("teams", "status", "团队状态:active启用,disabled禁用")
|
|
||||||
_comment_column("teams", "sort_order", "排序值,越小越靠前")
|
|
||||||
_comment_column("teams", "manager_id", "团队管理人ID")
|
|
||||||
_comment_column("teams", "created_at", "创建时间")
|
|
||||||
_comment_column("teams", "updated_at", "更新时间")
|
|
||||||
_comment_column("teams", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# user_resource_capacity_configs 表(已部分有comment)
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("user_resource_capacity_configs", "用户个人容量配置表")
|
|
||||||
_comment_column("user_resource_capacity_configs", "id", "主键ID")
|
|
||||||
_comment_column("user_resource_capacity_configs", "user_id", "用户ID")
|
|
||||||
_comment_column("user_resource_capacity_configs", "enabled", "是否启用该用户个人容量限制")
|
|
||||||
_comment_column("user_resource_capacity_configs", "limit_value", "容量数值,最小1,最多3位小数")
|
|
||||||
_comment_column("user_resource_capacity_configs", "limit_unit", "容量单位:MB/GB/TB")
|
|
||||||
_comment_column("user_resource_capacity_configs", "limit_bytes", "换算后的容量字节数")
|
|
||||||
_comment_column("user_resource_capacity_configs", "created_at", "创建时间")
|
|
||||||
_comment_column("user_resource_capacity_configs", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# resources_material 表(已部分有comment)
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("resources_material", "资源素材对接表(第三方平台素材同步)")
|
|
||||||
_comment_column("resources_material", "id", "主键")
|
|
||||||
_comment_column("resources_material", "oauth_id", "授权表user_oauth自增id")
|
|
||||||
_comment_column("resources_material", "advertiser_id", "广告主id")
|
|
||||||
_comment_column("resources_material", "target_table", "资源表名称")
|
|
||||||
_comment_column("resources_material", "target_id", "资源表id")
|
|
||||||
_comment_column("resources_material", "material_id", "素材id")
|
|
||||||
_comment_column("resources_material", "upload_id", "上传资源平台id,图片id,视频id")
|
|
||||||
_comment_column("resources_material", "resource_type", "资源类型,image或者video")
|
|
||||||
_comment_column("resources_material", "user_id", "用户登录id")
|
|
||||||
_comment_column("resources_material", "task_id", "前测任务id")
|
|
||||||
_comment_column("resources_material", "note", "前测失败备注或者其他备注")
|
|
||||||
_comment_column("resources_material", "status", "前测状态(FAILED/PENDING/SUCCESS)")
|
|
||||||
_comment_column("resources_material", "pre_result", "前测结果,JSON数组对象")
|
|
||||||
_comment_column("resources_material", "pre_test_template_id", "前测模板id")
|
|
||||||
_comment_column("resources_material", "created_at", "创建时间")
|
|
||||||
_comment_column("resources_material", "updated_at", "更新时间")
|
|
||||||
_comment_column("resources_material", "deleted_at", "软删除时间")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# 注释回滚时选择清空所有注释即可,不影响功能
|
|
||||||
op.execute("""
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
r record;
|
|
||||||
BEGIN
|
|
||||||
FOR r IN
|
|
||||||
SELECT table_name, column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name IN (
|
|
||||||
'users', 'projects', 'credit_ratios', 'credit_records',
|
|
||||||
'chat_generation_tasks', 'generation_records',
|
|
||||||
'generated_resources', 'upload_resources',
|
|
||||||
'image_engines', 'video_engines', 'model_configs',
|
|
||||||
'system_configs', 'operation_logs', 'notifications',
|
|
||||||
'recharge_packages', 'payment_orders',
|
|
||||||
'video_upscale_tasks', 'shot_replicate_task_sets',
|
|
||||||
'teams', 'user_resource_capacity_configs',
|
|
||||||
'resources_material'
|
|
||||||
)
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('COMMENT ON COLUMN %I.%I IS NULL', r.table_name, r.column_name);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
""")
|
|
||||||
# 清空表注释
|
|
||||||
for t in [
|
|
||||||
"users", "projects", "credit_ratios", "credit_records",
|
|
||||||
"chat_generation_tasks", "generation_records",
|
|
||||||
"generated_resources", "upload_resources",
|
|
||||||
"image_engines", "video_engines", "model_configs",
|
|
||||||
"system_configs", "operation_logs", "notifications",
|
|
||||||
"recharge_packages", "payment_orders",
|
|
||||||
"video_upscale_tasks", "shot_replicate_task_sets",
|
|
||||||
"teams", "user_resource_capacity_configs",
|
|
||||||
"resources_material",
|
|
||||||
]:
|
|
||||||
op.execute(f"COMMENT ON TABLE {t} IS NULL")
|
|
||||||
-193
@@ -1,193 +0,0 @@
|
|||||||
"""2026080401_add_vp_v3_virtual_portrait_tables_and_quota
|
|
||||||
|
|
||||||
Revision ID: 2026080401
|
|
||||||
Revises: 2026073101
|
|
||||||
Create Date: 2026-08-04 00:00:00.000000
|
|
||||||
|
|
||||||
API V3 虚拟素材库中转表 + 密钥配额表:
|
|
||||||
1. vp_v3_api_key_quotas: 每个 API Key 的虚拟素材配额(项目数/素材数/存储 MB)
|
|
||||||
2. vp_v3_projects: V3 虚拟素材项目(=火山一个 AssetGroup)
|
|
||||||
3. vp_v3_assets: V3 虚拟素材(图片/视频)
|
|
||||||
|
|
||||||
备注:
|
|
||||||
* 数据与前台用户私域素材库(private_portrait_* 表)完全隔离
|
|
||||||
* 归属按 api_keys.id(V3 调用方)而非 users.id
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
revision: str = '2026080401'
|
|
||||||
down_revision: Union[str, None] = '2026073101'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ==============================================================
|
|
||||||
# 1. vp_v3_api_key_quotas:API Key 虚拟素材配额
|
|
||||||
# ==============================================================
|
|
||||||
op.create_table(
|
|
||||||
'vp_v3_api_key_quotas',
|
|
||||||
sa.Column('id', sa.String(length=32), nullable=False, comment='主键ID'),
|
|
||||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
|
||||||
comment='所属 API Key,唯一:一个 API Key 只有一份虚拟素材配额'),
|
|
||||||
# 配额上限(默认 0=不可用)
|
|
||||||
sa.Column('project_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
|
||||||
comment='虚拟项目上限,默认 0 不可创建'),
|
|
||||||
sa.Column('asset_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
|
||||||
comment='虚拟素材总数上限(图片+视频),默认 0 不可上传'),
|
|
||||||
sa.Column('storage_mb_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
|
||||||
comment='上传存储上限 MB,默认 0 不可上传文件'),
|
|
||||||
# 已使用量(冗余,每次增删同步,和 COUNT 不一致时以 COUNT 为准)
|
|
||||||
sa.Column('project_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
|
||||||
comment='已创建项目数(未删除)'),
|
|
||||||
sa.Column('asset_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
|
||||||
comment='已上传素材数(未删除,图片+视频)'),
|
|
||||||
sa.Column('storage_mb_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
|
||||||
comment='已占用存储 MB(未删除文件大小合计,1MB=1024*1024)'),
|
|
||||||
sa.Column('remark', sa.Text(), nullable=True, comment='后台备注'),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
|
||||||
comment='创建时间'),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
onupdate=sa.func.now(),
|
|
||||||
comment='最后更新时间'),
|
|
||||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
)
|
|
||||||
op.create_unique_constraint('uq_vp_v3_api_key_quotas_key_id', 'vp_v3_api_key_quotas', ['api_key_id'])
|
|
||||||
op.create_index('idx_vp_v3_api_key_quotas_api_key_id', 'vp_v3_api_key_quotas', ['api_key_id'])
|
|
||||||
|
|
||||||
# ==============================================================
|
|
||||||
# 2. vp_v3_projects:V3 虚拟素材项目
|
|
||||||
# ==============================================================
|
|
||||||
op.create_table(
|
|
||||||
'vp_v3_projects',
|
|
||||||
sa.Column('id', sa.String(length=32), nullable=False, comment='项目ID'),
|
|
||||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
|
||||||
comment='所属 API Key(V3 调用方)'),
|
|
||||||
sa.Column('name', sa.String(length=128), nullable=False, comment='项目展示名称'),
|
|
||||||
sa.Column('name_slug', sa.String(length=128), nullable=False, comment='名称安全 slug(构建远端 GroupName 用)'),
|
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
|
||||||
sa.Column('remote_project_name', sa.String(length=256), nullable=False,
|
|
||||||
comment='火山 ProjectName(快照)'),
|
|
||||||
sa.Column('remote_group_id', sa.String(length=128), nullable=False,
|
|
||||||
comment='火山 AssetGroup Id'),
|
|
||||||
sa.Column('remote_group_name', sa.String(length=256), nullable=True,
|
|
||||||
comment='火山 AssetGroup Name 快照'),
|
|
||||||
sa.Column('status', sa.String(length=32), nullable=False, server_default=sa.text("'active'"),
|
|
||||||
index=True,
|
|
||||||
comment='项目状态:active/creating_remote_group/create_group_failed/deleting'),
|
|
||||||
# 计数
|
|
||||||
sa.Column('asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('active_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('image_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('video_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('active_image_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('active_video_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('storage_mb_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
|
||||||
comment='项目占用存储 MB(未删除素材文件大小合计)'),
|
|
||||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
# 远端删除状态
|
|
||||||
sa.Column('remote_delete_status', sa.String(length=32), nullable=False, server_default=sa.text("'none'"),
|
|
||||||
index=True, comment='远端删除状态:none/pending/processing/deleted/failed'),
|
|
||||||
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('remote_delete_error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('error_message', sa.Text(), nullable=True, comment='创建失败等错误信息'),
|
|
||||||
sa.Column('raw_response_json', sa.Text(), nullable=True, comment='火山原始响应'),
|
|
||||||
# 软删除 + 时间
|
|
||||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='删除时间(NULL=未删除)'),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
|
||||||
comment='创建时间'),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
onupdate=sa.func.now(),
|
|
||||||
comment='最后更新时间'),
|
|
||||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
)
|
|
||||||
op.create_index('idx_vp_v3_projects_key_status_created', 'vp_v3_projects',
|
|
||||||
['api_key_id', 'status', 'created_at'])
|
|
||||||
op.create_index('idx_vp_v3_projects_remote_project_name', 'vp_v3_projects', ['remote_project_name'])
|
|
||||||
op.create_index('idx_vp_v3_projects_remote_group_id', 'vp_v3_projects', ['remote_group_id'])
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX idx_vp_v3_projects_key_deleted ON vp_v3_projects (api_key_id, deleted_at)"
|
|
||||||
" WHERE deleted_at IS NULL;"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==============================================================
|
|
||||||
# 3. vp_v3_assets:V3 虚拟素材
|
|
||||||
# ==============================================================
|
|
||||||
op.create_table(
|
|
||||||
'vp_v3_assets',
|
|
||||||
sa.Column('id', sa.String(length=32), nullable=False, comment='素材ID'),
|
|
||||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
|
||||||
comment='所属 API Key(V3 调用方)'),
|
|
||||||
sa.Column('project_id', sa.String(length=32), nullable=False, comment='所属项目ID'),
|
|
||||||
sa.Column('remote_project_name', sa.String(length=256), nullable=False,
|
|
||||||
comment='火山 ProjectName'),
|
|
||||||
sa.Column('remote_group_id', sa.String(length=128), nullable=False,
|
|
||||||
comment='火山 AssetGroup Id'),
|
|
||||||
sa.Column('remote_asset_id', sa.String(length=128), nullable=True, comment='火山素材 Id'),
|
|
||||||
sa.Column('asset_type', sa.String(length=16), nullable=False, server_default=sa.text("'Image'"),
|
|
||||||
comment='素材类型:Image=图片 / Video=视频', index=True),
|
|
||||||
sa.Column('name', sa.String(length=128), nullable=True, comment='素材展示名称', index=True),
|
|
||||||
sa.Column('source_url', sa.Text(), nullable=False, comment='本地上传后的访问 URL'),
|
|
||||||
sa.Column('preview_url', sa.Text(), nullable=True, comment='给前端预览/显示用的 URL'),
|
|
||||||
sa.Column('remote_url', sa.Text(), nullable=True, comment='火山返回的资源访问 URL(可能带签名)'),
|
|
||||||
sa.Column('remote_url_expired_at', sa.DateTime(timezone=True), nullable=True,
|
|
||||||
comment='remote_url 过期时间'),
|
|
||||||
sa.Column('upload_resource_id', sa.String(length=32), nullable=True, index=True,
|
|
||||||
comment='本地上传 resource_id,供容量释放用'),
|
|
||||||
sa.Column('video_duration', sa.Float(), nullable=True, comment='视频时长,秒'),
|
|
||||||
sa.Column('video_cover_url', sa.Text(), nullable=True, comment='视频封面预览'),
|
|
||||||
sa.Column('file_size_bytes', sa.Integer(), nullable=True, comment='素材文件大小,字节'),
|
|
||||||
sa.Column('mime_type', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('status', sa.String(length=32), nullable=False, server_default=sa.text("'creating'"),
|
|
||||||
index=True,
|
|
||||||
comment='素材状态:creating/审核中 active/可用 failed/失败 deleting/删除中'),
|
|
||||||
sa.Column('moderation_json', sa.Text(), nullable=True, comment='火山审核结果 JSON'),
|
|
||||||
sa.Column('error_message', sa.Text(), nullable=True, comment='失败原因'),
|
|
||||||
sa.Column('raw_response_json', sa.Text(), nullable=True, comment='火山原始响应 JSON'),
|
|
||||||
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True, index=True,
|
|
||||||
comment='下次轮询时间(创建中状态自动轮询)'),
|
|
||||||
sa.Column('poll_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('remote_delete_status', sa.String(length=32), nullable=False, server_default=sa.text("'none'"),
|
|
||||||
index=True, comment='远端删除状态:none/pending/processing/deleted/failed'),
|
|
||||||
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('remote_delete_error', sa.Text(), nullable=True),
|
|
||||||
# 软删除 + 时间
|
|
||||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='删除时间(NULL=未删除)'),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
|
||||||
comment='创建时间'),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
onupdate=sa.func.now(),
|
|
||||||
comment='最后更新时间'),
|
|
||||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['project_id'], ['vp_v3_projects.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
)
|
|
||||||
op.create_unique_constraint('uq_vp_v3_assets_remote_asset_id', 'vp_v3_assets', ['remote_asset_id'])
|
|
||||||
op.create_index('idx_vp_v3_assets_key_status_created', 'vp_v3_assets',
|
|
||||||
['api_key_id', 'status', 'created_at'])
|
|
||||||
op.create_index('idx_vp_v3_assets_project_status_created', 'vp_v3_assets',
|
|
||||||
['project_id', 'status', 'created_at'])
|
|
||||||
op.create_index('idx_vp_v3_assets_asset_type', 'vp_v3_assets', ['asset_type'])
|
|
||||||
op.create_index('idx_vp_v3_assets_remote_delete_status', 'vp_v3_assets', ['remote_delete_status'])
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX idx_vp_v3_assets_next_poll_status ON vp_v3_assets (next_poll_at, status)"
|
|
||||||
" WHERE deleted_at IS NULL AND next_poll_at IS NOT NULL;"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table('vp_v3_assets')
|
|
||||||
op.drop_table('vp_v3_projects')
|
|
||||||
op.drop_index('idx_vp_v3_api_key_quotas_api_key_id', table_name='vp_v3_api_key_quotas')
|
|
||||||
op.drop_constraint('uq_vp_v3_api_key_quotas_key_id', 'vp_v3_api_key_quotas', type_='unique')
|
|
||||||
op.drop_table('vp_v3_api_key_quotas')
|
|
||||||
@@ -1,984 +0,0 @@
|
|||||||
"""2026080601_add_missing_table_and_column_comments
|
|
||||||
|
|
||||||
Revision ID: 2026080601
|
|
||||||
Revises: 2026080401
|
|
||||||
Create Date: 2026-08-06 00:00:00.000000
|
|
||||||
|
|
||||||
给前端迁移遗漏的 models 表添加表注释和字段注释。
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
|
|
||||||
revision: str = '2026080601'
|
|
||||||
down_revision: Union[str, None] = '2026080401'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _comment_table(table_name: str, comment: str) -> None:
|
|
||||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
|
||||||
|
|
||||||
|
|
||||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
|
||||||
escaped = comment.replace("'", "''")
|
|
||||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ============================================================
|
|
||||||
# notification_reads 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("notification_reads", "通知已读记录表")
|
|
||||||
_comment_column("notification_reads", "id", "主键ID")
|
|
||||||
_comment_column("notification_reads", "notification_id", "通知ID")
|
|
||||||
_comment_column("notification_reads", "user_id", "已读用户ID")
|
|
||||||
_comment_column("notification_reads", "created_at", "创建时间")
|
|
||||||
_comment_column("notification_reads", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# menu_configs 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("menu_configs", "菜单配置表")
|
|
||||||
_comment_column("menu_configs", "id", "主键ID")
|
|
||||||
_comment_column("menu_configs", "label", "菜单显示名称")
|
|
||||||
_comment_column("menu_configs", "path", "菜单路由路径")
|
|
||||||
_comment_column("menu_configs", "icon", "菜单图标名称")
|
|
||||||
_comment_column("menu_configs", "sort_order", "排序值,越小越靠前")
|
|
||||||
_comment_column("menu_configs", "is_active", "是否启用")
|
|
||||||
_comment_column("menu_configs", "parent_id", "父菜单ID")
|
|
||||||
_comment_column("menu_configs", "menu_type", "菜单类型:page页面/directory目录/link链接")
|
|
||||||
_comment_column("menu_configs", "menu_target", "菜单目标:frontend前台/admin后台")
|
|
||||||
_comment_column("menu_configs", "is_default", "是否默认菜单,新用户自动分配")
|
|
||||||
_comment_column("menu_configs", "created_at", "创建时间")
|
|
||||||
_comment_column("menu_configs", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# team_join_requests 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("team_join_requests", "团队加入申请记录表")
|
|
||||||
_comment_column("team_join_requests", "id", "主键ID")
|
|
||||||
_comment_column("team_join_requests", "team_id", "目标团队ID")
|
|
||||||
_comment_column("team_join_requests", "user_id", "申请人用户ID")
|
|
||||||
_comment_column("team_join_requests", "invitation_id", "关联邀请ID(通过邀请链接申请时记录)")
|
|
||||||
_comment_column("team_join_requests", "status", "申请状态:pending待处理/approved已通过/rejected已拒绝")
|
|
||||||
_comment_column("team_join_requests", "note", "申请备注")
|
|
||||||
_comment_column("team_join_requests", "handled_by", "处理人用户ID")
|
|
||||||
_comment_column("team_join_requests", "created_at", "创建时间")
|
|
||||||
_comment_column("team_join_requests", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# team_invitations 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("team_invitations", "团队邀请记录表")
|
|
||||||
_comment_column("team_invitations", "id", "主键ID")
|
|
||||||
_comment_column("team_invitations", "team_id", "所属团队ID")
|
|
||||||
_comment_column("team_invitations", "code", "邀请码,唯一")
|
|
||||||
_comment_column("team_invitations", "created_by", "创建人用户ID")
|
|
||||||
_comment_column("team_invitations", "status", "邀请状态:active启用/disabled禁用")
|
|
||||||
_comment_column("team_invitations", "max_uses", "最大使用次数,NULL表示不限")
|
|
||||||
_comment_column("team_invitations", "use_count", "已使用次数")
|
|
||||||
_comment_column("team_invitations", "expires_at", "过期时间,NULL表示永不过期")
|
|
||||||
_comment_column("team_invitations", "created_at", "创建时间")
|
|
||||||
_comment_column("team_invitations", "updated_at", "更新时间")
|
|
||||||
_comment_column("team_invitations", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# contact_requests 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("contact_requests", "用户联系/咨询申请表")
|
|
||||||
_comment_column("contact_requests", "id", "主键ID")
|
|
||||||
_comment_column("contact_requests", "user_id", "提交用户ID")
|
|
||||||
_comment_column("contact_requests", "phone", "联系电话")
|
|
||||||
_comment_column("contact_requests", "company_name", "公司名称")
|
|
||||||
_comment_column("contact_requests", "industry", "所属行业")
|
|
||||||
_comment_column("contact_requests", "name", "联系人姓名")
|
|
||||||
_comment_column("contact_requests", "message", "留言内容")
|
|
||||||
_comment_column("contact_requests", "is_handled", "是否已处理")
|
|
||||||
_comment_column("contact_requests", "submit_date", "提交日期(YYYY-MM-DD),用于每日限1次控制")
|
|
||||||
_comment_column("contact_requests", "created_at", "创建时间")
|
|
||||||
_comment_column("contact_requests", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# token_usage 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("token_usage", "Token消耗记录表")
|
|
||||||
_comment_column("token_usage", "id", "主键ID")
|
|
||||||
_comment_column("token_usage", "model_config_id", "模型配置ID")
|
|
||||||
_comment_column("token_usage", "user_id", "所属用户ID")
|
|
||||||
_comment_column("token_usage", "input_tokens", "输入Token数")
|
|
||||||
_comment_column("token_usage", "output_tokens", "输出Token数")
|
|
||||||
_comment_column("token_usage", "total_tokens", "总Token数")
|
|
||||||
_comment_column("token_usage", "owner_type", "归属类型:chat_generation_task/module_generation_step等")
|
|
||||||
_comment_column("token_usage", "owner_id", "归属记录ID")
|
|
||||||
_comment_column("token_usage", "biz_key", "业务幂等键")
|
|
||||||
_comment_column("token_usage", "source_module", "来源模块")
|
|
||||||
_comment_column("token_usage", "source_step_code", "来源步骤编码")
|
|
||||||
_comment_column("token_usage", "created_at", "创建时间")
|
|
||||||
_comment_column("token_usage", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# industry_configs 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("industry_configs", "行业配置表")
|
|
||||||
_comment_column("industry_configs", "id", "主键ID")
|
|
||||||
_comment_column("industry_configs", "key", "行业唯一标识键")
|
|
||||||
_comment_column("industry_configs", "label", "行业显示名称")
|
|
||||||
_comment_column("industry_configs", "icon", "图标名称")
|
|
||||||
_comment_column("industry_configs", "description", "行业描述")
|
|
||||||
_comment_column("industry_configs", "skills", "行业技能列表JSON数组")
|
|
||||||
_comment_column("industry_configs", "is_active", "是否启用")
|
|
||||||
_comment_column("industry_configs", "sort_order", "排序值,越小越靠前")
|
|
||||||
_comment_column("industry_configs", "created_at", "创建时间")
|
|
||||||
_comment_column("industry_configs", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# chat_generation_task_events 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("chat_generation_task_events", "AI创作任务事件日志表(追加写入)")
|
|
||||||
_comment_column("chat_generation_task_events", "id", "主键ID")
|
|
||||||
_comment_column("chat_generation_task_events", "owner_type", "归属类型:chat_generation_task/generation_record")
|
|
||||||
_comment_column("chat_generation_task_events", "task_id", "关联AI创作任务ID")
|
|
||||||
_comment_column("chat_generation_task_events", "generation_record_id", "关联项目生成记录ID")
|
|
||||||
_comment_column("chat_generation_task_events", "generation_attempt_no", "生成尝试次数")
|
|
||||||
_comment_column("chat_generation_task_events", "generation_mode", "生成模式")
|
|
||||||
_comment_column("chat_generation_task_events", "event_type", "事件类型")
|
|
||||||
_comment_column("chat_generation_task_events", "from_status", "变更前状态")
|
|
||||||
_comment_column("chat_generation_task_events", "to_status", "变更后状态")
|
|
||||||
_comment_column("chat_generation_task_events", "from_stage", "变更前阶段")
|
|
||||||
_comment_column("chat_generation_task_events", "to_stage", "变更后阶段")
|
|
||||||
_comment_column("chat_generation_task_events", "message", "事件描述信息")
|
|
||||||
_comment_column("chat_generation_task_events", "detail_json", "事件详情JSON")
|
|
||||||
_comment_column("chat_generation_task_events", "created_at", "创建时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# chat_provider_call_logs 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("chat_provider_call_logs", "供应商调用审计日志表")
|
|
||||||
_comment_column("chat_provider_call_logs", "id", "主键ID")
|
|
||||||
_comment_column("chat_provider_call_logs", "owner_type", "归属类型:chat_generation_task/generation_record")
|
|
||||||
_comment_column("chat_provider_call_logs", "task_id", "关联AI创作任务ID")
|
|
||||||
_comment_column("chat_provider_call_logs", "generation_record_id", "关联项目生成记录ID")
|
|
||||||
_comment_column("chat_provider_call_logs", "generation_attempt_no", "生成尝试次数")
|
|
||||||
_comment_column("chat_provider_call_logs", "generation_mode", "生成模式")
|
|
||||||
_comment_column("chat_provider_call_logs", "provider", "供应商:ark/seedance等")
|
|
||||||
_comment_column("chat_provider_call_logs", "api_type", "API类型:image_generate/video_create等")
|
|
||||||
_comment_column("chat_provider_call_logs", "model", "模型名称")
|
|
||||||
_comment_column("chat_provider_call_logs", "engine_id", "引擎ID")
|
|
||||||
_comment_column("chat_provider_call_logs", "status", "调用状态:success/failed")
|
|
||||||
_comment_column("chat_provider_call_logs", "latency_ms", "调用耗时(毫秒)")
|
|
||||||
_comment_column("chat_provider_call_logs", "http_status", "HTTP状态码")
|
|
||||||
_comment_column("chat_provider_call_logs", "provider_task_id", "供应商任务ID")
|
|
||||||
_comment_column("chat_provider_call_logs", "request_hash", "请求内容哈希")
|
|
||||||
_comment_column("chat_provider_call_logs", "response_hash", "响应内容哈希")
|
|
||||||
_comment_column("chat_provider_call_logs", "request_excerpt", "请求内容摘录")
|
|
||||||
_comment_column("chat_provider_call_logs", "response_excerpt", "响应内容摘录")
|
|
||||||
_comment_column("chat_provider_call_logs", "prompt_tokens", "提示词Token数")
|
|
||||||
_comment_column("chat_provider_call_logs", "completion_tokens", "补全Token数")
|
|
||||||
_comment_column("chat_provider_call_logs", "total_tokens", "总Token数")
|
|
||||||
_comment_column("chat_provider_call_logs", "error_code", "错误码")
|
|
||||||
_comment_column("chat_provider_call_logs", "error_message", "错误信息")
|
|
||||||
_comment_column("chat_provider_call_logs", "created_at", "创建时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# open_type 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("open_type", "开户方式管理表")
|
|
||||||
_comment_column("open_type", "id", "主键")
|
|
||||||
_comment_column("open_type", "type_name", "标题名称")
|
|
||||||
_comment_column("open_type", "open_type", "开户方式id")
|
|
||||||
_comment_column("open_type", "description", "开户方式描述")
|
|
||||||
_comment_column("open_type", "thumb", "缩略图")
|
|
||||||
_comment_column("open_type", "created_at", "创建时间")
|
|
||||||
_comment_column("open_type", "updated_at", "更新时间")
|
|
||||||
_comment_column("open_type", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# pre_test_template 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("pre_test_template", "素材前测模板表")
|
|
||||||
_comment_column("pre_test_template", "id", "主键")
|
|
||||||
_comment_column("pre_test_template", "name", "模板名称")
|
|
||||||
_comment_column("pre_test_template", "user_id", "用户id")
|
|
||||||
_comment_column("pre_test_template", "note", "模板备注")
|
|
||||||
_comment_column("pre_test_template", "platform", "投放平台(AD/QIANCHUAN/LOCAL)")
|
|
||||||
_comment_column("pre_test_template", "external_action", "转化目标")
|
|
||||||
_comment_column("pre_test_template", "cpa_bid", "目标转化成本:[1, 10000]")
|
|
||||||
_comment_column("pre_test_template", "audience_gender", "性别(ALL/MALE/FEMALE)")
|
|
||||||
_comment_column("pre_test_template", "audience_age", "受众年龄,JSON数组")
|
|
||||||
_comment_column("pre_test_template", "audience_region", "受众地区,JSON数组(二级行政区域code)")
|
|
||||||
_comment_column("pre_test_template", "audience_network", "网络类型,JSON数组")
|
|
||||||
_comment_column("pre_test_template", "cus_name", "客户主体名称")
|
|
||||||
_comment_column("pre_test_template", "pricing_type", "出价类型(OCPC/CPA/OCPM)")
|
|
||||||
_comment_column("pre_test_template", "cost_cap", "是否最优成本出价(仅AD支持)")
|
|
||||||
_comment_column("pre_test_template", "target_cost", "是否稳定成本出价(仅AD支持)")
|
|
||||||
_comment_column("pre_test_template", "nobid", "是否最大转化出价(仅AD支持)")
|
|
||||||
_comment_column("pre_test_template", "cpc_bid", "目标点击成本:[1, 10000]")
|
|
||||||
_comment_column("pre_test_template", "budget", "预算金额:[1, 10000]")
|
|
||||||
_comment_column("pre_test_template", "is_default", "是否默认模板")
|
|
||||||
_comment_column("pre_test_template", "created_at", "创建时间")
|
|
||||||
_comment_column("pre_test_template", "updated_at", "更新时间")
|
|
||||||
_comment_column("pre_test_template", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# material_cost 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("material_cost", "素材消耗数据表(广告投放消耗统计)")
|
|
||||||
_comment_column("material_cost", "id", "主键")
|
|
||||||
_comment_column("material_cost", "oauth_id", "授权表user_oauth自增id")
|
|
||||||
_comment_column("material_cost", "advertiser_id", "广告主id")
|
|
||||||
_comment_column("material_cost", "material_id", "素材id")
|
|
||||||
_comment_column("material_cost", "consume_date", "消耗日期")
|
|
||||||
_comment_column("material_cost", "stat_cost", "消耗金额")
|
|
||||||
_comment_column("material_cost", "show_cnt", "展示数")
|
|
||||||
_comment_column("material_cost", "cpm_platform", "平均千次展现费用(元)")
|
|
||||||
_comment_column("material_cost", "click_cnt", "点击数")
|
|
||||||
_comment_column("material_cost", "ctr", "点击率")
|
|
||||||
_comment_column("material_cost", "cpc_platform", "平均点击单价(元)")
|
|
||||||
_comment_column("material_cost", "convert_cnt", "转化数")
|
|
||||||
_comment_column("material_cost", "conversion_cost", "平均转化成本(元)")
|
|
||||||
_comment_column("material_cost", "conversion_rate", "转化率")
|
|
||||||
_comment_column("material_cost", "deep_convert_cnt", "深度转化数")
|
|
||||||
_comment_column("material_cost", "deep_convert_cost", "深度转化成本(元)")
|
|
||||||
_comment_column("material_cost", "deep_convert_rate", "深度转化率")
|
|
||||||
_comment_column("material_cost", "active", "激活数")
|
|
||||||
_comment_column("material_cost", "active_cost", "激活成本(元)")
|
|
||||||
_comment_column("material_cost", "active_rate", "激活率")
|
|
||||||
_comment_column("material_cost", "active_register", "注册数")
|
|
||||||
_comment_column("material_cost", "active_register_cost", "注册成本(元)")
|
|
||||||
_comment_column("material_cost", "active_register_rate", "注册率")
|
|
||||||
_comment_column("material_cost", "attribution_next_day_open_cnt", "次留数")
|
|
||||||
_comment_column("material_cost", "attribution_next_day_open_cost", "次留成本")
|
|
||||||
_comment_column("material_cost", "attribution_next_day_open_rate", "次留率")
|
|
||||||
_comment_column("material_cost", "active_pay", "首次付费数")
|
|
||||||
_comment_column("material_cost", "active_pay_cost", "首次付费成本(元)")
|
|
||||||
_comment_column("material_cost", "active_pay_rate", "首次付费率")
|
|
||||||
_comment_column("material_cost", "phone", "点击电话按钮")
|
|
||||||
_comment_column("material_cost", "form", "用户在门店落地页多线沟通提交表单的次数")
|
|
||||||
_comment_column("material_cost", "download_start", "用户点击下载开始的次数")
|
|
||||||
_comment_column("material_cost", "form_submit", "用户查看附加创意后,提交表单的次数")
|
|
||||||
_comment_column("material_cost", "button", "用户点击按钮button的次数")
|
|
||||||
_comment_column("material_cost", "view", "用户在关键页面的浏览次数")
|
|
||||||
_comment_column("material_cost", "message", "用户点击短信咨询的次数")
|
|
||||||
_comment_column("material_cost", "consult", "用户点击在线咨询按钮的次数")
|
|
||||||
_comment_column("material_cost", "consult_effective", "用户在门店落地页多线沟通的在线咨询中有效咨询的次数")
|
|
||||||
_comment_column("material_cost", "shopping", "用户购买商品的次数")
|
|
||||||
_comment_column("material_cost", "customer_effective", "有效获客")
|
|
||||||
_comment_column("material_cost", "attribution_game_in_app_ltv_1day", "当日付费金额")
|
|
||||||
_comment_column("material_cost", "attribution_game_in_app_roi_1day", "当日付费ROI")
|
|
||||||
_comment_column("material_cost", "loan_completion", "完件数")
|
|
||||||
_comment_column("material_cost", "loan_completion_cost", "完件成本(元)")
|
|
||||||
_comment_column("material_cost", "loan_completion_rate", "完件率")
|
|
||||||
_comment_column("material_cost", "loan_credit", "授信数")
|
|
||||||
_comment_column("material_cost", "loan_credit_cost", "授信成本(元)")
|
|
||||||
_comment_column("material_cost", "loan_credit_rate", "授信率")
|
|
||||||
_comment_column("material_cost", "in_app_order_gmv", "引流电商订单GMV")
|
|
||||||
_comment_column("material_cost", "in_app_order_roi", "引流电商订单ROI")
|
|
||||||
_comment_column("material_cost", "in_app_pay_gmv", "引流电商支付GMV")
|
|
||||||
_comment_column("material_cost", "in_app_pay_roi", "引流电商支付ROI")
|
|
||||||
_comment_column("material_cost", "total_play", "播放量")
|
|
||||||
_comment_column("material_cost", "valid_play", "有效播放数")
|
|
||||||
_comment_column("material_cost", "valid_play_cost", "有效播放成本(元)")
|
|
||||||
_comment_column("material_cost", "valid_play_rate", "有效播放率")
|
|
||||||
_comment_column("material_cost", "valid_play_of_mille", "千次有效播放数")
|
|
||||||
_comment_column("material_cost", "valid_play_cost_of_mille", "千次有效播放成本(元)")
|
|
||||||
_comment_column("material_cost", "average_play_time_per_play", "平均单次播放时长")
|
|
||||||
_comment_column("material_cost", "play_over_rate", "完播率")
|
|
||||||
_comment_column("material_cost", "dy_like", "点赞数")
|
|
||||||
_comment_column("material_cost", "dy_comment", "评论量")
|
|
||||||
_comment_column("material_cost", "dy_share", "分享量")
|
|
||||||
_comment_column("material_cost", "report_cnt", "举报数")
|
|
||||||
_comment_column("material_cost", "created_at", "创建时间")
|
|
||||||
_comment_column("material_cost", "updated_at", "更新时间")
|
|
||||||
_comment_column("material_cost", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# user_oauth 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("user_oauth", "用户授权账户表(第三方平台授权)")
|
|
||||||
_comment_column("user_oauth", "id", "主键")
|
|
||||||
_comment_column("user_oauth", "account_id", "授权账户id")
|
|
||||||
_comment_column("user_oauth", "account_name", "授权账户name")
|
|
||||||
_comment_column("user_oauth", "account_role", "授权账户角色")
|
|
||||||
_comment_column("user_oauth", "account_username", "授权账户登录账号")
|
|
||||||
_comment_column("user_oauth", "account_userid", "授权账户登录userid")
|
|
||||||
_comment_column("user_oauth", "user_id", "用户id")
|
|
||||||
_comment_column("user_oauth", "open_type", "开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)")
|
|
||||||
_comment_column("user_oauth", "port_type", "平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)")
|
|
||||||
_comment_column("user_oauth", "appid", "授权应用id")
|
|
||||||
_comment_column("user_oauth", "access_token", "授权token")
|
|
||||||
_comment_column("user_oauth", "access_token_expired", "token过期时间")
|
|
||||||
_comment_column("user_oauth", "refresh_token", "授权刷新token")
|
|
||||||
_comment_column("user_oauth", "refresh_token_expired", "刷新token过期时间")
|
|
||||||
_comment_column("user_oauth", "material_auth_status", "是否敏感物料授权(true=是,false=否)")
|
|
||||||
_comment_column("user_oauth", "created_at", "创建时间")
|
|
||||||
_comment_column("user_oauth", "updated_at", "更新时间")
|
|
||||||
_comment_column("user_oauth", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# user_oauth_account 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("user_oauth_account", "授权账户详情表(广告账户映射)")
|
|
||||||
_comment_column("user_oauth_account", "id", "主键")
|
|
||||||
_comment_column("user_oauth_account", "oauth_id", "授权表中的id")
|
|
||||||
_comment_column("user_oauth_account", "advertiser_id", "广告主账户id")
|
|
||||||
_comment_column("user_oauth_account", "advertiser_name", "广告账户名")
|
|
||||||
_comment_column("user_oauth_account", "advertiser_role", "广告账户类型")
|
|
||||||
_comment_column("user_oauth_account", "created_at", "创建时间")
|
|
||||||
_comment_column("user_oauth_account", "updated_at", "更新时间")
|
|
||||||
_comment_column("user_oauth_account", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# user_oauth_app 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("user_oauth_app", "授权应用管理表(应用密钥配置)")
|
|
||||||
_comment_column("user_oauth_app", "id", "主键")
|
|
||||||
_comment_column("user_oauth_app", "app_id", "应用id")
|
|
||||||
_comment_column("user_oauth_app", "secret", "应用密钥")
|
|
||||||
_comment_column("user_oauth_app", "status", "状态,1=正常,2=禁用")
|
|
||||||
_comment_column("user_oauth_app", "max_count", "应用最大可以授权多少个用户")
|
|
||||||
_comment_column("user_oauth_app", "auth_url", "应用授权链接")
|
|
||||||
_comment_column("user_oauth_app", "company", "应用归属公司名称")
|
|
||||||
_comment_column("user_oauth_app", "open_type", "开户方式")
|
|
||||||
_comment_column("user_oauth_app", "create_by", "创建者")
|
|
||||||
_comment_column("user_oauth_app", "created_at", "创建时间")
|
|
||||||
_comment_column("user_oauth_app", "updated_at", "更新时间")
|
|
||||||
_comment_column("user_oauth_app", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# upload_task 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("upload_task", "上传任务表(素材上传记录)")
|
|
||||||
_comment_column("upload_task", "id", "主键")
|
|
||||||
_comment_column("upload_task", "user_id", "用户登录id")
|
|
||||||
_comment_column("upload_task", "advertiser_id", "广告主id")
|
|
||||||
_comment_column("upload_task", "resource_id", "资源id")
|
|
||||||
_comment_column("upload_task", "status", "上传状态:1待上传,2上传中,3上传成功,4上传失败")
|
|
||||||
_comment_column("upload_task", "note", "上传备注")
|
|
||||||
_comment_column("upload_task", "oauth_id", "授权表id")
|
|
||||||
_comment_column("upload_task", "other_info", "其他信息")
|
|
||||||
_comment_column("upload_task", "created_at", "创建时间")
|
|
||||||
_comment_column("upload_task", "updated_at", "更新时间")
|
|
||||||
_comment_column("upload_task", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# user_resource_month_stats 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("user_resource_month_stats", "用户月份资源空间聚合表")
|
|
||||||
_comment_column("user_resource_month_stats", "id", "主键ID")
|
|
||||||
_comment_column("user_resource_month_stats", "user_id", "所属用户ID")
|
|
||||||
_comment_column("user_resource_month_stats", "stat_month", "统计月份")
|
|
||||||
_comment_column("user_resource_month_stats", "active_size_bytes", "活跃资源大小(字节)")
|
|
||||||
_comment_column("user_resource_month_stats", "deleted_size_bytes", "已删除资源大小(字节)")
|
|
||||||
_comment_column("user_resource_month_stats", "total_generated_size_bytes", "累计生成资源大小(字节)")
|
|
||||||
_comment_column("user_resource_month_stats", "upload_size_bytes", "上传资源大小(字节)")
|
|
||||||
_comment_column("user_resource_month_stats", "image_size_bytes", "图片资源大小(字节)")
|
|
||||||
_comment_column("user_resource_month_stats", "video_size_bytes", "视频资源大小(字节)")
|
|
||||||
_comment_column("user_resource_month_stats", "audio_size_bytes", "音频资源大小(字节)")
|
|
||||||
_comment_column("user_resource_month_stats", "shot_segment_size_bytes", "拆镜切片资源大小(字节)")
|
|
||||||
_comment_column("user_resource_month_stats", "active_count", "活跃资源数量")
|
|
||||||
_comment_column("user_resource_month_stats", "deleted_count", "已删除资源数量")
|
|
||||||
_comment_column("user_resource_month_stats", "image_count", "图片资源数量")
|
|
||||||
_comment_column("user_resource_month_stats", "video_count", "视频资源数量")
|
|
||||||
_comment_column("user_resource_month_stats", "upload_count", "上传资源数量")
|
|
||||||
_comment_column("user_resource_month_stats", "audio_count", "音频资源数量")
|
|
||||||
_comment_column("user_resource_month_stats", "shot_segment_count", "拆镜切片数量")
|
|
||||||
_comment_column("user_resource_month_stats", "last_recalculated_at", "最后重新计算时间")
|
|
||||||
_comment_column("user_resource_month_stats", "created_at", "创建时间")
|
|
||||||
_comment_column("user_resource_month_stats", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# user_resource_total_stats 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("user_resource_total_stats", "用户全局资源空间聚合表")
|
|
||||||
_comment_column("user_resource_total_stats", "id", "主键ID")
|
|
||||||
_comment_column("user_resource_total_stats", "user_id", "所属用户ID")
|
|
||||||
_comment_column("user_resource_total_stats", "active_size_bytes", "活跃资源大小(字节)")
|
|
||||||
_comment_column("user_resource_total_stats", "deleted_size_bytes", "已删除资源大小(字节)")
|
|
||||||
_comment_column("user_resource_total_stats", "total_generated_size_bytes", "累计生成资源大小(字节)")
|
|
||||||
_comment_column("user_resource_total_stats", "upload_size_bytes", "上传资源大小(字节)")
|
|
||||||
_comment_column("user_resource_total_stats", "image_size_bytes", "图片资源大小(字节)")
|
|
||||||
_comment_column("user_resource_total_stats", "video_size_bytes", "视频资源大小(字节)")
|
|
||||||
_comment_column("user_resource_total_stats", "audio_size_bytes", "音频资源大小(字节)")
|
|
||||||
_comment_column("user_resource_total_stats", "shot_segment_size_bytes", "拆镜切片资源大小(字节)")
|
|
||||||
_comment_column("user_resource_total_stats", "active_count", "活跃资源数量")
|
|
||||||
_comment_column("user_resource_total_stats", "deleted_count", "已删除资源数量")
|
|
||||||
_comment_column("user_resource_total_stats", "image_count", "图片资源数量")
|
|
||||||
_comment_column("user_resource_total_stats", "video_count", "视频资源数量")
|
|
||||||
_comment_column("user_resource_total_stats", "upload_count", "上传资源数量")
|
|
||||||
_comment_column("user_resource_total_stats", "audio_count", "音频资源数量")
|
|
||||||
_comment_column("user_resource_total_stats", "shot_segment_count", "拆镜切片数量")
|
|
||||||
_comment_column("user_resource_total_stats", "last_recalculated_at", "最后重新计算时间")
|
|
||||||
_comment_column("user_resource_total_stats", "created_at", "创建时间")
|
|
||||||
_comment_column("user_resource_total_stats", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# home_material_assets 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("home_material_assets", "首页素材资产表")
|
|
||||||
_comment_column("home_material_assets", "id", "主键ID")
|
|
||||||
_comment_column("home_material_assets", "category_id", "行业类别ID")
|
|
||||||
_comment_column("home_material_assets", "title", "素材标题")
|
|
||||||
_comment_column("home_material_assets", "media_type", "素材类型:image图片,video视频")
|
|
||||||
_comment_column("home_material_assets", "original_url", "原始素材URL")
|
|
||||||
_comment_column("home_material_assets", "original_storage_path", "原始素材本地路径")
|
|
||||||
_comment_column("home_material_assets", "watermarked_url", "水印素材URL")
|
|
||||||
_comment_column("home_material_assets", "watermarked_storage_path", "水印素材本地路径")
|
|
||||||
_comment_column("home_material_assets", "cover_url", "视频封面URL")
|
|
||||||
_comment_column("home_material_assets", "cover_storage_path", "视频封面本地路径")
|
|
||||||
_comment_column("home_material_assets", "watermark_id", "水印图片ID")
|
|
||||||
_comment_column("home_material_assets", "watermark_config_json", "水印配置快照JSON")
|
|
||||||
_comment_column("home_material_assets", "generation_prompt", "生成提词")
|
|
||||||
_comment_column("home_material_assets", "media_references_json", "附件/参考素材JSON字符串")
|
|
||||||
_comment_column("home_material_assets", "status", "处理状态:draft/processing/success/failed")
|
|
||||||
_comment_column("home_material_assets", "error_message", "处理失败原因")
|
|
||||||
_comment_column("home_material_assets", "width", "素材宽度")
|
|
||||||
_comment_column("home_material_assets", "height", "素材高度")
|
|
||||||
_comment_column("home_material_assets", "duration_seconds", "视频时长,图片为空")
|
|
||||||
_comment_column("home_material_assets", "file_size_bytes", "原始文件大小")
|
|
||||||
_comment_column("home_material_assets", "watermarked_file_size_bytes", "水印后文件大小")
|
|
||||||
_comment_column("home_material_assets", "is_active", "是否前台展示")
|
|
||||||
_comment_column("home_material_assets", "sort_order", "排序,越小越靠前")
|
|
||||||
_comment_column("home_material_assets", "processed_at", "处理完成时间")
|
|
||||||
_comment_column("home_material_assets", "created_by", "创建管理员ID")
|
|
||||||
_comment_column("home_material_assets", "updated_by", "更新管理员ID")
|
|
||||||
_comment_column("home_material_assets", "created_at", "创建时间")
|
|
||||||
_comment_column("home_material_assets", "updated_at", "更新时间")
|
|
||||||
_comment_column("home_material_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# home_material_categories 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("home_material_categories", "首页素材行业类别表")
|
|
||||||
_comment_column("home_material_categories", "id", "主键ID")
|
|
||||||
_comment_column("home_material_categories", "name", "行业名称")
|
|
||||||
_comment_column("home_material_categories", "key", "行业唯一标识,前台可按key查询")
|
|
||||||
_comment_column("home_material_categories", "description", "行业描述")
|
|
||||||
_comment_column("home_material_categories", "icon", "前端图标名称")
|
|
||||||
_comment_column("home_material_categories", "is_active", "是否启用")
|
|
||||||
_comment_column("home_material_categories", "sort_order", "排序,越小越靠前")
|
|
||||||
_comment_column("home_material_categories", "created_by", "创建管理员ID")
|
|
||||||
_comment_column("home_material_categories", "updated_by", "更新管理员ID")
|
|
||||||
_comment_column("home_material_categories", "created_at", "创建时间")
|
|
||||||
_comment_column("home_material_categories", "updated_at", "更新时间")
|
|
||||||
_comment_column("home_material_categories", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# home_material_watermarks 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("home_material_watermarks", "首页素材水印图片库")
|
|
||||||
_comment_column("home_material_watermarks", "id", "主键ID")
|
|
||||||
_comment_column("home_material_watermarks", "name", "水印名称")
|
|
||||||
_comment_column("home_material_watermarks", "file_url", "水印图片URL")
|
|
||||||
_comment_column("home_material_watermarks", "storage_path", "水印图片本地路径")
|
|
||||||
_comment_column("home_material_watermarks", "file_name", "原始文件名")
|
|
||||||
_comment_column("home_material_watermarks", "file_size_bytes", "文件大小")
|
|
||||||
_comment_column("home_material_watermarks", "width", "水印图片宽度")
|
|
||||||
_comment_column("home_material_watermarks", "height", "水印图片高度")
|
|
||||||
_comment_column("home_material_watermarks", "is_default", "是否默认水印")
|
|
||||||
_comment_column("home_material_watermarks", "is_active", "是否启用")
|
|
||||||
_comment_column("home_material_watermarks", "created_by", "创建管理员ID")
|
|
||||||
_comment_column("home_material_watermarks", "updated_by", "更新管理员ID")
|
|
||||||
_comment_column("home_material_watermarks", "created_at", "创建时间")
|
|
||||||
_comment_column("home_material_watermarks", "updated_at", "更新时间")
|
|
||||||
_comment_column("home_material_watermarks", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# module_generation_projects 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("module_generation_projects", "通用模块生成项目/总任务表")
|
|
||||||
_comment_column("module_generation_projects", "id", "主键ID")
|
|
||||||
_comment_column("module_generation_projects", "user_id", "所属用户ID")
|
|
||||||
_comment_column("module_generation_projects", "module", "业务模块标识")
|
|
||||||
_comment_column("module_generation_projects", "flow_version", "项目流程版本号")
|
|
||||||
_comment_column("module_generation_projects", "title", "项目标题")
|
|
||||||
_comment_column("module_generation_projects", "status", "项目状态:pending/processing/success/failed")
|
|
||||||
_comment_column("module_generation_projects", "current_step_code", "当前执行步骤编码")
|
|
||||||
_comment_column("module_generation_projects", "final_image_url", "最终生成图片URL")
|
|
||||||
_comment_column("module_generation_projects", "final_video_url", "最终生成视频URL")
|
|
||||||
_comment_column("module_generation_projects", "final_video_cover_url", "最终生成视频封面URL")
|
|
||||||
_comment_column("module_generation_projects", "error_message", "错误信息")
|
|
||||||
_comment_column("module_generation_projects", "idempotency_key", "幂等键")
|
|
||||||
_comment_column("module_generation_projects", "completed_at", "项目完成时间")
|
|
||||||
_comment_column("module_generation_projects", "created_at", "创建时间")
|
|
||||||
_comment_column("module_generation_projects", "updated_at", "更新时间")
|
|
||||||
_comment_column("module_generation_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# module_generation_steps 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("module_generation_steps", "通用模块生成步骤表")
|
|
||||||
_comment_column("module_generation_steps", "id", "主键ID")
|
|
||||||
_comment_column("module_generation_steps", "project_id", "所属项目ID")
|
|
||||||
_comment_column("module_generation_steps", "user_id", "所属用户ID")
|
|
||||||
_comment_column("module_generation_steps", "module", "业务模块标识")
|
|
||||||
_comment_column("module_generation_steps", "step_index", "步骤序号")
|
|
||||||
_comment_column("module_generation_steps", "step_code", "步骤编码")
|
|
||||||
_comment_column("module_generation_steps", "status", "步骤状态:pending/processing/success/failed")
|
|
||||||
_comment_column("module_generation_steps", "version", "步骤重建版本号")
|
|
||||||
_comment_column("module_generation_steps", "is_current", "是否为当前版本")
|
|
||||||
_comment_column("module_generation_steps", "parent_step_id", "父步骤ID")
|
|
||||||
_comment_column("module_generation_steps", "source_step_id", "源步骤ID(复制来源)")
|
|
||||||
_comment_column("module_generation_steps", "chat_task_id", "关联AI创作任务ID")
|
|
||||||
_comment_column("module_generation_steps", "input_json", "步骤输入JSON")
|
|
||||||
_comment_column("module_generation_steps", "output_json", "步骤输出JSON")
|
|
||||||
_comment_column("module_generation_steps", "error_message", "错误信息")
|
|
||||||
_comment_column("module_generation_steps", "started_at", "步骤开始时间")
|
|
||||||
_comment_column("module_generation_steps", "completed_at", "步骤完成时间")
|
|
||||||
_comment_column("module_generation_steps", "token_usage_id", "关联Token消耗记录ID")
|
|
||||||
_comment_column("module_generation_steps", "model_config_id", "模型配置ID")
|
|
||||||
_comment_column("module_generation_steps", "input_tokens", "输入Token数")
|
|
||||||
_comment_column("module_generation_steps", "output_tokens", "输出Token数")
|
|
||||||
_comment_column("module_generation_steps", "total_tokens", "总Token数")
|
|
||||||
_comment_column("module_generation_steps", "text_credits_cost", "提示词优化消耗积分")
|
|
||||||
_comment_column("module_generation_steps", "created_at", "创建时间")
|
|
||||||
_comment_column("module_generation_steps", "updated_at", "更新时间")
|
|
||||||
_comment_column("module_generation_steps", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# shot_replicate_segments 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("shot_replicate_segments", "拆镜复刻片段表")
|
|
||||||
_comment_column("shot_replicate_segments", "id", "主键ID")
|
|
||||||
_comment_column("shot_replicate_segments", "task_set_id", "所属任务集ID")
|
|
||||||
_comment_column("shot_replicate_segments", "user_id", "所属用户ID")
|
|
||||||
_comment_column("shot_replicate_segments", "segment_index", "镜头序号")
|
|
||||||
_comment_column("shot_replicate_segments", "source_mode", "来源模式:auto自动拆镜/manual手动")
|
|
||||||
_comment_column("shot_replicate_segments", "start_second", "片段开始时间(秒)")
|
|
||||||
_comment_column("shot_replicate_segments", "end_second", "片段结束时间(秒)")
|
|
||||||
_comment_column("shot_replicate_segments", "duration_seconds", "片段时长(秒)")
|
|
||||||
_comment_column("shot_replicate_segments", "time_node", "时间节点显示字符串")
|
|
||||||
_comment_column("shot_replicate_segments", "split_status", "切片状态:pending/slicing/sliced/failed")
|
|
||||||
_comment_column("shot_replicate_segments", "analysis_status", "AI分析状态:pending/processing/success/failed")
|
|
||||||
_comment_column("shot_replicate_segments", "replicate_status", "复刻状态:not_started/processing/completed/failed")
|
|
||||||
_comment_column("shot_replicate_segments", "segment_video_url", "片段视频访问URL")
|
|
||||||
_comment_column("shot_replicate_segments", "segment_video_path", "片段视频存储路径")
|
|
||||||
_comment_column("shot_replicate_segments", "original_video_content", "原视频内容描述")
|
|
||||||
_comment_column("shot_replicate_segments", "original_video_category", "原视频行业分类")
|
|
||||||
_comment_column("shot_replicate_segments", "original_video_audience", "原视频目标受众")
|
|
||||||
_comment_column("shot_replicate_segments", "segment_content", "片段内容描述")
|
|
||||||
_comment_column("shot_replicate_segments", "segment_category", "片段行业分类")
|
|
||||||
_comment_column("shot_replicate_segments", "segment_audience", "片段目标受众")
|
|
||||||
_comment_column("shot_replicate_segments", "analysis_json", "AI分析结果JSON")
|
|
||||||
_comment_column("shot_replicate_segments", "ai_suggestion_json", "AI复刻建议JSON")
|
|
||||||
_comment_column("shot_replicate_segments", "module_project_id", "关联模块生成项目ID")
|
|
||||||
_comment_column("shot_replicate_segments", "split_claim_token", "切片租约token")
|
|
||||||
_comment_column("shot_replicate_segments", "split_celery_task_id", "切片Celery任务ID")
|
|
||||||
_comment_column("shot_replicate_segments", "split_enqueued_at", "切片入队时间")
|
|
||||||
_comment_column("shot_replicate_segments", "split_started_at", "切片开始时间")
|
|
||||||
_comment_column("shot_replicate_segments", "split_lease_until", "切片租约过期时间")
|
|
||||||
_comment_column("shot_replicate_segments", "split_next_retry_at", "切片下次重试时间")
|
|
||||||
_comment_column("shot_replicate_segments", "split_retry_count", "切片重试次数")
|
|
||||||
_comment_column("shot_replicate_segments", "split_last_error", "切片最后错误信息")
|
|
||||||
_comment_column("shot_replicate_segments", "split_completed_at", "切片完成时间")
|
|
||||||
_comment_column("shot_replicate_segments", "analysis_attempt_no", "AI分析尝试次数")
|
|
||||||
_comment_column("shot_replicate_segments", "analysis_claim_token", "AI分析租约token")
|
|
||||||
_comment_column("shot_replicate_segments", "analysis_started_at", "AI分析开始时间")
|
|
||||||
_comment_column("shot_replicate_segments", "analysis_lease_until", "AI分析租约过期")
|
|
||||||
_comment_column("shot_replicate_segments", "analysis_error_message", "AI分析错误信息")
|
|
||||||
_comment_column("shot_replicate_segments", "created_at", "创建时间")
|
|
||||||
_comment_column("shot_replicate_segments", "updated_at", "更新时间")
|
|
||||||
_comment_column("shot_replicate_segments", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# private_portrait_projects 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("private_portrait_projects", "用户私域人像素材项目表")
|
|
||||||
_comment_column("private_portrait_projects", "id", "主键ID")
|
|
||||||
_comment_column("private_portrait_projects", "user_id", "所属用户ID")
|
|
||||||
_comment_column("private_portrait_projects", "library_type", "素材库类型:real_person真人认证/aigc_virtual虚拟人像")
|
|
||||||
_comment_column("private_portrait_projects", "name", "用户展示项目名")
|
|
||||||
_comment_column("private_portrait_projects", "name_slug", "项目名安全slug")
|
|
||||||
_comment_column("private_portrait_projects", "remote_project_name", "火山ProjectName快照")
|
|
||||||
_comment_column("private_portrait_projects", "description", "项目描述")
|
|
||||||
_comment_column("private_portrait_projects", "status", "项目状态:active/creating/create_failed/deleting")
|
|
||||||
_comment_column("private_portrait_projects", "asset_group_count", "素材分组数量")
|
|
||||||
_comment_column("private_portrait_projects", "asset_count", "素材总数")
|
|
||||||
_comment_column("private_portrait_projects", "image_asset_count", "图片素材数")
|
|
||||||
_comment_column("private_portrait_projects", "video_asset_count", "视频素材数")
|
|
||||||
_comment_column("private_portrait_projects", "active_asset_count", "有效素材数")
|
|
||||||
_comment_column("private_portrait_projects", "active_image_asset_count", "有效图片素材数")
|
|
||||||
_comment_column("private_portrait_projects", "active_video_asset_count", "有效视频素材数")
|
|
||||||
_comment_column("private_portrait_projects", "last_used_at", "最后使用时间")
|
|
||||||
_comment_column("private_portrait_projects", "created_at", "创建时间")
|
|
||||||
_comment_column("private_portrait_projects", "updated_at", "更新时间")
|
|
||||||
_comment_column("private_portrait_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# private_portrait_asset_groups 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("private_portrait_asset_groups", "本地项目组与火山Asset Group映射表")
|
|
||||||
_comment_column("private_portrait_asset_groups", "id", "主键ID")
|
|
||||||
_comment_column("private_portrait_asset_groups", "user_id", "所属用户ID")
|
|
||||||
_comment_column("private_portrait_asset_groups", "project_id", "所属项目ID")
|
|
||||||
_comment_column("private_portrait_asset_groups", "library_type", "素材库类型:real_person/aigc_virtual")
|
|
||||||
_comment_column("private_portrait_asset_groups", "remote_group_id", "火山远端AssetGroup ID")
|
|
||||||
_comment_column("private_portrait_asset_groups", "remote_group_name", "火山远端AssetGroup名称")
|
|
||||||
_comment_column("private_portrait_asset_groups", "remote_project_name", "火山ProjectName快照")
|
|
||||||
_comment_column("private_portrait_asset_groups", "group_type", "分组类型")
|
|
||||||
_comment_column("private_portrait_asset_groups", "status", "分组状态:active/creating/create_failed/deleting")
|
|
||||||
_comment_column("private_portrait_asset_groups", "remote_delete_status", "远端删除状态:none/deleting/deleted/failed")
|
|
||||||
_comment_column("private_portrait_asset_groups", "remote_deleted_at", "远端删除时间")
|
|
||||||
_comment_column("private_portrait_asset_groups", "remote_delete_error", "远端删除错误")
|
|
||||||
_comment_column("private_portrait_asset_groups", "raw_response_json", "火山原始响应JSON")
|
|
||||||
_comment_column("private_portrait_asset_groups", "created_at", "创建时间")
|
|
||||||
_comment_column("private_portrait_asset_groups", "updated_at", "更新时间")
|
|
||||||
_comment_column("private_portrait_asset_groups", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# private_portrait_assets 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("private_portrait_assets", "火山Asset本地映射表(素材文件记录)")
|
|
||||||
_comment_column("private_portrait_assets", "id", "主键ID")
|
|
||||||
_comment_column("private_portrait_assets", "user_id", "所属用户ID")
|
|
||||||
_comment_column("private_portrait_assets", "project_id", "所属项目ID")
|
|
||||||
_comment_column("private_portrait_assets", "group_id", "所属分组ID")
|
|
||||||
_comment_column("private_portrait_assets", "library_type", "素材库类型:real_person/aigc_virtual")
|
|
||||||
_comment_column("private_portrait_assets", "remote_group_id", "火山远端AssetGroup ID")
|
|
||||||
_comment_column("private_portrait_assets", "remote_asset_id", "火山远端Asset ID")
|
|
||||||
_comment_column("private_portrait_assets", "remote_project_name", "火山ProjectName快照")
|
|
||||||
_comment_column("private_portrait_assets", "asset_type", "素材类型:Image图片/Video视频")
|
|
||||||
_comment_column("private_portrait_assets", "name", "素材名称")
|
|
||||||
_comment_column("private_portrait_assets", "source_url", "本地上传后的访问URL")
|
|
||||||
_comment_column("private_portrait_assets", "preview_url", "前端预览URL")
|
|
||||||
_comment_column("private_portrait_assets", "remote_url", "火山返回的资源访问URL")
|
|
||||||
_comment_column("private_portrait_assets", "remote_url_expired_at", "火山URL过期时间")
|
|
||||||
_comment_column("private_portrait_assets", "video_duration", "视频素材时长,秒")
|
|
||||||
_comment_column("private_portrait_assets", "video_cover_url", "视频素材封面预览地址")
|
|
||||||
_comment_column("private_portrait_assets", "file_size", "素材文件大小,字节")
|
|
||||||
_comment_column("private_portrait_assets", "mime_type", "MIME类型")
|
|
||||||
_comment_column("private_portrait_assets", "status", "素材状态:creating/active/failed/deleting")
|
|
||||||
_comment_column("private_portrait_assets", "moderation_json", "火山审核结果JSON")
|
|
||||||
_comment_column("private_portrait_assets", "last_poll_at", "最后轮询时间")
|
|
||||||
_comment_column("private_portrait_assets", "next_poll_at", "下次轮询时间")
|
|
||||||
_comment_column("private_portrait_assets", "poll_count", "轮询次数")
|
|
||||||
_comment_column("private_portrait_assets", "remote_delete_status", "远端删除状态:none/deleting/deleted/failed")
|
|
||||||
_comment_column("private_portrait_assets", "remote_deleted_at", "远端删除时间")
|
|
||||||
_comment_column("private_portrait_assets", "remote_delete_error", "远端删除错误")
|
|
||||||
_comment_column("private_portrait_assets", "error_message", "错误信息")
|
|
||||||
_comment_column("private_portrait_assets", "raw_response_json", "火山原始响应JSON")
|
|
||||||
_comment_column("private_portrait_assets", "created_at", "创建时间")
|
|
||||||
_comment_column("private_portrait_assets", "updated_at", "更新时间")
|
|
||||||
_comment_column("private_portrait_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# private_portrait_validate_sessions 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("private_portrait_validate_sessions", "火山真人认证H5会话表")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "id", "主键ID")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "user_id", "所属用户ID")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "project_id", "关联项目ID")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "byted_token", "火山byted_token")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "h5_link", "认证H5链接")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "callback_url", "火山回调URL")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "result_code", "认证结果码")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "algorithm_base_resp_code", "算法基础响应码")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "verify_type", "认证类型")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "status", "会话状态:created/group_active/expired/failed")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "remote_group_id", "火山远端AssetGroup ID")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "remote_project_name", "火山ProjectName快照")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "expired_at", "会话过期时间")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "error_message", "错误信息")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "raw_callback_json", "火山回调原始JSON")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "raw_response_json", "火山原始响应JSON")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "created_at", "创建时间")
|
|
||||||
_comment_column("private_portrait_validate_sessions", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# vp_v3_projects 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("vp_v3_projects", "API V3虚拟素材项目表(按API Key隔离)")
|
|
||||||
_comment_column("vp_v3_projects", "id", "主键ID")
|
|
||||||
_comment_column("vp_v3_projects", "api_key_id", "所属API Key(V3调用方)")
|
|
||||||
_comment_column("vp_v3_projects", "name", "项目展示名称")
|
|
||||||
_comment_column("vp_v3_projects", "name_slug", "名称安全slug(构建远端GroupName用)")
|
|
||||||
_comment_column("vp_v3_projects", "description", "项目描述")
|
|
||||||
_comment_column("vp_v3_projects", "remote_project_name", "火山ProjectName快照")
|
|
||||||
_comment_column("vp_v3_projects", "remote_group_id", "火山AssetGroup Id")
|
|
||||||
_comment_column("vp_v3_projects", "remote_group_name", "火山AssetGroup Name快照")
|
|
||||||
_comment_column("vp_v3_projects", "status", "项目状态:active/creating_remote_group/create_group_failed/deleting")
|
|
||||||
_comment_column("vp_v3_projects", "asset_count", "素材总数")
|
|
||||||
_comment_column("vp_v3_projects", "active_asset_count", "有效素材数")
|
|
||||||
_comment_column("vp_v3_projects", "image_asset_count", "图片素材数")
|
|
||||||
_comment_column("vp_v3_projects", "video_asset_count", "视频素材数")
|
|
||||||
_comment_column("vp_v3_projects", "active_image_asset_count", "有效图片素材数")
|
|
||||||
_comment_column("vp_v3_projects", "active_video_asset_count", "有效视频素材数")
|
|
||||||
_comment_column("vp_v3_projects", "storage_mb_used", "项目占用存储MB")
|
|
||||||
_comment_column("vp_v3_projects", "last_used_at", "最后使用时间")
|
|
||||||
_comment_column("vp_v3_projects", "remote_delete_status", "远端删除状态")
|
|
||||||
_comment_column("vp_v3_projects", "remote_deleted_at", "远端删除时间")
|
|
||||||
_comment_column("vp_v3_projects", "remote_delete_error", "远端删除错误")
|
|
||||||
_comment_column("vp_v3_projects", "error_message", "创建失败等错误信息")
|
|
||||||
_comment_column("vp_v3_projects", "raw_response_json", "火山原始响应")
|
|
||||||
_comment_column("vp_v3_projects", "created_at", "创建时间")
|
|
||||||
_comment_column("vp_v3_projects", "updated_at", "更新时间")
|
|
||||||
_comment_column("vp_v3_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# vp_v3_assets 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("vp_v3_assets", "API V3虚拟素材表(图片/视频)")
|
|
||||||
_comment_column("vp_v3_assets", "id", "主键ID")
|
|
||||||
_comment_column("vp_v3_assets", "api_key_id", "所属API Key")
|
|
||||||
_comment_column("vp_v3_assets", "project_id", "所属项目ID")
|
|
||||||
_comment_column("vp_v3_assets", "remote_project_name", "火山ProjectName快照")
|
|
||||||
_comment_column("vp_v3_assets", "remote_group_id", "火山AssetGroup ID")
|
|
||||||
_comment_column("vp_v3_assets", "remote_asset_id", "火山远端Asset ID")
|
|
||||||
_comment_column("vp_v3_assets", "asset_type", "素材类型:Image=图片/Video=视频")
|
|
||||||
_comment_column("vp_v3_assets", "name", "素材名称")
|
|
||||||
_comment_column("vp_v3_assets", "source_url", "本地上传后的访问URL(UploadResource返回的)")
|
|
||||||
_comment_column("vp_v3_assets", "preview_url", "给前端预览/显示用的URL")
|
|
||||||
_comment_column("vp_v3_assets", "remote_url", "火山返回的资源访问URL")
|
|
||||||
_comment_column("vp_v3_assets", "remote_url_expired_at", "火山URL过期时间")
|
|
||||||
_comment_column("vp_v3_assets", "upload_resource_id", "本地UploadResource账本resource_id")
|
|
||||||
_comment_column("vp_v3_assets", "video_duration", "视频时长,秒")
|
|
||||||
_comment_column("vp_v3_assets", "video_cover_url", "视频封面预览")
|
|
||||||
_comment_column("vp_v3_assets", "file_size_bytes", "素材文件大小,字节")
|
|
||||||
_comment_column("vp_v3_assets", "mime_type", "MIME类型")
|
|
||||||
_comment_column("vp_v3_assets", "status", "素材状态:creating/审核中active/可用failed/失败deleting/删除中")
|
|
||||||
_comment_column("vp_v3_assets", "moderation_json", "火山审核结果JSON")
|
|
||||||
_comment_column("vp_v3_assets", "error_message", "失败原因")
|
|
||||||
_comment_column("vp_v3_assets", "raw_response_json", "火山原始响应JSON")
|
|
||||||
_comment_column("vp_v3_assets", "last_poll_at", "最后轮询时间")
|
|
||||||
_comment_column("vp_v3_assets", "next_poll_at", "下次轮询时间")
|
|
||||||
_comment_column("vp_v3_assets", "poll_count", "轮询次数")
|
|
||||||
_comment_column("vp_v3_assets", "remote_delete_status", "远端删除状态")
|
|
||||||
_comment_column("vp_v3_assets", "remote_deleted_at", "远端删除时间")
|
|
||||||
_comment_column("vp_v3_assets", "remote_delete_error", "远端删除错误")
|
|
||||||
_comment_column("vp_v3_assets", "created_at", "创建时间")
|
|
||||||
_comment_column("vp_v3_assets", "updated_at", "更新时间")
|
|
||||||
_comment_column("vp_v3_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# vp_v3_api_key_quotas 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("vp_v3_api_key_quotas", "API V3虚拟素材库配额表(每个ApiKey一份)")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "id", "主键ID")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "api_key_id", "所属API Key,唯一:一个API Key只有一份虚拟素材配额")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "project_limit", "虚拟项目上限,默认0不可创建")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "asset_limit", "虚拟素材总数上限(图片+视频),默认0不可上传")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "storage_mb_limit", "上传存储上限MB,默认0不可上传文件")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "project_used", "已创建项目数(未删除)")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "asset_used", "已上传素材数(未删除,图片+视频)")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "storage_mb_used", "已占用存储MB(未删除文件大小合计)")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "remark", "后台备注")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "created_at", "创建时间")
|
|
||||||
_comment_column("vp_v3_api_key_quotas", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# api_keys 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("api_keys", "对外开放API密钥管理表")
|
|
||||||
_comment_column("api_keys", "id", "主键ID")
|
|
||||||
_comment_column("api_keys", "company_name", "公司/组织名称")
|
|
||||||
_comment_column("api_keys", "api_key_hash", "API Key哈希值,唯一")
|
|
||||||
_comment_column("api_keys", "api_key_prefix", "API Key前缀")
|
|
||||||
_comment_column("api_keys", "api_key_encrypted", "AES-256-GCM加密的完整API Key")
|
|
||||||
_comment_column("api_keys", "description", "描述信息")
|
|
||||||
_comment_column("api_keys", "callable_models", "可调用模型配置JSON数组")
|
|
||||||
_comment_column("api_keys", "quota_limit", "配额总量,NULL=无限")
|
|
||||||
_comment_column("api_keys", "quota_cycle", "配额周期:daily/monthly/one_time/NULL=无限")
|
|
||||||
_comment_column("api_keys", "quota_used", "当前周期已使用量")
|
|
||||||
_comment_column("api_keys", "valid_from", "有效期开始时间")
|
|
||||||
_comment_column("api_keys", "valid_until", "有效期结束时间")
|
|
||||||
_comment_column("api_keys", "max_concurrent_video_tasks", "最大并发视频任务数,NULL=无限")
|
|
||||||
_comment_column("api_keys", "is_active", "是否启用")
|
|
||||||
_comment_column("api_keys", "last_used_at", "最后使用时间")
|
|
||||||
_comment_column("api_keys", "created_at", "创建时间")
|
|
||||||
_comment_column("api_keys", "updated_at", "更新时间")
|
|
||||||
_comment_column("api_keys", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# api_generation_tasks 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("api_generation_tasks", "对外开放API生成任务表")
|
|
||||||
_comment_column("api_generation_tasks", "id", "主键ID")
|
|
||||||
_comment_column("api_generation_tasks", "api_key_id", "所属API Key")
|
|
||||||
_comment_column("api_generation_tasks", "external_idempotency_key", "外部幂等键")
|
|
||||||
_comment_column("api_generation_tasks", "original_prompt", "原始提示词")
|
|
||||||
_comment_column("api_generation_tasks", "optimized_prompt", "优化后的提示词")
|
|
||||||
_comment_column("api_generation_tasks", "gen_type", "生成类型:image/video")
|
|
||||||
_comment_column("api_generation_tasks", "duration", "视频时长(秒)")
|
|
||||||
_comment_column("api_generation_tasks", "aspect_ratio", "视频比例")
|
|
||||||
_comment_column("api_generation_tasks", "resolution", "分辨率档位")
|
|
||||||
_comment_column("api_generation_tasks", "provider_generation_resolution", "供应商实际生成分辨率")
|
|
||||||
_comment_column("api_generation_tasks", "image_size", "图片分辨率档位")
|
|
||||||
_comment_column("api_generation_tasks", "image_proportion", "图片比例")
|
|
||||||
_comment_column("api_generation_tasks", "image_px", "图片像素")
|
|
||||||
_comment_column("api_generation_tasks", "generation_count", "生成份数")
|
|
||||||
_comment_column("api_generation_tasks", "engine_id", "引擎ID")
|
|
||||||
_comment_column("api_generation_tasks", "model_name", "模型名称")
|
|
||||||
_comment_column("api_generation_tasks", "media_references", "用户原始上传的媒体URL")
|
|
||||||
_comment_column("api_generation_tasks", "local_media_json", "下载到本地的媒体文件路径JSON")
|
|
||||||
_comment_column("api_generation_tasks", "engine_snapshot_json", "引擎参数快照JSON")
|
|
||||||
_comment_column("api_generation_tasks", "request_params_json", "完整原始请求参数")
|
|
||||||
_comment_column("api_generation_tasks", "status", "任务状态")
|
|
||||||
_comment_column("api_generation_tasks", "pipeline_stage", "流水线阶段")
|
|
||||||
_comment_column("api_generation_tasks", "generation_attempt_no", "生成尝试次数")
|
|
||||||
_comment_column("api_generation_tasks", "resource_generation_started_at", "资源生成开始时间")
|
|
||||||
_comment_column("api_generation_tasks", "deadline_at", "任务截止时间")
|
|
||||||
_comment_column("api_generation_tasks", "provider_task_id", "供应商任务ID")
|
|
||||||
_comment_column("api_generation_tasks", "remote_result_url", "供应商远程资源URL")
|
|
||||||
_comment_column("api_generation_tasks", "provider_response_json", "供应商响应JSON")
|
|
||||||
_comment_column("api_generation_tasks", "image_url", "图片结果URL")
|
|
||||||
_comment_column("api_generation_tasks", "video_url", "视频结果URL")
|
|
||||||
_comment_column("api_generation_tasks", "video_cover_url", "视频封面URL")
|
|
||||||
_comment_column("api_generation_tasks", "error_message", "错误信息")
|
|
||||||
_comment_column("api_generation_tasks", "generated_at", "生成完成时间")
|
|
||||||
_comment_column("api_generation_tasks", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
|
||||||
_comment_column("api_generation_tasks", "video_upscale_snapshot_json", "视频超分参数快照JSON")
|
|
||||||
_comment_column("api_generation_tasks", "credits_cost", "消耗积分")
|
|
||||||
_comment_column("api_generation_tasks", "video_tokens_used", "视频Token消耗")
|
|
||||||
_comment_column("api_generation_tasks", "image_tokens_used", "图片Token消耗")
|
|
||||||
_comment_column("api_generation_tasks", "next_poll_at", "下次轮询时间")
|
|
||||||
_comment_column("api_generation_tasks", "poll_interval_seconds", "轮询间隔秒数")
|
|
||||||
_comment_column("api_generation_tasks", "poll_count", "轮询次数")
|
|
||||||
_comment_column("api_generation_tasks", "last_poll_at", "最后轮询时间")
|
|
||||||
_comment_column("api_generation_tasks", "provider_create_claim_token", "供应商创建任务租约token")
|
|
||||||
_comment_column("api_generation_tasks", "provider_create_lease_until", "供应商创建租约过期")
|
|
||||||
_comment_column("api_generation_tasks", "provider_create_started_at", "供应商创建开始时间")
|
|
||||||
_comment_column("api_generation_tasks", "poll_started_at", "轮询开始时间")
|
|
||||||
_comment_column("api_generation_tasks", "poll_claim_token", "轮询租约token")
|
|
||||||
_comment_column("api_generation_tasks", "poll_lease_until", "轮询租约过期")
|
|
||||||
_comment_column("api_generation_tasks", "poll_error_count", "轮询错误次数")
|
|
||||||
_comment_column("api_generation_tasks", "download_celery_task_id", "下载Celery任务ID")
|
|
||||||
_comment_column("api_generation_tasks", "download_enqueued_at", "下载入队时间")
|
|
||||||
_comment_column("api_generation_tasks", "download_started_at", "下载开始时间")
|
|
||||||
_comment_column("api_generation_tasks", "download_claim_token", "下载租约token")
|
|
||||||
_comment_column("api_generation_tasks", "download_lease_until", "下载租约过期")
|
|
||||||
_comment_column("api_generation_tasks", "download_next_retry_at", "下载下次重试时间")
|
|
||||||
_comment_column("api_generation_tasks", "download_attempt_count", "下载重试次数")
|
|
||||||
_comment_column("api_generation_tasks", "download_last_error", "下载最后错误信息")
|
|
||||||
_comment_column("api_generation_tasks", "download_storage_date_dir", "下载存储日期目录")
|
|
||||||
_comment_column("api_generation_tasks", "local_path", "本地存储路径")
|
|
||||||
_comment_column("api_generation_tasks", "created_at", "创建时间")
|
|
||||||
_comment_column("api_generation_tasks", "updated_at", "更新时间")
|
|
||||||
_comment_column("api_generation_tasks", "deleted_at", "软删除时间,NULL表示未删除")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# api_usage_logs 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("api_usage_logs", "API调用详细消耗记录表")
|
|
||||||
_comment_column("api_usage_logs", "id", "主键ID")
|
|
||||||
_comment_column("api_usage_logs", "api_key_id", "所属API Key")
|
|
||||||
_comment_column("api_usage_logs", "api_generation_task_id", "关联生成任务ID")
|
|
||||||
_comment_column("api_usage_logs", "price_action", "操作类型:deduct=扣除, refund=退回")
|
|
||||||
_comment_column("api_usage_logs", "request_type", "请求类型:video_create/image_generate")
|
|
||||||
_comment_column("api_usage_logs", "model_name", "模型名称")
|
|
||||||
_comment_column("api_usage_logs", "gen_type", "生成类型:image/video")
|
|
||||||
_comment_column("api_usage_logs", "resolution", "分辨率")
|
|
||||||
_comment_column("api_usage_logs", "duration", "视频时长(秒)")
|
|
||||||
_comment_column("api_usage_logs", "credits_cost", "实际扣除金额")
|
|
||||||
_comment_column("api_usage_logs", "refund_amount", "退回金额")
|
|
||||||
_comment_column("api_usage_logs", "quota_before", "操作前配额余额")
|
|
||||||
_comment_column("api_usage_logs", "quota_after", "操作后配额余额")
|
|
||||||
_comment_column("api_usage_logs", "tokens_used", "Token用量")
|
|
||||||
_comment_column("api_usage_logs", "request_duration_ms", "端到端耗时")
|
|
||||||
_comment_column("api_usage_logs", "price_detail_json", "价格计算明细JSON")
|
|
||||||
_comment_column("api_usage_logs", "status", "调用状态:success/failed")
|
|
||||||
_comment_column("api_usage_logs", "error_message", "错误信息")
|
|
||||||
_comment_column("api_usage_logs", "error_code", "错误码")
|
|
||||||
_comment_column("api_usage_logs", "request_payload_json", "原始请求快照")
|
|
||||||
_comment_column("api_usage_logs", "created_at", "创建时间")
|
|
||||||
_comment_column("api_usage_logs", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# api_model_pricings 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("api_model_pricings", "API模型价格表(全局统一配置)")
|
|
||||||
_comment_column("api_model_pricings", "id", "主键ID")
|
|
||||||
_comment_column("api_model_pricings", "model_config_id", "引擎ID:图片对应image_engines.id,视频对应video_engines.id")
|
|
||||||
_comment_column("api_model_pricings", "gen_type", "生成类型:image/video")
|
|
||||||
_comment_column("api_model_pricings", "resolution", "分辨率档位")
|
|
||||||
_comment_column("api_model_pricings", "price_ratio", "价格系数(乘数)")
|
|
||||||
_comment_column("api_model_pricings", "base_price", "基础价格(元)")
|
|
||||||
_comment_column("api_model_pricings", "per_second_price", "每秒价格(视频,元)")
|
|
||||||
_comment_column("api_model_pricings", "input_video_ratio", "传入视频系数")
|
|
||||||
_comment_column("api_model_pricings", "input_video_base_price", "传入视频基础价(元)")
|
|
||||||
_comment_column("api_model_pricings", "input_video_per_second_price", "传入视频每秒价(元)")
|
|
||||||
_comment_column("api_model_pricings", "input_image_ratio", "传入图片系数")
|
|
||||||
_comment_column("api_model_pricings", "input_image_base_price", "传入图片基础价(元)")
|
|
||||||
_comment_column("api_model_pricings", "input_image_per_image_price", "传入图片每张价(元)")
|
|
||||||
_comment_column("api_model_pricings", "created_at", "创建时间")
|
|
||||||
_comment_column("api_model_pricings", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# api_upscale_links 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("api_upscale_links", "API任务与超分任务关联表")
|
|
||||||
_comment_column("api_upscale_links", "id", "主键ID")
|
|
||||||
_comment_column("api_upscale_links", "api_generation_task_id", "关联API生成任务ID")
|
|
||||||
_comment_column("api_upscale_links", "video_upscale_task_id", "关联视频超分任务ID")
|
|
||||||
_comment_column("api_upscale_links", "created_at", "创建时间")
|
|
||||||
_comment_column("api_upscale_links", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# api_key_upscale_configs 表
|
|
||||||
# ============================================================
|
|
||||||
_comment_table("api_key_upscale_configs", "API Key级别超分配置表")
|
|
||||||
_comment_column("api_key_upscale_configs", "id", "主键ID")
|
|
||||||
_comment_column("api_key_upscale_configs", "api_key_id", "所属API Key")
|
|
||||||
_comment_column("api_key_upscale_configs", "enabled", "是否启用超分")
|
|
||||||
_comment_column("api_key_upscale_configs", "delete_source_after_success", "超分成功后是否删除源文件")
|
|
||||||
_comment_column("api_key_upscale_configs", "rules_json", "超分规则JSON数组")
|
|
||||||
_comment_column("api_key_upscale_configs", "created_at", "创建时间")
|
|
||||||
_comment_column("api_key_upscale_configs", "updated_at", "更新时间")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute("""
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
r record;
|
|
||||||
BEGIN
|
|
||||||
FOR r IN
|
|
||||||
SELECT table_name, column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name IN (
|
|
||||||
'notification_reads', 'menu_configs',
|
|
||||||
'team_join_requests', 'team_invitations',
|
|
||||||
'contact_requests', 'token_usage',
|
|
||||||
'industry_configs', 'chat_generation_task_events',
|
|
||||||
'chat_provider_call_logs', 'open_type',
|
|
||||||
'pre_test_template', 'material_cost',
|
|
||||||
'user_oauth', 'user_oauth_account', 'user_oauth_app',
|
|
||||||
'upload_task', 'user_resource_month_stats',
|
|
||||||
'user_resource_total_stats', 'home_material_assets',
|
|
||||||
'home_material_categories', 'home_material_watermarks',
|
|
||||||
'module_generation_projects', 'module_generation_steps',
|
|
||||||
'shot_replicate_segments',
|
|
||||||
'private_portrait_projects', 'private_portrait_asset_groups',
|
|
||||||
'private_portrait_assets', 'private_portrait_validate_sessions',
|
|
||||||
'vp_v3_projects', 'vp_v3_assets', 'vp_v3_api_key_quotas',
|
|
||||||
'api_keys', 'api_generation_tasks', 'api_usage_logs',
|
|
||||||
'api_model_pricings', 'api_upscale_links',
|
|
||||||
'api_key_upscale_configs'
|
|
||||||
)
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('COMMENT ON COLUMN %I.%I IS NULL', r.table_name, r.column_name);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
""")
|
|
||||||
for t in [
|
|
||||||
"notification_reads", "menu_configs",
|
|
||||||
"team_join_requests", "team_invitations",
|
|
||||||
"contact_requests", "token_usage",
|
|
||||||
"industry_configs", "chat_generation_task_events",
|
|
||||||
"chat_provider_call_logs", "open_type",
|
|
||||||
"pre_test_template", "material_cost",
|
|
||||||
"user_oauth", "user_oauth_account", "user_oauth_app",
|
|
||||||
"upload_task", "user_resource_month_stats",
|
|
||||||
"user_resource_total_stats", "home_material_assets",
|
|
||||||
"home_material_categories", "home_material_watermarks",
|
|
||||||
"module_generation_projects", "module_generation_steps",
|
|
||||||
"shot_replicate_segments",
|
|
||||||
"private_portrait_projects", "private_portrait_asset_groups",
|
|
||||||
"private_portrait_assets", "private_portrait_validate_sessions",
|
|
||||||
"vp_v3_projects", "vp_v3_assets", "vp_v3_api_key_quotas",
|
|
||||||
"api_keys", "api_generation_tasks", "api_usage_logs",
|
|
||||||
"api_model_pricings", "api_upscale_links",
|
|
||||||
"api_key_upscale_configs",
|
|
||||||
]:
|
|
||||||
op.execute(f"COMMENT ON TABLE {t} IS NULL")
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
"""add api v3 tables (api_keys, api_generation_tasks, api_usage_logs, api_key_upscale_configs, api_upscale_links)
|
|
||||||
|
|
||||||
Revision ID: a1b2c3d4e5f6
|
|
||||||
Revises: 20da1d353914
|
|
||||||
Create Date: 2026-07-28 12:00:00.000000
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'a1b2c3d4e5f6g'
|
|
||||||
down_revision: Union[str, None] = '20da1d353914'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# === 1. api_keys ===
|
|
||||||
op.create_table(
|
|
||||||
'api_keys',
|
|
||||||
sa.Column('id', sa.String(32), primary_key=True),
|
|
||||||
sa.Column('company_name', sa.String(128), nullable=False),
|
|
||||||
sa.Column('api_key_hash', sa.String(64), nullable=False, unique=True),
|
|
||||||
sa.Column('api_key_prefix', sa.String(16), nullable=False),
|
|
||||||
sa.Column('description', sa.Text, nullable=True),
|
|
||||||
sa.Column('callable_models', sa.Text, nullable=False, server_default='[]'),
|
|
||||||
sa.Column('quota_limit', sa.Float, nullable=True),
|
|
||||||
sa.Column('quota_cycle', sa.String(16), nullable=True),
|
|
||||||
sa.Column('quota_used', sa.Float, nullable=False, server_default='0.0'),
|
|
||||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('max_concurrent_video_tasks', sa.Integer, nullable=True),
|
|
||||||
sa.Column('is_active', sa.Boolean, nullable=False, server_default='true'),
|
|
||||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index('idx_api_key_hash', 'api_keys', ['api_key_hash'], unique=True)
|
|
||||||
op.create_index('idx_api_keys_active', 'api_keys', ['is_active'])
|
|
||||||
op.create_index('idx_api_keys_company', 'api_keys', ['company_name'])
|
|
||||||
|
|
||||||
# === 2. api_generation_tasks ===
|
|
||||||
op.create_table(
|
|
||||||
'api_generation_tasks',
|
|
||||||
sa.Column('id', sa.String(32), primary_key=True),
|
|
||||||
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False),
|
|
||||||
sa.Column('external_idempotency_key', sa.String(64), nullable=True),
|
|
||||||
sa.Column('original_prompt', sa.Text, nullable=False),
|
|
||||||
sa.Column('optimized_prompt', sa.Text, nullable=True),
|
|
||||||
sa.Column('gen_type', sa.String(16), nullable=False, default='video'),
|
|
||||||
sa.Column('duration', sa.Integer, nullable=True),
|
|
||||||
sa.Column('aspect_ratio', sa.String(8), nullable=True),
|
|
||||||
sa.Column('resolution', sa.String(8), nullable=True),
|
|
||||||
sa.Column('provider_generation_resolution', sa.String(16), nullable=True),
|
|
||||||
sa.Column('image_size', sa.String(16), nullable=True),
|
|
||||||
sa.Column('image_proportion', sa.String(8), nullable=True),
|
|
||||||
sa.Column('image_px', sa.String(16), nullable=True),
|
|
||||||
sa.Column('generation_count', sa.Integer, nullable=False, default=1, server_default='1'),
|
|
||||||
sa.Column('engine_id', sa.String(32), nullable=True),
|
|
||||||
sa.Column('media_references', sa.Text, nullable=True),
|
|
||||||
sa.Column('engine_snapshot_json', sa.Text, nullable=True),
|
|
||||||
sa.Column('request_params_json', sa.Text, nullable=True),
|
|
||||||
sa.Column('status', sa.String(32), nullable=False, default='pending'),
|
|
||||||
sa.Column('pipeline_stage', sa.String(32), nullable=True),
|
|
||||||
sa.Column('generation_attempt_no', sa.Integer, nullable=False, default=1, server_default='1'),
|
|
||||||
sa.Column('resource_generation_started_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('deadline_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('provider_task_id', sa.String(128), nullable=True),
|
|
||||||
sa.Column('remote_result_url', sa.Text, nullable=True),
|
|
||||||
sa.Column('provider_response_json', sa.Text, nullable=True),
|
|
||||||
sa.Column('image_url', sa.String(512), nullable=True),
|
|
||||||
sa.Column('video_url', sa.String(512), nullable=True),
|
|
||||||
sa.Column('video_cover_url', sa.String(512), nullable=True),
|
|
||||||
sa.Column('video_upscale_enabled_snapshot', sa.Boolean, nullable=False, default=False, server_default='false'),
|
|
||||||
sa.Column('video_upscale_snapshot_json', sa.Text, nullable=True),
|
|
||||||
sa.Column('credits_cost', sa.Float, nullable=False, default=0.0, server_default='0.0'),
|
|
||||||
sa.Column('video_tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
|
|
||||||
sa.Column('image_tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
|
|
||||||
sa.Column('error_message', sa.Text, nullable=True),
|
|
||||||
sa.Column('generated_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('poll_interval_seconds', sa.Integer, nullable=False, default=30, server_default='30'),
|
|
||||||
sa.Column('poll_count', sa.Integer, nullable=False, default=0, server_default='0'),
|
|
||||||
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('provider_create_claim_token', sa.String(64), nullable=True),
|
|
||||||
sa.Column('provider_create_lease_until', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('provider_create_started_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('poll_started_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('poll_claim_token', sa.String(64), nullable=True),
|
|
||||||
sa.Column('poll_lease_until', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('poll_error_count', sa.Integer, nullable=False, default=0, server_default='0'),
|
|
||||||
sa.Column('download_celery_task_id', sa.String(160), nullable=True),
|
|
||||||
sa.Column('download_enqueued_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('download_started_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('download_claim_token', sa.String(64), nullable=True),
|
|
||||||
sa.Column('download_lease_until', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('download_next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('download_attempt_count', sa.Integer, nullable=False, default=0, server_default='0'),
|
|
||||||
sa.Column('download_last_error', sa.Text, nullable=True),
|
|
||||||
sa.Column('download_storage_date_dir', sa.String(16), nullable=True),
|
|
||||||
sa.Column('local_path', sa.Text, nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index('idx_api_generation_tasks_api_key', 'api_generation_tasks', ['api_key_id'])
|
|
||||||
op.create_index('idx_api_generation_tasks_status', 'api_generation_tasks', ['status'])
|
|
||||||
op.create_index('idx_api_generation_tasks_provider_task_id', 'api_generation_tasks', ['provider_task_id'])
|
|
||||||
op.create_index('idx_api_generation_tasks_next_poll_at', 'api_generation_tasks', ['next_poll_at'])
|
|
||||||
op.create_index('idx_api_generation_tasks_api_key_created', 'api_generation_tasks', ['api_key_id', 'created_at'])
|
|
||||||
op.create_index(
|
|
||||||
'uq_api_generation_tasks_key_idempotency',
|
|
||||||
'api_generation_tasks',
|
|
||||||
['api_key_id', 'external_idempotency_key'],
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=sa.text("deleted_at IS NULL AND external_idempotency_key IS NOT NULL"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# === 3. api_usage_logs ===
|
|
||||||
op.create_table(
|
|
||||||
'api_usage_logs',
|
|
||||||
sa.Column('id', sa.String(32), primary_key=True),
|
|
||||||
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False),
|
|
||||||
sa.Column('api_generation_task_id', sa.String(32), sa.ForeignKey('api_generation_tasks.id', ondelete='SET NULL'), nullable=True),
|
|
||||||
sa.Column('request_type', sa.String(32), nullable=False),
|
|
||||||
sa.Column('model_name', sa.String(128), nullable=False),
|
|
||||||
sa.Column('gen_type', sa.String(16), nullable=False),
|
|
||||||
sa.Column('credits_cost', sa.Float, nullable=False, default=0.0, server_default='0.0'),
|
|
||||||
sa.Column('tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
|
|
||||||
sa.Column('request_duration_ms', sa.Integer, nullable=False, default=0, server_default='0'),
|
|
||||||
sa.Column('status', sa.String(32), nullable=False),
|
|
||||||
sa.Column('error_message', sa.Text, nullable=True),
|
|
||||||
sa.Column('error_code', sa.String(64), nullable=True),
|
|
||||||
sa.Column('request_payload_json', sa.Text, nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
)
|
|
||||||
op.create_index('idx_api_usage_logs_api_key', 'api_usage_logs', ['api_key_id'])
|
|
||||||
op.create_index('idx_api_usage_logs_task_id', 'api_usage_logs', ['api_generation_task_id'])
|
|
||||||
op.create_index('idx_api_usage_logs_api_key_created', 'api_usage_logs', ['api_key_id', 'created_at'])
|
|
||||||
|
|
||||||
# === 4. api_key_upscale_configs ===
|
|
||||||
op.create_table(
|
|
||||||
'api_key_upscale_configs',
|
|
||||||
sa.Column('id', sa.String(32), primary_key=True),
|
|
||||||
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False, unique=True),
|
|
||||||
sa.Column('enabled', sa.Boolean, nullable=False, default=False, server_default='false'),
|
|
||||||
sa.Column('delete_source_after_success', sa.Boolean, nullable=False, default=True, server_default='true'),
|
|
||||||
sa.Column('rules_json', sa.Text, nullable=False, server_default='[]'),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
# === 5. api_upscale_links ===
|
|
||||||
op.create_table(
|
|
||||||
'api_upscale_links',
|
|
||||||
sa.Column('id', sa.String(32), primary_key=True),
|
|
||||||
sa.Column('api_generation_task_id', sa.String(32), sa.ForeignKey('api_generation_tasks.id', ondelete='CASCADE'), nullable=False),
|
|
||||||
sa.Column('video_upscale_task_id', sa.String(32), sa.ForeignKey('video_upscale_tasks.id', ondelete='CASCADE'), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
)
|
|
||||||
op.create_index('idx_api_upscale_links_api_task', 'api_upscale_links', ['api_generation_task_id'])
|
|
||||||
op.create_index('idx_api_upscale_links_video_task', 'api_upscale_links', ['video_upscale_task_id'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index('idx_api_upscale_links_video_task', table_name='api_upscale_links')
|
|
||||||
op.drop_index('idx_api_upscale_links_api_task', table_name='api_upscale_links')
|
|
||||||
op.drop_table('api_upscale_links')
|
|
||||||
|
|
||||||
op.drop_table('api_key_upscale_configs')
|
|
||||||
|
|
||||||
op.drop_index('idx_api_usage_logs_api_key_created', table_name='api_usage_logs')
|
|
||||||
op.drop_index('idx_api_usage_logs_task_id', table_name='api_usage_logs')
|
|
||||||
op.drop_index('idx_api_usage_logs_api_key', table_name='api_usage_logs')
|
|
||||||
op.drop_table('api_usage_logs')
|
|
||||||
|
|
||||||
op.drop_index('uq_api_generation_tasks_key_idempotency', table_name='api_generation_tasks')
|
|
||||||
op.drop_index('idx_api_generation_tasks_api_key_created', table_name='api_generation_tasks')
|
|
||||||
op.drop_index('idx_api_generation_tasks_next_poll_at', table_name='api_generation_tasks')
|
|
||||||
op.drop_index('idx_api_generation_tasks_provider_task_id', table_name='api_generation_tasks')
|
|
||||||
op.drop_index('idx_api_generation_tasks_status', table_name='api_generation_tasks')
|
|
||||||
op.drop_index('idx_api_generation_tasks_api_key', table_name='api_generation_tasks')
|
|
||||||
op.drop_table('api_generation_tasks')
|
|
||||||
|
|
||||||
op.drop_index('idx_api_keys_company', table_name='api_keys')
|
|
||||||
op.drop_index('idx_api_keys_active', table_name='api_keys')
|
|
||||||
op.drop_index('idx_api_key_hash', table_name='api_keys')
|
|
||||||
op.drop_table('api_keys')
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""add api_model_pricings table
|
|
||||||
|
|
||||||
Revision ID: b2c3d4e5f6g7
|
|
||||||
Revises: a1b2c3d4e5f6
|
|
||||||
Create Date: 2026-07-28 14:00:00.000000
|
|
||||||
|
|
||||||
API 模型价格表 - 使用直接金额(元)计费,镜像 credit_ratios 结构。
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'b2c3d4e5f6g7h'
|
|
||||||
down_revision: Union[str, None] = 'a1b2c3d4e5f6g'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
'api_model_pricings',
|
|
||||||
sa.Column('id', sa.String(32), primary_key=True),
|
|
||||||
sa.Column('model_config_id', sa.String(32), nullable=False, index=True),
|
|
||||||
sa.Column('gen_type', sa.String(16), nullable=False, default='video', index=True),
|
|
||||||
sa.Column('resolution', sa.String(16), nullable=False, index=True),
|
|
||||||
sa.Column('price_ratio', sa.Float, nullable=False, default=1.0),
|
|
||||||
sa.Column('base_price', sa.Float, nullable=False, default=0.0),
|
|
||||||
sa.Column('per_second_price', sa.Float, nullable=False, default=0.0),
|
|
||||||
sa.Column('input_video_ratio', sa.Float, nullable=False, default=1.0),
|
|
||||||
sa.Column('input_video_base_price', sa.Float, nullable=False, default=0.0),
|
|
||||||
sa.Column('input_video_per_second_price', sa.Float, nullable=False, default=0.0),
|
|
||||||
sa.Column('input_image_ratio', sa.Float, nullable=False, default=1.0),
|
|
||||||
sa.Column('input_image_base_price', sa.Float, nullable=False, default=0.0),
|
|
||||||
sa.Column('input_image_per_image_price', sa.Float, nullable=False, default=0.0),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
'ix_api_model_pricings_gen_type_engine_resolution',
|
|
||||||
'api_model_pricings',
|
|
||||||
['gen_type', 'model_config_id', 'resolution'],
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
'ix_api_model_pricings_gen_type_resolution',
|
|
||||||
'api_model_pricings',
|
|
||||||
['gen_type', 'resolution'],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index('ix_api_model_pricings_gen_type_resolution', table_name='api_model_pricings')
|
|
||||||
op.drop_index('ix_api_model_pricings_gen_type_engine_resolution', table_name='api_model_pricings')
|
|
||||||
op.drop_table('api_model_pricings')
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""add api_key_encrypted column to api_keys
|
|
||||||
|
|
||||||
Revision ID: c3d4e5f6g7h8
|
|
||||||
Revises: b2c3d4e5f6g7h
|
|
||||||
Create Date: 2026-07-28 16:00:00.000000
|
|
||||||
|
|
||||||
添加 api_key_encrypted 字段用于存储加密的完整 API Key,支持随时揭秘复制。
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'c3d4e5f6g7h8'
|
|
||||||
down_revision: Union[str, None] = 'b2c3d4e5f6g7h'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
'api_keys',
|
|
||||||
sa.Column('api_key_encrypted', sa.Text, nullable=True, comment='AES-256-GCM 加密的完整 API Key'),
|
|
||||||
)
|
|
||||||
# 为现有记录设置空值(新创建的 Key 会自动加密)
|
|
||||||
op.execute("UPDATE api_keys SET api_key_encrypted = '' WHERE api_key_encrypted IS NULL")
|
|
||||||
op.alter_column('api_keys', 'api_key_encrypted', nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column('api_keys', 'api_key_encrypted')
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""add model_name to api_generation_tasks
|
|
||||||
|
|
||||||
Revision ID: c4d5e6f7g8h9
|
|
||||||
Revises: c3d4e5f6g7h8
|
|
||||||
Create Date: 2026-07-29 16:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'c4d5e6f7g8h9'
|
|
||||||
down_revision: Union[str, None] = 'c3d4e5f6g7h8'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
'api_generation_tasks',
|
|
||||||
sa.Column('model_name', sa.String(128), nullable=False, server_default='', comment="模型名称,如 doubao-seedance-2-0-260128"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column('api_generation_tasks', 'model_name')
|
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
"""add api_generation_task_id to video_upscale_tasks
|
|
||||||
|
|
||||||
Revision ID: d5e6f7g8h9i0
|
|
||||||
Revises: c4d5e6f7g8h9
|
|
||||||
Create Date: 2026-07-29 16:30:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'd5e6f7g8h9i0'
|
|
||||||
down_revision: Union[str, None] = 'c4d5e6f7g8h9'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# 添加 api_generation_task_id 字段
|
|
||||||
op.add_column(
|
|
||||||
'video_upscale_tasks',
|
|
||||||
sa.Column('api_generation_task_id', sa.String(32), nullable=True, comment="API v3 任务ID,关联 api_generation_tasks.id"),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
'idx_video_upscale_tasks_api_generation_task_id',
|
|
||||||
'video_upscale_tasks',
|
|
||||||
['api_generation_task_id'],
|
|
||||||
)
|
|
||||||
op.create_foreign_key(
|
|
||||||
'fk_video_upscale_tasks_api_generation_task_id',
|
|
||||||
'video_upscale_tasks',
|
|
||||||
'api_generation_tasks',
|
|
||||||
['api_generation_task_id'],
|
|
||||||
['id'],
|
|
||||||
ondelete='CASCADE',
|
|
||||||
)
|
|
||||||
|
|
||||||
# 删除旧的检查约束,创建新的(允许 api_generation_task_id)
|
|
||||||
op.execute("ALTER TABLE video_upscale_tasks DROP CONSTRAINT IF EXISTS ck_video_upscale_tasks_exactly_one_owner")
|
|
||||||
op.execute("""
|
|
||||||
ALTER TABLE video_upscale_tasks
|
|
||||||
ADD CONSTRAINT ck_video_upscale_tasks_exactly_one_owner
|
|
||||||
CHECK (
|
|
||||||
(chat_generation_task_id IS NOT NULL)::int +
|
|
||||||
(generation_record_id IS NOT NULL)::int +
|
|
||||||
(api_generation_task_id IS NOT NULL)::int = 1
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# 恢复旧约束
|
|
||||||
op.execute("ALTER TABLE video_upscale_tasks DROP CONSTRAINT IF EXISTS ck_video_upscale_tasks_exactly_one_owner")
|
|
||||||
op.execute("""
|
|
||||||
ALTER TABLE video_upscale_tasks
|
|
||||||
ADD CONSTRAINT ck_video_upscale_tasks_exactly_one_owner
|
|
||||||
CHECK (
|
|
||||||
(chat_generation_task_id IS NOT NULL)::int +
|
|
||||||
(generation_record_id IS NOT NULL)::int = 1
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
op.drop_constraint('fk_video_upscale_tasks_api_generation_task_id', 'video_upscale_tasks', type_='foreignkey')
|
|
||||||
op.drop_index('idx_video_upscale_tasks_api_generation_task_id', table_name='video_upscale_tasks')
|
|
||||||
op.drop_column('video_upscale_tasks', 'api_generation_task_id')
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""enhance api_usage_logs with detailed consumption fields
|
|
||||||
|
|
||||||
Revision ID: e6f7g8h9i0j1
|
|
||||||
Revises: d5e6f7g8h9i0
|
|
||||||
Create Date: 2026-07-29 17:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'e6f7g8h9i0j1'
|
|
||||||
down_revision: Union[str, None] = 'd5e6f7g8h9i0'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# 添加新字段
|
|
||||||
op.add_column('api_usage_logs', sa.Column('price_action', sa.String(16), nullable=False, server_default='deduct', comment='deduct=扣除, refund=退回'))
|
|
||||||
op.add_column('api_usage_logs', sa.Column('resolution', sa.String(16), nullable=True, comment="分辨率: 480p/720p/1080p/2K/4K"))
|
|
||||||
op.add_column('api_usage_logs', sa.Column('duration', sa.Integer(), nullable=True, comment="视频时长(秒)"))
|
|
||||||
op.add_column('api_usage_logs', sa.Column('refund_amount', sa.Float(), nullable=False, server_default='0.0', comment='退回金额'))
|
|
||||||
op.add_column('api_usage_logs', sa.Column('quota_before', sa.Float(), nullable=True, comment='操作前配额余额'))
|
|
||||||
op.add_column('api_usage_logs', sa.Column('quota_after', sa.Float(), nullable=True, comment='操作后配额余额'))
|
|
||||||
op.add_column('api_usage_logs', sa.Column('price_detail_json', sa.Text(), nullable=True, comment='价格计算明细JSON'))
|
|
||||||
|
|
||||||
# 添加索引
|
|
||||||
op.create_index('idx_api_usage_logs_action', 'api_usage_logs', ['price_action'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index('idx_api_usage_logs_action', table_name='api_usage_logs')
|
|
||||||
op.drop_column('api_usage_logs', 'price_detail_json')
|
|
||||||
op.drop_column('api_usage_logs', 'quota_after')
|
|
||||||
op.drop_column('api_usage_logs', 'quota_before')
|
|
||||||
op.drop_column('api_usage_logs', 'refund_amount')
|
|
||||||
op.drop_column('api_usage_logs', 'duration')
|
|
||||||
op.drop_column('api_usage_logs', 'resolution')
|
|
||||||
op.drop_column('api_usage_logs', 'price_action')
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
"""add local_media_json to api_generation_tasks
|
|
||||||
|
|
||||||
Revision ID: f7g8h9i0j1k2
|
|
||||||
Revises: e6f7g8h9i0j1
|
|
||||||
Create Date: 2026-07-29 18:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'f7g8h9i0j1k2'
|
|
||||||
down_revision: Union[str, None] = 'e6f7g8h9i0j1'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
'api_generation_tasks',
|
|
||||||
sa.Column('local_media_json', sa.Text, nullable=True, comment='下载到本地的媒体文件路径JSON'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column('api_generation_tasks', 'local_media_json')
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from app.admin_api.api_keys.routes import router
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_admin_user, get_db
|
|
||||||
from app.models.api.api_key import ApiKey
|
|
||||||
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
|
|
||||||
from app.models.api.api_usage_log import ApiUsageLog
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.admin_api.api_key import (
|
|
||||||
ApiKeyCallableModel,
|
|
||||||
ApiKeyCreateRequest,
|
|
||||||
ApiKeyCreateResponse,
|
|
||||||
ApiKeyListItem,
|
|
||||||
ApiKeyListOut,
|
|
||||||
ApiKeyQuotaAdjustRequest,
|
|
||||||
ApiKeyRevealResponse,
|
|
||||||
ApiKeyResponse,
|
|
||||||
ApiKeyUpdateRequest,
|
|
||||||
)
|
|
||||||
from app.schemas.admin_api.api_upscale import (
|
|
||||||
ApiUpscaleConfigData,
|
|
||||||
ApiUpscaleConfigResponse,
|
|
||||||
ApiUpscaleConfigSaveRequest,
|
|
||||||
)
|
|
||||||
from app.schemas.admin_api.api_usage import ApiUsageLogResponse, ApiUsageSummaryResponse
|
|
||||||
from app.services.api_v3 import (
|
|
||||||
key_service,
|
|
||||||
upscale_service,
|
|
||||||
usage_log_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin/api-keys", tags=["admin-api-keys"])
|
|
||||||
|
|
||||||
|
|
||||||
def _key_to_list_item(key: ApiKey) -> ApiKeyListItem:
|
|
||||||
"""将 ApiKey 模型转为列表项 Schema。"""
|
|
||||||
try:
|
|
||||||
callable_models = json.loads(key.callable_models) if key.callable_models else []
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
callable_models = []
|
|
||||||
|
|
||||||
return ApiKeyListItem(
|
|
||||||
id=key.id,
|
|
||||||
company_name=key.company_name,
|
|
||||||
api_key_prefix=f"{key.api_key_prefix}****",
|
|
||||||
description=key.description,
|
|
||||||
callable_models=[ApiKeyCallableModel(**m) for m in callable_models],
|
|
||||||
quota_limit=key.quota_limit,
|
|
||||||
quota_cycle=key.quota_cycle,
|
|
||||||
quota_used=key.quota_used,
|
|
||||||
is_active=key.is_active,
|
|
||||||
valid_from=key.valid_from,
|
|
||||||
valid_until=key.valid_until,
|
|
||||||
max_concurrent_video_tasks=key.max_concurrent_video_tasks,
|
|
||||||
last_used_at=key.last_used_at,
|
|
||||||
created_at=key.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# === API Key CRUD ===
|
|
||||||
|
|
||||||
@router.get("", response_model=ApiKeyListOut, summary="列出 API Key")
|
|
||||||
async def list_keys(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
company_name: str | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiKeyListOut:
|
|
||||||
"""列出所有 API Key(分页+筛选)。"""
|
|
||||||
total, keys = await key_service.list_api_keys(
|
|
||||||
db, skip=skip, limit=limit,
|
|
||||||
company_name=company_name, is_active=is_active,
|
|
||||||
)
|
|
||||||
return ApiKeyListOut(
|
|
||||||
total=total,
|
|
||||||
items=[_key_to_list_item(k) for k in keys],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"",
|
|
||||||
response_model=ApiKeyCreateResponse,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="创建 API Key",
|
|
||||||
)
|
|
||||||
async def create_key(
|
|
||||||
req: ApiKeyCreateRequest,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiKeyCreateResponse:
|
|
||||||
"""创建新的 API Key。
|
|
||||||
|
|
||||||
返回包含完整明文 api_key,仅此一次。
|
|
||||||
"""
|
|
||||||
callable_models = [m.model_dump() for m in req.callable_models] if req.callable_models else []
|
|
||||||
|
|
||||||
key, raw_key = await key_service.create_api_key(
|
|
||||||
db=db,
|
|
||||||
company_name=req.company_name,
|
|
||||||
callable_models=callable_models,
|
|
||||||
quota_limit=req.quota_limit,
|
|
||||||
quota_cycle=req.quota_cycle,
|
|
||||||
valid_from=req.valid_from,
|
|
||||||
valid_until=req.valid_until,
|
|
||||||
max_concurrent_video_tasks=req.max_concurrent_video_tasks,
|
|
||||||
description=req.description,
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return ApiKeyCreateResponse(
|
|
||||||
id=key.id,
|
|
||||||
company_name=key.company_name,
|
|
||||||
api_key=raw_key,
|
|
||||||
api_key_prefix=key.api_key_prefix,
|
|
||||||
valid_until=key.valid_until,
|
|
||||||
created_at=key.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key_id}/reveal", response_model=ApiKeyRevealResponse, summary="揭秘 API Key")
|
|
||||||
async def reveal_key(
|
|
||||||
key_id: str = Path(..., description="API Key ID"),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiKeyRevealResponse:
|
|
||||||
"""揭秘 API Key(随时可获取完整明文 Key)。"""
|
|
||||||
key = await key_service.get_api_key(db, key_id)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
|
||||||
|
|
||||||
plaintext = key.decrypt_api_key()
|
|
||||||
if not plaintext:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="该 API Key 创建时未启用加密存储,无法揭秘。请重新创建 Key。",
|
|
||||||
)
|
|
||||||
|
|
||||||
return ApiKeyRevealResponse(
|
|
||||||
id=key.id,
|
|
||||||
company_name=key.company_name,
|
|
||||||
api_key=plaintext,
|
|
||||||
api_key_prefix=key.api_key_prefix,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key_id}", response_model=ApiKeyListItem, summary="获取 API Key 详情")
|
|
||||||
async def get_key(
|
|
||||||
key_id: str = Path(..., description="API Key ID"),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiKeyListItem:
|
|
||||||
"""获取单个 API Key 详情。"""
|
|
||||||
key = await key_service.get_api_key(db, key_id)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
|
||||||
return _key_to_list_item(key)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{key_id}", response_model=ApiKeyListItem, summary="更新 API Key")
|
|
||||||
async def update_key(
|
|
||||||
req: ApiKeyUpdateRequest,
|
|
||||||
key_id: str = Path(...),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiKeyResponse:
|
|
||||||
"""更新 API Key 配置。"""
|
|
||||||
key = await key_service.get_api_key(db, key_id)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
|
||||||
|
|
||||||
# model_dump 默认输出 snake_case 字段名,直接传给 service
|
|
||||||
update_data = req.model_dump(exclude_none=True)
|
|
||||||
if "callable_models" in update_data and update_data["callable_models"] is not None:
|
|
||||||
update_data["callable_models"] = [
|
|
||||||
m.model_dump() if hasattr(m, "model_dump") else m
|
|
||||||
for m in update_data["callable_models"]
|
|
||||||
]
|
|
||||||
|
|
||||||
key = await key_service.update_api_key(db, key, **update_data)
|
|
||||||
await db.commit()
|
|
||||||
return _key_to_list_item(key)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key_id}", summary="删除 API Key")
|
|
||||||
async def delete_key(
|
|
||||||
key_id: str = Path(...),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""软删除 API Key。"""
|
|
||||||
key = await key_service.get_api_key(db, key_id)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
|
||||||
|
|
||||||
await key_service.delete_api_key(db, key)
|
|
||||||
await db.commit()
|
|
||||||
return {"status": "deleted", "id": key_id}
|
|
||||||
|
|
||||||
|
|
||||||
# === 超分配置 ===
|
|
||||||
|
|
||||||
@router.get("/{key_id}/upscale", response_model=ApiUpscaleConfigResponse, summary="获取 API Key 超分配置")
|
|
||||||
async def get_upscale_config(
|
|
||||||
key_id: str = Path(...),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiUpscaleConfigResponse:
|
|
||||||
"""获取 API Key 的超分配置。"""
|
|
||||||
config = await upscale_service.get_or_create_upscale_config(db, key_id)
|
|
||||||
try:
|
|
||||||
rules = json.loads(config.rules_json) if config.rules_json else []
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
rules = []
|
|
||||||
|
|
||||||
return ApiUpscaleConfigResponse(
|
|
||||||
data=ApiUpscaleConfigData(
|
|
||||||
enabled=config.enabled,
|
|
||||||
delete_source_after_success=config.delete_source_after_success,
|
|
||||||
rules=rules,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{key_id}/upscale", response_model=ApiUpscaleConfigResponse, summary="保存 API Key 超分配置")
|
|
||||||
async def save_upscale_config(
|
|
||||||
req: ApiUpscaleConfigSaveRequest,
|
|
||||||
key_id: str = Path(...),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiUpscaleConfigResponse:
|
|
||||||
"""保存 API Key 的超分配置。"""
|
|
||||||
config = await upscale_service.save_upscale_config(
|
|
||||||
db=db,
|
|
||||||
api_key_id=key_id,
|
|
||||||
enabled=req.data.enabled,
|
|
||||||
delete_source_after_success=req.data.delete_source_after_success,
|
|
||||||
rules=[r.model_dump() for r in req.data.rules],
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return ApiUpscaleConfigResponse(
|
|
||||||
data=ApiUpscaleConfigData(
|
|
||||||
enabled=config.enabled,
|
|
||||||
delete_source_after_success=config.delete_source_after_success,
|
|
||||||
rules=json.loads(config.rules_json) if config.rules_json else [],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# === 使用日志 ===
|
|
||||||
|
|
||||||
@router.get("/{key_id}/usage", response_model=ApiUsageSummaryResponse, summary="获取 API Key 使用统计")
|
|
||||||
async def get_usage(
|
|
||||||
key_id: str = Path(...),
|
|
||||||
days: int = Query(30, ge=1, le=365),
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiUsageSummaryResponse:
|
|
||||||
"""获取 API Key 的使用统计和明细。"""
|
|
||||||
# 验证 key 存在
|
|
||||||
key = await key_service.get_api_key(db, key_id)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
|
||||||
|
|
||||||
summary = await usage_log_service.get_usage_summary(db, api_key_id=key_id, days=days)
|
|
||||||
total, logs = await usage_log_service.list_usage_logs(db, api_key_id=key_id, limit=page_size, skip=(page - 1) * page_size)
|
|
||||||
|
|
||||||
return ApiUsageSummaryResponse(
|
|
||||||
total_requests=summary["total_requests"],
|
|
||||||
total_credits_cost=summary["total_credits_cost"],
|
|
||||||
total_tokens_used=summary["total_tokens_used"],
|
|
||||||
success_count=summary["success_count"],
|
|
||||||
failed_count=summary["failed_count"],
|
|
||||||
avg_duration_ms=summary["avg_duration_ms"],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
items=[
|
|
||||||
ApiUsageLogResponse(
|
|
||||||
id=log.id,
|
|
||||||
api_key_id=log.api_key_id,
|
|
||||||
api_generation_task_id=log.api_generation_task_id,
|
|
||||||
request_type=log.request_type,
|
|
||||||
model_name=log.model_name,
|
|
||||||
gen_type=log.gen_type,
|
|
||||||
credits_cost=log.credits_cost,
|
|
||||||
tokens_used=log.tokens_used,
|
|
||||||
request_duration_ms=log.request_duration_ms,
|
|
||||||
status=log.status,
|
|
||||||
error_message=log.error_message,
|
|
||||||
error_code=log.error_code,
|
|
||||||
created_at=log.created_at,
|
|
||||||
)
|
|
||||||
for log in logs
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# === 整体消耗列表 ===
|
|
||||||
|
|
||||||
@router.get("/usage/all", response_model=dict, summary="获取整体 API 消耗列表")
|
|
||||||
async def list_all_usage(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
api_key_id: str | None = None,
|
|
||||||
gen_type: str | None = None,
|
|
||||||
status_filter: str | None = Query(None, alias="status"),
|
|
||||||
start_date: datetime | None = None,
|
|
||||||
end_date: datetime | None = None,
|
|
||||||
search: str | None = None,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""获取整体 API 消耗列表(跨所有 Key,支持筛选和分页)。"""
|
|
||||||
# 构建查询
|
|
||||||
query = select(ApiUsageLog, ApiKey.company_name, ApiKey.api_key_prefix).join(
|
|
||||||
ApiKey, ApiUsageLog.api_key_id == ApiKey.id
|
|
||||||
)
|
|
||||||
count_query = select(func.count(ApiUsageLog.id)).join(
|
|
||||||
ApiKey, ApiUsageLog.api_key_id == ApiKey.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# 筛选条件
|
|
||||||
filters = []
|
|
||||||
if api_key_id:
|
|
||||||
filters.append(ApiUsageLog.api_key_id == api_key_id)
|
|
||||||
if gen_type:
|
|
||||||
filters.append(ApiUsageLog.gen_type == gen_type)
|
|
||||||
if status_filter:
|
|
||||||
filters.append(ApiUsageLog.status == status_filter)
|
|
||||||
if start_date:
|
|
||||||
filters.append(ApiUsageLog.created_at >= start_date)
|
|
||||||
if end_date:
|
|
||||||
filters.append(ApiUsageLog.created_at <= end_date)
|
|
||||||
if search:
|
|
||||||
search_pattern = f"%{search}%"
|
|
||||||
filters.append(
|
|
||||||
(ApiKey.company_name.ilike(search_pattern))
|
|
||||||
| (ApiKey.api_key_prefix.ilike(search_pattern))
|
|
||||||
)
|
|
||||||
|
|
||||||
for f in filters:
|
|
||||||
query = query.where(f)
|
|
||||||
count_query = count_query.where(f)
|
|
||||||
|
|
||||||
# 总数
|
|
||||||
total_result = await db.execute(count_query)
|
|
||||||
total = total_result.scalar_one()
|
|
||||||
|
|
||||||
# 分页查询
|
|
||||||
query = query.order_by(ApiUsageLog.created_at.desc()).offset(skip).limit(limit)
|
|
||||||
result = await db.execute(query)
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for log, company_name, key_prefix in rows:
|
|
||||||
items.append({
|
|
||||||
"id": log.id,
|
|
||||||
"apiKeyId": log.api_key_id,
|
|
||||||
"companyName": company_name,
|
|
||||||
"apiKeyPrefix": f"{key_prefix}****" if key_prefix else None,
|
|
||||||
"taskId": log.api_generation_task_id,
|
|
||||||
"requestType": log.request_type,
|
|
||||||
"modelName": log.model_name,
|
|
||||||
"genType": log.gen_type,
|
|
||||||
"creditsCost": log.credits_cost,
|
|
||||||
"tokensUsed": log.tokens_used,
|
|
||||||
"requestDurationMs": log.request_duration_ms,
|
|
||||||
"duration": log.duration,
|
|
||||||
"resolution": log.resolution,
|
|
||||||
"status": log.status,
|
|
||||||
"errorMessage": log.error_message,
|
|
||||||
"errorCode": log.error_code,
|
|
||||||
"createdAt": log.created_at.isoformat() if log.created_at else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total,
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{key_id}/quota-adjust", response_model=ApiKeyListItem, summary="调整 API Key 配额")
|
|
||||||
async def quota_adjust(
|
|
||||||
req: ApiKeyQuotaAdjustRequest,
|
|
||||||
key_id: str = Path(..., description="API Key ID"),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiKeyListItem:
|
|
||||||
"""调整 API Key 配额(增加总额/重置已用/设置限额/修改周期)。"""
|
|
||||||
key = await key_service.get_api_key(db, key_id)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
|
||||||
|
|
||||||
key, changes = await key_service.adjust_quota(
|
|
||||||
db,
|
|
||||||
key,
|
|
||||||
action=req.action,
|
|
||||||
quota_limit_delta=req.quota_limit_delta,
|
|
||||||
quota_limit=req.quota_limit,
|
|
||||||
quota_cycle=req.quota_cycle,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 审计日志
|
|
||||||
try:
|
|
||||||
from app.services.operation_log import log_operation
|
|
||||||
await log_operation(
|
|
||||||
db=db,
|
|
||||||
user_id=str(admin.id),
|
|
||||||
username=str(admin.username),
|
|
||||||
action=f"quota_adjust:{req.action}",
|
|
||||||
method="POST",
|
|
||||||
path=f"/admin/api-keys/{key_id}/quota-adjust",
|
|
||||||
detail=json.dumps(
|
|
||||||
{**changes, "reason": req.reason},
|
|
||||||
ensure_ascii=False,
|
|
||||||
default=str,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as log_exc:
|
|
||||||
logger.warning("配额调整审计日志记录失败: %s", log_exc)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return _key_to_list_item(key)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from app.admin_api.api_model_pricings.routes import router
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_admin_user, get_db
|
|
||||||
from app.models.api.api_model_pricing import ApiModelPricing
|
|
||||||
from app.models.image_engine import ImageEngine
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.video_engine import VideoEngine
|
|
||||||
from app.schemas.admin_api.api_model_pricing import ApiModelPricingCreate, ApiModelPricingOut
|
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin/api-model-pricings", tags=["admin-api-model-pricings"])
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_pricing_engine(db: AsyncSession, req: ApiModelPricingCreate) -> None:
|
|
||||||
"""校验定价规则绑定的引擎是否存在。"""
|
|
||||||
gen_type = (req.gen_type or "").lower().strip()
|
|
||||||
engine_id = (req.model_config_id or "").strip()
|
|
||||||
if gen_type not in ("image", "video"):
|
|
||||||
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
|
|
||||||
if not engine_id:
|
|
||||||
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
|
|
||||||
|
|
||||||
model = ImageEngine if gen_type == "image" else VideoEngine
|
|
||||||
result = await db.execute(
|
|
||||||
select(model).where(model.id == engine_id, model.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
|
||||||
engine = result.scalar_one_or_none()
|
|
||||||
if not engine:
|
|
||||||
detail = "图片定价规则绑定的图片引擎不存在" if gen_type == "image" else "视频定价规则绑定的视频引擎不存在"
|
|
||||||
raise HTTPException(status_code=400, detail=detail)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ApiModelPricingOut])
|
|
||||||
async def list_pricings(
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""列出所有 API 模型价格。"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiModelPricing).order_by(
|
|
||||||
ApiModelPricing.gen_type.desc(),
|
|
||||||
ApiModelPricing.model_config_id.desc(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ApiModelPricingOut)
|
|
||||||
async def create_pricing(
|
|
||||||
req: ApiModelPricingCreate,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""创建 API 模型价格。"""
|
|
||||||
await _validate_pricing_engine(db, req)
|
|
||||||
data = req.model_dump()
|
|
||||||
data["gen_type"] = data["gen_type"].lower().strip()
|
|
||||||
data["model_config_id"] = data["model_config_id"].strip()
|
|
||||||
pricing = ApiModelPricing(id=generate_id(), **data)
|
|
||||||
db.add(pricing)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(pricing)
|
|
||||||
return pricing
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{pricing_id}", response_model=ApiModelPricingOut)
|
|
||||||
async def update_pricing(
|
|
||||||
pricing_id: str,
|
|
||||||
req: ApiModelPricingCreate,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""更新 API 模型价格。"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiModelPricing).where(ApiModelPricing.id == pricing_id).limit(1)
|
|
||||||
)
|
|
||||||
pricing = result.scalar_one_or_none()
|
|
||||||
if not pricing:
|
|
||||||
raise HTTPException(status_code=404, detail="定价规则不存在")
|
|
||||||
await _validate_pricing_engine(db, req)
|
|
||||||
data = req.model_dump()
|
|
||||||
data["gen_type"] = data["gen_type"].lower().strip()
|
|
||||||
data["model_config_id"] = data["model_config_id"].strip()
|
|
||||||
for k, v in data.items():
|
|
||||||
setattr(pricing, k, v)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(pricing)
|
|
||||||
return pricing
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{pricing_id}")
|
|
||||||
async def delete_pricing(
|
|
||||||
pricing_id: str,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""删除 API 模型价格。"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiModelPricing).where(ApiModelPricing.id == pricing_id).limit(1)
|
|
||||||
)
|
|
||||||
pricing = result.scalar_one_or_none()
|
|
||||||
if not pricing:
|
|
||||||
raise HTTPException(status_code=404, detail="定价规则不存在")
|
|
||||||
await db.delete(pricing)
|
|
||||||
await db.commit()
|
|
||||||
return {"message": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/grouped", response_model=dict)
|
|
||||||
async def list_pricings_grouped(
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""按 gen_type 分组列出价格。"""
|
|
||||||
result = await db.execute(select(ApiModelPricing))
|
|
||||||
pricings = result.scalars().all()
|
|
||||||
|
|
||||||
grouped = {}
|
|
||||||
for pricing in pricings:
|
|
||||||
if pricing.gen_type not in grouped:
|
|
||||||
grouped[pricing.gen_type] = []
|
|
||||||
grouped[pricing.gen_type].append(ApiModelPricingOut.model_validate(pricing))
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from app.admin_api.vp_v3_quota.routes import router
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_admin_user, get_db
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.virtual_portrait_v3.api_key_quota import VpV3ApiKeyQuota
|
|
||||||
from app.schemas.admin_api.vp_v3_quota import (
|
|
||||||
VpV3QuotaConfigData,
|
|
||||||
VpV3QuotaConfigResponse,
|
|
||||||
)
|
|
||||||
from app.services.api_v3 import key_service
|
|
||||||
from app.services.virtual_portrait_v3.quota_service import get_quota
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin/api-keys", tags=["admin-vp-v3-quota"])
|
|
||||||
|
|
||||||
|
|
||||||
def _to_response(quota: VpV3ApiKeyQuota) -> VpV3QuotaConfigResponse:
|
|
||||||
enabled = any([
|
|
||||||
(quota.project_limit or 0) > 0,
|
|
||||||
(quota.asset_limit or 0) > 0,
|
|
||||||
(quota.storage_mb_limit or 0) > 0,
|
|
||||||
])
|
|
||||||
return VpV3QuotaConfigResponse(
|
|
||||||
api_key_id=quota.api_key_id,
|
|
||||||
project_limit=int(quota.project_limit or 0),
|
|
||||||
asset_limit=int(quota.asset_limit or 0),
|
|
||||||
storage_mb_limit=int(quota.storage_mb_limit or 0),
|
|
||||||
remark=quota.remark,
|
|
||||||
project_used=int(quota.project_used or 0),
|
|
||||||
asset_used=int(quota.asset_used or 0),
|
|
||||||
storage_mb_used=float(quota.storage_mb_used or 0),
|
|
||||||
enabled=enabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{key_id}/vp-v3-quota",
|
|
||||||
response_model=VpV3QuotaConfigResponse,
|
|
||||||
summary="获取 API Key 的虚拟素材库配额配置",
|
|
||||||
description="返回指定 API Key 的虚拟素材库配额上限及当前使用量。不存在配额记录时自动创建默认 0 值。",
|
|
||||||
)
|
|
||||||
async def get_vp_v3_quota(
|
|
||||||
key_id: str = Path(..., description="API Key ID"),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> VpV3QuotaConfigResponse:
|
|
||||||
key = await key_service.get_api_key(db, key_id)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
|
||||||
quota = await get_quota(db, api_key_id=key_id, refresh=True)
|
|
||||||
await db.commit()
|
|
||||||
return _to_response(quota)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{key_id}/vp-v3-quota",
|
|
||||||
response_model=VpV3QuotaConfigResponse,
|
|
||||||
summary="保存 API Key 的虚拟素材库配额配置",
|
|
||||||
description="保存虚拟素材库配额(项目数/素材数/存储 MB),默认 0=不可使用该功能。保存后自动刷新已使用量。",
|
|
||||||
)
|
|
||||||
async def save_vp_v3_quota(
|
|
||||||
payload: VpV3QuotaConfigData,
|
|
||||||
key_id: str = Path(..., description="API Key ID"),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> VpV3QuotaConfigResponse:
|
|
||||||
key = await key_service.get_api_key(db, key_id)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
|
||||||
quota = await get_quota(db, api_key_id=key_id, refresh=True)
|
|
||||||
quota.project_limit = int(payload.project_limit or 0)
|
|
||||||
quota.asset_limit = int(payload.asset_limit or 0)
|
|
||||||
quota.storage_mb_limit = int(payload.storage_mb_limit or 0)
|
|
||||||
quota.remark = payload.remark if payload.remark is not None else quota.remark
|
|
||||||
await db.flush()
|
|
||||||
await db.refresh(quota)
|
|
||||||
await db.commit()
|
|
||||||
return _to_response(quota)
|
|
||||||
@@ -10,9 +10,6 @@ from app.api.admin.recharge_package import router as recharge_package_router
|
|||||||
from app.api.admin.menu_config import router as menu_config_router
|
from app.api.admin.menu_config import router as menu_config_router
|
||||||
from app.api.admin.upload import router as admin_upload_router
|
from app.api.admin.upload import router as admin_upload_router
|
||||||
from app.api.admin.contact import router as admin_contact_router
|
from app.api.admin.contact import router as admin_contact_router
|
||||||
from app.admin_api.api_keys import router as api_keys_admin_router
|
|
||||||
from app.admin_api.api_model_pricings import router as api_model_pricings_admin_router
|
|
||||||
from app.admin_api.vp_v3_quota import router as vp_v3_quota_admin_router
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(video_prompt_schema_config_router)
|
router.include_router(video_prompt_schema_config_router)
|
||||||
@@ -25,6 +22,3 @@ router.include_router(recharge_package_router)
|
|||||||
router.include_router(menu_config_router)
|
router.include_router(menu_config_router)
|
||||||
router.include_router(admin_upload_router)
|
router.include_router(admin_upload_router)
|
||||||
router.include_router(admin_contact_router)
|
router.include_router(admin_contact_router)
|
||||||
router.include_router(api_keys_admin_router)
|
|
||||||
router.include_router(api_model_pricings_admin_router)
|
|
||||||
router.include_router(vp_v3_quota_admin_router)
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import and_, case, delete, func, or_, select, update
|
from sqlalchemy import delete, func, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dependencies import get_db, get_admin_user
|
from app.dependencies import get_db, get_admin_user
|
||||||
@@ -1696,62 +1696,17 @@ async def update_system_config(
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@router.post("/system-configs/banner/reset", summary="重置活动横幅展示")
|
|
||||||
async def reset_banner(
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""递增 site_banner_version,使所有用户再次看到横幅。"""
|
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
result = await db.execute(select(SystemConfig).where(SystemConfig.key == "site_banner_version").limit(1))
|
|
||||||
config = result.scalar_one_or_none()
|
|
||||||
new_version = 1
|
|
||||||
if config:
|
|
||||||
try:
|
|
||||||
new_version = int(config.value or 0) + 1
|
|
||||||
except ValueError:
|
|
||||||
new_version = 1
|
|
||||||
config.value = str(new_version)
|
|
||||||
else:
|
|
||||||
config = SystemConfig(
|
|
||||||
id=generate_id(),
|
|
||||||
key="site_banner_version",
|
|
||||||
value=str(new_version),
|
|
||||||
description="活动横幅版本号,递增后所有用户重新看到横幅",
|
|
||||||
)
|
|
||||||
db.add(config)
|
|
||||||
await db.flush()
|
|
||||||
await log_operation(
|
|
||||||
db,
|
|
||||||
admin.id,
|
|
||||||
admin.username,
|
|
||||||
f"重置活动横幅 (版本 → {new_version})",
|
|
||||||
"POST",
|
|
||||||
"/admin/system-configs/banner/reset",
|
|
||||||
detail=json.dumps({"new_version": new_version}),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await invalidate_system_config_cache(["site_banner_version"])
|
|
||||||
return {"site_banner_version": new_version}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Operation Logs ──────────────────────────────────────
|
# ── Operation Logs ──────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/operation-logs")
|
@router.get("/operation-logs")
|
||||||
async def list_operation_logs(
|
async def list_operation_logs(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=500),
|
page_size: int = Query(20, ge=1, le=500),
|
||||||
action: str | None = Query(None, description="按 action 过滤(前缀匹配)"),
|
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
query = select(OperationLog).order_by(OperationLog.created_at.desc())
|
query = select(OperationLog).order_by(OperationLog.created_at.desc())
|
||||||
count_query = select(func.count(OperationLog.id))
|
count_query = select(func.count(OperationLog.id))
|
||||||
|
|
||||||
if action:
|
|
||||||
query = query.where(OperationLog.action.like(f"{action}%"))
|
|
||||||
count_query = count_query.where(OperationLog.action.like(f"{action}%"))
|
|
||||||
|
|
||||||
total = (await db.execute(count_query)).scalar() or 0
|
total = (await db.execute(count_query)).scalar() or 0
|
||||||
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
||||||
items = result.scalars().all()
|
items = result.scalars().all()
|
||||||
@@ -1794,26 +1749,17 @@ async def get_stats(
|
|||||||
):
|
):
|
||||||
today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
date_start: datetime
|
|
||||||
date_end: datetime
|
|
||||||
try:
|
try:
|
||||||
if start_date:
|
if start_date:
|
||||||
date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
|
date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
|
||||||
else:
|
else:
|
||||||
date_start = today_start
|
date_start = today_start
|
||||||
if end_date:
|
if end_date:
|
||||||
# 先构造完整的 naive 日期时刻,再一次性 attach tzinfo(避免分步 replace 丢 tzinfo)
|
date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST)
|
||||||
naive_end = datetime.strptime(end_date, "%Y-%m-%d").replace(
|
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
hour=23, minute=59, second=59, microsecond=999999,
|
|
||||||
)
|
|
||||||
date_end = naive_end.replace(tzinfo=CST)
|
|
||||||
else:
|
else:
|
||||||
date_end = datetime.now(CST)
|
date_end = datetime.now(CST)
|
||||||
# 合法性:end >= start
|
except:
|
||||||
if date_end < date_start:
|
|
||||||
date_end = date_start.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
# 只拦截日期解析错误,不吞掉 SQL/运行时异常(原裸 except 会吞所有错误导致用户看不到报错)
|
|
||||||
date_start = today_start
|
date_start = today_start
|
||||||
date_end = datetime.now(CST)
|
date_end = datetime.now(CST)
|
||||||
|
|
||||||
@@ -1856,45 +1802,20 @@ async def get_stats(
|
|||||||
)
|
)
|
||||||
)).scalar() or 0
|
)).scalar() or 0
|
||||||
|
|
||||||
# 消费类(真实扣费 + 预扣占用):charge_action 为空时仍按真实扣费兼容;hold 为预扣占用。
|
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。
|
||||||
credit_charge_action_filter = or_(
|
|
||||||
CreditRecord.charge_action.is_(None),
|
|
||||||
CreditRecord.charge_action == "charge",
|
|
||||||
CreditRecord.charge_action == "hold",
|
|
||||||
)
|
|
||||||
# 「仅真实扣费」filter 用于图表、模型使用次数等需要按实际产出(非预扣)统计的场景。
|
|
||||||
real_credit_charge_filter = or_(
|
real_credit_charge_filter = or_(
|
||||||
CreditRecord.charge_action.is_(None),
|
CreditRecord.charge_action.is_(None),
|
||||||
CreditRecord.charge_action == "charge",
|
CreditRecord.charge_action == "charge",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 核心数据「消耗积分」= 净消耗 = 真实消费 + 预扣占用 - 真实退款 - 预扣释放。
|
credits_consumed = (await db.execute(
|
||||||
# 说明:
|
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||||
# hold(预扣占用):type=consume,charge_action='hold',amount<0
|
CreditRecord.type == "consume",
|
||||||
# hold_release(预扣释放退回):type=refund,charge_action='hold_release',amount>0
|
real_credit_charge_filter,
|
||||||
# (账本 L256 强校验:hold_release.type 必须是 'refund',不是 consume)
|
|
||||||
# charge(真实扣费):type=consume,charge_action='charge' 或 NULL(历史),amount<0
|
|
||||||
# refund(真实退款):type=refund,charge_action='refund' 或 NULL(历史兼容),amount>0
|
|
||||||
# 因此 type=refund 天然包含「真实退款 + 预扣释放退回」两类子流水。
|
|
||||||
_stats_real_and_hold = case(
|
|
||||||
(and_(CreditRecord.type == "consume", credit_charge_action_filter), func.abs(CreditRecord.amount)),
|
|
||||||
else_=0,
|
|
||||||
)
|
|
||||||
_stats_refund_and_release = case(
|
|
||||||
(CreditRecord.type == "refund", func.abs(CreditRecord.amount)),
|
|
||||||
else_=0,
|
|
||||||
)
|
|
||||||
_net_row = (await db.execute(
|
|
||||||
select(
|
|
||||||
func.coalesce(func.sum(_stats_real_and_hold), 0),
|
|
||||||
func.coalesce(func.sum(_stats_refund_and_release), 0),
|
|
||||||
).where(
|
|
||||||
CreditRecord.type.in_(["consume", "refund"]),
|
|
||||||
CreditRecord.created_at >= date_start,
|
CreditRecord.created_at >= date_start,
|
||||||
CreditRecord.created_at <= date_end,
|
CreditRecord.created_at <= date_end,
|
||||||
)
|
)
|
||||||
)).one()
|
)).scalar() or 0
|
||||||
credits_consumed = round(max(float(_net_row[0] or 0) - float(_net_row[1] or 0), 0.0), 2)
|
|
||||||
|
|
||||||
alipay_revenue = (await db.execute(
|
alipay_revenue = (await db.execute(
|
||||||
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
|
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
|
||||||
@@ -1958,32 +1879,21 @@ async def get_stats(
|
|||||||
)
|
)
|
||||||
)).scalar() or 0
|
)).scalar() or 0
|
||||||
|
|
||||||
last_period_net_row = (await db.execute(
|
last_period_credits_consumed = (await db.execute(
|
||||||
select(
|
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||||
func.coalesce(func.sum(_stats_real_and_hold), 0),
|
CreditRecord.type == "consume",
|
||||||
func.coalesce(func.sum(_stats_refund_and_release), 0),
|
real_credit_charge_filter,
|
||||||
).where(
|
|
||||||
CreditRecord.type.in_(["consume", "refund"]),
|
|
||||||
CreditRecord.created_at >= last_period_start,
|
CreditRecord.created_at >= last_period_start,
|
||||||
CreditRecord.created_at <= last_period_end,
|
CreditRecord.created_at <= last_period_end,
|
||||||
)
|
)
|
||||||
)).one()
|
)).scalar() or 0
|
||||||
last_period_credits_consumed = round(
|
|
||||||
max(float(last_period_net_row[0] or 0) - float(last_period_net_row[1] or 0), 0.0), 2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
|
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
|
||||||
# 把 timestamptz 按东八区(业务时区)偏移后再转 DATE,
|
|
||||||
# 直接手动 +8 小时再 CAST 成日期,简单稳妥,不依赖数据库时区名配置。
|
|
||||||
# 与代码中 CST = timezone(timedelta(hours=8)) 保持一致。
|
|
||||||
from sqlalchemy import Date, cast as sa_cast
|
from sqlalchemy import Date, cast as sa_cast
|
||||||
_day_expr = sa_cast(CreditRecord.created_at + timedelta(hours=8), Date)
|
_day_expr = sa_cast(CreditRecord.created_at, Date)
|
||||||
# 图表固定展示 [date_end - 6天, date_end] 共7天
|
# 图表固定展示 [date_end - 6天, date_end] 共7天
|
||||||
_chart_end_dt = date_end
|
_chart_end_dt = date_end
|
||||||
_chart_start_dt = datetime(
|
_chart_start_dt = _chart_end_dt - timedelta(days=6)
|
||||||
_chart_end_dt.year, _chart_end_dt.month, _chart_end_dt.day, 0, 0, 0, 0, tzinfo=CST,
|
|
||||||
) - timedelta(days=6)
|
|
||||||
_chart_end_dt_inclusive = _chart_end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
||||||
_inner = (
|
_inner = (
|
||||||
select(
|
select(
|
||||||
_day_expr.label('date'),
|
_day_expr.label('date'),
|
||||||
@@ -1994,7 +1904,7 @@ async def get_stats(
|
|||||||
CreditRecord.type == "consume",
|
CreditRecord.type == "consume",
|
||||||
real_credit_charge_filter,
|
real_credit_charge_filter,
|
||||||
CreditRecord.created_at >= _chart_start_dt,
|
CreditRecord.created_at >= _chart_start_dt,
|
||||||
CreditRecord.created_at <= _chart_end_dt_inclusive,
|
CreditRecord.created_at <= _chart_end_dt,
|
||||||
)
|
)
|
||||||
.group_by(_day_expr, CreditRecord.source_module)
|
.group_by(_day_expr, CreditRecord.source_module)
|
||||||
.subquery()
|
.subquery()
|
||||||
@@ -2038,41 +1948,23 @@ async def get_stats(
|
|||||||
]
|
]
|
||||||
|
|
||||||
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
|
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
|
||||||
# 净消耗 = (真实消费 charge + 预扣占用 hold) - (真实退款 refund + 预扣释放 hold_release)
|
|
||||||
# 注意:
|
|
||||||
# hold(预扣占用):type=consume,charge_action='hold',amount<0 → 加项
|
|
||||||
# hold_release(预扣释放):type=refund,charge_action='hold_release',amount>0 → 减项(type=refund 天然包含)
|
|
||||||
# charge(真实扣费):type=consume,charge/NULL → 加项
|
|
||||||
# refund(真实退款):type=refund,refund/NULL → 减项
|
|
||||||
_charge_hold_filter = and_(
|
|
||||||
CreditRecord.type == "consume",
|
|
||||||
credit_charge_action_filter, # charge / hold / NULL(历史 charge)
|
|
||||||
)
|
|
||||||
_charge_hold_expr = case((_charge_hold_filter, func.abs(CreditRecord.amount)), else_=0)
|
|
||||||
# type=refund = 真实退款 + 预扣释放退回(账本强制 hold_release.type=refund)
|
|
||||||
_refund_release_expr = case((CreditRecord.type == "refund", func.abs(CreditRecord.amount)), else_=0)
|
|
||||||
team_credit_rows = (await db.execute(
|
team_credit_rows = (await db.execute(
|
||||||
select(
|
select(
|
||||||
func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'),
|
func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'),
|
||||||
CreditRecord.team_id_snapshot.label('team_id'),
|
CreditRecord.team_id_snapshot.label('team_id'),
|
||||||
func.coalesce(func.sum(_charge_hold_expr), 0).label("total_charge_hold"),
|
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
|
||||||
func.coalesce(func.sum(_refund_release_expr), 0).label("total_refund_release"),
|
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
CreditRecord.type.in_(["consume", "refund"]),
|
CreditRecord.type == "consume",
|
||||||
|
real_credit_charge_filter,
|
||||||
CreditRecord.created_at >= date_start,
|
CreditRecord.created_at >= date_start,
|
||||||
CreditRecord.created_at <= date_end,
|
CreditRecord.created_at <= date_end,
|
||||||
)
|
)
|
||||||
.group_by(CreditRecord.team_id_snapshot, CreditRecord.team_name_snapshot)
|
.group_by(CreditRecord.team_id_snapshot, CreditRecord.team_name_snapshot)
|
||||||
# 按"净消耗 = 真实+预扣 - 退款+释放"倒序排序(排行榜)
|
.order_by(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).desc())
|
||||||
.order_by((func.coalesce(func.sum(_charge_hold_expr), 0) - func.coalesce(func.sum(_refund_release_expr), 0)).desc())
|
|
||||||
)).all()
|
)).all()
|
||||||
credits_by_team = [
|
credits_by_team = [
|
||||||
TeamCreditOut(
|
TeamCreditOut(team_name=row.team_name, team_id=row.team_id, credits=float(row.credits or 0))
|
||||||
team_name=row.team_name,
|
|
||||||
team_id=row.team_id,
|
|
||||||
credits=round(max(float(row.total_charge_hold or 0) - float(row.total_refund_release or 0), 0.0), 2),
|
|
||||||
)
|
|
||||||
for row in team_credit_rows
|
for row in team_credit_rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
|||||||
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemConfig).where(SystemConfig.key.in_([
|
select(SystemConfig).where(SystemConfig.key.in_([
|
||||||
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits", "site_banner", "site_banner_version"
|
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits"
|
||||||
]))
|
]))
|
||||||
)
|
)
|
||||||
configs = result.scalars().all()
|
configs = result.scalars().all()
|
||||||
@@ -367,8 +367,6 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
|||||||
"operation_manual": info.get("operation_manual", ""),
|
"operation_manual": info.get("operation_manual", ""),
|
||||||
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
|
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
|
||||||
"optimize_hold_credits": int(info.get("optimize_hold_credits") or 5),
|
"optimize_hold_credits": int(info.get("optimize_hold_credits") or 5),
|
||||||
"site_banner": info.get("site_banner", ""),
|
|
||||||
"site_banner_version": int(info.get("site_banner_version") or 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ from app.services.generation.pipeline.db_lock_service import (
|
|||||||
execute_with_lock_timeout,
|
execute_with_lock_timeout,
|
||||||
)
|
)
|
||||||
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
|
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
|
||||||
from app.services.private_portrait.reference_resolver import (
|
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
||||||
batch_resolve_private_portrait_reference_display_urls,
|
|
||||||
resolve_private_portrait_reference_display_urls,
|
|
||||||
resolve_private_portrait_references,
|
|
||||||
)
|
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||||
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
|
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
|
||||||
@@ -409,24 +405,6 @@ async def generate_record_resource(
|
|||||||
else:
|
else:
|
||||||
await get_image_engine(db, record.engine_id)
|
await get_image_engine(db, record.engine_id)
|
||||||
frozen_engine = _frozen_engine_view(record)
|
frozen_engine = _frozen_engine_view(record)
|
||||||
|
|
||||||
# 触发生成前兜底:私域素材在 GenerationRecord 创建时可能未走 resolve_private_portrait_references,
|
|
||||||
# 导致入库 url 存的是前端预览地址而非供应商需要的 asset://。这里强制重新解析,
|
|
||||||
# 确保供应商侧拿到正确的 remote_asset_id / asset:// URI。
|
|
||||||
try:
|
|
||||||
raw_refs = json.loads(record.media_references) if record.media_references else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raw_refs = None
|
|
||||||
if raw_refs:
|
|
||||||
resolved_refs = await resolve_private_portrait_references(
|
|
||||||
db,
|
|
||||||
user_id=user_id_snapshot,
|
|
||||||
media_references=raw_refs,
|
|
||||||
gen_type=record.gen_type,
|
|
||||||
)
|
|
||||||
if resolved_refs is not None:
|
|
||||||
record.media_references = json.dumps(resolved_refs, ensure_ascii=False)
|
|
||||||
|
|
||||||
reference_usage = calculate_media_reference_usage(
|
reference_usage = calculate_media_reference_usage(
|
||||||
record.media_references,
|
record.media_references,
|
||||||
include=bool(record.include_media_references),
|
include=bool(record.include_media_references),
|
||||||
@@ -569,22 +547,6 @@ async def retry_generation(
|
|||||||
else:
|
else:
|
||||||
await get_image_engine(db, record.engine_id)
|
await get_image_engine(db, record.engine_id)
|
||||||
frozen_engine = _frozen_engine_view(record)
|
frozen_engine = _frozen_engine_view(record)
|
||||||
|
|
||||||
# 重试前兜底:私域素材 url 可能仍然是预览地址,重新解析确保供应商拿到 asset://
|
|
||||||
try:
|
|
||||||
raw_refs = json.loads(record.media_references) if record.media_references else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raw_refs = None
|
|
||||||
if raw_refs:
|
|
||||||
resolved_refs = await resolve_private_portrait_references(
|
|
||||||
db,
|
|
||||||
user_id=user_id_snapshot,
|
|
||||||
media_references=raw_refs,
|
|
||||||
gen_type=record.gen_type,
|
|
||||||
)
|
|
||||||
if resolved_refs is not None:
|
|
||||||
record.media_references = json.dumps(resolved_refs, ensure_ascii=False)
|
|
||||||
|
|
||||||
reference_usage = calculate_media_reference_usage(
|
reference_usage = calculate_media_reference_usage(
|
||||||
record.media_references,
|
record.media_references,
|
||||||
include=bool(record.include_media_references),
|
include=bool(record.include_media_references),
|
||||||
|
|||||||
@@ -379,41 +379,15 @@ async def export_team_credit_records(
|
|||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 生成 CSV(兼容 Excel 打开,UTF-8 BOM)
|
# 生成 CSV(兼容 Excel 打开)
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from datetime import datetime as _dt
|
|
||||||
|
|
||||||
def _format_dt(val):
|
def _format_dt(val):
|
||||||
if val is None:
|
if val is None:
|
||||||
return "-"
|
return "-"
|
||||||
try:
|
|
||||||
# 情况 1:已经是 datetime
|
return str(datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
if isinstance(val, _dt):
|
|
||||||
dt = val
|
|
||||||
elif isinstance(val, (int, float)):
|
|
||||||
# 情况 2:Unix 时间戳(极少,兼容旧代码)
|
|
||||||
dt = _dt.fromtimestamp(val)
|
|
||||||
elif isinstance(val, str):
|
|
||||||
# 情况 3:ISO 字符串(admin_credit_record_service._iso 返回的格式)
|
|
||||||
s = val.strip()
|
|
||||||
if s.endswith("Z"):
|
|
||||||
s = s[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
dt = _dt.fromisoformat(s)
|
|
||||||
except ValueError:
|
|
||||||
# 兼容旧格式 YYYY-MM-DD HH:MM:SS
|
|
||||||
dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S")
|
|
||||||
else:
|
|
||||||
return str(val)
|
|
||||||
# 统一转东八区展示
|
|
||||||
if getattr(dt, "tzinfo", None) is None:
|
|
||||||
dt = dt.replace(tzinfo=CST)
|
|
||||||
else:
|
|
||||||
dt = dt.astimezone(CST)
|
|
||||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return str(val) if val else "-"
|
|
||||||
|
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
writer = csv.writer(output)
|
writer = csv.writer(output)
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from app.api.v3.videos import router as videos_router
|
|
||||||
from app.api.v3.images import router as images_router
|
|
||||||
from app.api.v3.models import router as models_router
|
|
||||||
from app.api.v3.virtual_portrait import router as virtual_portrait_router
|
|
||||||
|
|
||||||
api_router_v3 = APIRouter()
|
|
||||||
api_router_v3.include_router(models_router)
|
|
||||||
api_router_v3.include_router(videos_router)
|
|
||||||
api_router_v3.include_router(images_router)
|
|
||||||
api_router_v3.include_router(virtual_portrait_router)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class ApiError(BaseModel):
|
|
||||||
"""API 错误详情。"""
|
|
||||||
|
|
||||||
code: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class ApiErrorResponse(BaseModel):
|
|
||||||
"""API 错误响应(旧格式,保留兼容)。"""
|
|
||||||
|
|
||||||
error: ApiError
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_db
|
|
||||||
from app.schemas.api_v3.image import (
|
|
||||||
ApiImageGenerateRequest,
|
|
||||||
ApiImageGenerateResponse,
|
|
||||||
)
|
|
||||||
from app.services.api_v3 import auth_service, generation_service
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/images", tags=["api-v3-images"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"",
|
|
||||||
summary="生成图片",
|
|
||||||
description="同步生成图片,等待完成后直接返回结果",
|
|
||||||
)
|
|
||||||
async def generate_image(
|
|
||||||
req: ApiImageGenerateRequest,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""同步生成图片。"""
|
|
||||||
start_time = time.perf_counter()
|
|
||||||
try:
|
|
||||||
result = await generation_service.generate_image_sync(
|
|
||||||
db=db,
|
|
||||||
key=key_context.api_key,
|
|
||||||
callable_models=key_context.callable_models,
|
|
||||||
req=req,
|
|
||||||
start_time=start_time,
|
|
||||||
)
|
|
||||||
data = result.model_dump()
|
|
||||||
# 处理 datetime 序列化
|
|
||||||
if data.get("created"):
|
|
||||||
data["created"] = data["created"] if isinstance(data["created"], int) else int(data["created"])
|
|
||||||
return JSONResponse(
|
|
||||||
content={"code": 0, "data": data, "message": "ok"},
|
|
||||||
status_code=200,
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("API image generation failed")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
||||||
detail=f"图片生成失败: {str(exc)[:200]}",
|
|
||||||
)
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_db
|
|
||||||
from app.models.image_engine import ImageEngine
|
|
||||||
from app.models.video_engine import VideoEngine
|
|
||||||
from app.schemas.api_v3.model import ApiModelInfo, ApiModelsResponse
|
|
||||||
from app.services.api_v3 import auth_service
|
|
||||||
from app.services.api_v3.pricing_service import get_priced_models
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/models", tags=["api-v3-models"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"",
|
|
||||||
summary="获取可用模型列表",
|
|
||||||
description="获取当前 API Key 可调用的所有视频和图片模型(仅返回已配置价格的模型)",
|
|
||||||
)
|
|
||||||
async def list_models(
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""获取当前 API Key 可用的模型列表。"""
|
|
||||||
models: list[ApiModelInfo] = []
|
|
||||||
|
|
||||||
# 获取所有已配置价格的引擎 ID 集合
|
|
||||||
priced_engine_ids = await get_priced_models(db)
|
|
||||||
|
|
||||||
# 获取 API Key 的白名单引擎 ID 集合
|
|
||||||
allowed_engine_ids = {m.get("engine_id", "") for m in key_context.callable_models} if key_context.callable_models else set()
|
|
||||||
|
|
||||||
# 确定要返回的引擎 ID 列表
|
|
||||||
target_engine_ids = priced_engine_ids if not allowed_engine_ids else (allowed_engine_ids & priced_engine_ids)
|
|
||||||
|
|
||||||
# 构建引擎信息映射
|
|
||||||
engine_info_map = {m.get("engine_id", ""): m for m in key_context.callable_models}
|
|
||||||
|
|
||||||
for engine_id in target_engine_ids:
|
|
||||||
engine_type = engine_info_map.get(engine_id, {}).get("engine_type", "")
|
|
||||||
model_name = engine_info_map.get(engine_id, {}).get("model_name", "")
|
|
||||||
|
|
||||||
# 如果没有从白名单获取到类型,尝试从数据库加载
|
|
||||||
if not engine_type:
|
|
||||||
video_result = await db.execute(
|
|
||||||
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
|
||||||
if video_result.scalar_one_or_none():
|
|
||||||
engine_type = "video"
|
|
||||||
else:
|
|
||||||
image_result = await db.execute(
|
|
||||||
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
|
||||||
if image_result.scalar_one_or_none():
|
|
||||||
engine_type = "image"
|
|
||||||
|
|
||||||
# 加载引擎详情
|
|
||||||
supported_ratios = None
|
|
||||||
supported_resolutions = None
|
|
||||||
supported_durations = None
|
|
||||||
supported_sizes = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
if engine_type == "video":
|
|
||||||
result = await db.execute(
|
|
||||||
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
|
||||||
engine = result.scalar_one_or_none()
|
|
||||||
if engine:
|
|
||||||
if not model_name:
|
|
||||||
model_name = engine.model_name
|
|
||||||
supported_ratios = _parse_json_list(engine.supported_ratios)
|
|
||||||
supported_resolutions = _parse_json_list(engine.supported_resolutions)
|
|
||||||
supported_durations = _parse_json_list(engine.supported_durations)
|
|
||||||
elif engine_type == "image":
|
|
||||||
result = await db.execute(
|
|
||||||
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
|
||||||
engine = result.scalar_one_or_none()
|
|
||||||
if engine:
|
|
||||||
if not model_name:
|
|
||||||
model_name = engine.model_name
|
|
||||||
supported_sizes = _parse_json_list(engine.supported_sizes)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
info = ApiModelInfo(
|
|
||||||
model=model_name,
|
|
||||||
engine_type=engine_type,
|
|
||||||
engine_id=engine_id,
|
|
||||||
supported_ratios=supported_ratios,
|
|
||||||
supported_resolutions=supported_resolutions,
|
|
||||||
supported_durations=supported_durations,
|
|
||||||
supported_sizes=supported_sizes,
|
|
||||||
)
|
|
||||||
|
|
||||||
models.append(info)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
content={"code": 0, "data": {"models": [m.model_dump() for m in models]}, "message": "ok"},
|
|
||||||
status_code=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_list(value: str | None) -> list[str | int] | None:
|
|
||||||
"""解析 JSON 列表字段。"""
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = json.loads(value)
|
|
||||||
return parsed if isinstance(parsed, list) else None
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return None
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_db
|
|
||||||
from app.models.api.api_generation_task import ApiGenerationTask
|
|
||||||
from app.schemas.api_v3.video import (
|
|
||||||
ApiVideoCreateRequest,
|
|
||||||
ApiVideoCreateResponse,
|
|
||||||
ApiVideoStatusResponse,
|
|
||||||
)
|
|
||||||
from app.services.api_v3 import auth_service, generation_service, task_service
|
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/videos", tags=["api-v3-videos"])
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_request(
|
|
||||||
db: AsyncSession,
|
|
||||||
key_context: auth_service.ApiKeyContext,
|
|
||||||
req: ApiVideoCreateRequest,
|
|
||||||
) -> ApiGenerationTask | None:
|
|
||||||
"""请求层校验:参数、权限、幂等性。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None = 校验通过,继续创建
|
|
||||||
ApiGenerationTask = 幂等请求,返回已有任务
|
|
||||||
"""
|
|
||||||
# 模型权限校验
|
|
||||||
allowed_model_names = {m.get("model_name", "") for m in key_context.callable_models}
|
|
||||||
if allowed_model_names and req.model not in allowed_model_names:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"无权使用模型 {req.model}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 幂等性检查
|
|
||||||
if req.idempotency_key:
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiGenerationTask).where(
|
|
||||||
ApiGenerationTask.api_key_id == key_context.api_key.id,
|
|
||||||
ApiGenerationTask.external_idempotency_key == req.idempotency_key,
|
|
||||||
ApiGenerationTask.deleted_at.is_(None),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
existing_task = result.scalar_one_or_none()
|
|
||||||
if existing_task:
|
|
||||||
logger.info(
|
|
||||||
"Idempotent request: returning existing task %s for key=%s",
|
|
||||||
existing_task.id, req.idempotency_key,
|
|
||||||
)
|
|
||||||
return existing_task
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _map_status(internal_status: str) -> str:
|
|
||||||
"""将内部状态映射为 API 状态。"""
|
|
||||||
status_map = {
|
|
||||||
"pending": "queued",
|
|
||||||
"queued": "queued",
|
|
||||||
"generating": "running",
|
|
||||||
"processing": "running",
|
|
||||||
"completed": "succeeded",
|
|
||||||
"failed": "failed",
|
|
||||||
"timeout": "expired",
|
|
||||||
}
|
|
||||||
return status_map.get(internal_status, internal_status)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"",
|
|
||||||
response_model=ApiVideoCreateResponse,
|
|
||||||
summary="创建视频生成任务",
|
|
||||||
)
|
|
||||||
async def create_video(
|
|
||||||
req: ApiVideoCreateRequest,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiVideoCreateResponse:
|
|
||||||
"""创建视频生成任务(异步)。
|
|
||||||
|
|
||||||
幂等性说明:如果 idempotency_key 已存在,直接返回已有任务 ID(不会重复创建)。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 路由层校验:权限、幂等性
|
|
||||||
existing_task = await _validate_request(db, key_context, req)
|
|
||||||
if existing_task:
|
|
||||||
logger.info(
|
|
||||||
"Idempotent request: returning existing task %s for key=%s",
|
|
||||||
existing_task.id, req.idempotency_key,
|
|
||||||
)
|
|
||||||
return ApiVideoCreateResponse(id=f"zc-{existing_task.id}")
|
|
||||||
|
|
||||||
# 调用服务层创建任务
|
|
||||||
result = await generation_service.submit_video_generation(
|
|
||||||
db=db,
|
|
||||||
key=key_context.api_key,
|
|
||||||
callable_models=key_context.callable_models,
|
|
||||||
req=req,
|
|
||||||
)
|
|
||||||
return ApiVideoCreateResponse(id=f"zc-{result.id}")
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("API video creation failed")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"创建视频任务失败: {str(exc)[:200]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{task_id}",
|
|
||||||
response_model=ApiVideoStatusResponse,
|
|
||||||
summary="查询视频任务状态",
|
|
||||||
)
|
|
||||||
async def get_video_status(
|
|
||||||
task_id: str,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiVideoStatusResponse:
|
|
||||||
"""查询视频任务状态。"""
|
|
||||||
# 去掉 zc- 前缀
|
|
||||||
if task_id.startswith("zc-"):
|
|
||||||
task_id = task_id[3:]
|
|
||||||
task = await task_service.get_task(db, task_id, key_context.api_key.id)
|
|
||||||
if not task:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"任务 {task_id} 不存在或不属于当前 API Key",
|
|
||||||
)
|
|
||||||
|
|
||||||
now = int(time.time())
|
|
||||||
# 构建 content(成功时返回完整视频URL,包含 BASE_URL)
|
|
||||||
content = None
|
|
||||||
if task.status == "completed" and task.video_url:
|
|
||||||
from app.schemas.api_v3.video import ApiVideoContent
|
|
||||||
from app.config import settings
|
|
||||||
# 拼接完整 URL
|
|
||||||
video_url = build_resource_signed_url(task.video_url)
|
|
||||||
if video_url and not video_url.startswith(("http://", "https://")):
|
|
||||||
base = settings.BASE_URL.rstrip("/")
|
|
||||||
if video_url.startswith("/"):
|
|
||||||
video_url = f"{base}{video_url}"
|
|
||||||
else:
|
|
||||||
video_url = f"{base}/{video_url}"
|
|
||||||
content = ApiVideoContent(video_url=video_url)
|
|
||||||
|
|
||||||
return ApiVideoStatusResponse(
|
|
||||||
id=f"zc-{task.id}",
|
|
||||||
model=task.model_name,
|
|
||||||
status=_map_status(task.status),
|
|
||||||
created_at=int(task.created_at.timestamp()) if task.created_at else now,
|
|
||||||
updated_at=int(task.updated_at.timestamp()) if task.updated_at else now,
|
|
||||||
content=content,
|
|
||||||
duration=task.duration,
|
|
||||||
ratio=task.aspect_ratio,
|
|
||||||
resolution=task.resolution,
|
|
||||||
error=task.error_message if task.status in ("failed", "timeout") else None,
|
|
||||||
)
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_db
|
|
||||||
from app.enums.upload_resource import UploadResourceTypeEnum # noqa: F401 (内部引用保留)
|
|
||||||
from app.enums.private_portrait import (
|
|
||||||
PrivatePortraitAssetStatus,
|
|
||||||
PrivatePortraitAssetType,
|
|
||||||
PrivatePortraitProjectStatus,
|
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
|
||||||
)
|
|
||||||
from app.schemas.virtual_portrait_v3 import (
|
|
||||||
VpV3AssetCreate,
|
|
||||||
VpV3AssetDeleteOut,
|
|
||||||
VpV3AssetListOut,
|
|
||||||
VpV3EnumMeta,
|
|
||||||
VpV3IdOut,
|
|
||||||
VpV3ProjectCreate,
|
|
||||||
VpV3ProjectDeleteOut,
|
|
||||||
VpV3ProjectListOut,
|
|
||||||
VpV3ProjectOut,
|
|
||||||
VpV3ProjectUpdate,
|
|
||||||
VpV3QuotaConfigOut,
|
|
||||||
VpV3SelectableAssetListOut,
|
|
||||||
)
|
|
||||||
from app.services import virtual_portrait_v3 as vp_v3
|
|
||||||
from app.services.api_v3 import auth_service
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/virtual-portrait", tags=["api-v3-virtual-portrait"])
|
|
||||||
|
|
||||||
API_PREFIX_INFO = """
|
|
||||||
> **虚拟素材库(V3 中转 API)**
|
|
||||||
>
|
|
||||||
> - 数据与前台用户私域素材库完全隔离(独立 `vp_v3_*` 表),归属按 API Key 管理
|
|
||||||
> - 所有接口需要在 Header 中携带 `Authorization: Bearer <API Key>`(或通过 `X-API-Key`,详见鉴权说明)
|
|
||||||
> - 配额:每个 API Key 需要管理员在后台配置虚拟素材额度(项目数/素材数/存储 MB),默认 0=不可使用
|
|
||||||
> - 生命周期:上传文件 → 创建素材(异步审核,会自动轮询)→ 状态 Active 后可用于 AI 创作
|
|
||||||
> - 远端删除遵循「先本地软删 → commit 后投递 Celery 异步任务删火山」模式,API 返回 `remote_delete_status=pending` 表示处理中
|
|
||||||
""" # noqa: E501
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 基础 & 配置
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/config",
|
|
||||||
response_model=VpV3QuotaConfigOut,
|
|
||||||
summary="获取虚拟素材库配额配置",
|
|
||||||
description=(
|
|
||||||
"返回当前 API Key 的虚拟素材配额上限(项目/素材/存储)和已使用量。"
|
|
||||||
"任一上限大于 0 表示启用虚拟素材库功能。"
|
|
||||||
+ API_PREFIX_INFO
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def get_virtual_portrait_config(
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
quota = await vp_v3.quota_service.get_quota(db, api_key_id=key_context.api_key_id, refresh=True)
|
|
||||||
enabled = any([
|
|
||||||
(quota.project_limit or 0) > 0,
|
|
||||||
(quota.asset_limit or 0) > 0,
|
|
||||||
(quota.storage_mb_limit or 0) > 0,
|
|
||||||
])
|
|
||||||
return VpV3QuotaConfigOut(
|
|
||||||
project_limit=int(quota.project_limit or 0),
|
|
||||||
asset_limit=int(quota.asset_limit or 0),
|
|
||||||
storage_mb_limit=int(quota.storage_mb_limit or 0),
|
|
||||||
project_used=int(quota.project_used or 0),
|
|
||||||
asset_used=int(quota.asset_used or 0),
|
|
||||||
storage_mb_used=float(quota.storage_mb_used or 0),
|
|
||||||
enabled=bool(enabled),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/enums",
|
|
||||||
response_model=VpV3EnumMeta,
|
|
||||||
summary="获取虚拟素材库枚举元数据",
|
|
||||||
description="返回素材类型、素材状态、项目状态、远端删除状态等枚举说明。",
|
|
||||||
)
|
|
||||||
async def get_virtual_portrait_enums(
|
|
||||||
_: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
):
|
|
||||||
return VpV3EnumMeta(
|
|
||||||
asset_type={
|
|
||||||
PrivatePortraitAssetType.IMAGE.value: "图片素材",
|
|
||||||
PrivatePortraitAssetType.VIDEO.value: "视频素材",
|
|
||||||
},
|
|
||||||
asset_status={
|
|
||||||
PrivatePortraitAssetStatus.CREATING.value: "创建中/审核中",
|
|
||||||
PrivatePortraitAssetStatus.ACTIVE.value: "已就绪/可用",
|
|
||||||
PrivatePortraitAssetStatus.FAILED.value: "失败",
|
|
||||||
PrivatePortraitAssetStatus.DELETING.value: "删除中",
|
|
||||||
},
|
|
||||||
project_status={
|
|
||||||
PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value: "远端组创建中",
|
|
||||||
PrivatePortraitProjectStatus.ACTIVE.value: "就绪",
|
|
||||||
PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value: "远端组创建失败",
|
|
||||||
PrivatePortraitProjectStatus.DELETING.value: "删除中",
|
|
||||||
},
|
|
||||||
remote_delete_status={
|
|
||||||
PrivatePortraitRemoteDeleteStatus.NONE.value: "未删除",
|
|
||||||
PrivatePortraitRemoteDeleteStatus.PENDING.value: "待异步删除",
|
|
||||||
PrivatePortraitRemoteDeleteStatus.PROCESSING.value: "远端删除中",
|
|
||||||
PrivatePortraitRemoteDeleteStatus.DELETED.value: "远端已删除",
|
|
||||||
PrivatePortraitRemoteDeleteStatus.FAILED.value: "远端删除失败",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 项目 CRUD
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/projects",
|
|
||||||
response_model=VpV3IdOut,
|
|
||||||
summary="创建虚拟素材项目",
|
|
||||||
description=(
|
|
||||||
"在当前 API Key 下创建一个虚拟素材项目(同步调用火山创建远端 AssetGroup)。"
|
|
||||||
"项目名称 1-100 字符;描述最多 500 字符。"
|
|
||||||
"创建项目会占用 1 个项目配额,超出上限将返回 403。"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def create_virtual_portrait_project(
|
|
||||||
payload: VpV3ProjectCreate,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
project = await vp_v3.project_service.create_project(
|
|
||||||
db, api_key_id=key_context.api_key_id, payload=payload
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
except HTTPException:
|
|
||||||
await db.rollback()
|
|
||||||
raise
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=f"创建项目失败:{exc}") from exc
|
|
||||||
return VpV3IdOut(Id=project.remote_group_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/projects",
|
|
||||||
response_model=VpV3ProjectListOut,
|
|
||||||
summary="查询虚拟素材项目列表",
|
|
||||||
description="按 API Key 分页查询虚拟素材项目。支持项目名称模糊搜索、状态筛选。默认按创建时间倒序。",
|
|
||||||
)
|
|
||||||
async def list_virtual_portrait_projects(
|
|
||||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
|
||||||
page_size: int = Query(20, ge=1, le=100, description="每页数量 1-100"),
|
|
||||||
keyword: str | None = Query(None, description="项目名称模糊搜索"),
|
|
||||||
status: str | None = Query(None, description="项目状态筛选(不传查全部)"),
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
items, total = await vp_v3.project_service.list_projects(
|
|
||||||
db,
|
|
||||||
api_key_id=key_context.api_key_id,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
keyword=keyword,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
return VpV3ProjectListOut(
|
|
||||||
items=[vp_v3.project_service.project_to_out(it) for it in items],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/projects/{project_id}",
|
|
||||||
response_model=VpV3ProjectOut,
|
|
||||||
summary="获取虚拟素材项目详情",
|
|
||||||
)
|
|
||||||
async def get_virtual_portrait_project(
|
|
||||||
project_id: str,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
project = await vp_v3.project_service.get_project(
|
|
||||||
db, api_key_id=key_context.api_key_id, project_id=project_id
|
|
||||||
)
|
|
||||||
return vp_v3.project_service.project_to_out(project)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
"/projects/{project_id}",
|
|
||||||
response_model=VpV3ProjectOut,
|
|
||||||
summary="更新虚拟素材项目",
|
|
||||||
description="更新虚拟素材项目本地展示信息(名称/描述),不会重新创建火山远端 Group。",
|
|
||||||
)
|
|
||||||
async def update_virtual_portrait_project(
|
|
||||||
project_id: str,
|
|
||||||
payload: VpV3ProjectUpdate,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
project = await vp_v3.project_service.update_project(
|
|
||||||
db, api_key_id=key_context.api_key_id, project_id=project_id, payload=payload
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
except HTTPException:
|
|
||||||
await db.rollback()
|
|
||||||
raise
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=f"更新项目失败:{exc}") from exc
|
|
||||||
return vp_v3.project_service.project_to_out(project)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/projects/{project_id}",
|
|
||||||
response_model=VpV3ProjectDeleteOut,
|
|
||||||
summary="删除虚拟素材项目",
|
|
||||||
description=(
|
|
||||||
"软删虚拟素材项目及其下所有素材。本地 commit 后会投递 Celery 异步任务去删除火山远端 AssetGroup/Asset。"
|
|
||||||
"返回的 remote_delete_status=pending 表示远端删除处理中(可通过项目详情接口轮询)。"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def delete_virtual_portrait_project(
|
|
||||||
project_id: str,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
project = await vp_v3.project_service.soft_delete_project(
|
|
||||||
db, api_key_id=key_context.api_key_id, project_id=project_id
|
|
||||||
)
|
|
||||||
project_id_snapshot = project.id
|
|
||||||
try:
|
|
||||||
await db.commit()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=f"删除项目失败:{exc}") from exc
|
|
||||||
# commit 后投递 V3 专属的异步删除任务
|
|
||||||
try:
|
|
||||||
from app.tasks.vp_v3_asset_tasks import delete_v3_project_remote_task # type: ignore
|
|
||||||
|
|
||||||
delete_v3_project_remote_task.delay(project_id_snapshot)
|
|
||||||
logger.info("vp_v3 project %s 已投递远端删除任务", project_id_snapshot)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.warning("vp_v3 项目删除任务投递失败:project_id=%s err=%s", project_id_snapshot, exc)
|
|
||||||
return VpV3ProjectDeleteOut(
|
|
||||||
success=True,
|
|
||||||
remote_delete_status=project.remote_delete_status or PrivatePortraitRemoteDeleteStatus.PENDING.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 素材 CRUD
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/projects/{project_id}/assets",
|
|
||||||
response_model=VpV3IdOut,
|
|
||||||
summary="创建虚拟素材(提交审核)",
|
|
||||||
description=(
|
|
||||||
"在指定项目下创建虚拟素材,提交到火山进行异步审核。\n"
|
|
||||||
"- source_url:必填,必须是 POST /uploads/image 或 /uploads/video 返回的 url(或 /uploads/* 路径)\n"
|
|
||||||
"- asset_type:Image/Video;Video 必须提供 video_duration(秒),最多 60 秒\n"
|
|
||||||
"- 创建成功后 status=Creating;建议调用方自行轮询 /assets/{id}/sync 或详情接口直到 status=Active\n"
|
|
||||||
"- 同时会占用 1 份素材配额和文件大小对应的存储配额"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def create_virtual_portrait_asset(
|
|
||||||
project_id: str,
|
|
||||||
payload: VpV3AssetCreate,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
project = await vp_v3.project_service.get_project(
|
|
||||||
db, api_key_id=key_context.api_key_id, project_id=project_id
|
|
||||||
)
|
|
||||||
asset = await vp_v3.asset_service.create_asset(
|
|
||||||
db, api_key_id=key_context.api_key_id, project=project, payload=payload
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
except HTTPException:
|
|
||||||
await db.rollback()
|
|
||||||
raise
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=f"创建素材失败:{exc}") from exc
|
|
||||||
asset_id_snapshot = asset.remote_asset_id
|
|
||||||
# commit 成功后投递 V3 专属轮询任务
|
|
||||||
try:
|
|
||||||
from app.tasks.vp_v3_asset_tasks import poll_v3_asset_status # type: ignore
|
|
||||||
|
|
||||||
async_result = poll_v3_asset_status.delay(asset_id_snapshot)
|
|
||||||
logger.info(
|
|
||||||
"vp_v3 素材轮询任务投递成功:asset_id=%s celery_task_id=%s",
|
|
||||||
asset_id_snapshot,
|
|
||||||
getattr(async_result, "id", None),
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.warning("vp_v3 素材轮询任务投递失败:asset_id=%s err=%s", asset_id_snapshot, exc)
|
|
||||||
return VpV3IdOut(Id=asset.remote_asset_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/projects/{project_id}/assets",
|
|
||||||
response_model=VpV3AssetListOut,
|
|
||||||
summary="查询指定项目下的虚拟素材列表",
|
|
||||||
description="按项目分页查询素材。可按 status/asset_type 筛选,按素材名称 keyword 模糊搜索。",
|
|
||||||
)
|
|
||||||
async def list_virtual_portrait_project_assets(
|
|
||||||
project_id: str,
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
status: str | None = Query(None, description="素材状态筛选(Creating/Active/Failed/Deleting)"),
|
|
||||||
keyword: str | None = Query(None, description="素材名称模糊搜索"),
|
|
||||||
asset_type: str | None = Query(None, description="素材类型:Image/Video"),
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
# 先校验项目归属
|
|
||||||
await vp_v3.project_service.get_project(db, api_key_id=key_context.api_key_id, project_id=project_id)
|
|
||||||
items, total = await vp_v3.asset_service.list_assets(
|
|
||||||
db,
|
|
||||||
api_key_id=key_context.api_key_id,
|
|
||||||
project_id=project_id,
|
|
||||||
status=status,
|
|
||||||
keyword=keyword,
|
|
||||||
asset_type=asset_type,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
return VpV3AssetListOut(
|
|
||||||
items=[vp_v3.asset_service.asset_to_out(it) for it in items],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/assets/{asset_id}",
|
|
||||||
summary="获取虚拟素材审核详情",
|
|
||||||
description=(
|
|
||||||
"返回素材的 moderation_json(火山审核 JSON)。\n"
|
|
||||||
"- 若素材状态为 Creating(审核中)且 next_poll_at 已到期,内部会自动调火山 GetAsset 同步最新状态。\n"
|
|
||||||
"- 返回内容为解析后的 JSON 对象。"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def get_virtual_portrait_asset(
|
|
||||||
asset_id: str,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
# 北京时间(UTC+8)统一基准
|
|
||||||
_BJ_TZ = timezone(timedelta(hours=8))
|
|
||||||
|
|
||||||
def _bj_now() -> datetime:
|
|
||||||
"""返回当前北京时间(UTC+8)naive datetime。"""
|
|
||||||
return datetime.now(_BJ_TZ).replace(tzinfo=None)
|
|
||||||
|
|
||||||
asset = await vp_v3.asset_service.get_asset(db, api_key_id=key_context.api_key_id, asset_id=asset_id)
|
|
||||||
# 统一为 naive 北京时间比较
|
|
||||||
def _naive(dt: datetime | None) -> datetime | None:
|
|
||||||
if dt is None:
|
|
||||||
return None
|
|
||||||
return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
|
|
||||||
need_sync = (
|
|
||||||
asset.status == PrivatePortraitAssetStatus.CREATING.value
|
|
||||||
and asset.remote_asset_id
|
|
||||||
and (_naive(asset.next_poll_at) is None or _naive(asset.next_poll_at) <= _bj_now())
|
|
||||||
)
|
|
||||||
if need_sync:
|
|
||||||
try:
|
|
||||||
asset = await vp_v3.asset_service.sync_asset_status(
|
|
||||||
db, api_key_id=key_context.api_key_id, asset_id=asset_id,
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(asset)
|
|
||||||
except HTTPException:
|
|
||||||
await db.rollback()
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=f"同步素材状态失败:{exc}") from exc
|
|
||||||
|
|
||||||
# 只返回 moderation_json 解析后的内容
|
|
||||||
moderation = None
|
|
||||||
if asset.moderation_json:
|
|
||||||
try:
|
|
||||||
moderation = json.loads(asset.moderation_json)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
moderation = asset.moderation_json
|
|
||||||
|
|
||||||
return JSONResponse(content=moderation)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/assets/{asset_id}",
|
|
||||||
response_model=VpV3AssetDeleteOut,
|
|
||||||
summary="删除虚拟素材",
|
|
||||||
description=(
|
|
||||||
"软删虚拟素材。本地 commit 后会投递 Celery 异步任务去删除火山远端 Asset。"
|
|
||||||
"返回 remote_delete_status=pending 表示处理中(可通过素材详情接口轮询)。"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def delete_virtual_portrait_asset(
|
|
||||||
asset_id: str,
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
asset = await vp_v3.asset_service.soft_delete_asset(
|
|
||||||
db, api_key_id=key_context.api_key_id, asset_id=asset_id
|
|
||||||
)
|
|
||||||
asset_id_snapshot = asset.remote_asset_id
|
|
||||||
try:
|
|
||||||
await db.commit()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=f"删除素材失败:{exc}") from exc
|
|
||||||
# commit 后投递 V3 专属的异步删除任务
|
|
||||||
try:
|
|
||||||
from app.tasks.vp_v3_asset_tasks import delete_v3_asset_remote_task # type: ignore
|
|
||||||
|
|
||||||
delete_v3_asset_remote_task.delay(asset_id_snapshot)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.warning("vp_v3 素材远端删除任务投递失败:asset_id=%s err=%s", asset_id_snapshot, exc)
|
|
||||||
return VpV3AssetDeleteOut(
|
|
||||||
success=True,
|
|
||||||
remote_delete_status=asset.remote_delete_status or PrivatePortraitRemoteDeleteStatus.PENDING.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# AI 创作选择器用
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/selectable-assets",
|
|
||||||
response_model=VpV3SelectableAssetListOut,
|
|
||||||
summary="查询可用于 AI 创作的虚拟素材",
|
|
||||||
description=(
|
|
||||||
"只返回当前 API Key 虚拟素材库中 status=Active 的图片/视频素材。"
|
|
||||||
"该接口提供给 AI 创作参考素材选择器使用。"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def list_virtual_portrait_selectable_assets(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
project_id: str | None = Query(None, description="按项目筛选(可选)"),
|
|
||||||
keyword: str | None = Query(None, description="素材名称模糊搜索"),
|
|
||||||
asset_type: str | None = Query(None, description="素材类型:Image/Video"),
|
|
||||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
items, total = await vp_v3.asset_service.list_selectable_assets(
|
|
||||||
db,
|
|
||||||
api_key_id=key_context.api_key_id,
|
|
||||||
project_id=project_id,
|
|
||||||
keyword=keyword,
|
|
||||||
asset_type=asset_type,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
return VpV3SelectableAssetListOut(
|
|
||||||
items=[vp_v3.asset_service.asset_to_selectable(it) for it in items],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
@@ -346,20 +346,6 @@ class Settings(BaseSettings):
|
|||||||
PRIVATE_PORTRAIT_DISPATCH_LOCK_KEY: str = "vg:celery:private_portrait:dispatch_lock"
|
PRIVATE_PORTRAIT_DISPATCH_LOCK_KEY: str = "vg:celery:private_portrait:dispatch_lock"
|
||||||
PRIVATE_PORTRAIT_DELETE_RECOVERY_LOCK_KEY: str = "vg:celery:private_portrait:delete_recovery_lock"
|
PRIVATE_PORTRAIT_DELETE_RECOVERY_LOCK_KEY: str = "vg:celery:private_portrait:delete_recovery_lock"
|
||||||
|
|
||||||
# V3 虚拟素材库 Celery Runtime(与前台私域素材库独立隔离,避免任务集合 key 冲突和相互影响)
|
|
||||||
VP_V3_POLL_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:vp_v3_poll:active"
|
|
||||||
VP_V3_POLL_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:vp_v3_poll:active_index"
|
|
||||||
VP_V3_POLL_LOCK_KEY_PREFIX: str = "vg:lock:vp_v3:poll"
|
|
||||||
VP_V3_DELETE_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:vp_v3_delete:active"
|
|
||||||
VP_V3_DELETE_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:vp_v3_delete:active_index"
|
|
||||||
VP_V3_DELETE_LOCK_KEY_PREFIX: str = "vg:lock:vp_v3:delete"
|
|
||||||
VP_V3_RUNTIME_LOCK_TTL_SECONDS: int = 180
|
|
||||||
VP_V3_RUNTIME_HEARTBEAT_SECONDS: int = 30
|
|
||||||
VP_V3_DISPATCH_LOCK_KEY: str = "vg:celery:vp_v3:dispatch_lock"
|
|
||||||
VP_V3_DELETE_RECOVERY_LOCK_KEY: str = "vg:celery:vp_v3:delete_recovery_lock"
|
|
||||||
VP_V3_ASSET_POLL_BATCH_SIZE: int = 50
|
|
||||||
VP_V3_REMOTE_DELETE_RECOVERY_BATCH_SIZE: int = 50
|
|
||||||
|
|
||||||
SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4
|
SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4
|
||||||
SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16"
|
SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16"
|
||||||
SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p"
|
SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ class CeleryQueue(str, Enum):
|
|||||||
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
|
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
|
||||||
GEN_SHOT_ANALYSIS = "gen_shot_analysis"
|
GEN_SHOT_ANALYSIS = "gen_shot_analysis"
|
||||||
GEN_SHOT_SPLIT = "gen_shot_split"
|
GEN_SHOT_SPLIT = "gen_shot_split"
|
||||||
GEN_API_UPSCALE = "gen_api_upscale"
|
|
||||||
DEFAULT = "default"
|
DEFAULT = "default"
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ class CeleryTaskName(str, Enum):
|
|||||||
VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT = "video_upscale.download_remote_result"
|
VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT = "video_upscale.download_remote_result"
|
||||||
VIDEO_UPSCALE_FINALIZE = "video_upscale.finalize"
|
VIDEO_UPSCALE_FINALIZE = "video_upscale.finalize"
|
||||||
VIDEO_UPSCALE_RECOVER = "video_upscale.recover_once"
|
VIDEO_UPSCALE_RECOVER = "video_upscale.recover_once"
|
||||||
API_GENERATION_RECOVER = "api_generation.recover_tasks_once"
|
|
||||||
DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks"
|
DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks"
|
||||||
STARTUP_RECOVERY = "recovery.startup_recovery_once"
|
STARTUP_RECOVERY = "recovery.startup_recovery_once"
|
||||||
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
|
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
|
||||||
@@ -45,8 +43,3 @@ class CeleryTaskName(str, Enum):
|
|||||||
PRIVATE_PORTRAIT_DELETE_GROUP = "private_portrait.delete_group_remote"
|
PRIVATE_PORTRAIT_DELETE_GROUP = "private_portrait.delete_group_remote"
|
||||||
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
|
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
|
||||||
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
|
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
|
||||||
VP_V3_POLL_ASSET = "vp_v3.asset.poll_status"
|
|
||||||
VP_V3_SYNC_DUE_ASSETS = "vp_v3.sync_due_assets"
|
|
||||||
VP_V3_DELETE_ASSET = "vp_v3.asset.delete_remote"
|
|
||||||
VP_V3_DELETE_PROJECT = "vp_v3.project.delete_remote"
|
|
||||||
VP_V3_RECOVER_REMOTE_DELETES = "vp_v3.recover_remote_deletes"
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class GenerationType(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
# 生成配置常量
|
# 生成配置常量
|
||||||
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]
|
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
||||||
RESOLUTIONS = ["480p", "720p", "1080p"]
|
RESOLUTIONS = ["480p", "720p", "1080p"]
|
||||||
IMAGE_SIZES = ["1K", "2K", "4K"]
|
IMAGE_SIZES = ["1K", "2K", "4K"]
|
||||||
@@ -74,7 +74,6 @@ class PrivatePortraitProjectStatus(str, Enum):
|
|||||||
VALIDATE_FAILED = "validate_failed"
|
VALIDATE_FAILED = "validate_failed"
|
||||||
CREATING_REMOTE_GROUP = "creating_remote_group"
|
CREATING_REMOTE_GROUP = "creating_remote_group"
|
||||||
CREATE_GROUP_FAILED = "create_group_failed"
|
CREATE_GROUP_FAILED = "create_group_failed"
|
||||||
DELETING = "deleting"
|
|
||||||
DELETED = "deleted"
|
DELETED = "deleted"
|
||||||
|
|
||||||
|
|
||||||
@@ -102,7 +101,6 @@ class PrivatePortraitAssetStatus(str, Enum):
|
|||||||
ACTIVE = "Active"
|
ACTIVE = "Active"
|
||||||
FAILED = "Failed"
|
FAILED = "Failed"
|
||||||
LOCAL_DELETED = "local_deleted"
|
LOCAL_DELETED = "local_deleted"
|
||||||
DELETING = "deleting"
|
|
||||||
REMOTE_DELETED = "remote_deleted"
|
REMOTE_DELETED = "remote_deleted"
|
||||||
DELETE_FAILED = "delete_failed"
|
DELETE_FAILED = "delete_failed"
|
||||||
|
|
||||||
@@ -161,9 +159,6 @@ class PrivatePortraitEventType(str, Enum):
|
|||||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START"
|
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START"
|
||||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS"
|
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS"
|
||||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED"
|
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED"
|
||||||
VIRTUAL_ASSET_CREATE_REMOTE_START = "VIRTUAL_ASSET_CREATE_REMOTE_START"
|
|
||||||
VIRTUAL_ASSET_CREATE_REMOTE_SUCCESS = "VIRTUAL_ASSET_CREATE_REMOTE_SUCCESS"
|
|
||||||
VIRTUAL_ASSET_CREATE_REMOTE_FAILED = "VIRTUAL_ASSET_CREATE_REMOTE_FAILED"
|
|
||||||
|
|
||||||
VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE"
|
VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE"
|
||||||
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
|
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ class UploadResourceModuleEnum(StrEnum):
|
|||||||
class UploadResourceTypeEnum(StrEnum):
|
class UploadResourceTypeEnum(StrEnum):
|
||||||
"""上传资源类型。"""
|
"""上传资源类型。"""
|
||||||
|
|
||||||
IMAGE = "Image"
|
IMAGE = "image"
|
||||||
VIDEO = "Video"
|
VIDEO = "video"
|
||||||
AUDIO = "Audio"
|
AUDIO = "audio"
|
||||||
SHOT_SEGMENT = "shot_segment"
|
SHOT_SEGMENT = "shot_segment"
|
||||||
PDF = "pdf"
|
PDF = "pdf"
|
||||||
FILE = "file"
|
FILE = "file"
|
||||||
|
|||||||
+1
-105
@@ -3,7 +3,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException, Request
|
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -554,116 +554,12 @@ def create_app() -> FastAPI:
|
|||||||
# Routes
|
# Routes
|
||||||
application.include_router(api_router, prefix="/api")
|
application.include_router(api_router, prefix="/api")
|
||||||
application.include_router(api_router_v2, prefix="/api/v2")
|
application.include_router(api_router_v2, prefix="/api/v2")
|
||||||
from app.api.v3 import api_router_v3
|
|
||||||
application.include_router(api_router_v3, prefix="/api/v3")
|
|
||||||
|
|
||||||
# === API v3 请求日志中间件 ===
|
|
||||||
import json as _json
|
|
||||||
import time as _time
|
|
||||||
from app.services.api_v3.logging_service import log_request, log_response, log_request_error
|
|
||||||
|
|
||||||
@application.middleware("http")
|
|
||||||
async def v3_request_logger(request: Request, call_next):
|
|
||||||
"""记录所有 /api/v3/ 请求和响应。"""
|
|
||||||
if not str(request.url.path).startswith("/api/v3"):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
start_time = _time.perf_counter()
|
|
||||||
|
|
||||||
# 提取 API Key ID
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
|
||||||
api_key_id = "unknown"
|
|
||||||
if auth_header.startswith("Bearer "):
|
|
||||||
api_key_id = auth_header[7:15] + "..."
|
|
||||||
|
|
||||||
# 读取请求体
|
|
||||||
body = None
|
|
||||||
if request.method in ("POST", "PUT", "PATCH"):
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
log_request(
|
|
||||||
method=request.method,
|
|
||||||
path=str(request.url.path),
|
|
||||||
api_key_id=api_key_id,
|
|
||||||
body=body,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = int((_time.perf_counter() - start_time) * 1000)
|
|
||||||
log_request_error(
|
|
||||||
method=request.method,
|
|
||||||
path=str(request.url.path),
|
|
||||||
api_key_id=api_key_id,
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
content={"code": 50000, "data": None, "message": f"服务器内部错误: {str(exc)[:200]}"},
|
|
||||||
status_code=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
duration_ms = int((_time.perf_counter() - start_time) * 1000)
|
|
||||||
|
|
||||||
# 读取响应体
|
|
||||||
response_body = None
|
|
||||||
try:
|
|
||||||
response_body = _json.loads(response.body)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
log_response(
|
|
||||||
method=request.method,
|
|
||||||
path=str(request.url.path),
|
|
||||||
api_key_id=api_key_id,
|
|
||||||
status_code=response.status_code,
|
|
||||||
body=response_body,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
# === API v3 统一异常处理 ===
|
|
||||||
from fastapi.exceptions import HTTPException
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from app.services.api_v3.pricing_service import PricingNotConfiguredError
|
|
||||||
|
|
||||||
@application.exception_handler(HTTPException)
|
|
||||||
async def v3_http_exception_handler(request: Request, exc: HTTPException):
|
|
||||||
"""仅对 /api/v3/ 路径返回统一格式,HTTP 状态码固定 200。"""
|
|
||||||
if not str(request.url.path).startswith("/api/v3"):
|
|
||||||
raise exc # 交给其他处理器
|
|
||||||
detail = exc.detail
|
|
||||||
message = detail.get("message", str(detail)) if isinstance(detail, dict) else str(detail)
|
|
||||||
code_map = {400: 40000, 401: 40100, 403: 40300, 404: 40400, 429: 42900, 422: 42200, 500: 50000, 504: 50400}
|
|
||||||
code = code_map.get(exc.status_code, exc.status_code * 100)
|
|
||||||
return JSONResponse(content={"code": code, "data": None, "message": message}, status_code=200)
|
|
||||||
|
|
||||||
@application.exception_handler(PricingNotConfiguredError)
|
|
||||||
async def v3_pricing_not_configured_handler(request: Request, exc: PricingNotConfiguredError):
|
|
||||||
if not str(request.url.path).startswith("/api/v3"):
|
|
||||||
raise exc
|
|
||||||
return JSONResponse(content={"code": 40001, "data": None, "message": str(exc)}, status_code=200)
|
|
||||||
|
|
||||||
@application.exception_handler(Exception)
|
|
||||||
async def v3_general_exception_handler(request: Request, exc: Exception):
|
|
||||||
if not str(request.url.path).startswith("/api/v3"):
|
|
||||||
raise exc
|
|
||||||
return JSONResponse(content={"code": 50000, "data": None, "message": f"服务器内部错误: {str(exc)[:200]}"}, status_code=200)
|
|
||||||
|
|
||||||
# Static files for uploads
|
# Static files for uploads
|
||||||
upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH)
|
upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH)
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
application.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
|
application.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
|
||||||
|
|
||||||
# 挂载 API v3 生成文件静态目录
|
|
||||||
generate_dir = os.path.join(os.path.dirname(upload_dir), "generate")
|
|
||||||
os.makedirs(generate_dir, exist_ok=True)
|
|
||||||
application.mount("/generate", StaticFiles(directory=generate_dir), name="generate")
|
|
||||||
|
|
||||||
@application.get("/internal/health")
|
@application.get("/internal/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ from app.models.user_oauth_app import UserOAuthApp
|
|||||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||||
from app.models.contact_request import ContactRequest
|
from app.models.contact_request import ContactRequest
|
||||||
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
|
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
|
||||||
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||||
@@ -55,6 +54,4 @@ __all__ = [
|
|||||||
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
|
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
|
||||||
"PrivatePortraitProject", "PrivatePortraitValidateSession",
|
"PrivatePortraitProject", "PrivatePortraitValidateSession",
|
||||||
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
|
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
|
||||||
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
|
|
||||||
"ApiModelPricing",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
from app.models.api.api_key import ApiKey
|
|
||||||
from app.models.api.api_generation_task import ApiGenerationTask
|
|
||||||
from app.models.api.api_usage_log import ApiUsageLog
|
|
||||||
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
|
|
||||||
from app.models.api.api_upscale_link import ApiUpscaleLink
|
|
||||||
from app.models.api.api_model_pricing import ApiModelPricing
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ApiKey",
|
|
||||||
"ApiGenerationTask",
|
|
||||||
"ApiUsageLog",
|
|
||||||
"ApiKeyUpscaleConfig",
|
|
||||||
"ApiUpscaleLink",
|
|
||||||
"ApiModelPricing",
|
|
||||||
]
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text, text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
|
||||||
|
|
||||||
|
|
||||||
class ApiGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
|
|
||||||
"""对外开放 API 的生成任务表。
|
|
||||||
|
|
||||||
该表设计满足 ProviderGenerationRecordLike 协议,
|
|
||||||
使现有的 Volcano Ark SDK 封装函数可以直接复用。
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "api_generation_tasks"
|
|
||||||
__table_args__ = (
|
|
||||||
# 幂等键唯一索引
|
|
||||||
Index(
|
|
||||||
"uq_api_generation_tasks_key_idempotency",
|
|
||||||
"api_key_id",
|
|
||||||
"external_idempotency_key",
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=text("deleted_at IS NULL AND external_idempotency_key IS NOT NULL"),
|
|
||||||
),
|
|
||||||
# 视频轮询调度索引
|
|
||||||
Index(
|
|
||||||
"idx_api_generation_tasks_next_poll_at",
|
|
||||||
"next_poll_at",
|
|
||||||
postgresql_where=text(
|
|
||||||
"deleted_at IS NULL "
|
|
||||||
"AND status = 'generating' "
|
|
||||||
"AND gen_type = 'video' "
|
|
||||||
"AND next_poll_at IS NOT NULL"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Index("idx_api_generation_tasks_api_key_created", "api_key_id", "created_at"),
|
|
||||||
Index("idx_api_generation_tasks_provider_task_id", "provider_task_id"),
|
|
||||||
Index("idx_api_generation_tasks_status", "status"),
|
|
||||||
CheckConstraint("generation_count BETWEEN 1 AND 5", name="ck_api_generation_tasks_generation_count"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
api_key_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
external_idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
|
|
||||||
# === ProviderGenerationRecordLike 协议字段 ===
|
|
||||||
original_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
optimized_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
gen_type: Mapped[str] = mapped_column(String(16), default="video", nullable=False)
|
|
||||||
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
|
||||||
resolution: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
|
||||||
provider_generation_resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
||||||
image_size: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
||||||
image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
|
||||||
image_px: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
||||||
generation_count: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
|
|
||||||
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
||||||
model_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="模型名称")
|
|
||||||
media_references: Mapped[str | None] = mapped_column(Text, nullable=True, comment="用户原始上传的媒体URL")
|
|
||||||
local_media_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="下载到本地的媒体文件路径JSON")
|
|
||||||
engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
# === 请求参数快照 ===
|
|
||||||
request_params_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="完整原始请求参数")
|
|
||||||
|
|
||||||
# === 流水线状态(镜像 ChatGenerationTask) ===
|
|
||||||
status: Mapped[str] = mapped_column(String(32), default="pending", nullable=False)
|
|
||||||
pipeline_stage: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
||||||
generation_attempt_no: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
|
|
||||||
resource_generation_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
# === 供应商交互 ===
|
|
||||||
provider_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
||||||
remote_result_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
# === 结果 ===
|
|
||||||
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
||||||
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
||||||
video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
# === 超分 ===
|
|
||||||
video_upscale_enabled_snapshot: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
video_upscale_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
# === 配额消耗 ===
|
|
||||||
credits_cost: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0")
|
|
||||||
video_tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
||||||
image_tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
||||||
|
|
||||||
# === 轮询控制 ===
|
|
||||||
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=30, server_default="30")
|
|
||||||
poll_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
||||||
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
# === Celery 执行租约(镜像 ChatGenerationTask) ===
|
|
||||||
provider_create_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
provider_create_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
provider_create_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
poll_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
poll_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
poll_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
poll_error_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
||||||
download_celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
|
||||||
download_enqueued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
download_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
download_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
download_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
download_next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
download_attempt_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
||||||
download_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
download_storage_date_dir: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
||||||
|
|
||||||
# === 存储 ===
|
|
||||||
local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Float, Index, Integer, String, Text, text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
|
||||||
|
|
||||||
from app.utils.security import encrypt_text, decrypt_text
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKey(Base, TimestampMixin, SoftDeleteMixin):
|
|
||||||
"""对外开放 API 的密钥管理表。
|
|
||||||
|
|
||||||
每个 api-key 对应一个外部调用方(公司/组织),
|
|
||||||
可配置可调用模型、配额、有效期、并发限制。
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "api_keys"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_api_keys_active", "is_active", postgresql_where=text("deleted_at IS NULL")),
|
|
||||||
Index("idx_api_keys_company", "company_name"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
company_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
api_key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
|
||||||
api_key_prefix: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
||||||
api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False, comment="AES-256-GCM 加密的完整 API Key")
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
def decrypt_api_key(self) -> str | None:
|
|
||||||
"""解密并返回完整 API Key。"""
|
|
||||||
return decrypt_text(self.api_key_encrypted)
|
|
||||||
|
|
||||||
def set_plaintext_key(self, plaintext: str) -> None:
|
|
||||||
"""设置明文 API Key(自动加密存储)。"""
|
|
||||||
self.api_key_encrypted = encrypt_text(plaintext)
|
|
||||||
|
|
||||||
# === 可调用模型配置 ===
|
|
||||||
callable_models: Mapped[str] = mapped_column(Text, nullable=False, server_default="[]",
|
|
||||||
comment='JSON数组: [{"engine_type":"video","engine_id":"xxx","model_name":"doubao-seedance-2-0-260128"}]')
|
|
||||||
|
|
||||||
# === 配额配置(不设置=无限制) ===
|
|
||||||
quota_limit: Mapped[float | None] = mapped_column(Float, nullable=True, comment="配额总量,NULL=无限")
|
|
||||||
quota_cycle: Mapped[str | None] = mapped_column(String(16), nullable=True, comment="daily|monthly|one_time|NULL=无限")
|
|
||||||
quota_used: Mapped[float] = mapped_column(Float, nullable=False, default=0.0, server_default="0.0", comment="当前周期已使用量")
|
|
||||||
|
|
||||||
# === 有效期(不设置=永不过期) ===
|
|
||||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
# === 并发限制(不设置=无限制) ===
|
|
||||||
max_concurrent_video_tasks: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="最大并发视频任务数,NULL=无限")
|
|
||||||
|
|
||||||
# === 状态 ===
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
|
||||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyUpscaleConfig(Base, TimestampMixin):
|
|
||||||
"""API Key 级别的超分配置表。
|
|
||||||
|
|
||||||
每个 API Key 可独立配置超分规则,不依赖现有的 video_upscale 配置。
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "api_key_upscale_configs"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
api_key_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), unique=True, nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
|
||||||
delete_source_after_success: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True, server_default="true"
|
|
||||||
)
|
|
||||||
rules_json: Mapped[str] = mapped_column(
|
|
||||||
Text, nullable=False, server_default="[]",
|
|
||||||
comment='JSON数组: [{"target_resolution":"1080p","provider_generation_resolution":"720p","processor_key":"volc_standard_v1","enabled":true}]'
|
|
||||||
)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
from sqlalchemy import Float, Index, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
|
||||||
|
|
||||||
|
|
||||||
class ApiModelPricing(Base, TimestampMixin):
|
|
||||||
"""API 模型价格表(全局统一配置)。
|
|
||||||
|
|
||||||
完全镜像 credit_ratios 表结构,将积分字段替换为金额字段(元)。
|
|
||||||
所有 API Key 共用一套价格表。
|
|
||||||
|
|
||||||
model_config_id 兼容 credit_ratios 字段名约定:
|
|
||||||
- gen_type=image 时,该字段保存 image_engines.id
|
|
||||||
- gen_type=video 时,该字段保存 video_engines.id
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "api_model_pricings"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_api_model_pricings_gen_type_engine_resolution", "gen_type", "model_config_id", "resolution"),
|
|
||||||
Index("ix_api_model_pricings_gen_type_resolution", "gen_type", "resolution"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
model_config_id: Mapped[str] = mapped_column(String(32), index=True)
|
|
||||||
gen_type: Mapped[str] = mapped_column(String(16), default="video", index=True)
|
|
||||||
resolution: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
|
||||||
|
|
||||||
# === 价格字段(元) ===
|
|
||||||
price_ratio: Mapped[float] = mapped_column(Float, nullable=False, default=1.0, comment="价格系数(乘数)")
|
|
||||||
base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="基础价格(元)")
|
|
||||||
per_second_price: Mapped[float] = mapped_column(Float, default=0.0, comment="每秒价格(视频,元)")
|
|
||||||
|
|
||||||
# === 传入媒体附加费 ===
|
|
||||||
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0, comment="传入视频系数")
|
|
||||||
input_video_base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入视频基础价(元)")
|
|
||||||
input_video_per_second_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入视频每秒价(元)")
|
|
||||||
input_image_ratio: Mapped[float] = mapped_column(Float, default=1.0, comment="传入图片系数")
|
|
||||||
input_image_base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入图片基础价(元)")
|
|
||||||
input_image_per_image_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入图片每张价(元)")
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from sqlalchemy import ForeignKey, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
|
||||||
|
|
||||||
|
|
||||||
class ApiUpscaleLink(Base, TimestampMixin):
|
|
||||||
"""API 任务与超分任务的关联表。
|
|
||||||
|
|
||||||
由于不能修改现有的 video_upscale_tasks 表结构,
|
|
||||||
通过此关联表追踪 API 任务对应的超分子任务。
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "api_upscale_links"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
api_generation_task_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("api_generation_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
video_upscale_task_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("video_upscale_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
from sqlalchemy import Float, ForeignKey, Index, Integer, String, Text, text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
|
||||||
|
|
||||||
|
|
||||||
class ApiUsageLog(Base, TimestampMixin):
|
|
||||||
"""API 调用详细消耗记录表。
|
|
||||||
|
|
||||||
记录每次 API 请求的完整消费信息,包括:
|
|
||||||
- 扣除金额和退回金额
|
|
||||||
- 模型详情(名称、分辨率、时长等)
|
|
||||||
- 对应的生成任务 ID
|
|
||||||
- 操作类型(扣除/退回)
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "api_usage_logs"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_api_usage_logs_api_key_created", "api_key_id", "created_at"),
|
|
||||||
Index("idx_api_usage_logs_task_id", "api_generation_task_id"),
|
|
||||||
Index("idx_api_usage_logs_action", "price_action"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
api_key_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
api_generation_task_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(32), ForeignKey("api_generation_tasks.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# === 操作类型 ===
|
|
||||||
price_action: Mapped[str] = mapped_column(String(16), nullable=False, comment="deduct=扣除, refund=退回")
|
|
||||||
|
|
||||||
# === 请求信息 ===
|
|
||||||
request_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="video_create|image_generate")
|
|
||||||
model_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
gen_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
||||||
resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
|
||||||
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
|
|
||||||
# === 金额信息 ===
|
|
||||||
credits_cost: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0", comment="实际扣除金额")
|
|
||||||
refund_amount: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0", comment="退回金额")
|
|
||||||
quota_before: Mapped[float | None] = mapped_column(Float, nullable=True, comment="操作前配额余额")
|
|
||||||
quota_after: Mapped[float | None] = mapped_column(Float, nullable=True, comment="操作后配额余额")
|
|
||||||
|
|
||||||
# === Token 用量 ===
|
|
||||||
tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
||||||
request_duration_ms: Mapped[int] = mapped_column(Integer, default=0, server_default="0", comment="端到端耗时")
|
|
||||||
|
|
||||||
# === 价格明细(JSON) ===
|
|
||||||
price_detail_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="价格计算明细JSON")
|
|
||||||
|
|
||||||
# === 结果 ===
|
|
||||||
status: Mapped[str] = mapped_column(String(32), nullable=False, comment="success|failed")
|
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
|
|
||||||
# === 调试 ===
|
|
||||||
request_payload_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始请求快照")
|
|
||||||
@@ -49,16 +49,16 @@ class Base(AsyncAttrs, DeclarativeBase):
|
|||||||
|
|
||||||
class TimestampMixin:
|
class TimestampMixin:
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="更新时间"
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SoftDeleteMixin:
|
class SoftDeleteMixin:
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, index=True, comment="软删除时间,NULL表示未删除"
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ class VideoUpscaleTask(Base, TimestampMixin):
|
|||||||
ForeignKey("generation_records.id", ondelete="CASCADE"),
|
ForeignKey("generation_records.id", ondelete="CASCADE"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
api_generation_task_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(32),
|
|
||||||
ForeignKey("api_generation_tasks.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", server_default="pending")
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", server_default="pending")
|
||||||
stage: Mapped[str] = mapped_column(String(48), nullable=False, default="upscale_queued", server_default="upscale_queued")
|
stage: Mapped[str] = mapped_column(String(48), nullable=False, default="upscale_queued", server_default="upscale_queued")
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
from app.models.virtual_portrait_v3.api_key_quota import VpV3ApiKeyQuota
|
|
||||||
from app.models.virtual_portrait_v3.project import VpV3Project
|
|
||||||
from app.models.virtual_portrait_v3.asset import VpV3Asset
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"VpV3ApiKeyQuota",
|
|
||||||
"VpV3Project",
|
|
||||||
"VpV3Asset",
|
|
||||||
]
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3ApiKeyQuota(Base, TimestampMixin):
|
|
||||||
"""API V3 虚拟素材库配额(每个 ApiKey 一份,默认 0=不可用)。
|
|
||||||
|
|
||||||
配额在创建/删除项目、上传/删除素材时实时统计(直接 COUNT/SUM),
|
|
||||||
避免缓存不准;配额字段默认 0,后台管理配置后才可用。
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "vp_v3_api_key_quotas"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("uq_vp_v3_api_key_quotas_key_id", "api_key_id", unique=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
api_key_id: Mapped[str] = mapped_column(
|
|
||||||
String(32),
|
|
||||||
ForeignKey("api_keys.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
unique=True,
|
|
||||||
index=True,
|
|
||||||
comment="所属 API Key,唯一:一个 API Key 只有一份虚拟素材配额",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 配额上限(默认 0 = 不可使用该功能)
|
|
||||||
project_limit: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=0, server_default="0",
|
|
||||||
comment="虚拟项目上限,默认 0 不可创建",
|
|
||||||
)
|
|
||||||
asset_limit: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=0, server_default="0",
|
|
||||||
comment="虚拟素材总数上限(图片+视频),默认 0 不可上传",
|
|
||||||
)
|
|
||||||
storage_mb_limit: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=0, server_default="0",
|
|
||||||
comment="上传存储上限 MB,默认 0 不可上传文件",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 已使用量(冗余字段提升性能,每次增删同步,和真实 COUNT 不一致时以 COUNT 为准)
|
|
||||||
project_used: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=0, server_default="0",
|
|
||||||
comment="已创建项目数(未删除)",
|
|
||||||
)
|
|
||||||
asset_used: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=0, server_default="0",
|
|
||||||
comment="已上传素材数(未删除,图片+视频)",
|
|
||||||
)
|
|
||||||
storage_mb_used: Mapped[float] = mapped_column(
|
|
||||||
Integer, nullable=False, default=0, server_default="0",
|
|
||||||
comment="已占用存储 MB(未删除文件大小合计,1MB=1024*1024)",
|
|
||||||
)
|
|
||||||
|
|
||||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True, comment="后台备注")
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.enums.private_portrait import (
|
|
||||||
PrivatePortraitAssetStatus,
|
|
||||||
PrivatePortraitAssetType,
|
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
|
||||||
)
|
|
||||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3Asset(Base, TimestampMixin, SoftDeleteMixin):
|
|
||||||
"""API V3 虚拟素材(图片/视频),归属某个 Project(=火山 1 个 AssetGroup)。
|
|
||||||
|
|
||||||
字段语义和 private_portrait.PrivatePortraitAsset 保持一致,便于 service 层复用逻辑。
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "vp_v3_assets"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("uq_vp_v3_assets_remote_asset_id", "remote_asset_id", unique=True),
|
|
||||||
Index("idx_vp_v3_assets_key_status_created", "api_key_id", "status", "created_at"),
|
|
||||||
Index("idx_vp_v3_assets_project_status_created", "project_id", "status", "created_at"),
|
|
||||||
Index(
|
|
||||||
"idx_vp_v3_assets_next_poll_status",
|
|
||||||
"next_poll_at",
|
|
||||||
"status",
|
|
||||||
postgresql_where=text("deleted_at IS NULL AND next_poll_at IS NOT NULL"),
|
|
||||||
),
|
|
||||||
Index("idx_vp_v3_assets_remote_delete_status", "remote_delete_status"),
|
|
||||||
Index("idx_vp_v3_assets_asset_type", "asset_type"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
api_key_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True,
|
|
||||||
)
|
|
||||||
project_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("vp_v3_projects.id", ondelete="CASCADE"), nullable=False, index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 火山远端映射
|
|
||||||
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
|
|
||||||
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
|
||||||
remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
|
||||||
|
|
||||||
# 素材元信息
|
|
||||||
asset_type: Mapped[str] = mapped_column(
|
|
||||||
String(16), nullable=False, default=PrivatePortraitAssetType.IMAGE.value, index=True,
|
|
||||||
comment="素材类型:Image=图片 / Video=视频",
|
|
||||||
)
|
|
||||||
name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
|
||||||
|
|
||||||
# 资源 URL
|
|
||||||
source_url: Mapped[str] = mapped_column(Text, nullable=False, comment="本地上传后的访问 URL(UploadResource 返回的)")
|
|
||||||
preview_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="给前端预览/显示用的 URL(签名 URL 可能过期)")
|
|
||||||
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山返回的资源访问 URL(可能带签名和过期)")
|
|
||||||
remote_url_expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
upload_resource_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(32), nullable=True, index=True, comment="本地 UploadResource 账本 resource_id(容量释放用)",
|
|
||||||
)
|
|
||||||
video_duration: Mapped[float | None] = mapped_column(Float, nullable=True, comment="视频时长,秒")
|
|
||||||
video_cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面预览")
|
|
||||||
file_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材文件大小,字节")
|
|
||||||
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(32), nullable=False,
|
|
||||||
default=PrivatePortraitAssetStatus.CREATING.value,
|
|
||||||
server_default=PrivatePortraitAssetStatus.CREATING.value,
|
|
||||||
index=True,
|
|
||||||
comment="素材状态:creating/审核中 active/可用 failed/失败 deleting/删除中",
|
|
||||||
)
|
|
||||||
moderation_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山审核结果 JSON")
|
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因")
|
|
||||||
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山原始响应 JSON")
|
|
||||||
|
|
||||||
# 轮询控制(异步审核)
|
|
||||||
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
||||||
poll_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
||||||
|
|
||||||
# 远端删除
|
|
||||||
remote_delete_status: Mapped[str] = mapped_column(
|
|
||||||
String(32), nullable=False,
|
|
||||||
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
|
|
||||||
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.enums.private_portrait import (
|
|
||||||
PrivatePortraitProjectStatus,
|
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
|
||||||
)
|
|
||||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3Project(Base, TimestampMixin, SoftDeleteMixin):
|
|
||||||
"""API V3 虚拟素材项目(按 API Key 隔离)。
|
|
||||||
|
|
||||||
一个 VpV3Project 对应火山远端的 1 个 AssetGroup(一对一:这里不做 nested group)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "vp_v3_projects"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_vp_v3_projects_key_status_created", "api_key_id", "status", "created_at"),
|
|
||||||
Index(
|
|
||||||
"idx_vp_v3_projects_key_deleted",
|
|
||||||
"api_key_id",
|
|
||||||
"deleted_at",
|
|
||||||
postgresql_where=text("deleted_at IS NULL"),
|
|
||||||
),
|
|
||||||
Index("idx_vp_v3_projects_remote_project_name", "remote_project_name"),
|
|
||||||
Index("idx_vp_v3_projects_remote_group_id", "remote_group_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
||||||
api_key_id: Mapped[str] = mapped_column(
|
|
||||||
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True,
|
|
||||||
comment="所属 API Key(V3 调用方)",
|
|
||||||
)
|
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="项目展示名称")
|
|
||||||
name_slug: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="名称安全 slug(构建远端 GroupName 用)")
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
# 火山远端映射
|
|
||||||
remote_project_name: Mapped[str] = mapped_column(
|
|
||||||
String(256), nullable=False, index=True, comment="火山 ProjectName(快照)",
|
|
||||||
)
|
|
||||||
remote_group_id: Mapped[str] = mapped_column(
|
|
||||||
String(128), nullable=False, index=True, comment="火山 AssetGroup Id",
|
|
||||||
)
|
|
||||||
remote_group_name: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="火山 AssetGroup Name 快照")
|
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(32),
|
|
||||||
nullable=False,
|
|
||||||
default=PrivatePortraitProjectStatus.ACTIVE.value,
|
|
||||||
server_default=PrivatePortraitProjectStatus.ACTIVE.value,
|
|
||||||
index=True,
|
|
||||||
comment="项目状态:active/creating_remote_group/create_group_failed/deleting",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 计数
|
|
||||||
asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
||||||
active_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
||||||
image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
||||||
video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
||||||
active_image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
||||||
active_video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
|
||||||
storage_mb_used: Mapped[float] = mapped_column(Integer, nullable=False, default=0, server_default="0",
|
|
||||||
comment="项目占用存储 MB(未删除素材文件大小合计)")
|
|
||||||
|
|
||||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
# 远端删除状态(沿用 private_portrait 枚举)
|
|
||||||
remote_delete_status: Mapped[str] = mapped_column(
|
|
||||||
String(32),
|
|
||||||
nullable=False,
|
|
||||||
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
|
|
||||||
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="创建失败等错误信息")
|
|
||||||
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山原始响应")
|
|
||||||
@@ -162,18 +162,6 @@ class AdminCreditRecordSummaryOut(BaseModel):
|
|||||||
total_recharge: float = 0.0
|
total_recharge: float = 0.0
|
||||||
total_consume: float = 0.0
|
total_consume: float = 0.0
|
||||||
total_refund: float = 0.0
|
total_refund: float = 0.0
|
||||||
# 消费类分解(仅 type=consume,不含 team_internal 团队内部转账;真实扣费 + 预扣占用 = total_consume)
|
|
||||||
# - total_charge : 真实扣费 charge(含历史 NULL),对应"筛选明细类型=消费 且 action=charge/NULL"求和
|
|
||||||
# - total_hold : 预扣占用 hold
|
|
||||||
total_charge: float = 0.0
|
|
||||||
total_hold: float = 0.0
|
|
||||||
# 回退类分解(仅 type=refund;真实退款 + 预扣释放 = total_refund)
|
|
||||||
# - total_refund_real : 真实退款 refund(含历史 NULL)
|
|
||||||
# - total_hold_release: 预扣释放 hold_release
|
|
||||||
total_refund_real: float = 0.0
|
|
||||||
total_hold_release: float = 0.0
|
|
||||||
# 净消耗 = max(total_consume - total_refund, 0) = 实际"用掉了"的积分
|
|
||||||
net_consume: float = 0.0
|
|
||||||
transaction_count: int = 0
|
transaction_count: int = 0
|
||||||
generation_count: int = 0
|
generation_count: int = 0
|
||||||
generation_attempt_count: int = 0
|
generation_attempt_count: int = 0
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
from app.schemas.admin_api.api_key import (
|
|
||||||
ApiKeyCreateRequest,
|
|
||||||
ApiKeyUpdateRequest,
|
|
||||||
ApiKeyResponse,
|
|
||||||
ApiKeyCreateResponse,
|
|
||||||
ApiKeyListItem,
|
|
||||||
ApiKeyListOut,
|
|
||||||
)
|
|
||||||
from app.schemas.admin_api.api_upscale import (
|
|
||||||
ApiUpscaleConfigData,
|
|
||||||
ApiUpscaleConfigSaveRequest,
|
|
||||||
ApiUpscaleConfigResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.admin_api.api_usage import (
|
|
||||||
ApiUsageLogResponse,
|
|
||||||
ApiUsageSummaryResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ApiKeyCreateRequest",
|
|
||||||
"ApiKeyUpdateRequest",
|
|
||||||
"ApiKeyResponse",
|
|
||||||
"ApiKeyCreateResponse",
|
|
||||||
"ApiKeyListItem",
|
|
||||||
"ApiKeyListOut",
|
|
||||||
"ApiUpscaleConfigData",
|
|
||||||
"ApiUpscaleConfigSaveRequest",
|
|
||||||
"ApiUpscaleConfigResponse",
|
|
||||||
"ApiUsageLogResponse",
|
|
||||||
"ApiUsageSummaryResponse",
|
|
||||||
]
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
||||||
|
|
||||||
|
|
||||||
# 保留供其他地方使用
|
|
||||||
def _empty_to_null(value):
|
|
||||||
"""将空字符串转为 None,避免 Pydantic 校验失败。"""
|
|
||||||
if value == "" or value == "null":
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyCallableModel(BaseModel):
|
|
||||||
"""API Key 可调用模型配置。支持 camelCase 和 snake_case 两种字段名。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
engine_type: str = Field(..., description="video | image", alias="engineType")
|
|
||||||
engine_id: str = Field(..., description="引擎ID", alias="engineId")
|
|
||||||
model_name: str = Field(..., description="模型名称", alias="modelName")
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyCreateRequest(BaseModel):
|
|
||||||
"""创建 API Key 请求。支持 camelCase 和 snake_case 两种字段名。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
company_name: str = Field(..., max_length=128, description="公司名称", alias="companyName")
|
|
||||||
description: str | None = Field(None, description="备注")
|
|
||||||
callable_models: list[ApiKeyCallableModel] = Field(
|
|
||||||
default_factory=list, description="可调用模型列表", alias="callableModels",
|
|
||||||
)
|
|
||||||
quota_limit: float | None = Field(None, description="配额总量,NULL=无限", alias="quotaLimit")
|
|
||||||
quota_cycle: str | None = Field(None, description="daily | monthly | one_time | NULL=无限", alias="quotaCycle")
|
|
||||||
valid_from: datetime | None = Field(None, description="生效时间", alias="validFrom")
|
|
||||||
valid_until: datetime | None = Field(None, description="过期时间", alias="validUntil")
|
|
||||||
max_concurrent_video_tasks: int | None = Field(
|
|
||||||
None, description="最大并发视频任务数", alias="maxConcurrentVideoTasks",
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("valid_from", "valid_until", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def empty_str_to_none(cls, v):
|
|
||||||
if v == "" or v == "null" or v == 0:
|
|
||||||
return None
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyUpdateRequest(BaseModel):
|
|
||||||
"""更新 API Key 请求。支持 camelCase 和 snake_case 两种字段名。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
company_name: str | None = Field(None, max_length=128, alias="companyName")
|
|
||||||
description: str | None = None
|
|
||||||
callable_models: list[ApiKeyCallableModel] | None = Field(None, alias="callableModels")
|
|
||||||
quota_limit: float | None = Field(None, alias="quotaLimit")
|
|
||||||
quota_cycle: str | None = Field(None, alias="quotaCycle")
|
|
||||||
valid_from: datetime | None = Field(None, alias="validFrom")
|
|
||||||
valid_until: datetime | None = Field(None, alias="validUntil")
|
|
||||||
max_concurrent_video_tasks: int | None = Field(None, alias="maxConcurrentVideoTasks")
|
|
||||||
is_active: bool | None = Field(None, alias="isActive")
|
|
||||||
|
|
||||||
@field_validator("valid_from", "valid_until", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def empty_str_to_none(cls, v):
|
|
||||||
if v == "" or v == "null" or v == 0:
|
|
||||||
return None
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyResponse(BaseModel):
|
|
||||||
"""API Key 详情响应。"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
company_name: str
|
|
||||||
api_key_prefix: str = Field(..., description="Key 前缀,如 vk_xxxx****")
|
|
||||||
description: str | None
|
|
||||||
callable_models: list[ApiKeyCallableModel]
|
|
||||||
quota_limit: float | None
|
|
||||||
quota_cycle: str | None
|
|
||||||
quota_used: float
|
|
||||||
valid_from: datetime | None
|
|
||||||
valid_until: datetime | None
|
|
||||||
max_concurrent_video_tasks: int | None
|
|
||||||
is_active: bool
|
|
||||||
last_used_at: datetime | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyCreateResponse(BaseModel):
|
|
||||||
"""创建 API Key 响应(包含完整明文 Key,仅此一次)。"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
company_name: str
|
|
||||||
api_key: str = Field(..., description="完整 API Key,仅创建时返回一次")
|
|
||||||
api_key_prefix: str
|
|
||||||
valid_until: datetime | None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyRevealResponse(BaseModel):
|
|
||||||
"""揭秘 API Key 响应(随时可获取明文)。"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
company_name: str
|
|
||||||
api_key: str = Field(..., description="完整 API Key")
|
|
||||||
api_key_prefix: str
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyListItem(BaseModel):
|
|
||||||
"""API Key 列表项。"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
company_name: str
|
|
||||||
api_key_prefix: str
|
|
||||||
description: str | None
|
|
||||||
callable_models: list[ApiKeyCallableModel] = []
|
|
||||||
quota_limit: float | None
|
|
||||||
quota_cycle: str | None
|
|
||||||
quota_used: float
|
|
||||||
is_active: bool
|
|
||||||
valid_from: datetime | None
|
|
||||||
valid_until: datetime | None
|
|
||||||
max_concurrent_video_tasks: int | None
|
|
||||||
last_used_at: datetime | None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyQuotaAdjustRequest(BaseModel):
|
|
||||||
"""配额调整请求。支持 camelCase 和 snake_case 两种字段名。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
action: str = Field(
|
|
||||||
...,
|
|
||||||
pattern=r"^(adjust|reset_usage|set_limit|change_cycle)$",
|
|
||||||
description="adjust=增加总额 | reset_usage=重置已用 | set_limit=设置限额 | change_cycle=修改周期",
|
|
||||||
)
|
|
||||||
quota_limit_delta: float | None = Field(None, ge=0, description="增加总额时的增量", alias="quotaLimitDelta")
|
|
||||||
quota_limit: float | None = Field(None, description="设置新限额时的值(NULL=无限)", alias="quotaLimit")
|
|
||||||
quota_cycle: str | None = Field(None, description="修改周期时的值", alias="quotaCycle")
|
|
||||||
reason: str | None = Field(None, max_length=500, description="调整原因/备注")
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyListOut(BaseModel):
|
|
||||||
"""API Key 列表响应。"""
|
|
||||||
|
|
||||||
total: int
|
|
||||||
items: list[ApiKeyListItem]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.schemas.common import NaiveDatetime
|
|
||||||
|
|
||||||
|
|
||||||
class ApiModelPricingCreate(BaseModel):
|
|
||||||
"""创建 API 模型价格请求。支持 camelCase 和 snake_case 两种字段名。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
model_config_id: str = Field(
|
|
||||||
..., max_length=32, description="引擎ID", alias="modelConfigId",
|
|
||||||
)
|
|
||||||
gen_type: str = Field(default="video", max_length=16, description="image | video", alias="genType")
|
|
||||||
resolution: str = Field(..., max_length=16, description="分辨率")
|
|
||||||
price_ratio: float = Field(default=1.0, gt=0, description="价格系数", alias="priceRatio")
|
|
||||||
base_price: float = Field(default=0.0, ge=0, description="基础价格(元)", alias="basePrice")
|
|
||||||
per_second_price: float = Field(default=0.0, ge=0, description="每秒价格(元)", alias="perSecondPrice")
|
|
||||||
input_video_ratio: float = Field(default=1.0, ge=0, description="传入视频系数", alias="inputVideoRatio")
|
|
||||||
input_video_base_price: float = Field(default=0.0, ge=0, description="传入视频基础价(元)", alias="inputVideoBasePrice")
|
|
||||||
input_video_per_second_price: float = Field(default=0.0, ge=0, description="传入视频每秒价(元)", alias="inputVideoPerSecondPrice")
|
|
||||||
input_image_ratio: float = Field(default=1.0, ge=0, description="传入图片系数", alias="inputImageRatio")
|
|
||||||
input_image_base_price: float = Field(default=0.0, ge=0, description="传入图片基础价(元)", alias="inputImageBasePrice")
|
|
||||||
input_image_per_image_price: float = Field(default=0.0, ge=0, description="传入图片每张价(元)", alias="inputImagePerImagePrice")
|
|
||||||
|
|
||||||
|
|
||||||
class ApiModelPricingOut(ApiModelPricingCreate):
|
|
||||||
"""API 模型价格响应。"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
created_at: NaiveDatetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ApiUpscaleRule(BaseModel):
|
|
||||||
"""API 超分规则。支持 camelCase 和 snake_case 两种字段名。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
target_resolution: str = Field(..., description="目标分辨率: 480p | 720p | 1080p | 2K | 4K", alias="targetResolution")
|
|
||||||
provider_generation_resolution: str = Field(..., description="供应商生成分辨率", alias="providerGenerationResolution")
|
|
||||||
processor_key: str = Field(
|
|
||||||
...,
|
|
||||||
description="处理器: local_ffmpeg_crop_v1 | volc_standard_v1 | volc_professional_v1 | volc_large_model_v1",
|
|
||||||
alias="processorKey",
|
|
||||||
)
|
|
||||||
enabled: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class ApiUpscaleConfigData(BaseModel):
|
|
||||||
"""API 超分配置数据。支持 camelCase 和 snake_case 两种字段名。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
delete_source_after_success: bool = Field(True, alias="deleteSourceAfterSuccess")
|
|
||||||
rules: list[ApiUpscaleRule] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiUpscaleConfigSaveRequest(BaseModel):
|
|
||||||
"""保存 API 超分配置请求。"""
|
|
||||||
|
|
||||||
data: ApiUpscaleConfigData
|
|
||||||
|
|
||||||
|
|
||||||
class ApiUpscaleConfigResponse(BaseModel):
|
|
||||||
"""API 超分配置响应。"""
|
|
||||||
|
|
||||||
data: ApiUpscaleConfigData
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class ApiUsageLogResponse(BaseModel):
|
|
||||||
"""API 使用日志响应。"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
api_key_id: str
|
|
||||||
api_generation_task_id: str | None
|
|
||||||
request_type: str
|
|
||||||
model_name: str
|
|
||||||
gen_type: str
|
|
||||||
credits_cost: float
|
|
||||||
tokens_used: int
|
|
||||||
request_duration_ms: int
|
|
||||||
status: str
|
|
||||||
error_message: str | None
|
|
||||||
error_code: str | None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class ApiUsageSummaryResponse(BaseModel):
|
|
||||||
"""API 使用汇总响应。"""
|
|
||||||
|
|
||||||
total_requests: int
|
|
||||||
total_credits_cost: float
|
|
||||||
total_tokens_used: int
|
|
||||||
success_count: int
|
|
||||||
failed_count: int
|
|
||||||
avg_duration_ms: int
|
|
||||||
total: int = 0
|
|
||||||
page: int = 1
|
|
||||||
page_size: int = 20
|
|
||||||
items: list[ApiUsageLogResponse]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3QuotaConfigData(BaseModel):
|
|
||||||
"""后台保存虚拟素材库配额。"""
|
|
||||||
|
|
||||||
project_limit: int = Field(0, ge=0, description="虚拟项目上限,0=不可创建")
|
|
||||||
asset_limit: int = Field(0, ge=0, description="虚拟素材总数上限,0=不可上传")
|
|
||||||
storage_mb_limit: int = Field(0, ge=0, description="存储上限 MB,0=不可上传文件")
|
|
||||||
remark: str | None = Field(None, max_length=500, description="后台备注")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3QuotaConfigResponse(BaseModel):
|
|
||||||
"""虚拟素材库配额响应。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
api_key_id: str = Field(description="API Key ID")
|
|
||||||
|
|
||||||
# 上限
|
|
||||||
project_limit: int = Field(0, description="虚拟项目上限")
|
|
||||||
asset_limit: int = Field(0, description="虚拟素材上限")
|
|
||||||
storage_mb_limit: int = Field(0, description="存储上限 MB")
|
|
||||||
remark: str | None = Field(None, description="备注")
|
|
||||||
|
|
||||||
# 已使用
|
|
||||||
project_used: int = Field(0, description="已创建项目数")
|
|
||||||
asset_used: int = Field(0, description="已上传素材数")
|
|
||||||
storage_mb_used: float = Field(0.0, description="已使用存储 MB")
|
|
||||||
|
|
||||||
enabled: bool = Field(False, description="是否启用(任一上限 > 0)")
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from app.schemas.api_v3.video import (
|
|
||||||
ApiVideoContentPart,
|
|
||||||
ApiVideoCreateRequest,
|
|
||||||
ApiVideoCreateResponse,
|
|
||||||
ApiVideoStatusResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.api_v3.image import (
|
|
||||||
ApiImageGenerateRequest,
|
|
||||||
ApiImageGenerateResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.api_v3.model import (
|
|
||||||
ApiModelInfo,
|
|
||||||
ApiModelsResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.api_v3.common import (
|
|
||||||
ApiError,
|
|
||||||
ApiErrorResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ApiVideoContentPart",
|
|
||||||
"ApiVideoCreateRequest",
|
|
||||||
"ApiVideoCreateResponse",
|
|
||||||
"ApiVideoStatusResponse",
|
|
||||||
"ApiImageGenerateRequest",
|
|
||||||
"ApiImageGenerateResponse",
|
|
||||||
"ApiModelInfo",
|
|
||||||
"ApiModelsResponse",
|
|
||||||
"ApiError",
|
|
||||||
"ApiErrorResponse",
|
|
||||||
]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class ApiError(BaseModel):
|
|
||||||
"""API 错误详情。"""
|
|
||||||
|
|
||||||
code: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class ApiErrorResponse(BaseModel):
|
|
||||||
"""API 错误响应。"""
|
|
||||||
|
|
||||||
error: ApiError
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ApiImageGenerateRequest(BaseModel):
|
|
||||||
"""图片生成请求。支持全量 Volcano Ark SDK 参数。"""
|
|
||||||
|
|
||||||
model: str = Field(..., description="模型名称, 如 doubao-seedream-5-0-260128")
|
|
||||||
prompt: str = Field(..., description="图片描述提示词")
|
|
||||||
size: str | None = Field("2K", description="图片尺寸: 2K | 4K 或 2048x2048")
|
|
||||||
response_format: str | None = Field("url", description="返回格式: url | b64_json")
|
|
||||||
watermark: bool | None = Field(False, description="是否添加水印")
|
|
||||||
image: list[str] | None = Field(None, description="参考图片URL列表")
|
|
||||||
output_format: str | None = Field(None, description="输出格式: jpeg | png | webp")
|
|
||||||
sequential_image_generation: str | None = Field(
|
|
||||||
None, description="组图模式: auto 开启"
|
|
||||||
)
|
|
||||||
generation_count: int | None = Field(1, ge=1, le=5, description="生成数量: 1-5")
|
|
||||||
|
|
||||||
|
|
||||||
class ApiImageGenerateDataItem(BaseModel):
|
|
||||||
"""单张图片结果。"""
|
|
||||||
|
|
||||||
url: str | None = None
|
|
||||||
b64_json: str | None = None
|
|
||||||
size: str | None = None
|
|
||||||
output_format: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ApiImageGenerateResponse(BaseModel):
|
|
||||||
"""图片生成响应(同步返回)。"""
|
|
||||||
|
|
||||||
created: int
|
|
||||||
data: list[ApiImageGenerateDataItem]
|
|
||||||
model: str
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ApiModelInfo(BaseModel):
|
|
||||||
"""可用模型信息。"""
|
|
||||||
|
|
||||||
model: str = Field(..., description="模型名称")
|
|
||||||
engine_type: str = Field(..., description="引擎类型: video | image")
|
|
||||||
engine_id: str = Field(..., description="引擎ID")
|
|
||||||
supported_ratios: list[str] | None = None
|
|
||||||
supported_resolutions: list[str] | None = None
|
|
||||||
supported_durations: list[int] | None = None
|
|
||||||
supported_sizes: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ApiModelsResponse(BaseModel):
|
|
||||||
"""可用模型列表响应。"""
|
|
||||||
|
|
||||||
models: list[ApiModelInfo]
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
|
|
||||||
class ApiVideoContentPart(BaseModel):
|
|
||||||
"""视频生成内容部分:文本/图片/视频/音频参考。"""
|
|
||||||
|
|
||||||
type: str = Field(..., description="内容类型: text | image_url | video_url | audio_url")
|
|
||||||
text: str | None = None
|
|
||||||
image_url: dict | None = Field(None, description="图片URL对象: {\"url\": \"...\"}")
|
|
||||||
video_url: dict | None = Field(None, description="视频URL对象: {\"url\": \"...\"}")
|
|
||||||
audio_url: dict | None = Field(None, description="音频URL对象: {\"url\": \"...\"}")
|
|
||||||
role: str | None = Field(
|
|
||||||
None,
|
|
||||||
description="参考角色: first_frame | last_frame | reference_image | reference_video | reference_audio",
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("type")
|
|
||||||
@classmethod
|
|
||||||
def validate_type(cls, v):
|
|
||||||
allowed = {"text", "image_url", "video_url", "audio_url"}
|
|
||||||
if v not in allowed:
|
|
||||||
raise ValueError(f"type 必须是 {allowed} 之一,当前值: {v}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("role")
|
|
||||||
@classmethod
|
|
||||||
def validate_role(cls, v, info):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
type_value = info.data.get("type")
|
|
||||||
role_map = {
|
|
||||||
"image_url": {"first_frame", "last_frame", "reference_image"},
|
|
||||||
"video_url": {"reference_video"},
|
|
||||||
"audio_url": {"reference_audio"},
|
|
||||||
}
|
|
||||||
allowed_roles = role_map.get(type_value, set())
|
|
||||||
if v not in allowed_roles:
|
|
||||||
raise ValueError(
|
|
||||||
f"type={type_value} 时 role 必须是 {allowed_roles} 之一,当前值: {v}"
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("image_url")
|
|
||||||
@classmethod
|
|
||||||
def validate_image_url(cls, v, info):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
type_value = info.data.get("type")
|
|
||||||
if type_value == "image_url" and (not v or not v.get("url")):
|
|
||||||
raise ValueError("type=image_url 时 image_url.url 不能为空")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("video_url")
|
|
||||||
@classmethod
|
|
||||||
def validate_video_url(cls, v, info):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
type_value = info.data.get("type")
|
|
||||||
if type_value == "video_url" and (not v or not v.get("url")):
|
|
||||||
raise ValueError("type=video_url 时 video_url.url 不能为空")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("audio_url")
|
|
||||||
@classmethod
|
|
||||||
def validate_audio_url(cls, v, info):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
type_value = info.data.get("type")
|
|
||||||
if type_value == "audio_url" and (not v or not v.get("url")):
|
|
||||||
raise ValueError("type=audio_url 时 audio_url.url 不能为空")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ApiVideoCreateRequest(BaseModel):
|
|
||||||
"""视频生成请求。支持全量 Volcano Ark SDK 参数。"""
|
|
||||||
|
|
||||||
model: str = Field(..., description="模型名称, 如 doubao-seedance-2-0-260128")
|
|
||||||
content: list[ApiVideoContentPart] = Field(
|
|
||||||
..., min_length=1, description="生成内容: 文本提示词 + 可选的图片/视频/音频参考"
|
|
||||||
)
|
|
||||||
ratio: str | None = Field("16:9", description="视频比例: 16:9 | 9:16 | 1:1 | 4:3 | 3:4 | 21:9")
|
|
||||||
duration: int | None = Field(5, ge=3, le=30, description="视频时长(秒): 3-30")
|
|
||||||
resolution: str | None = Field("480p", description="分辨率: 480p | 720p | 1080p")
|
|
||||||
generate_audio: bool | None = Field(True, description="是否生成音频")
|
|
||||||
watermark: bool | None = Field(False, description="是否添加水印")
|
|
||||||
idempotency_key: str | None = Field(None, description="幂等键,防止重复创建")
|
|
||||||
|
|
||||||
@field_validator("ratio")
|
|
||||||
@classmethod
|
|
||||||
def validate_ratio(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
allowed = {"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}
|
|
||||||
if v not in allowed:
|
|
||||||
raise ValueError(f"ratio 必须是 {allowed} 之一,当前值: {v}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("resolution")
|
|
||||||
@classmethod
|
|
||||||
def validate_resolution(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
allowed = {"480p", "720p", "1080p"}
|
|
||||||
if v not in allowed:
|
|
||||||
raise ValueError(f"resolution 必须是 {allowed} 之一,当前值: {v}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ApiVideoCreateResponse(BaseModel):
|
|
||||||
"""视频任务创建响应。"""
|
|
||||||
|
|
||||||
id: str = Field(..., description="任务ID")
|
|
||||||
|
|
||||||
|
|
||||||
class ApiVideoContent(BaseModel):
|
|
||||||
"""视频内容(成功时返回)。"""
|
|
||||||
|
|
||||||
video_url: str = Field(..., description="视频URL")
|
|
||||||
|
|
||||||
|
|
||||||
class ApiVideoStatusResponse(BaseModel):
|
|
||||||
"""视频任务状态查询响应。"""
|
|
||||||
|
|
||||||
id: str = Field(..., description="任务ID")
|
|
||||||
model: str = Field(..., description="模型名称")
|
|
||||||
status: str = Field(..., description="任务状态: queued | running | succeeded | failed | expired")
|
|
||||||
created_at: int = Field(..., description="创建时间戳(Unix)")
|
|
||||||
updated_at: int = Field(..., description="更新时间戳(Unix)")
|
|
||||||
content: ApiVideoContent | None = Field(None, description="视频内容(成功时返回)")
|
|
||||||
duration: int | None = Field(None, description="视频时长(秒)")
|
|
||||||
ratio: str | None = Field(None, description="视频比例")
|
|
||||||
resolution: str | None = Field(None, description="分辨率")
|
|
||||||
error: str | None = Field(None, description="错误信息(失败时返回)")
|
|
||||||
@@ -11,11 +11,11 @@ class VideoEngineCreate(BaseModel):
|
|||||||
model_name: str = Field(default="", max_length=128)
|
model_name: str = Field(default="", max_length=128)
|
||||||
supported_ratios: str = Field(default='["16:9","4:3","1:1","3:4","9:16","21:9"]')
|
supported_ratios: str = Field(default='["16:9","4:3","1:1","3:4","9:16","21:9"]')
|
||||||
supported_resolutions: str = Field(default='["480p","720p","1080p"]')
|
supported_resolutions: str = Field(default='["480p","720p","1080p"]')
|
||||||
supported_durations: str = Field(default='[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30]')
|
supported_durations: str = Field(default='[4,5,6,7,8,9,10,11,12,13,14,15]')
|
||||||
max_duration: int = Field(default=15)
|
max_duration: int = Field(default=15)
|
||||||
max_image_count: int = Field(default=2)
|
max_image_count: int = Field(default=2)
|
||||||
max_video_count: int = Field(default=0)
|
max_video_count: int = Field(default=0)
|
||||||
max_audio_count: int = Field(default=0, description="最大参考音频数量,0 表示不支持音频参考")
|
max_audio_count: int = Field(default=0, ge=0, le=3, description="最大参考音频数量,0 表示不支持音频参考")
|
||||||
multi_generation_enabled: bool = Field(
|
multi_generation_enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="是否允许客户端选择生成多个视频;关闭时客户端只能选择 1 份",
|
description="是否允许客户端选择生成多个视频;关闭时客户端只能选择 1 份",
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
from app.schemas.virtual_portrait_v3.common import VpV3EnumMeta
|
|
||||||
from app.schemas.virtual_portrait_v3.quota import VpV3QuotaConfigOut
|
|
||||||
from app.schemas.virtual_portrait_v3.project import (
|
|
||||||
VpV3IdOut,
|
|
||||||
VpV3ProjectCreate,
|
|
||||||
VpV3ProjectDeleteOut,
|
|
||||||
VpV3ProjectListOut,
|
|
||||||
VpV3ProjectOut,
|
|
||||||
VpV3ProjectUpdate,
|
|
||||||
)
|
|
||||||
from app.schemas.virtual_portrait_v3.asset import (
|
|
||||||
VpV3AssetCreate,
|
|
||||||
VpV3AssetDeleteOut,
|
|
||||||
VpV3AssetListOut,
|
|
||||||
VpV3AssetOut,
|
|
||||||
VpV3SelectableAssetListOut,
|
|
||||||
VpV3SelectableAssetOut,
|
|
||||||
)
|
|
||||||
from app.schemas.virtual_portrait_v3.upload import VpV3UploadOut
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"VpV3EnumMeta",
|
|
||||||
"VpV3QuotaConfigOut",
|
|
||||||
"VpV3IdOut",
|
|
||||||
"VpV3ProjectCreate",
|
|
||||||
"VpV3ProjectUpdate",
|
|
||||||
"VpV3ProjectOut",
|
|
||||||
"VpV3ProjectListOut",
|
|
||||||
"VpV3ProjectDeleteOut",
|
|
||||||
"VpV3AssetCreate",
|
|
||||||
"VpV3AssetOut",
|
|
||||||
"VpV3AssetListOut",
|
|
||||||
"VpV3AssetDeleteOut",
|
|
||||||
"VpV3SelectableAssetOut",
|
|
||||||
"VpV3SelectableAssetListOut",
|
|
||||||
"VpV3UploadOut",
|
|
||||||
]
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3AssetCreate(BaseModel):
|
|
||||||
"""在项目下创建虚拟素材请求(一步到位:接收远程 URL 先下载到本地,再同步火山)。"""
|
|
||||||
|
|
||||||
source_url: str = Field(
|
|
||||||
...,
|
|
||||||
min_length=8,
|
|
||||||
max_length=2000,
|
|
||||||
description=(
|
|
||||||
"素材源 URL(必须 http/https 公网可访问的图片/视频直链,"
|
|
||||||
"系统先将其下载保存到本地存储并占用存储配额,再同步到火山)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
name: str | None = Field(
|
|
||||||
None, max_length=100, description="素材名称(可选;不传则自动从 URL 文件名 / Content-Disposition 推断)",
|
|
||||||
)
|
|
||||||
asset_type: str = Field(
|
|
||||||
..., pattern=r"^(Image|Video)$", description="素材类型:Image=图片 / Video=视频",
|
|
||||||
)
|
|
||||||
video_duration: float | None = Field(
|
|
||||||
None, description="视频时长,秒(Video 可选;不传时系统自动用 ffprobe 探测;最大 60 秒)",
|
|
||||||
)
|
|
||||||
video_cover_url: str | None = Field(
|
|
||||||
None, description="视频封面图 URL(可选,仅 Video 用,建议 16:9)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3AssetOut(BaseModel):
|
|
||||||
"""虚拟素材详情响应。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
asset_id: str = Field(description="素材 ID")
|
|
||||||
project_id: str = Field(description="所属项目 ID")
|
|
||||||
name: str | None = Field(None, description="素材名称")
|
|
||||||
asset_type: str = Field(description="素材类型:Image/Video")
|
|
||||||
status: str = Field(description="素材状态")
|
|
||||||
|
|
||||||
# 显示 URL
|
|
||||||
source_url: str = Field(description="原始上传 URL")
|
|
||||||
preview_url: str | None = Field(None, description="显示用预览 URL(可能带签名过期)")
|
|
||||||
remote_url: str | None = Field(None, description="火山返回的资源访问 URL(可能带签名过期)")
|
|
||||||
remote_url_expired_at: datetime | None = Field(None, description="remote_url 过期时间")
|
|
||||||
|
|
||||||
video_duration: float | None = Field(None, description="视频时长秒")
|
|
||||||
video_cover_url: str | None = Field(None, description="视频封面")
|
|
||||||
file_size_bytes: int | None = Field(None, description="文件大小字节")
|
|
||||||
mime_type: str | None = Field(None, description="MIME 类型")
|
|
||||||
|
|
||||||
moderation_json: dict | None = Field(None, description="火山审核 JSON(失败时可查看原因)")
|
|
||||||
error_message: str | None = Field(None, description="失败原因")
|
|
||||||
remote_delete_status: str = Field("none", description="远端删除状态")
|
|
||||||
|
|
||||||
created_at: datetime = Field(description="创建时间")
|
|
||||||
updated_at: datetime = Field(description="最后更新时间")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3AssetListOut(BaseModel):
|
|
||||||
"""虚拟素材列表响应。"""
|
|
||||||
|
|
||||||
items: list[VpV3AssetOut] = Field(default_factory=list)
|
|
||||||
total: int = Field(0, description="总数")
|
|
||||||
page: int = Field(1, description="当前页码")
|
|
||||||
page_size: int = Field(20, description="每页数量")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3AssetDeleteOut(BaseModel):
|
|
||||||
"""删除响应。"""
|
|
||||||
|
|
||||||
success: bool = Field(True)
|
|
||||||
remote_delete_status: str = Field(description="远端删除状态")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3SelectableAssetOut(BaseModel):
|
|
||||||
"""AI 创作选择器使用的素材条目。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
asset_id: str = Field(description="素材 ID(带入生成用 source=vp_v3_asset + asset_id)")
|
|
||||||
project_id: str = Field(description="项目 ID")
|
|
||||||
name: str | None = Field(None)
|
|
||||||
asset_type: str = Field(description="Image/Video")
|
|
||||||
status: str = Field(description="状态=Active")
|
|
||||||
|
|
||||||
source_url: str = Field(description="原始上传 URL")
|
|
||||||
preview_url: str | None = Field(None, description="预览 URL(直接显示用)")
|
|
||||||
video_duration: float | None = Field(None)
|
|
||||||
video_cover_url: str | None = Field(None)
|
|
||||||
file_size_bytes: int | None = Field(None)
|
|
||||||
|
|
||||||
created_at: datetime = Field(description="创建时间")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3SelectableAssetListOut(BaseModel):
|
|
||||||
"""AI 创作选择器素材列表。"""
|
|
||||||
|
|
||||||
items: list[VpV3SelectableAssetOut] = Field(default_factory=list)
|
|
||||||
total: int = Field(0)
|
|
||||||
page: int = Field(1)
|
|
||||||
page_size: int = Field(20)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3EnumMeta(BaseModel):
|
|
||||||
"""虚拟素材库枚举元数据。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
asset_type: dict[str, str] = Field(description="素材类型:Image=图片 / Video=视频")
|
|
||||||
asset_status: dict[str, str] = Field(description="素材状态:creating/审核中 active/可用 failed/失败")
|
|
||||||
project_status: dict[str, str] = Field(description="项目状态:active/creating_remote_group/create_group_failed/deleting")
|
|
||||||
remote_delete_status: dict[str, str] = Field(description="远端删除状态:none/pending/processing/deleted/failed")
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3ProjectCreate(BaseModel):
|
|
||||||
"""创建虚拟素材项目请求。"""
|
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=100, description="项目名称,1-100 字符")
|
|
||||||
description: str | None = Field(None, max_length=500, description="项目描述,最多 500 字符")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3ProjectUpdate(BaseModel):
|
|
||||||
"""更新虚拟素材项目请求。"""
|
|
||||||
|
|
||||||
name: str | None = Field(None, min_length=1, max_length=100, description="项目名称,1-100 字符")
|
|
||||||
description: str | None = Field(None, max_length=500, description="项目描述,最多 500 字符")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3ProjectOut(BaseModel):
|
|
||||||
"""虚拟素材项目详情响应。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
project_id: str = Field(description="项目 ID")
|
|
||||||
name: str = Field(description="项目名称")
|
|
||||||
description: str | None = Field(None, description="项目描述")
|
|
||||||
status: str = Field(description="项目状态")
|
|
||||||
|
|
||||||
# 计数
|
|
||||||
asset_count: int = Field(0, description="素材总数(含失败、删除中)")
|
|
||||||
active_asset_count: int = Field(0, description="可用素材数(status=active)")
|
|
||||||
image_asset_count: int = Field(0, description="图片素材数")
|
|
||||||
video_asset_count: int = Field(0, description="视频素材数")
|
|
||||||
storage_mb_used: float = Field(0, description="已占用存储 MB")
|
|
||||||
|
|
||||||
remote_delete_status: str = Field("none", description="远端删除状态")
|
|
||||||
error_message: str | None = Field(None, description="最近一次错误信息")
|
|
||||||
|
|
||||||
created_at: datetime = Field(description="创建时间")
|
|
||||||
updated_at: datetime = Field(description="最后更新时间")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3ProjectListOut(BaseModel):
|
|
||||||
"""虚拟素材项目列表响应。"""
|
|
||||||
|
|
||||||
items: list[VpV3ProjectOut] = Field(default_factory=list)
|
|
||||||
total: int = Field(0, description="总数")
|
|
||||||
page: int = Field(1, ge=1, description="当前页码")
|
|
||||||
page_size: int = Field(20, ge=1, description="每页数量")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3ProjectDeleteOut(BaseModel):
|
|
||||||
"""删除响应。"""
|
|
||||||
|
|
||||||
success: bool = Field(True)
|
|
||||||
remote_delete_status: str = Field(description="远端删除状态:none/pending/...")
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3IdOut(BaseModel):
|
|
||||||
"""创建接口的简单 ID 响应。"""
|
|
||||||
|
|
||||||
Id: str = Field(description="资源 ID")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3QuotaConfigOut(BaseModel):
|
|
||||||
"""当前 API Key 的虚拟素材配额&已使用量。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
# 上限
|
|
||||||
project_limit: int = Field(0, description="虚拟项目上限,0=不可创建")
|
|
||||||
asset_limit: int = Field(0, description="虚拟素材总数上限,0=不可上传")
|
|
||||||
storage_mb_limit: int = Field(0, description="上传存储上限 MB,0=不可上传文件")
|
|
||||||
|
|
||||||
# 已使用
|
|
||||||
project_used: int = Field(0, description="已创建项目数(未删除)")
|
|
||||||
asset_used: int = Field(0, description="已上传素材数(未删除,图片+视频)")
|
|
||||||
storage_mb_used: float = Field(0, description="已占用存储 MB(未删除文件大小合计)")
|
|
||||||
|
|
||||||
enabled: bool = Field(False, description="该 API Key 是否可使用虚拟素材库功能(任一上限 > 0 即可)")
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class VpV3UploadOut(BaseModel):
|
|
||||||
"""上传文件响应(写入 UploadResource 账本后返回)。"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
url: str = Field(description="上传后的访问 URL,创建素材时作为 source_url 传入")
|
|
||||||
filename: str = Field(description="文件名")
|
|
||||||
type: str = Field(description="资源类型:Image/Video")
|
|
||||||
resource_id: str = Field(description="UploadResource 的 resource_id,创建素材时请回传 upload_resource_id")
|
|
||||||
file_size_bytes: int = Field(0, description="文件大小字节")
|
|
||||||
duration_seconds: float | None = Field(None, description="视频时长秒(Video 上传返回)")
|
|
||||||
@@ -59,9 +59,7 @@ def _as_date_start(value: str | None) -> datetime | None:
|
|||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
# 构造东八区 00:00:00 与 DB timezone-aware created_at 比较,避免 8 小时偏移
|
return datetime.strptime(value, "%Y-%m-%d")
|
||||||
naive = datetime.strptime(value, "%Y-%m-%d")
|
|
||||||
return naive.replace(tzinfo=CST)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -70,11 +68,7 @@ def _as_date_end(value: str | None) -> datetime | None:
|
|||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
# 构造东八区 23:59:59.999999
|
return datetime.strptime(value, "%Y-%m-%d").replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
naive = datetime.strptime(value, "%Y-%m-%d").replace(
|
|
||||||
hour=23, minute=59, second=59, microsecond=999999,
|
|
||||||
)
|
|
||||||
return naive.replace(tzinfo=CST)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -256,9 +250,7 @@ async def list_admin_credit_records(
|
|||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
page = max(int(page or 1), 1)
|
page = max(int(page or 1), 1)
|
||||||
# 列表页默认最多 1000 条;导出接口可传较大值(最多 100000 条),避免月度导出被截断
|
page_size = min(max(int(page_size or 20), 1), 1000)
|
||||||
max_page_size = 100000 if page_size is not None and int(page_size) > 1000 else 1000
|
|
||||||
page_size = min(max(int(page_size or 20), 1), max_page_size)
|
|
||||||
filters = _build_filters(
|
filters = _build_filters(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
@@ -295,103 +287,30 @@ async def list_admin_credit_records(
|
|||||||
deleted_map = await _load_deleted_map(db, records)
|
deleted_map = await _load_deleted_map(db, records)
|
||||||
items = [_record_to_item(record, user, deleted_map) for record, user in rows]
|
items = [_record_to_item(record, user, deleted_map) for record, user in rows]
|
||||||
|
|
||||||
# 说明:
|
|
||||||
# - consume 类型:amount 是负数(扣减积分),统计用 abs() 保证为正值
|
|
||||||
# team_internal(团队内部积分流转/管理员分配)不参与消费/扣费统计——它不是真实消费
|
|
||||||
# - refund 类型:amount 是正数(退回积分),为兼容旧数据/边缘场景也用 abs() 保证统计值恒正
|
|
||||||
# 子分类:真实退款 refund(action='refund'/NULL) + 预扣释放 hold_release(action='hold_release')
|
|
||||||
# - recharge 类型:amount 是正数(充值增加),金额直接求和,不需要 abs
|
|
||||||
#
|
|
||||||
# 口径更新(Bug 修复 · 第二次修正):
|
|
||||||
# 1. 消费类统计仅看 type=consume(排除 team_internal 团队内部转账)
|
|
||||||
# 2. 真实扣费 / 预扣占用 / 真实退款 / 预扣释放 全部改为"独立统计列",不再用差值推导
|
|
||||||
# (避免任何一类范围不同导致推导失真)
|
|
||||||
#
|
|
||||||
# 消费类(type=consume):
|
|
||||||
# - total_charge :真实扣费 charge_action in (NULL, 'charge') abs 求和
|
|
||||||
# - total_hold :预扣占用 charge_action = 'hold' abs 求和
|
|
||||||
# - total_consume :total_charge + total_hold = charge_action in (NULL, charge, hold) abs 求和
|
|
||||||
# 回退类(type=refund):
|
|
||||||
# - total_refund_real :真实退款 charge_action in (NULL, 'refund') abs 求和
|
|
||||||
# - total_hold_release :预扣释放 charge_action = 'hold_release' abs 求和
|
|
||||||
# - total_refund :total_refund_real + total_hold_release = type=refund 全部 abs 求和
|
|
||||||
# 净消耗 net_consume = max(total_consume - total_refund, 0)
|
|
||||||
#
|
|
||||||
# 按积分 subject 分类的子项(图片/视频/提词/分析)仍保持「仅真实扣费 charge」口径不变:
|
|
||||||
# 预扣是按任务预估的冻结,不是按图/视频实际产出,会让子分类统计失真。
|
|
||||||
_real_charge_action = or_(
|
|
||||||
CreditRecord.charge_action.is_(None),
|
|
||||||
CreditRecord.charge_action == "charge",
|
|
||||||
)
|
|
||||||
_charge_or_hold_action = or_(
|
|
||||||
CreditRecord.charge_action.is_(None),
|
|
||||||
CreditRecord.charge_action == "charge",
|
|
||||||
CreditRecord.charge_action == "hold",
|
|
||||||
)
|
|
||||||
_real_refund_action = or_(
|
|
||||||
CreditRecord.charge_action.is_(None),
|
|
||||||
CreditRecord.charge_action == "refund",
|
|
||||||
)
|
|
||||||
# 仅统计 type=consume 的消费类(排除 team_internal 团队内部转账)
|
|
||||||
_consume_type = CreditRecord.type == "consume"
|
|
||||||
# 预扣释放 / 真实退款 filter(都是 type=refund,账本 L256 强校验 hold_release.type=refund)
|
|
||||||
_hold_release_filter = and_(
|
|
||||||
CreditRecord.type == "refund",
|
|
||||||
CreditRecord.charge_action == "hold_release",
|
|
||||||
)
|
|
||||||
_refund_type = CreditRecord.type == "refund"
|
|
||||||
summary_query = select(
|
summary_query = select(
|
||||||
# 0: 充值
|
|
||||||
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
|
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
|
||||||
# 1: 总消费 = total_charge + total_hold(真实扣费 + 预扣占用)
|
func.coalesce(func.sum(case((and_(CreditRecord.type.in_(["consume", "team_internal"]), (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||||
func.coalesce(func.sum(case((and_(_consume_type, _charge_or_hold_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
|
||||||
# 2: 总回退 = 真实退款 + 预扣释放(type=refund 全部流水)
|
|
||||||
func.coalesce(func.sum(case((_refund_type, func.abs(CreditRecord.amount)), else_=0)), 0),
|
|
||||||
# 3: 交易笔数
|
|
||||||
func.count(CreditRecord.id),
|
func.count(CreditRecord.id),
|
||||||
# 4-11: 生成条数 / 尝试次数 / 图片视频条数 / 图片视频提词分析消费(仍按 charge 口径)
|
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
||||||
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), 1), else_=None)),
|
||||||
func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), 1), else_=None)),
|
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
||||||
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
||||||
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||||
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||||
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||||
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||||
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
|
||||||
# 12-14: Token
|
|
||||||
func.coalesce(func.sum(CreditRecord.total_tokens), 0),
|
func.coalesce(func.sum(CreditRecord.total_tokens), 0),
|
||||||
func.coalesce(func.sum(CreditRecord.input_tokens), 0),
|
func.coalesce(func.sum(CreditRecord.input_tokens), 0),
|
||||||
func.coalesce(func.sum(CreditRecord.output_tokens), 0),
|
func.coalesce(func.sum(CreditRecord.output_tokens), 0),
|
||||||
# 15: 真实扣费 total_charge(独立列:type=consume AND charge_action in (NULL, 'charge'))
|
|
||||||
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
|
||||||
# 16: 预扣占用 total_hold(独立列:type=consume AND charge_action='hold')
|
|
||||||
func.coalesce(func.sum(case((and_(_consume_type, CreditRecord.charge_action == "hold"), func.abs(CreditRecord.amount)), else_=0)), 0),
|
|
||||||
# 17: 真实退款 total_refund_real(独立列:type=refund AND charge_action in (NULL, 'refund'))
|
|
||||||
func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
|
||||||
# 18: 预扣释放 total_hold_release(独立列:type=refund AND charge_action='hold_release')
|
|
||||||
func.coalesce(func.sum(case((_hold_release_filter, func.abs(CreditRecord.amount)), else_=0)), 0),
|
|
||||||
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
|
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
|
||||||
if where_clause is not None:
|
if where_clause is not None:
|
||||||
summary_query = summary_query.where(where_clause)
|
summary_query = summary_query.where(where_clause)
|
||||||
s = (await db.execute(summary_query)).one()
|
s = (await db.execute(summary_query)).one()
|
||||||
_total_recharge = _round2(s[0])
|
|
||||||
_total_consume = _round2(s[1])
|
|
||||||
_total_refund = _round2(s[2])
|
|
||||||
_total_charge = _round2(s[15])
|
|
||||||
_total_hold = _round2(s[16])
|
|
||||||
_total_refund_real = _round2(s[17])
|
|
||||||
_total_hold_release = _round2(s[18])
|
|
||||||
# 净消耗 = 总消费 − 总回退;若回退跨周期导致负数,按 0 兜底
|
|
||||||
_net_consume = _round2(max(_total_consume - _total_refund, 0.0))
|
|
||||||
summary = {
|
summary = {
|
||||||
"total_recharge": _total_recharge,
|
"total_recharge": _round2(s[0]),
|
||||||
"total_consume": _total_consume,
|
"total_consume": _round2(s[1]),
|
||||||
"total_refund": _total_refund,
|
"total_refund": _round2(s[2]),
|
||||||
"total_charge": _total_charge,
|
|
||||||
"total_hold": _total_hold,
|
|
||||||
"total_refund_real": _total_refund_real,
|
|
||||||
"total_hold_release": _total_hold_release,
|
|
||||||
"net_consume": _net_consume,
|
|
||||||
"transaction_count": int(s[3] or 0),
|
"transaction_count": int(s[3] or 0),
|
||||||
"generation_count": int(s[4] or 0),
|
"generation_count": int(s[4] or 0),
|
||||||
"generation_attempt_count": int(s[5] or 0),
|
"generation_attempt_count": int(s[5] or 0),
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
from app.services.api_v3.auth_service import ApiKeyContext, get_api_key_dependency
|
|
||||||
from app.services.api_v3.key_service import (
|
|
||||||
create_api_key,
|
|
||||||
list_api_keys,
|
|
||||||
get_api_key,
|
|
||||||
update_api_key,
|
|
||||||
delete_api_key,
|
|
||||||
reset_quota_if_needed,
|
|
||||||
)
|
|
||||||
from app.services.api_v3.quota_service import check_quota, can_start_video_task, get_active_video_tasks_count, get_queued_video_tasks
|
|
||||||
from app.services.api_v3.usage_log_service import record_usage, get_usage_summary, list_usage_logs
|
|
||||||
from app.services.api_v3.generation_service import submit_video_generation, generate_image_sync
|
|
||||||
from app.services.api_v3.task_service import create_video_task, create_image_task, get_task, map_task_to_status_response
|
|
||||||
from app.services.api_v3.upscale_service import (
|
|
||||||
get_or_create_upscale_config,
|
|
||||||
save_upscale_config,
|
|
||||||
build_api_upscale_snapshot,
|
|
||||||
prepare_api_upscale_task,
|
|
||||||
)
|
|
||||||
from app.services.api_v3.engine_service import resolve_video_engine, resolve_image_engine, build_engine_snapshot
|
|
||||||
from app.services.api_v3.pricing_service import (
|
|
||||||
calc_api_video_price,
|
|
||||||
calc_api_image_price,
|
|
||||||
get_priced_models,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ApiKeyContext",
|
|
||||||
"get_api_key_dependency",
|
|
||||||
"create_api_key",
|
|
||||||
"list_api_keys",
|
|
||||||
"get_api_key",
|
|
||||||
"update_api_key",
|
|
||||||
"delete_api_key",
|
|
||||||
"reset_quota_if_needed",
|
|
||||||
"check_quota",
|
|
||||||
"can_start_video_task",
|
|
||||||
"get_active_video_tasks_count",
|
|
||||||
"get_queued_video_tasks",
|
|
||||||
"record_usage",
|
|
||||||
"get_usage_summary",
|
|
||||||
"list_usage_logs",
|
|
||||||
"submit_video_generation",
|
|
||||||
"generate_image_sync",
|
|
||||||
"create_video_task",
|
|
||||||
"create_image_task",
|
|
||||||
"get_task",
|
|
||||||
"map_task_to_status_response",
|
|
||||||
"get_or_create_upscale_config",
|
|
||||||
"save_upscale_config",
|
|
||||||
"build_api_upscale_snapshot",
|
|
||||||
"prepare_api_upscale_task",
|
|
||||||
"resolve_video_engine",
|
|
||||||
"resolve_image_engine",
|
|
||||||
"build_engine_snapshot",
|
|
||||||
"calc_api_video_price",
|
|
||||||
"calc_api_image_price",
|
|
||||||
"get_priced_models",
|
|
||||||
]
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_db
|
|
||||||
from app.models.api.api_key import ApiKey
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyContext:
|
|
||||||
"""API Key 验证上下文,携带解析后的可调用模型列表。"""
|
|
||||||
|
|
||||||
def __init__(self, api_key: ApiKey, callable_models: list[dict]):
|
|
||||||
self.api_key = api_key
|
|
||||||
self.api_key_id = api_key.id
|
|
||||||
self.callable_models = callable_models
|
|
||||||
|
|
||||||
|
|
||||||
async def get_api_key_dependency(
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> ApiKeyContext:
|
|
||||||
"""FastAPI Dependency: 验证 API Key 并返回上下文。
|
|
||||||
|
|
||||||
验证流程:
|
|
||||||
1. 提取 Bearer <REDACTED>
|
|
||||||
2. SHA-256 哈希后查询数据库
|
|
||||||
3. 检查 is_active、deleted_at
|
|
||||||
4. 检查有效期 (valid_from, valid_until)
|
|
||||||
5. 检查配额 (quota_limit, quota_used)
|
|
||||||
6. 重置过期周期的配额
|
|
||||||
"""
|
|
||||||
if not credentials:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="缺少 Authorization 头,请提供 Bearer <REDACTED>",
|
|
||||||
)
|
|
||||||
|
|
||||||
token_hash = hashlib.sha256(credentials.credentials.encode()).hexdigest()
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiKey).where(
|
|
||||||
ApiKey.api_key_hash == token_hash,
|
|
||||||
ApiKey.is_active == True,
|
|
||||||
ApiKey.deleted_at.is_(None),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
key = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="无效的 API Key",
|
|
||||||
)
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# 检查有效期
|
|
||||||
if key.valid_from and now < key.valid_from:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="API Key 尚未生效",
|
|
||||||
)
|
|
||||||
if key.valid_until and now >= key.valid_until:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="API Key 已过期",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 配额周期重置
|
|
||||||
from app.services.api_v3.key_service import reset_quota_if_needed
|
|
||||||
key = await reset_quota_if_needed(db, key)
|
|
||||||
|
|
||||||
# 检查配额
|
|
||||||
if key.quota_limit is not None and key.quota_used >= key.quota_limit:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
detail=f"API Key 配额已用尽 (已用 {key.quota_used:.2f} / 限额 {key.quota_limit:.2f})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 解析可调用模型
|
|
||||||
try:
|
|
||||||
callable_models = json.loads(key.callable_models) if key.callable_models else []
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
callable_models = []
|
|
||||||
|
|
||||||
# 更新最后使用时间
|
|
||||||
key.last_used_at = now
|
|
||||||
|
|
||||||
return ApiKeyContext(api_key=key, callable_models=callable_models)
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.image_engine import ImageEngine
|
|
||||||
from app.models.video_engine import VideoEngine
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_video_engine(
|
|
||||||
db: AsyncSession,
|
|
||||||
engine_id: str,
|
|
||||||
callable_models: list[dict],
|
|
||||||
) -> VideoEngine:
|
|
||||||
"""根据 engine_id 解析视频引擎,并验证是否在 api-key 的可调用列表中。"""
|
|
||||||
# 验证授权
|
|
||||||
allowed = {m["engine_id"] for m in callable_models if m.get("engine_type") == "video"}
|
|
||||||
if engine_id not in allowed:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"该 API Key 无权使用引擎 {engine_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(VideoEngine).where(
|
|
||||||
VideoEngine.id == engine_id,
|
|
||||||
VideoEngine.is_active == True,
|
|
||||||
VideoEngine.deleted_at.is_(None),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
engine = result.scalar_one_or_none()
|
|
||||||
if not engine:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"视频引擎 {engine_id} 不存在或未启用",
|
|
||||||
)
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_image_engine(
|
|
||||||
db: AsyncSession,
|
|
||||||
engine_id: str,
|
|
||||||
callable_models: list[dict],
|
|
||||||
) -> ImageEngine:
|
|
||||||
"""根据 engine_id 解析图片引擎,并验证是否在 api-key 的可调用列表中。"""
|
|
||||||
allowed = {m["engine_id"] for m in callable_models if m.get("engine_type") == "image"}
|
|
||||||
if engine_id not in allowed:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"该 API Key 无权使用引擎 {engine_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(ImageEngine).where(
|
|
||||||
ImageEngine.id == engine_id,
|
|
||||||
ImageEngine.is_active == True,
|
|
||||||
ImageEngine.deleted_at.is_(None),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
engine = result.scalar_one_or_none()
|
|
||||||
if not engine:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"图片引擎 {engine_id} 不存在或未启用",
|
|
||||||
)
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
def build_engine_snapshot(engine: VideoEngine | ImageEngine) -> dict:
|
|
||||||
"""构建引擎配置快照。"""
|
|
||||||
snapshot = {
|
|
||||||
"id": str(engine.id),
|
|
||||||
"name": str(engine.name),
|
|
||||||
"provider": str(getattr(engine, "provider", "")),
|
|
||||||
"api_base": str(engine.api_base),
|
|
||||||
"model_name": str(engine.model_name),
|
|
||||||
}
|
|
||||||
# 可选字段
|
|
||||||
for field in [
|
|
||||||
"supported_ratios", "supported_resolutions", "supported_durations",
|
|
||||||
"default_size", "multi_generation_enabled", "max_generation_count",
|
|
||||||
]:
|
|
||||||
val = getattr(engine, field, None)
|
|
||||||
if val is not None:
|
|
||||||
if isinstance(val, str):
|
|
||||||
try:
|
|
||||||
val = json.loads(val)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
snapshot[field] = val
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_engine_by_model_name(
|
|
||||||
db: AsyncSession,
|
|
||||||
model_name: str,
|
|
||||||
callable_models: list[dict],
|
|
||||||
engine_type: str,
|
|
||||||
) -> tuple[str, VideoEngine | ImageEngine]:
|
|
||||||
"""根据模型名称查找对应的引擎。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(engine_id, engine 对象)
|
|
||||||
"""
|
|
||||||
# 在 callable_models 中查找
|
|
||||||
target = None
|
|
||||||
for m in callable_models:
|
|
||||||
if m.get("model_name") == model_name and m.get("engine_type") == engine_type:
|
|
||||||
target = m
|
|
||||||
break
|
|
||||||
|
|
||||||
if not target:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"该 API Key 无权使用模型 {model_name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
engine_id = target["engine_id"]
|
|
||||||
if engine_type == "video":
|
|
||||||
engine = await resolve_video_engine(db, engine_id, callable_models)
|
|
||||||
else:
|
|
||||||
engine = await resolve_image_engine(db, engine_id, callable_models)
|
|
||||||
|
|
||||||
return engine_id, engine
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""API v3 文件下载服务。
|
|
||||||
|
|
||||||
下载用户提供的图片/视频/音频到本地存储。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_date_str() -> str:
|
|
||||||
"""获取当前日期字符串。"""
|
|
||||||
return datetime.now().strftime("%Y%m%d")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_uploads_dir() -> str:
|
|
||||||
"""获取上传文件存储目录。"""
|
|
||||||
upload_dir = os.path.join(os.path.dirname(settings.STORAGE_LOCAL_PATH), "uploads", "api")
|
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
|
||||||
return upload_dir
|
|
||||||
|
|
||||||
|
|
||||||
async def download_file_from_url(url: str, sub_dir: str = "") -> str:
|
|
||||||
"""从 URL 下载文件到本地。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: 文件 URL
|
|
||||||
sub_dir: 子目录(如 images/videos/audios)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
相对路径: /uploads/api/{sub_dir}/{date}/{filename}
|
|
||||||
"""
|
|
||||||
upload_dir = _get_uploads_dir()
|
|
||||||
date_str = _get_date_str()
|
|
||||||
|
|
||||||
# 创建目标目录
|
|
||||||
dest_dir = os.path.join(upload_dir, sub_dir, date_str)
|
|
||||||
os.makedirs(dest_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# 从 URL 提取扩展名
|
|
||||||
parsed = urlparse(url)
|
|
||||||
path = parsed.path
|
|
||||||
ext = os.path.splitext(path)[1].lower()
|
|
||||||
if not ext or len(ext) > 10:
|
|
||||||
ext = ".bin" # 默认扩展名
|
|
||||||
|
|
||||||
# 生成唯一文件名
|
|
||||||
filename = f"{uuid.uuid4().hex}{ext}"
|
|
||||||
dest_path = os.path.join(dest_dir, filename)
|
|
||||||
|
|
||||||
# 下载文件
|
|
||||||
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
|
|
||||||
async with client.stream("GET", url) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
with open(dest_path, "wb") as f:
|
|
||||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
# 返回相对路径
|
|
||||||
rel_path = f"/uploads/api/{sub_dir}/{date_str}/{filename}"
|
|
||||||
logger.info("Downloaded file: %s -> %s", url[:80], rel_path)
|
|
||||||
return rel_path
|
|
||||||
|
|
||||||
|
|
||||||
def save_base64_file(data: str, sub_dir: str = "") -> str:
|
|
||||||
"""保存 Base64 编码的文件到本地。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Base64 编码的数据(可包含 data:...;base64, 前缀)
|
|
||||||
sub_dir: 子目录
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
相对路径: /uploads/api/{sub_dir}/{date}/{filename}
|
|
||||||
"""
|
|
||||||
upload_dir = _get_uploads_dir()
|
|
||||||
date_str = _get_date_str()
|
|
||||||
|
|
||||||
# 创建目标目录
|
|
||||||
dest_dir = os.path.join(upload_dir, sub_dir, date_str)
|
|
||||||
os.makedirs(dest_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# 解析 Base64 数据
|
|
||||||
if "," in data:
|
|
||||||
header, b64_data = data.split(",", 1)
|
|
||||||
# 从 header 提取 MIME 类型
|
|
||||||
mime_match = re.search(r"data:([^;]+)", header)
|
|
||||||
mime_type = mime_match.group(1) if mime_match else "application/octet-stream"
|
|
||||||
# 根据 MIME 类型确定扩展名
|
|
||||||
ext_map = {
|
|
||||||
"image/jpeg": ".jpg",
|
|
||||||
"image/png": ".png",
|
|
||||||
"image/webp": ".webp",
|
|
||||||
"image/gif": ".gif",
|
|
||||||
"video/mp4": ".mp4",
|
|
||||||
"video/webm": ".webm",
|
|
||||||
"audio/mpeg": ".mp3",
|
|
||||||
"audio/wav": ".wav",
|
|
||||||
"audio/ogg": ".ogg",
|
|
||||||
}
|
|
||||||
ext = ext_map.get(mime_type, ".bin")
|
|
||||||
else:
|
|
||||||
b64_data = data
|
|
||||||
ext = ".bin"
|
|
||||||
|
|
||||||
# 解码并保存
|
|
||||||
try:
|
|
||||||
file_data = base64.b64decode(b64_data)
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError(f"Base64 解码失败: {exc}")
|
|
||||||
|
|
||||||
filename = f"{uuid.uuid4().hex}{ext}"
|
|
||||||
dest_path = os.path.join(dest_dir, filename)
|
|
||||||
|
|
||||||
with open(dest_path, "wb") as f:
|
|
||||||
f.write(file_data)
|
|
||||||
|
|
||||||
rel_path = f"/uploads/api/{sub_dir}/{date_str}/{filename}"
|
|
||||||
logger.info("Saved base64 file: %s (%d bytes)", rel_path, len(file_data))
|
|
||||||
return rel_path
|
|
||||||
|
|
||||||
|
|
||||||
async def process_media_url(url: str, media_type: str) -> str:
|
|
||||||
"""处理媒体 URL:下载到本地或保存 Base64。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: URL 或 Base64 数据
|
|
||||||
media_type: image / video / audio
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
相对路径: /uploads/api/{type}/{date}/{filename}
|
|
||||||
"""
|
|
||||||
sub_dir = {"image": "images", "video": "videos", "audio": "audios"}.get(media_type, "files")
|
|
||||||
|
|
||||||
# 判断是 Base64 还是 URL
|
|
||||||
if url.startswith("data:"):
|
|
||||||
return save_base64_file(url, sub_dir)
|
|
||||||
else:
|
|
||||||
return await download_file_from_url(url, sub_dir)
|
|
||||||
@@ -1,542 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.api.api_key import ApiKey
|
|
||||||
from app.schemas.api_v3.image import ApiImageGenerateRequest, ApiImageGenerateResponse, ApiImageGenerateDataItem
|
|
||||||
from app.schemas.api_v3.video import ApiVideoCreateRequest, ApiVideoCreateResponse
|
|
||||||
from app.services.api_v3 import task_service, engine_service, upscale_service
|
|
||||||
from app.services.api_v3.quota_service import can_start_video_task
|
|
||||||
from app.services.api_v3.pricing_service import calc_api_video_price, calc_api_image_price, PricingNotConfiguredError
|
|
||||||
from app.services.api_v3.logging_service import log_model_request
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
|
|
||||||
def _make_image_url(local_path: str) -> str:
|
|
||||||
"""将本地图片路径转为完整可访问 URL。"""
|
|
||||||
if not local_path:
|
|
||||||
return local_path
|
|
||||||
# 如果已经是完整 URL,直接返回
|
|
||||||
if local_path.startswith(("http://", "https://")):
|
|
||||||
return local_path
|
|
||||||
from app.config import settings
|
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
|
||||||
# 生成签名 URL
|
|
||||||
signed = build_resource_signed_url(local_path)
|
|
||||||
if signed and not signed.startswith(("http://", "https://")):
|
|
||||||
base = settings.BASE_URL.rstrip("/")
|
|
||||||
if signed.startswith("/"):
|
|
||||||
signed = f"{base}{signed}"
|
|
||||||
else:
|
|
||||||
signed = f"{base}/{signed}"
|
|
||||||
return signed or local_path
|
|
||||||
|
|
||||||
|
|
||||||
async def submit_video_generation(
|
|
||||||
db: AsyncSession,
|
|
||||||
key: ApiKey,
|
|
||||||
callable_models: list[dict],
|
|
||||||
req: ApiVideoCreateRequest,
|
|
||||||
) -> ApiVideoCreateResponse:
|
|
||||||
"""提交视频生成任务(异步)。
|
|
||||||
|
|
||||||
流程:
|
|
||||||
1. 检查并发视频任务数
|
|
||||||
2. 解析引擎
|
|
||||||
3. 构建超分快照
|
|
||||||
4. 创建任务记录
|
|
||||||
5. 入队 Celery 任务
|
|
||||||
6. 返回 task_id
|
|
||||||
"""
|
|
||||||
# 1. 检查是否可以立即启动(并发限制)
|
|
||||||
can_start = await can_start_video_task(key, db)
|
|
||||||
|
|
||||||
# 2. 解析引擎
|
|
||||||
engine_id, engine = await engine_service.resolve_engine_by_model_name(
|
|
||||||
db, req.model, callable_models, "video"
|
|
||||||
)
|
|
||||||
engine_snapshot = engine_service.build_engine_snapshot(engine)
|
|
||||||
|
|
||||||
# 3. 构建超分快照
|
|
||||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await upscale_service.build_api_upscale_snapshot(
|
|
||||||
db, key.id, req.resolution or "480p", req.ratio
|
|
||||||
)
|
|
||||||
|
|
||||||
# 如果超分要求不同的生成分辨率,使用超分的
|
|
||||||
final_provider_resolution = provider_resolution or req.resolution or "480p"
|
|
||||||
|
|
||||||
def _get_max_supported_duration(engine) -> int | None:
|
|
||||||
"""从引擎 supported_durations 获取最大时长。"""
|
|
||||||
try:
|
|
||||||
durations = json.loads(engine.supported_durations) if engine.supported_durations else []
|
|
||||||
return max(durations) if durations else engine.max_duration
|
|
||||||
except (json.JSONDecodeError, TypeError, ValueError):
|
|
||||||
return engine.max_duration
|
|
||||||
|
|
||||||
# 3.5 验证传入的媒体文件是否符合引擎配置要求
|
|
||||||
from app.services.api_v3.file_service import process_media_url
|
|
||||||
from app.services.video_upscale.media_service import probe_video
|
|
||||||
from fastapi import HTTPException, status # noqa: F401
|
|
||||||
|
|
||||||
input_image_count = 0
|
|
||||||
input_video_count = 0
|
|
||||||
input_audio_count = 0
|
|
||||||
input_video_duration = 0.0
|
|
||||||
local_media_refs = [] # 存储本地路径
|
|
||||||
|
|
||||||
# 统计各类媒体数量
|
|
||||||
for p in req.content:
|
|
||||||
ptype = p.type
|
|
||||||
if ptype == "image_url":
|
|
||||||
input_image_count += 1
|
|
||||||
elif ptype == "video_url":
|
|
||||||
input_video_count += 1
|
|
||||||
elif ptype == "audio_url":
|
|
||||||
input_audio_count += 1
|
|
||||||
|
|
||||||
# 视频引擎校验(本函数仅处理视频生成)
|
|
||||||
# 校验图片数量限制
|
|
||||||
if input_image_count > (engine.max_image_count or 0):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"该引擎最多支持 {engine.max_image_count} 张参考图片,当前传入 {input_image_count} 张",
|
|
||||||
)
|
|
||||||
# 校验视频数量限制
|
|
||||||
if input_video_count > (engine.max_video_count or 0):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"该引擎最多支持 {engine.max_video_count} 个参考视频,当前传入 {input_video_count} 个",
|
|
||||||
)
|
|
||||||
# 校验音频数量限制
|
|
||||||
if input_audio_count > (engine.max_audio_count or 0):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"该引擎最多支持 {engine.max_audio_count} 个参考音频,当前传入 {input_audio_count} 个",
|
|
||||||
)
|
|
||||||
|
|
||||||
for p in req.content:
|
|
||||||
ptype = p.type
|
|
||||||
if ptype == "text":
|
|
||||||
local_media_refs.append(p.model_dump(exclude_none=True))
|
|
||||||
continue
|
|
||||||
|
|
||||||
original_url = ""
|
|
||||||
if ptype == "image_url" and p.image_url:
|
|
||||||
original_url = p.image_url.get("url", "")
|
|
||||||
elif ptype == "video_url" and p.video_url:
|
|
||||||
original_url = p.video_url.get("url", "")
|
|
||||||
elif ptype == "audio_url" and p.audio_url:
|
|
||||||
original_url = p.audio_url.get("url", "")
|
|
||||||
|
|
||||||
# 下载文件到本地
|
|
||||||
try:
|
|
||||||
local_path = await process_media_url(original_url, ptype.replace("_url", ""))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to download media %s: %s", original_url[:80], exc)
|
|
||||||
local_path = original_url
|
|
||||||
|
|
||||||
# 如果是视频/音频,探测实际时长并校验
|
|
||||||
if ptype in ("video_url", "audio_url") and local_path:
|
|
||||||
try:
|
|
||||||
media_info = await probe_video(local_path)
|
|
||||||
if media_info and media_info.duration_seconds:
|
|
||||||
duration = media_info.duration_seconds
|
|
||||||
# 校验最低时长(2秒)
|
|
||||||
if duration < 2.0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"上传的{ptype.replace('_url', '')}时长不能低于2秒,当前时长: {duration:.1f}秒",
|
|
||||||
)
|
|
||||||
# 校验最高时长(根据引擎 supported_durations 最大值)
|
|
||||||
max_duration = _get_max_supported_duration(engine)
|
|
||||||
if max_duration and duration > max_duration:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"上传的{ptype.replace('_url', '')}时长不能超过{max_duration}秒,当前时长: {duration:.1f}秒",
|
|
||||||
)
|
|
||||||
if ptype == "video_url":
|
|
||||||
input_video_duration += duration
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to probe media duration: %s", exc)
|
|
||||||
|
|
||||||
local_media_refs.append({
|
|
||||||
"type": ptype,
|
|
||||||
ptype: {"url": local_path},
|
|
||||||
"role": p.role,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 3.6 计算价格(基于实际探测的视频时长)
|
|
||||||
try:
|
|
||||||
estimated_price = await calc_api_video_price(
|
|
||||||
db,
|
|
||||||
duration=req.duration or 5,
|
|
||||||
resolution=req.resolution or "480p",
|
|
||||||
engine_id=engine_id,
|
|
||||||
input_video_duration=input_video_duration,
|
|
||||||
input_image_count=input_image_count,
|
|
||||||
)
|
|
||||||
except PricingNotConfiguredError as exc:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
# 预检配额
|
|
||||||
if key.quota_limit is not None and key.quota_used + estimated_price > key.quota_limit:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
detail=f"配额不足 (需要 {estimated_price:.2f} 元, 剩余 {key.quota_limit - key.quota_used:.2f} 元)",
|
|
||||||
)
|
|
||||||
# 预扣配额
|
|
||||||
if estimated_price > 0:
|
|
||||||
key.quota_used = round((key.quota_used or 0.0) + estimated_price, 2)
|
|
||||||
|
|
||||||
# 4. 创建任务记录(幂等性已在路由层检查)
|
|
||||||
content_dicts = [p.model_dump(exclude_none=True) for p in req.content]
|
|
||||||
task = await task_service.create_video_task(
|
|
||||||
db=db,
|
|
||||||
api_key_id=key.id,
|
|
||||||
model_name=req.model,
|
|
||||||
engine_id=engine_id,
|
|
||||||
engine_snapshot=engine_snapshot,
|
|
||||||
content=content_dicts,
|
|
||||||
ratio=req.ratio,
|
|
||||||
duration=req.duration,
|
|
||||||
resolution=req.resolution,
|
|
||||||
provider_generation_resolution=final_provider_resolution,
|
|
||||||
upscale_enabled=upscale_enabled,
|
|
||||||
upscale_snapshot_json=upscale_snapshot_json,
|
|
||||||
idempotency_key=req.idempotency_key,
|
|
||||||
local_media_refs=local_media_refs,
|
|
||||||
)
|
|
||||||
task.credits_cost = estimated_price # 记录预扣金额
|
|
||||||
|
|
||||||
# 根据并发限制决定立即执行还是排队
|
|
||||||
if can_start:
|
|
||||||
# 立即执行
|
|
||||||
task.status = "pending"
|
|
||||||
task.pipeline_stage = "queued"
|
|
||||||
else:
|
|
||||||
# 排队等待
|
|
||||||
task.status = "queued"
|
|
||||||
task.pipeline_stage = "waiting_concurrency"
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# 提交时即记录使用日志(配额已预扣)
|
|
||||||
try:
|
|
||||||
from app.services.api_v3.usage_log_service import record_usage
|
|
||||||
quota_before = key.quota_used - estimated_price # 扣减前的余额
|
|
||||||
quota_after = key.quota_used # 扣减后的余额
|
|
||||||
price_detail = {
|
|
||||||
"base_price": getattr(locals(), "base_price", 0),
|
|
||||||
"per_second_price": getattr(locals(), "per_second_price", 0),
|
|
||||||
"duration": req.duration,
|
|
||||||
"resolution": req.resolution,
|
|
||||||
"ratio": req.ratio,
|
|
||||||
"total": estimated_price,
|
|
||||||
}
|
|
||||||
await record_usage(
|
|
||||||
db=db,
|
|
||||||
api_key_id=key.id,
|
|
||||||
request_type="video_create",
|
|
||||||
model_name=req.model,
|
|
||||||
gen_type="video",
|
|
||||||
status="success",
|
|
||||||
task_id=task.id,
|
|
||||||
credits_cost=estimated_price,
|
|
||||||
price_action="deduct",
|
|
||||||
resolution=req.resolution,
|
|
||||||
duration=req.duration,
|
|
||||||
quota_before=quota_before,
|
|
||||||
quota_after=quota_after,
|
|
||||||
price_detail_json=json.dumps(price_detail, ensure_ascii=False),
|
|
||||||
)
|
|
||||||
except Exception as log_exc:
|
|
||||||
logger.error("Failed to record usage on submit: %s", log_exc)
|
|
||||||
|
|
||||||
# 记录模型调用日志
|
|
||||||
log_model_request(
|
|
||||||
engine_id=engine_id,
|
|
||||||
model_name=req.model,
|
|
||||||
task_id=task.id,
|
|
||||||
params={
|
|
||||||
"ratio": req.ratio,
|
|
||||||
"duration": req.duration,
|
|
||||||
"resolution": req.resolution,
|
|
||||||
"generate_audio": req.generate_audio,
|
|
||||||
"watermark": req.watermark,
|
|
||||||
"content_count": len(req.content),
|
|
||||||
"queued": not can_start,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 只有立即执行的才入队 Celery
|
|
||||||
if can_start:
|
|
||||||
from app.tasks.api_generation_tasks import api_create_generation_task
|
|
||||||
api_create_generation_task.apply_async(
|
|
||||||
args=[task.id],
|
|
||||||
queue="gen_api_create",
|
|
||||||
)
|
|
||||||
|
|
||||||
status_str = "queued" if can_start else "pending_queue"
|
|
||||||
logger.info("API video task created: task_id=%s model=%s key=%s price=%.2f status=%s", task.id, req.model, key.id, estimated_price, status_str)
|
|
||||||
|
|
||||||
return ApiVideoCreateResponse(id=task.id)
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_image_sync(
|
|
||||||
db: AsyncSession,
|
|
||||||
key: ApiKey,
|
|
||||||
callable_models: list[dict],
|
|
||||||
req: "ApiImageGenerateRequest",
|
|
||||||
start_time: float,
|
|
||||||
) -> ApiImageGenerateResponse:
|
|
||||||
"""同步生成图片。
|
|
||||||
|
|
||||||
流程:
|
|
||||||
1. 解析引擎
|
|
||||||
2. 创建任务记录
|
|
||||||
3. 调用 Volcano Ark SDK(同步)
|
|
||||||
4. 下载图片
|
|
||||||
5. 更新任务状态
|
|
||||||
6. 记录使用日志
|
|
||||||
7. 返回结果
|
|
||||||
"""
|
|
||||||
from app.services.api_v3.usage_log_service import record_usage
|
|
||||||
|
|
||||||
# 1. 解析引擎
|
|
||||||
engine_id, engine = await engine_service.resolve_engine_by_model_name(
|
|
||||||
db, req.model, callable_models, "image"
|
|
||||||
)
|
|
||||||
engine_snapshot = engine_service.build_engine_snapshot(engine)
|
|
||||||
|
|
||||||
# 2. 创建任务记录
|
|
||||||
task = await task_service.create_image_task(
|
|
||||||
db=db,
|
|
||||||
api_key_id=key.id,
|
|
||||||
model_name=req.model,
|
|
||||||
engine_id=engine_id,
|
|
||||||
engine_snapshot=engine_snapshot,
|
|
||||||
prompt=req.prompt,
|
|
||||||
size=req.size,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2.5 验证传入的媒体文件是否符合引擎配置要求
|
|
||||||
# 校验参考图片数量限制
|
|
||||||
input_image_count = len(req.image) if req.image else 0
|
|
||||||
if input_image_count > (engine.max_reference_image_count or 0):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"该引擎最多支持 {engine.max_reference_image_count} 张参考图片,当前传入 {input_image_count} 张",
|
|
||||||
)
|
|
||||||
# 校验组图数量限制
|
|
||||||
generation_count = req.generation_count or 1
|
|
||||||
if generation_count > (engine.multi_image_max_images or 1):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"该引擎最多支持生成 {engine.multi_image_max_images} 张图片,当前请求 {generation_count} 张",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
estimated_price = await calc_api_image_price(
|
|
||||||
db,
|
|
||||||
image_size=req.size or "2K",
|
|
||||||
engine_id=engine_id,
|
|
||||||
input_image_count=input_image_count,
|
|
||||||
)
|
|
||||||
except PricingNotConfiguredError as exc:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
# 预检配额
|
|
||||||
if key.quota_limit is not None and key.quota_used + estimated_price > key.quota_limit:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
detail=f"配额不足 (需要 {estimated_price:.2f} 元, 剩余 {key.quota_limit - key.quota_used:.2f} 元)",
|
|
||||||
)
|
|
||||||
# 预扣配额
|
|
||||||
if estimated_price > 0:
|
|
||||||
key.quota_used = round((key.quota_used or 0.0) + estimated_price, 2)
|
|
||||||
task.credits_cost = estimated_price
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# 提交时即记录使用日志(配额已预扣)
|
|
||||||
try:
|
|
||||||
quota_before = key.quota_used - estimated_price
|
|
||||||
quota_after = key.quota_used
|
|
||||||
price_detail = {
|
|
||||||
"base_price": getattr(locals(), "base_price", 0),
|
|
||||||
"size": req.size,
|
|
||||||
"generation_count": req.generation_count or 1,
|
|
||||||
"total": estimated_price,
|
|
||||||
}
|
|
||||||
await record_usage(
|
|
||||||
db=db,
|
|
||||||
api_key_id=key.id,
|
|
||||||
request_type="image_generate",
|
|
||||||
model_name=req.model,
|
|
||||||
gen_type="image",
|
|
||||||
status="success",
|
|
||||||
task_id=task.id,
|
|
||||||
credits_cost=estimated_price,
|
|
||||||
price_action="deduct",
|
|
||||||
resolution=req.size,
|
|
||||||
quota_before=quota_before,
|
|
||||||
quota_after=quota_after,
|
|
||||||
price_detail_json=json.dumps(price_detail, ensure_ascii=False),
|
|
||||||
)
|
|
||||||
except Exception as log_exc:
|
|
||||||
logger.error("Failed to record usage on image submit: %s", log_exc)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 设置总体超时(120秒,防止同步请求长时间挂起)
|
|
||||||
_IMAGE_GEN_TIMEOUT = 120
|
|
||||||
|
|
||||||
# 3. 调用 Volcano Ark SDK(同步函数,在线程中执行)
|
|
||||||
from app.services.image_gen import submit_image_task, download_image
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
# 构建 media_references,下载图片到本地
|
|
||||||
from app.services.api_v3.file_service import process_media_url
|
|
||||||
|
|
||||||
image_refs = []
|
|
||||||
if req.image:
|
|
||||||
for url in req.image:
|
|
||||||
try:
|
|
||||||
local_path = await process_media_url(url, "image")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to download image %s: %s", url[:80], exc)
|
|
||||||
local_path = url
|
|
||||||
image_refs.append({"type": "image", "url": local_path})
|
|
||||||
|
|
||||||
# 临时设置 media_references
|
|
||||||
task.media_references = json.dumps(image_refs, ensure_ascii=False) if image_refs else None
|
|
||||||
task.image_size = req.size or "2K"
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
# 在线程中执行同步 SDK 调用(带超时保护)
|
|
||||||
result = await asyncio.wait_for(
|
|
||||||
asyncio.to_thread(
|
|
||||||
submit_image_task,
|
|
||||||
db,
|
|
||||||
engine,
|
|
||||||
task,
|
|
||||||
True, # include_media_references
|
|
||||||
req.generation_count or 1,
|
|
||||||
),
|
|
||||||
timeout=_IMAGE_GEN_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 下载图片
|
|
||||||
items = result.get("items", [])
|
|
||||||
downloaded_items: list[ApiImageGenerateDataItem] = []
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
url = item.get("remote_result_url")
|
|
||||||
if url:
|
|
||||||
# 下载到本地
|
|
||||||
date_dir = datetime.now().strftime("%Y%m%d")
|
|
||||||
dest_dir = f"./storage/generate/api/images/{date_dir}"
|
|
||||||
os.makedirs(dest_dir, exist_ok=True)
|
|
||||||
dest_path = os.path.join(dest_dir, f"{task.id}_{item.get('generation_index', 1)}.png")
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
download_image(url, dest_path),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Image download timeout: %s", url[:80])
|
|
||||||
except Exception as dl_err:
|
|
||||||
logger.warning("Image download failed: %s", dl_err)
|
|
||||||
|
|
||||||
# 将本地路径转为完整 URL
|
|
||||||
image_url = _make_image_url(dest_path)
|
|
||||||
downloaded_items.append(ApiImageGenerateDataItem(
|
|
||||||
url=image_url,
|
|
||||||
size=item.get("size"),
|
|
||||||
output_format=item.get("output_format"),
|
|
||||||
))
|
|
||||||
elif item.get("error_message"):
|
|
||||||
downloaded_items.append(ApiImageGenerateDataItem(
|
|
||||||
url=None,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 5. 使用预扣金额(不再重复扣减)
|
|
||||||
task.credits_cost = estimated_price
|
|
||||||
|
|
||||||
# 6. 更新任务状态
|
|
||||||
task.status = "completed"
|
|
||||||
task.pipeline_stage = "done"
|
|
||||||
task.generated_at = datetime.now(timezone.utc)
|
|
||||||
if downloaded_items and downloaded_items[0].url:
|
|
||||||
task.image_url = downloaded_items[0].url
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# 提交时已记录使用日志,成功时无需重复记录
|
|
||||||
duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
||||||
|
|
||||||
return ApiImageGenerateResponse(
|
|
||||||
created=result.get("created", int(time.time())),
|
|
||||||
data=downloaded_items,
|
|
||||||
model=result.get("model", req.model),
|
|
||||||
)
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
logger.exception("API image generation timed out (task_id=%s)", task.id)
|
|
||||||
# 超时:退回预扣配额
|
|
||||||
if estimated_price > 0:
|
|
||||||
key.quota_used = round(max(0, (key.quota_used or 0.0) - estimated_price), 2)
|
|
||||||
task.status = "failed"
|
|
||||||
task.error_message = "图片生成超时(超过120秒)"
|
|
||||||
task.credits_cost = 0
|
|
||||||
await db.commit()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=504,
|
|
||||||
detail="图片生成超时,请稍后重试",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
# 失败:退回预扣配额
|
|
||||||
if estimated_price > 0:
|
|
||||||
key.quota_used = round(max(0, (key.quota_used or 0.0) - estimated_price), 2)
|
|
||||||
|
|
||||||
task.status = "failed"
|
|
||||||
task.error_message = str(exc)
|
|
||||||
task.credits_cost = 0 # 实际消耗为0(已退回)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
||||||
quota_after_refund = key.quota_used # 退回后的余额
|
|
||||||
await record_usage(
|
|
||||||
db=db,
|
|
||||||
api_key_id=key.id,
|
|
||||||
request_type="image_generate",
|
|
||||||
model_name=req.model,
|
|
||||||
gen_type="image",
|
|
||||||
status="failed",
|
|
||||||
task_id=task.id,
|
|
||||||
credits_cost=estimated_price,
|
|
||||||
refund_amount=estimated_price,
|
|
||||||
request_duration_ms=duration_ms,
|
|
||||||
error_message=str(exc),
|
|
||||||
error_code="generation_failed",
|
|
||||||
price_action="refund",
|
|
||||||
resolution=req.size,
|
|
||||||
generation_count=req.generation_count or 1,
|
|
||||||
quota_before=quota_after_refund,
|
|
||||||
quota_after=quota_after_refund + estimated_price,
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
raise
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.api.api_key import ApiKey
|
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
API_KEY_PREFIX = "vk_"
|
|
||||||
|
|
||||||
|
|
||||||
async def create_api_key(
|
|
||||||
db: AsyncSession,
|
|
||||||
company_name: str,
|
|
||||||
callable_models: list[dict] | None = None,
|
|
||||||
quota_limit: float | None = None,
|
|
||||||
quota_cycle: str | None = None,
|
|
||||||
valid_from: datetime | None = None,
|
|
||||||
valid_until: datetime | None = None,
|
|
||||||
max_concurrent_video_tasks: int | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
) -> tuple[ApiKey, str]:
|
|
||||||
"""创建新的 API Key。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(ApiKey 对象, 明文 API Key) — 明文仅返回这一次。
|
|
||||||
"""
|
|
||||||
# 生成密钥: vk_ + 32字节随机hex
|
|
||||||
raw_key = API_KEY_PREFIX + secrets.token_hex(24) # vk_ + 48位hex = 51字符
|
|
||||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
||||||
key_prefix = raw_key[:8] # 前8位用于展示: vk_xxxxx
|
|
||||||
|
|
||||||
api_key = ApiKey(
|
|
||||||
id=generate_id(),
|
|
||||||
company_name=company_name,
|
|
||||||
api_key_hash=key_hash,
|
|
||||||
api_key_prefix=key_prefix,
|
|
||||||
description=description,
|
|
||||||
callable_models=json.dumps(callable_models or [], ensure_ascii=False),
|
|
||||||
quota_limit=quota_limit,
|
|
||||||
quota_cycle=quota_cycle,
|
|
||||||
quota_used=0.0,
|
|
||||||
valid_from=valid_from,
|
|
||||||
valid_until=valid_until,
|
|
||||||
max_concurrent_video_tasks=max_concurrent_video_tasks,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
api_key.set_plaintext_key(raw_key) # 加密存储完整 Key
|
|
||||||
db.add(api_key)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
logger.info("API Key created: id=%s company=%s prefix=%s", api_key.id, company_name, key_prefix)
|
|
||||||
return api_key, raw_key
|
|
||||||
|
|
||||||
|
|
||||||
async def list_api_keys(
|
|
||||||
db: AsyncSession,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 50,
|
|
||||||
company_name: str | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
) -> tuple[int, list[ApiKey]]:
|
|
||||||
"""列出 API Key(分页+筛选)。"""
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
query = select(ApiKey).where(ApiKey.deleted_at.is_(None))
|
|
||||||
count_query = select(func.count(ApiKey.id)).where(ApiKey.deleted_at.is_(None))
|
|
||||||
|
|
||||||
if company_name:
|
|
||||||
query = query.where(ApiKey.company_name.ilike(f"%{company_name}%"))
|
|
||||||
count_query = count_query.where(ApiKey.company_name.ilike(f"%{company_name}%"))
|
|
||||||
if is_active is not None:
|
|
||||||
query = query.where(ApiKey.is_active == is_active)
|
|
||||||
count_query = count_query.where(ApiKey.is_active == is_active)
|
|
||||||
|
|
||||||
total_result = await db.execute(count_query)
|
|
||||||
total = total_result.scalar_one()
|
|
||||||
|
|
||||||
query = query.order_by(ApiKey.created_at.desc()).offset(skip).limit(limit)
|
|
||||||
result = await db.execute(query)
|
|
||||||
keys = list(result.scalars().all())
|
|
||||||
|
|
||||||
return total, keys
|
|
||||||
|
|
||||||
|
|
||||||
async def get_api_key(db: AsyncSession, key_id: str) -> ApiKey | None:
|
|
||||||
"""获取单个 API Key 详情。"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiKey).where(ApiKey.id == key_id, ApiKey.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_api_key(db: AsyncSession, key: ApiKey, **kwargs) -> ApiKey:
|
|
||||||
"""更新 API Key 配置。"""
|
|
||||||
updatable_fields = {
|
|
||||||
"company_name", "description", "callable_models",
|
|
||||||
"quota_limit", "quota_cycle", "valid_from", "valid_until",
|
|
||||||
"max_concurrent_video_tasks", "is_active",
|
|
||||||
}
|
|
||||||
for field, value in kwargs.items():
|
|
||||||
if field in updatable_fields and value is not None:
|
|
||||||
if field == "callable_models" and isinstance(value, list):
|
|
||||||
value = json.dumps(value, ensure_ascii=False)
|
|
||||||
setattr(key, field, value)
|
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
async def adjust_quota(
|
|
||||||
db: AsyncSession,
|
|
||||||
key: ApiKey,
|
|
||||||
action: str,
|
|
||||||
quota_limit_delta: float | None = None,
|
|
||||||
quota_limit: float | None = None,
|
|
||||||
quota_cycle: str | None = None,
|
|
||||||
) -> tuple[ApiKey, dict]:
|
|
||||||
"""调整 API Key 配额。
|
|
||||||
|
|
||||||
返回 (更新后的 key, 变更详情 dict)。
|
|
||||||
|
|
||||||
action:
|
|
||||||
- adjust: 增加总额,quota_limit_delta 累加到当前 quota_limit
|
|
||||||
- reset_usage: 重置 quota_used 为 0
|
|
||||||
- set_limit: 直接设置 quota_limit
|
|
||||||
- change_cycle: 修改 quota_cycle
|
|
||||||
"""
|
|
||||||
old_limit = key.quota_limit
|
|
||||||
old_used = key.quota_used
|
|
||||||
old_cycle = key.quota_cycle
|
|
||||||
|
|
||||||
if action == "adjust":
|
|
||||||
delta = quota_limit_delta or 0
|
|
||||||
key.quota_limit = round((key.quota_limit or 0) + delta, 2)
|
|
||||||
elif action == "reset_usage":
|
|
||||||
key.quota_used = 0.0
|
|
||||||
elif action == "set_limit":
|
|
||||||
key.quota_limit = quota_limit # 允许设为 None(无限)
|
|
||||||
elif action == "change_cycle":
|
|
||||||
key.quota_cycle = quota_cycle # 允许设为 None(无限)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"未知的调整操作: {action}")
|
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
changes = {
|
|
||||||
"old_limit": old_limit, "new_limit": key.quota_limit,
|
|
||||||
"old_used": old_used, "new_used": key.quota_used,
|
|
||||||
"old_cycle": old_cycle, "new_cycle": key.quota_cycle,
|
|
||||||
}
|
|
||||||
return key, changes
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_api_key(db: AsyncSession, key: ApiKey) -> None:
|
|
||||||
"""软删除 API Key。"""
|
|
||||||
key.deleted_at = datetime.now(timezone.utc)
|
|
||||||
key.is_active = False
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
async def reset_quota_if_needed(db: AsyncSession, key: ApiKey) -> ApiKey:
|
|
||||||
"""检查并重置过期周期的配额。
|
|
||||||
|
|
||||||
- daily: 如果上次重置不是今天,重置 quota_used=0
|
|
||||||
- monthly: 如果上次重置不是本月,重置 quota_used=0
|
|
||||||
"""
|
|
||||||
if key.quota_limit is None or key.quota_cycle is None:
|
|
||||||
return key
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# 使用 quota_used 的 updated_at 作为周期判断依据
|
|
||||||
last_reset = key.updated_at or key.created_at
|
|
||||||
if last_reset is None:
|
|
||||||
return key
|
|
||||||
|
|
||||||
should_reset = False
|
|
||||||
if key.quota_cycle == "daily":
|
|
||||||
should_reset = last_reset.date() < now.date()
|
|
||||||
elif key.quota_cycle == "monthly":
|
|
||||||
should_reset = (last_reset.year, last_reset.month) < (now.year, now.month)
|
|
||||||
|
|
||||||
if should_reset and key.quota_used > 0:
|
|
||||||
key.quota_used = 0.0
|
|
||||||
await db.flush()
|
|
||||||
logger.info("Quota reset for API Key %s (cycle=%s)", key.id, key.quota_cycle)
|
|
||||||
|
|
||||||
return key
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"""外部 API v3 日志服务。
|
|
||||||
|
|
||||||
按天分类存储在 log/api/ 目录下:
|
|
||||||
- log/api/requests/YYYY-MM-DD.log — 所有外部请求和响应
|
|
||||||
- log/api/models/YYYY-MM-DD.log — 模型调用(Volcano Ark SDK)
|
|
||||||
- log/api/upscale/YYYY-MM-DD.log — 超分轮询
|
|
||||||
- log/api/errors/YYYY-MM-DD.log — 错误日志
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# === 日志目录 ===
|
|
||||||
# video-gen-api/log/api/
|
|
||||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
# 上溯3级: services/api_v3 -> services -> app -> video-gen-api (即项目根目录)
|
|
||||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(_THIS_DIR)))
|
|
||||||
BASE_LOG_DIR = os.path.join(_BASE_DIR, "log", "api")
|
|
||||||
os.makedirs(BASE_LOG_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
# 子目录
|
|
||||||
REQUESTS_LOG_DIR = os.path.join(BASE_LOG_DIR, "requests")
|
|
||||||
MODELS_LOG_DIR = os.path.join(BASE_LOG_DIR, "models")
|
|
||||||
UPSCALE_LOG_DIR = os.path.join(BASE_LOG_DIR, "upscale")
|
|
||||||
ERRORS_LOG_DIR = os.path.join(BASE_LOG_DIR, "errors")
|
|
||||||
|
|
||||||
for d in [REQUESTS_LOG_DIR, MODELS_LOG_DIR, UPSCALE_LOG_DIR, ERRORS_LOG_DIR]:
|
|
||||||
os.makedirs(d, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_date_str() -> str:
|
|
||||||
"""获取当前日期字符串。"""
|
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
class _DailyFileHandler(logging.Handler):
|
|
||||||
"""按天写入的日志处理器。"""
|
|
||||||
|
|
||||||
def __init__(self, log_dir: str):
|
|
||||||
super().__init__()
|
|
||||||
self.log_dir = log_dir
|
|
||||||
self._current_date = None
|
|
||||||
self._file_handler = None
|
|
||||||
self._open_file()
|
|
||||||
|
|
||||||
def _open_file(self):
|
|
||||||
"""打开当天的日志文件。"""
|
|
||||||
date_str = _get_date_str()
|
|
||||||
if date_str == self._current_date and self._file_handler:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._file_handler:
|
|
||||||
self._file_handler.close()
|
|
||||||
|
|
||||||
self._current_date = date_str
|
|
||||||
filepath = os.path.join(self.log_dir, f"{date_str}.log")
|
|
||||||
self._file_handler = logging.FileHandler(filepath, encoding="utf-8")
|
|
||||||
self._file_handler.setFormatter(
|
|
||||||
logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
|
|
||||||
)
|
|
||||||
|
|
||||||
def emit(self, record):
|
|
||||||
try:
|
|
||||||
self._open_file()
|
|
||||||
self._file_handler.emit(record)
|
|
||||||
except Exception:
|
|
||||||
self.handleError(record)
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self._file_handler:
|
|
||||||
self._file_handler.close()
|
|
||||||
super().close()
|
|
||||||
|
|
||||||
|
|
||||||
def _create_logger(name: str, log_dir: str) -> logging.Logger:
|
|
||||||
"""创建按天写入的 Logger。"""
|
|
||||||
logger = logging.getLogger(name)
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
# 避免重复添加 handler
|
|
||||||
if not logger.handlers:
|
|
||||||
handler = _DailyFileHandler(log_dir)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
# === Logger 实例 ===
|
|
||||||
requests_logger = _create_logger("api_v3.requests", REQUESTS_LOG_DIR)
|
|
||||||
models_logger = _create_logger("api_v3.models", MODELS_LOG_DIR)
|
|
||||||
upscale_logger = _create_logger("api_v3.upscale", UPSCALE_LOG_DIR)
|
|
||||||
errors_logger = _create_logger("api_v3.errors", ERRORS_LOG_DIR)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_json(obj) -> str:
|
|
||||||
"""安全地序列化为 JSON。"""
|
|
||||||
try:
|
|
||||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
|
||||||
except Exception:
|
|
||||||
return str(obj)
|
|
||||||
|
|
||||||
|
|
||||||
# === 请求/响应日志 ===
|
|
||||||
|
|
||||||
def log_request(method: str, path: str, api_key_id: str, body: dict | None = None):
|
|
||||||
"""记录外部请求。"""
|
|
||||||
requests_logger.info(
|
|
||||||
f"REQUEST | {method} {path} | key={api_key_id} | body={_safe_json(body)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def log_response(method: str, path: str, api_key_id: str, status_code: int, body=None, duration_ms: int = 0):
|
|
||||||
"""记录外部响应。"""
|
|
||||||
requests_logger.info(
|
|
||||||
f"RESPONSE | {method} {path} | key={api_key_id} | status={status_code} | duration={duration_ms}ms | body={_safe_json(body)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def log_request_error(method: str, path: str, api_key_id: str, error: str, status_code: int = 500):
|
|
||||||
"""记录请求错误。"""
|
|
||||||
errors_logger.error(
|
|
||||||
f"REQUEST_ERROR | {method} {path} | key={api_key_id} | status={status_code} | error={error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# === 模型调用日志 ===
|
|
||||||
|
|
||||||
def log_model_request(engine_id: str, model_name: str, task_id: str, params: dict):
|
|
||||||
"""记录模型调用请求。"""
|
|
||||||
models_logger.info(
|
|
||||||
f"MODEL_REQUEST | engine={engine_id} | model={model_name} | task={task_id} | params={_safe_json(params)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def log_model_response(engine_id: str, model_name: str, task_id: str, success: bool, result: dict | None = None, error: str | None = None):
|
|
||||||
"""记录模型调用响应。"""
|
|
||||||
if success:
|
|
||||||
models_logger.info(
|
|
||||||
f"MODEL_RESPONSE | engine={engine_id} | model={model_name} | task={task_id} | success | result={_safe_json(result)}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
models_logger.error(
|
|
||||||
f"MODEL_RESPONSE | engine={engine_id} | model={model_name} | task={task_id} | failed | error={error}"
|
|
||||||
)
|
|
||||||
errors_logger.error(
|
|
||||||
f"MODEL_ERROR | engine={engine_id} | model={model_name} | task={task_id} | error={error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# === 超分轮询日志 ===
|
|
||||||
|
|
||||||
def log_upscale_poll_start(task_id: str, api_task_id: str):
|
|
||||||
"""记录超分轮询开始。"""
|
|
||||||
upscale_logger.info(f"UPSCALE_POLL_START | task={task_id} | api_task={api_task_id}")
|
|
||||||
|
|
||||||
|
|
||||||
def log_upscale_poll(task_id: str, api_task_id: str, status: str, attempt: int, result: dict | None = None):
|
|
||||||
"""记录超分轮询状态。"""
|
|
||||||
upscale_logger.info(
|
|
||||||
f"UPSCALE_POLL | task={task_id} | api_task={api_task_id} | status={status} | attempt={attempt} | result={_safe_json(result)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def log_upscale_poll_end(task_id: str, api_task_id: str, success: bool, final_status: str, total_attempts: int):
|
|
||||||
"""记录超分轮询结束。"""
|
|
||||||
if success:
|
|
||||||
upscale_logger.info(
|
|
||||||
f"UPSCALE_POLL_END | task={task_id} | api_task={api_task_id} | success | status={final_status} | attempts={total_attempts}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
upscale_logger.error(
|
|
||||||
f"UPSCALE_POLL_END | task={task_id} | api_task={api_task_id} | failed | status={final_status} | attempts={total_attempts}"
|
|
||||||
)
|
|
||||||
errors_logger.error(
|
|
||||||
f"UPSCALE_ERROR | task={task_id} | api_task={api_task_id} | status={final_status} | attempts={total_attempts}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# === 通用错误日志 ===
|
|
||||||
|
|
||||||
def log_error(category: str, message: str, details: dict | None = None):
|
|
||||||
"""记录通用错误。"""
|
|
||||||
errors_logger.error(
|
|
||||||
f"{category} | {message} | details={_safe_json(details)}"
|
|
||||||
)
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.api.api_model_pricing import ApiModelPricing
|
|
||||||
from app.models.image_engine import ImageEngine
|
|
||||||
from app.models.video_engine import VideoEngine
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
|
|
||||||
class PricingNotConfiguredError(Exception):
|
|
||||||
"""模型+分辨率组合未配置价格。"""
|
|
||||||
|
|
||||||
def __init__(self, *, model_name: str, resolution: str):
|
|
||||||
self.model_name = model_name
|
|
||||||
self.resolution = resolution
|
|
||||||
super().__init__(
|
|
||||||
f"模型或引擎 '{self.model_name}' 在分辨率 '{self.resolution}' 下未配置,无法生成"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_engine_display_name(db: AsyncSession, engine_id: str) -> str:
|
|
||||||
"""根据引擎 ID 解析展示名称(找不到时原样返回 ID)。"""
|
|
||||||
if not engine_id:
|
|
||||||
return engine_id or "unknown"
|
|
||||||
result = await db.execute(
|
|
||||||
select(VideoEngine.name).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
|
||||||
name = result.scalar_one_or_none()
|
|
||||||
if name:
|
|
||||||
return name
|
|
||||||
result = await db.execute(
|
|
||||||
select(ImageEngine.name).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
|
||||||
name = result.scalar_one_or_none()
|
|
||||||
return name or engine_id
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_api_pricing(
|
|
||||||
db: AsyncSession,
|
|
||||||
*,
|
|
||||||
gen_type: str,
|
|
||||||
resolution: str,
|
|
||||||
engine_id: str | None = None,
|
|
||||||
) -> ApiModelPricing | None:
|
|
||||||
"""按引擎精确规则优先获取定价;找不到时回退到同类型同分辨率。
|
|
||||||
|
|
||||||
查询优先级:
|
|
||||||
1. gen_type + engine_id + resolution 精确规则
|
|
||||||
2. gen_type + resolution 下 base_price 最高规则
|
|
||||||
"""
|
|
||||||
gen_type = (gen_type or "").lower().strip()
|
|
||||||
resolution = (resolution or "").strip()
|
|
||||||
engine_id = (engine_id or "").strip() or None
|
|
||||||
|
|
||||||
if engine_id:
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiModelPricing)
|
|
||||||
.where(ApiModelPricing.gen_type == gen_type)
|
|
||||||
.where(ApiModelPricing.model_config_id == engine_id)
|
|
||||||
.where(ApiModelPricing.resolution == resolution)
|
|
||||||
.order_by(ApiModelPricing.base_price.desc(), ApiModelPricing.per_second_price.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
pricing = result.scalar_one_or_none()
|
|
||||||
if pricing:
|
|
||||||
return pricing
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiModelPricing)
|
|
||||||
.where(ApiModelPricing.gen_type == gen_type)
|
|
||||||
.where(ApiModelPricing.resolution == resolution)
|
|
||||||
.order_by(ApiModelPricing.base_price.desc(), ApiModelPricing.per_second_price.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def calc_api_video_price(
|
|
||||||
db: AsyncSession,
|
|
||||||
duration: int,
|
|
||||||
resolution: str,
|
|
||||||
engine_id: str | None = None,
|
|
||||||
input_video_duration: float = 0,
|
|
||||||
input_image_count: int = 0,
|
|
||||||
) -> float:
|
|
||||||
"""计算 API 视频生成价格(元)。
|
|
||||||
|
|
||||||
未配置价格时抛出 PricingNotConfiguredError。
|
|
||||||
|
|
||||||
公式(与 credit_ratios 一致):
|
|
||||||
base_cost = (base_price + per_second_price × duration) × price_ratio
|
|
||||||
if 传入视频: += (input_video_base_price + input_video_per_second_price × input_video_duration) × input_video_ratio
|
|
||||||
if 传入图片: += (input_image_base_price + input_image_per_image_price × input_image_count) × input_image_ratio
|
|
||||||
"""
|
|
||||||
if not engine_id:
|
|
||||||
result = await db.execute(
|
|
||||||
select(VideoEngine.id)
|
|
||||||
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
|
||||||
.order_by(VideoEngine.priority.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
engine_id = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
pricing = await _get_api_pricing(db, gen_type="video", resolution=resolution, engine_id=engine_id)
|
|
||||||
|
|
||||||
if not pricing:
|
|
||||||
raise PricingNotConfiguredError(
|
|
||||||
model_name=await resolve_engine_display_name(db, engine_id),
|
|
||||||
resolution=resolution,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 基础价格
|
|
||||||
base_cost = (pricing.base_price + pricing.per_second_price * duration) * pricing.price_ratio
|
|
||||||
# 传入视频附加费(每秒 × 倍率)
|
|
||||||
if input_video_duration > 0:
|
|
||||||
base_cost += (pricing.input_video_base_price + pricing.input_video_per_second_price * input_video_duration) * pricing.input_video_ratio
|
|
||||||
# 传入图片附加费(每张 × 倍率)
|
|
||||||
if input_image_count > 0:
|
|
||||||
base_cost += (pricing.input_image_base_price + pricing.input_image_per_image_price * input_image_count) * pricing.input_image_ratio
|
|
||||||
return round(base_cost, 2)
|
|
||||||
|
|
||||||
|
|
||||||
async def calc_api_image_price(
|
|
||||||
db: AsyncSession,
|
|
||||||
image_size: str,
|
|
||||||
engine_id: str | None = None,
|
|
||||||
input_image_count: int = 0,
|
|
||||||
) -> float:
|
|
||||||
"""计算 API 图片生成价格(元)。
|
|
||||||
|
|
||||||
未配置价格时抛出 PricingNotConfiguredError。
|
|
||||||
|
|
||||||
公式(与 credit_ratios 一致):
|
|
||||||
base_cost = base_price × price_ratio
|
|
||||||
if 传入图片: += (input_image_base_price + input_image_per_image_price × input_image_count) × input_image_ratio
|
|
||||||
"""
|
|
||||||
if not engine_id:
|
|
||||||
result = await db.execute(
|
|
||||||
select(ImageEngine.id)
|
|
||||||
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
|
|
||||||
.order_by(ImageEngine.priority.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
engine_id = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
pricing = await _get_api_pricing(db, gen_type="image", resolution=image_size, engine_id=engine_id)
|
|
||||||
|
|
||||||
if not pricing:
|
|
||||||
raise PricingNotConfiguredError(
|
|
||||||
model_name=await resolve_engine_display_name(db, engine_id),
|
|
||||||
resolution=image_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 基础价格
|
|
||||||
base_cost = pricing.base_price * pricing.price_ratio
|
|
||||||
# 传入图片附加费(每张 × 倍率)
|
|
||||||
if input_image_count > 0:
|
|
||||||
base_cost += (pricing.input_image_base_price + pricing.input_image_per_image_price * input_image_count) * pricing.input_image_ratio
|
|
||||||
return round(base_cost, 2)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_priced_models(db: AsyncSession) -> set[str]:
|
|
||||||
"""获取所有已配置价格的引擎 ID 集合。
|
|
||||||
|
|
||||||
用于过滤 /api/v3/models 接口,仅返回已定价的模型。
|
|
||||||
"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiModelPricing.model_config_id).distinct()
|
|
||||||
)
|
|
||||||
return {row[0] for row in result.all()}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.api.api_generation_task import ApiGenerationTask
|
|
||||||
from app.models.api.api_key import ApiKey
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
|
|
||||||
async def check_quota(key: ApiKey) -> bool:
|
|
||||||
"""检查 API Key 配额是否充足。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True = 配额充足或无限额, False = 已超限。
|
|
||||||
"""
|
|
||||||
if key.quota_limit is None:
|
|
||||||
return True
|
|
||||||
return key.quota_used < key.quota_limit
|
|
||||||
|
|
||||||
|
|
||||||
async def get_active_video_tasks_count(api_key_id: str, db: AsyncSession) -> int:
|
|
||||||
"""统计 API Key 当前活跃的视频任务数。
|
|
||||||
|
|
||||||
活跃 = status IN ('pending', 'generating', 'processing') AND gen_type='video'
|
|
||||||
"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(func.count(ApiGenerationTask.id)).where(
|
|
||||||
ApiGenerationTask.api_key_id == api_key_id,
|
|
||||||
ApiGenerationTask.gen_type == "video",
|
|
||||||
ApiGenerationTask.status.in_(["pending", "generating", "processing"]),
|
|
||||||
ApiGenerationTask.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar_one() or 0
|
|
||||||
|
|
||||||
|
|
||||||
async def can_start_video_task(key: ApiKey, db: AsyncSession) -> bool:
|
|
||||||
"""检查是否可以立即启动新的视频任务。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True = 可以立即启动, False = 需要排队。
|
|
||||||
"""
|
|
||||||
if key.max_concurrent_video_tasks is None:
|
|
||||||
return True # 无限制
|
|
||||||
current = await get_active_video_tasks_count(key.id, db)
|
|
||||||
return current < key.max_concurrent_video_tasks
|
|
||||||
|
|
||||||
|
|
||||||
async def get_queued_video_tasks(key: ApiKey, db: AsyncSession, limit: int = 10) -> list[ApiGenerationTask]:
|
|
||||||
"""获取排队的视频任务列表(按创建时间排序)。"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiGenerationTask).where(
|
|
||||||
ApiGenerationTask.api_key_id == key.id,
|
|
||||||
ApiGenerationTask.gen_type == "video",
|
|
||||||
ApiGenerationTask.status == "queued",
|
|
||||||
ApiGenerationTask.deleted_at.is_(None),
|
|
||||||
).order_by(ApiGenerationTask.created_at.asc()).limit(limit)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
async def increment_quota(db: AsyncSession, key: ApiKey, credits_cost: float) -> None:
|
|
||||||
"""原子性增加配额使用量。"""
|
|
||||||
key.quota_used = round((key.quota_used or 0.0) + credits_cost, 2)
|
|
||||||
await db.flush()
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.api.api_generation_task import ApiGenerationTask
|
|
||||||
from app.schemas.api_v3.video import ApiVideoStatusResponse
|
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_video_task(
|
|
||||||
db: AsyncSession,
|
|
||||||
api_key_id: str,
|
|
||||||
model_name: str,
|
|
||||||
engine_id: str,
|
|
||||||
engine_snapshot: dict,
|
|
||||||
content: list[dict],
|
|
||||||
ratio: str | None,
|
|
||||||
duration: int | None,
|
|
||||||
resolution: str | None,
|
|
||||||
provider_generation_resolution: str | None,
|
|
||||||
upscale_enabled: bool,
|
|
||||||
upscale_snapshot_json: str | None,
|
|
||||||
idempotency_key: str | None = None,
|
|
||||||
local_media_refs: list[dict] | None = None,
|
|
||||||
) -> ApiGenerationTask:
|
|
||||||
"""创建视频生成任务记录。
|
|
||||||
|
|
||||||
如果调用方已下载好媒体文件(local_media_refs),则直接复用,避免重复下载。
|
|
||||||
"""
|
|
||||||
# 提取文本提示词
|
|
||||||
text_parts = [p.get("text", "") for p in content if p.get("type") == "text"]
|
|
||||||
original_prompt = " ".join(text_parts) if text_parts else content[0].get("text", "") if content else ""
|
|
||||||
|
|
||||||
# 构建 media_references(扁平格式,便于外部读取)
|
|
||||||
# 构建 local_media_json(嵌套格式,与 Volcano SDK 兼容)
|
|
||||||
media_refs = [] # 扁平格式: {"type": "image", "url": "...", "role": "..."}
|
|
||||||
|
|
||||||
for p in content:
|
|
||||||
ptype = p.get("type", "")
|
|
||||||
if ptype == "text":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 提取原始 URL(从嵌套格式中提取)
|
|
||||||
original_url = ""
|
|
||||||
media_type = ptype.replace("_url", "") # image_url -> image
|
|
||||||
if ptype == "image_url" and p.get("image_url"):
|
|
||||||
original_url = p["image_url"].get("url", "")
|
|
||||||
elif ptype == "video_url" and p.get("video_url"):
|
|
||||||
original_url = p["video_url"].get("url", "")
|
|
||||||
elif ptype == "audio_url" and p.get("audio_url"):
|
|
||||||
original_url = p["audio_url"].get("url", "")
|
|
||||||
|
|
||||||
# 存储扁平格式到 media_references
|
|
||||||
media_refs.append({
|
|
||||||
"type": media_type,
|
|
||||||
"url": original_url,
|
|
||||||
"role": p.get("role"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# 如果调用方已传入 local_media_refs(已下载),直接使用,不再重复下载
|
|
||||||
if local_media_refs is None:
|
|
||||||
from app.services.api_v3.file_service import process_media_url
|
|
||||||
|
|
||||||
local_media_refs = [] # 本地下载路径(嵌套格式)
|
|
||||||
for p in content:
|
|
||||||
ptype = p.get("type", "")
|
|
||||||
if ptype == "text":
|
|
||||||
continue
|
|
||||||
|
|
||||||
original_url = ""
|
|
||||||
if ptype == "image_url" and p.get("image_url"):
|
|
||||||
original_url = p["image_url"].get("url", "")
|
|
||||||
elif ptype == "video_url" and p.get("video_url"):
|
|
||||||
original_url = p["video_url"].get("url", "")
|
|
||||||
elif ptype == "audio_url" and p.get("audio_url"):
|
|
||||||
original_url = p["audio_url"].get("url", "")
|
|
||||||
|
|
||||||
# 下载文件到本地
|
|
||||||
try:
|
|
||||||
local_path = await process_media_url(original_url, ptype.replace("_url", ""))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to download media %s: %s", original_url[:80], exc)
|
|
||||||
local_path = original_url
|
|
||||||
|
|
||||||
# 本地路径使用嵌套格式(与 Volcano SDK 兼容)
|
|
||||||
local_media_refs.append({
|
|
||||||
"type": ptype,
|
|
||||||
ptype: {"url": local_path},
|
|
||||||
"role": p.get("role"),
|
|
||||||
})
|
|
||||||
|
|
||||||
media_references_json = json.dumps(media_refs, ensure_ascii=False) if media_refs else None
|
|
||||||
local_media_json = json.dumps(local_media_refs, ensure_ascii=False) if local_media_refs else None
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
deadline = now + timedelta(hours=24)
|
|
||||||
|
|
||||||
task = ApiGenerationTask(
|
|
||||||
id=generate_id(),
|
|
||||||
api_key_id=api_key_id,
|
|
||||||
external_idempotency_key=idempotency_key,
|
|
||||||
original_prompt=original_prompt,
|
|
||||||
gen_type="video",
|
|
||||||
model_name=model_name,
|
|
||||||
duration=duration,
|
|
||||||
aspect_ratio=ratio,
|
|
||||||
resolution=resolution,
|
|
||||||
provider_generation_resolution=provider_generation_resolution,
|
|
||||||
generation_count=1,
|
|
||||||
engine_id=engine_id,
|
|
||||||
media_references=media_references_json,
|
|
||||||
local_media_json=local_media_json,
|
|
||||||
engine_snapshot_json=json.dumps(engine_snapshot, ensure_ascii=False),
|
|
||||||
status="pending",
|
|
||||||
pipeline_stage="queued",
|
|
||||||
deadline_at=deadline,
|
|
||||||
video_upscale_enabled_snapshot=upscale_enabled,
|
|
||||||
video_upscale_snapshot_json=upscale_snapshot_json,
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
await db.flush()
|
|
||||||
return task
|
|
||||||
|
|
||||||
|
|
||||||
async def create_image_task(
|
|
||||||
db: AsyncSession,
|
|
||||||
api_key_id: str,
|
|
||||||
model_name: str,
|
|
||||||
engine_id: str,
|
|
||||||
engine_snapshot: dict,
|
|
||||||
prompt: str,
|
|
||||||
size: str | None,
|
|
||||||
idempotency_key: str | None = None,
|
|
||||||
) -> ApiGenerationTask:
|
|
||||||
"""创建图片生成任务记录。"""
|
|
||||||
task = ApiGenerationTask(
|
|
||||||
id=generate_id(),
|
|
||||||
api_key_id=api_key_id,
|
|
||||||
external_idempotency_key=idempotency_key,
|
|
||||||
original_prompt=prompt,
|
|
||||||
gen_type="image",
|
|
||||||
image_size=size,
|
|
||||||
generation_count=1,
|
|
||||||
engine_id=engine_id,
|
|
||||||
engine_snapshot_json=json.dumps(engine_snapshot, ensure_ascii=False),
|
|
||||||
status="processing",
|
|
||||||
pipeline_stage="creating_provider_task",
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
await db.flush()
|
|
||||||
return task
|
|
||||||
|
|
||||||
|
|
||||||
async def get_task(db: AsyncSession, task_id: str, api_key_id: str) -> ApiGenerationTask | None:
|
|
||||||
"""获取任务(带所有权验证)。"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiGenerationTask).where(
|
|
||||||
ApiGenerationTask.id == task_id,
|
|
||||||
ApiGenerationTask.api_key_id == api_key_id,
|
|
||||||
ApiGenerationTask.deleted_at.is_(None),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def find_by_idempotency_key(db: AsyncSession, api_key_id: str, idempotency_key: str) -> ApiGenerationTask | None:
|
|
||||||
"""根据幂等键查找已存在的任务。"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(ApiGenerationTask).where(
|
|
||||||
ApiGenerationTask.api_key_id == api_key_id,
|
|
||||||
ApiGenerationTask.external_idempotency_key == idempotency_key,
|
|
||||||
ApiGenerationTask.deleted_at.is_(None),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
def map_task_to_status_response(task: ApiGenerationTask) -> ApiVideoStatusResponse:
|
|
||||||
"""将任务对象映射为状态查询响应。"""
|
|
||||||
from app.config import settings
|
|
||||||
# 返回完整 URL(包含 BASE_URL)
|
|
||||||
video_url = _make_full_url(task.video_url)
|
|
||||||
video_cover_url = _make_full_url(task.video_cover_url)
|
|
||||||
return ApiVideoStatusResponse(
|
|
||||||
task_id=task.id,
|
|
||||||
status=_map_status(task.status),
|
|
||||||
video_url=video_url,
|
|
||||||
video_cover_url=video_cover_url,
|
|
||||||
duration=task.duration,
|
|
||||||
ratio=task.aspect_ratio,
|
|
||||||
resolution=task.resolution,
|
|
||||||
error=task.error_message,
|
|
||||||
created_at=task.created_at,
|
|
||||||
completed_at=task.generated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_full_url(path: str | None) -> str | None:
|
|
||||||
"""将本地路径转换为完整 URL。"""
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
from app.config import settings
|
|
||||||
# 如果已经是完整 URL,直接返回
|
|
||||||
if path.startswith(("http://", "https://")):
|
|
||||||
return path
|
|
||||||
# 处理 ./storage/generate/... 格式 → /generate/...
|
|
||||||
if path.startswith("./storage"):
|
|
||||||
url_path = path[len("./storage"):]
|
|
||||||
elif path.startswith("/"):
|
|
||||||
url_path = path
|
|
||||||
else:
|
|
||||||
url_path = f"/{path}"
|
|
||||||
# 拼接 BASE_URL
|
|
||||||
base = settings.BASE_URL.rstrip("/")
|
|
||||||
return f"{base}{url_path}"
|
|
||||||
|
|
||||||
|
|
||||||
def _map_status(status: str) -> str:
|
|
||||||
"""将内部状态映射为 API 状态。"""
|
|
||||||
status_map = {
|
|
||||||
"pending": "queued",
|
|
||||||
"queued": "pending_queue",
|
|
||||||
"generating": "generating",
|
|
||||||
"processing": "generating",
|
|
||||||
"completed": "completed",
|
|
||||||
"failed": "failed",
|
|
||||||
}
|
|
||||||
return status_map.get(status, status)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user