merge main

This commit is contained in:
2026-08-11 10:16:38 +08:00
156 changed files with 22362 additions and 1211 deletions
-1
View File
@@ -28,4 +28,3 @@ bak/
# !dir/*.pycnode_modules/ # !dir/*.pycnode_modules/
*.tmp.* *.tmp.*
*_上线.py *_上线.py
.env*
+953
View File
@@ -0,0 +1,953 @@
# 对外开放模型 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 非法 → 415application/octet-stream 除外);大小超限 → 413
- 任一步失败:立即清理临时文件,不占配额
3. 配额校验(素材数 + 存储 MB,按真实大小)→ 不足则删除刚下载的本地文件,403
4. Video 时长合并校验(payload 优先,否则 ffprobe 探测) → 非法删本地文件并 400
5. 写 VpV3Assetstatus=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/201-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
View File
@@ -383,6 +383,75 @@ 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
+117 -7
View File
@@ -234,29 +234,72 @@ sudo systemctl start videogen-api
### 6. Celery Worker(可选) ### 6. Celery Worker(可选)
ChatAPI 异步生成流水线需要 Celery Worker,依赖 Redis 作为 Broker。 异步任务流水线需要 Celery Worker,依赖 Redis 作为 Broker。
Celery 使用 **6 个队列**,按功能分离: Celery 使用 **12 个队列**,按功能分离:
| 队列 | 用途 | | 队列 | 用途 |
|------|------| |------|------|
| `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 -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default 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:处理本地和远程超分(含 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 服务文件** `/etc/systemd/system/videogen-worker.service` #### systemd 服务文件
**业务 Worker** `/etc/systemd/system/videogen-worker-busy.service`
```ini ```ini
[Unit] [Unit]
Description=VideoGen Celery Worker Description=VideoGen Celery Worker (Busy)
After=network.target redis.service After=network.target redis.service
[Service] [Service]
@@ -264,7 +307,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_recovery,gen_private_portrait,default 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
Restart=always Restart=always
RestartSec=5 RestartSec=5
@@ -272,6 +315,73 @@ 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
View File
@@ -1,6 +1,7 @@
# 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=
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-BZDhy9nW.js"></script> <script type="module" crossorigin src="/assets/index-D-7FxsJd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css"> <link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head> </head>
<body> <body>
+8
View File
@@ -41,6 +41,10 @@ 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 AdminInvoices from './pages/AdminInvoices';
import { useAdminStore } from './store'; import { useAdminStore } from './store';
@@ -104,6 +108,10 @@ 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="invoices" element={<AdminInvoices />} />
<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 />} />
+177 -2
View File
@@ -241,6 +241,10 @@ 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');
} }
@@ -390,6 +394,131 @@ 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');
} }
@@ -439,6 +568,40 @@ export async function refundPaymentOrder(orderNo: string): Promise<void> {
await api.post(`/admin/payment-orders/${orderNo}/refund`); await api.post(`/admin/payment-orders/${orderNo}/refund`);
} }
// ── Invoice Management ───────────────────────────────────
export async function getAdminInvoices(params?: {
page?: number;
pageSize?: number;
status?: string;
phone?: string;
startDate?: string;
endDate?: string;
}): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone);
if (params?.startDate) qs.set('start_date', params.startDate);
if (params?.endDate) qs.set('end_date', params.endDate);
return api.get(`/admin/invoices?${qs.toString()}`);
}
export async function getAdminInvoiceDetail(id: string): Promise<any> {
return api.get(`/admin/invoices/${id}`);
}
export async function updateInvoiceStatus(id: string, data: {
status: 'success' | 'failed';
failureReason?: string;
}): Promise<void> {
await api.put(`/admin/invoices/${id}/status`, {
status: data.status,
failure_reason: data.failureReason,
});
}
export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ total: number; items: any[] }> { export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ total: number; items: any[] }> {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('page', String(page)); params.set('page', String(page));
@@ -516,8 +679,16 @@ export async function deleteRechargePackage(id: string): Promise<void> {
// ── Operation Logs ────────────────────────────────────── // ── Operation Logs ──────────────────────────────────────
export async function getOperationLogs(page?: number): Promise<{ total: number; items: any[] }> { export async function getOperationLogs(params?: {
const q = page ? `?page=${page}` : ''; page?: number;
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}`);
} }
@@ -623,6 +794,8 @@ export async function getAdminGenerationRecords(params?: {
status?: string; status?: string;
engineId?: string; engineId?: string;
includeMediaReferences?: boolean; includeMediaReferences?: boolean;
startDate?: string;
endDate?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}): Promise<{ total: number; items: any[] }> { }): Promise<{ total: number; items: any[] }> {
@@ -633,6 +806,8 @@ export async function getAdminGenerationRecords(params?: {
if (params?.includeMediaReferences !== undefined) { if (params?.includeMediaReferences !== undefined) {
q.set('include_media_references', String(params.includeMediaReferences)); q.set('include_media_references', String(params.includeMediaReferences));
} }
if (params?.startDate) q.set('start_date', params.startDate);
if (params?.endDate) q.set('end_date', params.endDate);
if (params?.page) q.set('page', String(params.page)); if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('page_size', String(params.pageSize)); if (params?.pageSize) q.set('page_size', String(params.pageSize));
const qs = q.toString(); const qs = q.toString();
@@ -0,0 +1,192 @@
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;
+760
View File
@@ -0,0 +1,760 @@
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 使 &gt; 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;
@@ -0,0 +1,297 @@
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;
+258
View File
@@ -0,0 +1,258 @@
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, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined, ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, 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,6 +17,11 @@ 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,
@@ -318,17 +323,22 @@ const AdminCreditRecords: React.FC = () => {
], ],
summaryRows: [ summaryRows: [
['总充值', exportSummary.totalRecharge], ['总充值', exportSummary.totalRecharge],
['总消费', exportSummary.totalConsume], ['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume],
['总回退', exportSummary.totalRefund], [' · 真实扣费(独立统计:type=消费 & action=charge/NULL', exportSummary.totalCharge],
[' · 预扣占用(独立统计: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],
@@ -369,11 +379,64 @@ const AdminCreditRecords: React.FC = () => {
return ( return (
<div> <div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<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> <Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<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> <Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} />
<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>
<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={{ color: '#94a3b8' }}></div>
<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 }}>
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Button, Button,
Card, Card,
DatePicker,
Empty, Empty,
Input, Input,
message, message,
@@ -24,6 +25,7 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
FileImageOutlined, FileImageOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api'; import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types'; import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
@@ -178,6 +180,9 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
</div> </div>
); );
const todayStart = () => dayjs().startOf('day');
const todayEnd = () => dayjs().endOf('day');
const AdminGenerationRecords: React.FC = () => { const AdminGenerationRecords: React.FC = () => {
const [records, setRecords] = useState<AdminGenerationRecord[]>([]); const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -195,6 +200,10 @@ const AdminGenerationRecords: React.FC = () => {
const [videoPlaying, setVideoPlaying] = useState(false); const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
// 时间筛选(默认当天)
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -203,6 +212,8 @@ const AdminGenerationRecords: React.FC = () => {
status: filterStatus || undefined, status: filterStatus || undefined,
engineId: filterEngineId || undefined, engineId: filterEngineId || undefined,
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true', includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
page, page,
pageSize, pageSize,
}); });
@@ -246,7 +257,7 @@ const AdminGenerationRecords: React.FC = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]); }, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize, queryCreatedRange]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -300,6 +311,19 @@ const AdminGenerationRecords: React.FC = () => {
}, [preview]); }, [preview]);
const handleSearch = () => { const handleSearch = () => {
setPage(1);
setQueryCreatedRange(createdRange);
setReloadKey((v) => v + 1);
};
const handleReset = () => {
setFilterStatus('');
setFilterUserId('');
setFilterEngineId('');
setFilterIncludeMedia('');
const defaultRange = [todayStart(), todayEnd()];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1); setPage(1);
setReloadKey((v) => v + 1); setReloadKey((v) => v + 1);
}; };
@@ -870,9 +894,23 @@ const AdminGenerationRecords: React.FC = () => {
onPressEnter={handleSearch} onPressEnter={handleSearch}
allowClear allowClear
/> />
<DatePicker.RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}> <Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button> </Button>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
</Button>
</Space> </Space>
</div> </div>
+435
View File
@@ -0,0 +1,435 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty, Input, DatePicker } from 'antd';
import { CheckOutlined, CloseOutlined, EyeOutlined, FileTextOutlined, FilterOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminInvoices, getAdminInvoiceDetail, updateInvoiceStatus } from '../api';
import { formatDate } from '../utils/formatDate';
import type { InvoiceItem, InvoiceDetail } from '../types';
const AdminInvoices: React.FC = () => {
const [data, setData] = useState<InvoiceItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [phoneFilter, setPhoneFilter] = useState<string>('');
const [createdRange, setCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
// 详情弹窗
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [currentDetail, setCurrentDetail] = useState<InvoiceDetail | null>(null);
// 失败原因弹窗
const [failModalOpen, setFailModalOpen] = useState(false);
const [failReason, setFailReason] = useState('');
const [failTargetId, setFailTargetId] = useState<string | null>(null);
const [failSubmitting, setFailSubmitting] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const res = await getAdminInvoices({
page,
pageSize,
status: statusFilter || undefined,
phone: phoneFilter || undefined,
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
});
setData(res.items);
setTotal(res.total);
} catch (err: any) {
message.error(err?.message || '获取失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [page, pageSize, statusFilter, queryCreatedRange]);
const handleSearch = () => {
setPage(1);
setQueryCreatedRange(createdRange);
};
const handleReset = () => {
setStatusFilter(null);
setPhoneFilter('');
const defaultRange = [dayjs().startOf('day'), dayjs().endOf('day')];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
};
const handleViewDetail = async (id: string) => {
setDetailLoading(true);
setDetailModalOpen(true);
try {
const detail = await getAdminInvoiceDetail(id);
setCurrentDetail(detail);
} catch (err: any) {
message.error(err?.message || '获取详情失败');
setDetailModalOpen(false);
} finally {
setDetailLoading(false);
}
};
const handleMarkSuccess = async (id: string) => {
try {
await updateInvoiceStatus(id, { status: 'success' });
message.success('已标记为开具成功');
fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
};
const handleOpenFailModal = (id: string) => {
setFailTargetId(id);
setFailReason('');
setFailModalOpen(true);
};
const handleConfirmFail = async () => {
if (!failReason.trim()) {
message.warning('请填写失败原因');
return;
}
if (!failTargetId) return;
setFailSubmitting(true);
try {
await updateInvoiceStatus(failTargetId, { status: 'failed', failureReason: failReason.trim() });
message.success('已标记为开具失败');
setFailModalOpen(false);
setFailTargetId(null);
setFailReason('');
fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
setFailSubmitting(false);
};
const handlePageChange = (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
};
const statusTag = (status: string) => {
const config: Record<string, { color: string; label: string }> = {
processing: { color: 'blue', label: '开具中' },
success: { color: 'green', label: '已开具' },
failed: { color: 'red', label: '已失败' },
};
const c = config[status] || { color: 'default', label: status };
return <Tag color={c.color}>{c.label}</Tag>;
};
const columns = [
{
title: '发票编号',
dataIndex: 'invoiceNo',
key: 'invoiceNo',
width: 180,
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 13 }}>{v}</span>,
},
{
title: '用户',
dataIndex: 'username',
key: 'username',
width: 100,
render: (_: string, record: InvoiceItem) => (
<div>
<div><Typography.Text strong>{record.username}</Typography.Text></div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{record.phone}</div>
</div>
),
},
{
title: '抬头类型',
dataIndex: 'headerType',
key: 'headerType',
width: 80,
render: (v: string) => (v === 'company' ? '企业' : '个人'),
},
{
title: '抬头名称',
dataIndex: 'headerName',
key: 'headerName',
width: 160,
ellipsis: true,
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 160,
ellipsis: true,
},
{
title: '总金额',
dataIndex: 'totalAmount',
key: 'totalAmount',
width: 100,
align: 'right' as const,
render: (v: number) => <span style={{ fontWeight: 600 }}>¥{v.toFixed(2)}</span>,
},
{
title: '订单数',
dataIndex: 'orderCount',
key: 'orderCount',
width: 70,
align: 'center' as const,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (v: string) => statusTag(v),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (v: string) => formatDate(v),
},
{
title: '操作',
key: 'actions',
width: 260,
render: (_: unknown, record: InvoiceItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
</Button>
{record.status === 'processing' && (
<>
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkSuccess(record.id)}>
</Button>
<Button type="link" size="small" danger icon={<CloseOutlined />} onClick={() => handleOpenFailModal(record.id)}>
</Button>
</>
)}
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<FileTextOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type={statusFilter === null ? 'primary' : 'default'}
onClick={() => { setStatusFilter(null); setPage(1); }}
icon={<FilterOutlined />}
size="small"
>
</Button>
<Button
type={statusFilter === 'processing' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('processing'); setPage(1); }}
size="small"
>
</Button>
<Button
type={statusFilter === 'success' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('success'); setPage(1); }}
size="small"
>
</Button>
<Button
type={statusFilter === 'failed' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('failed'); setPage(1); }}
size="small"
>
</Button>
</div>
</div>
{/* 搜索栏 */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Input
placeholder="按用户手机号搜索"
value={phoneFilter}
onChange={(e) => setPhoneFilter(e.target.value)}
style={{ width: 200 }}
allowClear
onPressEnter={handleSearch}
/>
<DatePicker.RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : data.length === 0 ? (
<Empty description="暂无发票记录" style={{ padding: '40px 0' }} />
) : (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 1400 }}
/>
)}
</Card>
{/* 详情弹窗 */}
<Modal
title={<Space><EyeOutlined /></Space>}
open={detailModalOpen}
onCancel={() => { setDetailModalOpen(false); setCurrentDetail(null); }}
footer={null}
width={700}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : currentDetail && (
<div style={{ padding: 8 }}>
{/* 基本信息 */}
<div style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
{currentDetail.invoiceNo}
<span style={{ marginLeft: 12 }}>{statusTag(currentDetail.status)}</span>
</Typography.Title>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
<Typography.Text style={{ color: '#64748b' }}>ID</Typography.Text>
<Typography.Text>{currentDetail.userId}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerType === 'company' ? '企业' : '个人'}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerName}</Typography.Text>
{currentDetail.headerTaxNo && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerTaxNo}</Typography.Text>
</>
)}
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.email}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text strong style={{ color: '#ef4444' }}>¥{currentDetail.totalAmount.toFixed(2)}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(currentDetail.createdAt)}</Typography.Text>
{currentDetail.issuedAt && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(currentDetail.issuedAt)}</Typography.Text>
</>
)}
{currentDetail.failureReason && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text type="danger">{currentDetail.failureReason}</Typography.Text>
</>
)}
</div>
</div>
{/* 关联订单 */}
{currentDetail.orders && currentDetail.orders.length > 0 && (
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
{currentDetail.orders.length}
</Typography.Text>
<Table
dataSource={currentDetail.orders}
columns={[
{
title: '订单号',
dataIndex: 'orderNo',
key: 'orderNo',
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>,
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
align: 'right' as const,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '积分',
dataIndex: 'credits',
key: 'credits',
align: 'right' as const,
},
]}
rowKey="id"
pagination={false}
size="small"
/>
</div>
)}
</div>
)}
</Modal>
{/* 失败原因弹窗 */}
<Modal
title="开具失败"
open={failModalOpen}
onCancel={() => { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }}
onOk={handleConfirmFail}
okText="确认"
cancelText="取消"
confirmLoading={failSubmitting}
>
<Typography.Text style={{ display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Input.TextArea
value={failReason}
onChange={(e) => setFailReason(e.target.value)}
placeholder="例如:抬头信息有误,请重新提交"
rows={3}
maxLength={500}
showCount
/>
</Modal>
</div>
);
};
export default AdminInvoices;
@@ -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, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty, Tabs,
} from 'antd'; } from 'antd';
import { import {
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined, BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined, NotificationOutlined, SaveOutlined,
} 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 } from '../api'; import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers, getSystemConfigs, updateSystemConfig, createSystemConfig, resetActivityBanner } from '../api';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
interface NotificationRecord { interface NotificationRecord {
@@ -31,6 +31,19 @@ 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);
@@ -43,6 +56,11 @@ 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);
@@ -71,8 +89,29 @@ 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);
@@ -117,6 +156,39 @@ 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';
@@ -167,8 +239,13 @@ const AdminNotificationManager: React.FC = () => {
}, },
]; ];
return ( const tabItems = [
<div> {
key: 'notifications',
label: (
<span><BellOutlined style={{ marginRight: 6 }} /></span>
),
children: (
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space> <Space>
@@ -198,6 +275,112 @@ const AdminNotificationManager: React.FC = () => {
scroll={{ x: 900 }} scroll={{ x: 900 }}
/> />
</Card> </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 (
<div>
<Tabs items={tabItems} defaultActiveKey="notifications" />
{/* Send Notification Modal */} {/* Send Notification Modal */}
<Modal <Modal
@@ -214,7 +397,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" placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} /> <ReactQuill theme="snow" modules={editorModules} 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 }}
@@ -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(p || page); const res = await getOperationLogs({ page: p || page });
setLogs(res.items || []); setLogs(res.items || []);
setTotal(res.total || 0); setTotal(res.total || 0);
} catch { } catch {
+27 -1
View File
@@ -62,6 +62,32 @@ 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 = values.llm_billing_enabled !== undefined && values.llm_billing_enabled !== null
? !['0', 'false', 'no', 'off', 'disabled'].includes(
String(values.llm_billing_enabled).trim().toLowerCase(),
)
: false;
if (llmBillingEnabled) {
const holdKeys = [
'optimize_hold_credits',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
];
const invalidKey = holdKeys.find((key) => {
const numericValue = Number(values[key]);
return !Number.isFinite(numericValue) || numericValue <= 0;
});
if (invalidKey) {
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
return;
}
}
setSaving(true); setSaving(true);
for (const config of configs) { for (const config of configs) {
const newVal = values[config.key]; const newVal = values[config.key];
@@ -174,7 +200,7 @@ const AdminSettings: React.FC = () => {
}; };
const groupedConfigs: Record<string, SystemConfig[]> = { const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')), '站点信息': configs.filter(c => c.key.startsWith('site_') && c.key !== 'site_banner'),
'协议配置': 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: 30, maxDuration: 15,
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: 12 }, (_, i) => ({ value: i + 4, label: `${i + 4}` })) Array.from({ length: 27 }, (_, 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 表示不支持音频参考,最大 3 段" extra="0 表示不支持音频参考"
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, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd'; import { Col, ColorPicker, 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,7 +87,15 @@ const WatermarkEditor: React.FC<WatermarkEditorProps> = ({ value, onChange, wate
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="文字颜色" required> <Form.Item label="文字颜色" required>
<Input value={textWatermark.color} onChange={(e) => patchText({ color: e.target.value || '#ffffff' })} placeholder="#ffffff" /> <ColorPicker
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}>
+64
View File
@@ -868,16 +868,32 @@ 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_releasetype=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;
@@ -1542,3 +1558,51 @@ export interface LlmBillingExecution {
finalErrorMessage?: string | null; finalErrorMessage?: string | null;
calls: LlmCallAttempt[]; calls: LlmCallAttempt[];
} }
// ── Invoice Types ───────────────────────────────────────
export interface InvoiceItem {
id: string;
invoiceNo: string;
userId: string;
username: string;
phone: string;
headerType: string;
headerName: string;
email: string;
totalAmount: number;
totalCredits: number;
orderCount: number;
status: string;
failureReason: string | null;
issuedAt: string | null;
createdAt: string | null;
}
export interface InvoiceOrder {
id: string;
orderNo: string;
amount: number;
credits: number;
}
export interface InvoiceDetail {
id: string;
invoiceNo: string;
userId: string;
headerType: string;
headerName: string;
headerTaxNo: string | null;
headerRegisterAddress: string | null;
headerRegisterPhone: string | null;
headerBankName: string | null;
headerBankAccount: string | null;
email: string;
totalAmount: number;
totalCredits: number;
status: string;
failureReason: string | null;
issuedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
orders: InvoiceOrder[];
}
+1 -1
View File
@@ -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/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"} {"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/admininvoices.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"}
+2 -1
View File
@@ -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,6 +44,7 @@ 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
@@ -0,0 +1,654 @@
"""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", "接收用户IDNULL表示全体广播")
_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")
@@ -0,0 +1,193 @@
"""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.idV3 调用方)而非 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_quotasAPI 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_projectsV3 虚拟素材项目
# ==============================================================
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 KeyV3 调用方)'),
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_assetsV3 虚拟素材
# ==============================================================
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 KeyV3 调用方)'),
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')
@@ -0,0 +1,984 @@
"""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 KeyV3调用方)")
_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", "本地上传后的访问URLUploadResource返回的)")
_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")
@@ -0,0 +1,109 @@
"""发票管理表迁移
创建 invoices(发票主表)和 invoice_orders(发票-订单关联表)。
Revision ID: 20260810_20260810
Revises: 2026080601
Create Date: 2026-08-10 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '20260810_20260810'
down_revision: Union[str, None] = '2026080601'
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:
# ============================================================
# 1. 创建 invoices 表
# ============================================================
op.create_table(
'invoices',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('invoice_no', sa.String(32), nullable=False, unique=True),
sa.Column('header_type', sa.String(16), nullable=False),
sa.Column('header_name', sa.String(128), nullable=False),
sa.Column('header_tax_no', sa.String(32), nullable=True),
sa.Column('header_register_address', sa.String(256), nullable=True),
sa.Column('header_register_phone', sa.String(32), nullable=True),
sa.Column('header_bank_name', sa.String(128), nullable=True),
sa.Column('header_bank_account', sa.String(64), nullable=True),
sa.Column('email', sa.String(128), nullable=False),
sa.Column('total_amount', sa.Float, nullable=False, server_default='0'),
sa.Column('total_credits', sa.Float, nullable=False, server_default='0'),
sa.Column('status', sa.String(16), nullable=False, server_default='processing'),
sa.Column('failure_reason', sa.Text, nullable=True),
sa.Column('issued_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),
)
op.create_index('idx_invoices_user_created', 'invoices', ['user_id', 'created_at'])
op.create_index('idx_invoices_status_created', 'invoices', ['status', 'created_at'])
op.create_index('idx_invoices_invoice_no', 'invoices', ['invoice_no'], unique=True)
# ============================================================
# 2. 创建 invoice_orders 表
# ============================================================
op.create_table(
'invoice_orders',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('invoice_id', sa.String(32), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False),
sa.Column('order_id', sa.String(32), sa.ForeignKey('payment_orders.id', ondelete='CASCADE'), nullable=False),
sa.Column('order_no', sa.String(64), nullable=False),
sa.Column('amount', sa.Float, nullable=False, server_default='0'),
sa.Column('credits', sa.Float, nullable=False, server_default='0'),
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_invoice_orders_invoice', 'invoice_orders', ['invoice_id'])
op.create_index('idx_invoice_orders_order', 'invoice_orders', ['order_id'])
op.create_unique_constraint('uq_invoice_orders', 'invoice_orders', ['invoice_id', 'order_id'])
# ============================================================
# 3. 表注释和字段注释
# ============================================================
_comment_table('invoices', '发票主表')
_comment_column('invoices', 'id', '主键')
_comment_column('invoices', 'user_id', '申请用户ID')
_comment_column('invoices', 'invoice_no', '发票编号')
_comment_column('invoices', 'header_type', '抬头类型: personal/company')
_comment_column('invoices', 'header_name', '抬头名称')
_comment_column('invoices', 'header_tax_no', '税号')
_comment_column('invoices', 'header_register_address', '注册地址')
_comment_column('invoices', 'header_register_phone', '注册电话')
_comment_column('invoices', 'header_bank_name', '开户行')
_comment_column('invoices', 'header_bank_account', '银行账号')
_comment_column('invoices', 'email', '电子邮箱(必填)')
_comment_column('invoices', 'total_amount', '开票总金额')
_comment_column('invoices', 'total_credits', '总积分')
_comment_column('invoices', 'status', '状态: processing/success/failed')
_comment_column('invoices', 'failure_reason', '失败原因')
_comment_column('invoices', 'issued_at', '开票成功时间')
_comment_table('invoice_orders', '发票-订单关联表')
_comment_column('invoice_orders', 'id', '主键')
_comment_column('invoice_orders', 'invoice_id', '发票ID')
_comment_column('invoice_orders', 'order_id', '订单ID')
_comment_column('invoice_orders', 'order_no', '订单号(冗余)')
_comment_column('invoice_orders', 'amount', '订单金额(冗余)')
_comment_column('invoice_orders', 'credits', '订单积分(冗余)')
def downgrade() -> None:
op.drop_table('invoice_orders')
op.drop_table('invoices')
@@ -0,0 +1,65 @@
"""发票抬头表迁移
创建 invoice_headers(发票抬头表),用于用户管理常用发票抬头。
Revision ID: 20260811_20260811
Revises: 20260810_20260810
Create Date: 2026-08-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '20260811_20260811'
down_revision: Union[str, None] = '20260810_20260810'
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:
op.create_table(
'invoice_headers',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('type', sa.String(16), nullable=False),
sa.Column('name', sa.String(128), nullable=False),
sa.Column('tax_no', sa.String(32), nullable=True),
sa.Column('register_address', sa.String(256), nullable=True),
sa.Column('register_phone', sa.String(32), nullable=True),
sa.Column('bank_name', sa.String(128), nullable=True),
sa.Column('bank_account', sa.String(64), nullable=True),
sa.Column('email', sa.String(128), nullable=True),
sa.Column('is_default', sa.Boolean, nullable=False, server_default='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_invoice_headers_user', 'invoice_headers', ['user_id'])
# 表注释和字段注释
_comment_table('invoice_headers', '发票抬头表')
_comment_column('invoice_headers', 'id', '主键')
_comment_column('invoice_headers', 'user_id', '用户ID')
_comment_column('invoice_headers', 'type', '抬头类型: personal/company')
_comment_column('invoice_headers', 'name', '抬头名称')
_comment_column('invoice_headers', 'tax_no', '税号')
_comment_column('invoice_headers', 'register_address', '注册地址')
_comment_column('invoice_headers', 'register_phone', '注册电话')
_comment_column('invoice_headers', 'bank_name', '开户行')
_comment_column('invoice_headers', 'bank_account', '银行账号')
_comment_column('invoice_headers', 'email', '接收邮箱')
_comment_column('invoice_headers', 'is_default', '是否默认')
def downgrade() -> None:
op.drop_table('invoice_headers')
@@ -0,0 +1,194 @@
"""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')
@@ -0,0 +1,56 @@
"""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')
@@ -0,0 +1,33 @@
"""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')
@@ -0,0 +1,29 @@
"""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')
@@ -0,0 +1,68 @@
"""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')
@@ -0,0 +1,43 @@
"""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')
@@ -0,0 +1,29 @@
"""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')
@@ -0,0 +1,3 @@
from app.admin_api.api_keys.routes import router
__all__ = ["router"]
@@ -0,0 +1,435 @@
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)
@@ -0,0 +1,3 @@
from app.admin_api.api_model_pricings.routes import router
__all__ = ["router"]
@@ -0,0 +1,131 @@
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
@@ -0,0 +1,3 @@
from app.admin_api.vp_v3_quota.routes import router
__all__ = ["router"]
@@ -0,0 +1,80 @@
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)
+6
View File
@@ -12,6 +12,9 @@ from app.api.admin.llm_billing import router as llm_billing_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)
@@ -26,3 +29,6 @@ router.include_router(llm_billing_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)
+4
View File
@@ -36,6 +36,8 @@ from app.api.v1.material_admin import router as material_admin_router
from app.api.v1.private_portrait import router as private_portrait_router from app.api.v1.private_portrait import router as private_portrait_router
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
from app.api.v1.upload_resource import router as upload_resource_router from app.api.v1.upload_resource import router as upload_resource_router
from app.api.v1.invoices import router as invoices_router
from app.api.v1.invoice_headers import router as invoice_headers_router
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth_router) api_router.include_router(auth_router)
@@ -74,3 +76,5 @@ api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router) api_router.include_router(private_portrait_router)
api_router.include_router(private_portrait_virtual_router) api_router.include_router(private_portrait_virtual_router)
api_router.include_router(upload_resource_router) api_router.include_router(upload_resource_router)
api_router.include_router(invoices_router)
api_router.include_router(invoice_headers_router)
+253 -23
View File
@@ -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 delete, func, or_, select, update from sqlalchemy import and_, case, 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
@@ -63,6 +63,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import process_refund from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.schemas.invoice import InvoiceStatusUpdateRequest
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -1721,17 +1722,62 @@ 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()
@@ -1774,17 +1820,26 @@ 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:
date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST) # 先构造完整的 naive 日期时刻,再一次性 attach tzinfo(避免分步 replace 丢 tzinfo
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999) naive_end = datetime.strptime(end_date, "%Y-%m-%d").replace(
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)
except: # 合法性:end >= start
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)
@@ -1827,20 +1882,45 @@ async def get_stats(
) )
)).scalar() or 0 )).scalar() or 0
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。 # 消费类(真实扣费 + 预扣占用):charge_action 为空时仍按真实扣费兼容hold 为预扣占用
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( # 说明:
CreditRecord.type == "consume", # hold(预扣占用):type=consumecharge_action='hold'amount<0
real_credit_charge_filter, # hold_release(预扣释放退回):type=refundcharge_action='hold_release'amount>0
# (账本 L256 强校验:hold_release.type 必须是 'refund',不是 consume
# charge(真实扣费):type=consumecharge_action='charge' 或 NULL(历史)amount<0
# refund(真实退款):type=refundcharge_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,
) )
)).scalar() or 0 )).one()
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(
@@ -1904,21 +1984,31 @@ async def get_stats(
) )
)).scalar() or 0 )).scalar() or 0
last_period_credits_consumed = (await db.execute( last_period_net_row = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where( select(
CreditRecord.type == "consume", func.coalesce(func.sum(_stats_real_and_hold), 0),
real_credit_charge_filter, func.coalesce(func.sum(_stats_refund_and_release), 0),
).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,
) )
)).scalar() or 0 )).one()
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天,便于图表展示)
# created_at 为 timestamptz,数据库 session 时区已是东八区(CST),
# 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时。
from sqlalchemy import Date, cast as sa_cast from sqlalchemy import Date, cast as sa_cast
_day_expr = sa_cast(CreditRecord.created_at, 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 = _chart_end_dt - timedelta(days=6) _chart_start_dt = datetime(
_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'),
@@ -1929,7 +2019,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, CreditRecord.created_at <= _chart_end_dt_inclusive,
) )
.group_by(_day_expr, CreditRecord.source_module) .group_by(_day_expr, CreditRecord.source_module)
.subquery() .subquery()
@@ -1973,23 +2063,41 @@ async def get_stats(
] ]
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照) # ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
# 净消耗 = (真实消费 charge + 预扣占用 hold) - (真实退款 refund + 预扣释放 hold_release)
# 注意:
# hold(预扣占用):type=consumecharge_action='hold'amount<0 → 加项
# hold_release(预扣释放):type=refundcharge_action='hold_release'amount>0 → 减项(type=refund 天然包含)
# charge(真实扣费):type=consumecharge/NULL → 加项
# refund(真实退款):type=refundrefund/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(func.abs(CreditRecord.amount)), 0).label('credits'), func.coalesce(func.sum(_charge_hold_expr), 0).label("total_charge_hold"),
func.coalesce(func.sum(_refund_release_expr), 0).label("total_refund_release"),
) )
.where( .where(
CreditRecord.type == "consume", CreditRecord.type.in_(["consume", "refund"]),
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(team_name=row.team_name, team_id=row.team_id, credits=float(row.credits or 0)) TeamCreditOut(
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
] ]
@@ -2127,6 +2235,8 @@ async def admin_list_generation_records(
status: str | None = Query(None), status: str | None = Query(None),
engine_id: str | None = Query(None), engine_id: str | None = Query(None),
include_media_references: bool | None = Query(None), include_media_references: bool | None = Query(None),
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
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),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
@@ -2149,6 +2259,10 @@ async def admin_list_generation_records(
query = query.where(GenerationRecord.engine_id == engine_id) query = query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None: if include_media_references is not None:
query = query.where(GenerationRecord.include_media_references.is_(include_media_references)) query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
query = query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
query = query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
# Count total # Count total
count_query = ( count_query = (
@@ -2164,6 +2278,10 @@ async def admin_list_generation_records(
count_query = count_query.where(GenerationRecord.engine_id == engine_id) count_query = count_query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None: if include_media_references is not None:
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references)) count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
count_query = count_query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
count_query = count_query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
total_result = await db.execute(count_query) total_result = await db.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
@@ -2404,6 +2522,118 @@ async def upload_login_video(
return {"url": url} return {"url": url}
# ── Payment Stats ──────────────────────────────────────── # ── Invoice Management ───────────────────────────────────
@router.get("/invoices")
async def admin_list_invoices(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""后台发票列表(分页+筛选)。"""
from app.services.invoice import get_admin_invoices
items, total = await get_admin_invoices(
db, page, page_size,
status_filter=status,
phone=phone,
start_date=start_date,
end_date=end_date,
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.get("/invoices/{invoice_id}")
async def admin_get_invoice(
invoice_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""后台获取发票详情(含关联订单)。"""
from app.services.invoice import get_invoice_with_orders
detail = await get_invoice_with_orders(db, invoice_id)
if not detail:
raise HTTPException(status_code=404, detail="发票不存在")
invoice = detail["invoice"]
orders = detail["orders"]
return {
"id": invoice.id,
"invoiceNo": invoice.invoice_no,
"userId": invoice.user_id,
"headerType": invoice.header_type,
"headerName": invoice.header_name,
"headerTaxNo": invoice.header_tax_no,
"headerRegisterAddress": invoice.header_register_address,
"headerRegisterPhone": invoice.header_register_phone,
"headerBankName": invoice.header_bank_name,
"headerBankAccount": invoice.header_bank_account,
"email": invoice.email,
"totalAmount": round(float(invoice.total_amount), 2),
"totalCredits": round(float(invoice.total_credits), 2),
"status": invoice.status,
"failureReason": invoice.failure_reason,
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
"updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None,
"orders": [
{
"id": o.id,
"orderNo": o.order_no,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
}
for o in orders
],
}
@router.put("/invoices/{invoice_id}/status")
async def admin_update_invoice_status(
invoice_id: str,
req: InvoiceStatusUpdateRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新发票状态(success/failed)。"""
from app.services.invoice import update_invoice_status
invoice, old_status = await update_invoice_status(db, invoice_id, req, admin.id)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"发票状态变更: {invoice.invoice_no} {old_status}{req.status}",
"PUT",
f"/admin/invoices/{invoice_id}/status",
detail=json.dumps(
{
"invoice_id": invoice_id,
"invoice_no": invoice.invoice_no,
"old_status": old_status,
"new_status": req.status,
"failure_reason": req.failure_reason,
},
ensure_ascii=False,
),
)
await db.commit()
return {
"id": invoice.id,
"invoiceNo": invoice.invoice_no,
"status": invoice.status,
"failureReason": invoice.failure_reason,
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
}
+4 -1
View File
@@ -351,7 +351,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" "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits", "site_banner", "site_banner_version"
])) ]))
) )
configs = result.scalars().all() configs = result.scalars().all()
@@ -375,6 +375,9 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"site_copyright": info.get("site_copyright", "© 2026 智创 版权所有"), "site_copyright": info.get("site_copyright", "© 2026 智创 版权所有"),
"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),
"site_banner": info.get("site_banner", ""),
"site_banner_version": int(info.get("site_banner_version") or 0),
} }
@@ -0,0 +1,96 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderOut, InvoiceHeaderUpdate
from app.services.invoice_header import (
create_header,
delete_header,
get_user_headers,
set_default_header,
update_header,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/invoice-headers", tags=["invoice-headers"])
def _header_to_out(header) -> dict:
return {
"id": header.id,
"user_id": header.user_id,
"type": header.type,
"name": header.name,
"tax_no": header.tax_no,
"register_address": header.register_address,
"register_phone": header.register_phone,
"bank_name": header.bank_name,
"bank_account": header.bank_account,
"email": header.email,
"is_default": header.is_default,
"created_at": header.created_at.isoformat() if header.created_at else None,
"updated_at": header.updated_at.isoformat() if header.updated_at else None,
}
@router.post("", response_model=InvoiceHeaderOut)
async def create_invoice_header(
req: InvoiceHeaderCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建发票抬头。"""
header = await create_header(db, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.get("")
async def list_invoice_headers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的所有发票抬头。"""
headers = await get_user_headers(db, current_user.id)
return {"items": [_header_to_out(h) for h in headers]}
@router.put("/{header_id}", response_model=InvoiceHeaderOut)
async def update_invoice_header(
header_id: str,
req: InvoiceHeaderUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新发票抬头。"""
header = await update_header(db, header_id, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.delete("/{header_id}")
async def delete_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除发票抬头。"""
await delete_header(db, header_id, current_user.id)
await db.commit()
return {"success": True}
@router.put("/{header_id}/set-default", response_model=InvoiceHeaderOut)
async def set_default_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""设置默认发票抬头。"""
header = await set_default_header(db, header_id, current_user.id)
await db.commit()
return _header_to_out(header)
+110
View File
@@ -0,0 +1,110 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.invoice import Invoice, InvoiceOrder
from app.models.user import User
from app.schemas.invoice import InvoiceCreateRequest, InvoiceOut, InvoiceOrderOut
from app.services.invoice import (
create_invoice,
get_user_invoices,
get_invoice_by_id,
get_invoice_with_orders,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/invoices", tags=["invoices"])
@router.post("", response_model=InvoiceOut)
async def create_invoice_endpoint(
req: InvoiceCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建发票申请。"""
invoice = await create_invoice(db, current_user.id, req)
await db.commit()
# 重新查询以获取关联订单
detail = await get_invoice_with_orders(db, invoice.id)
return _invoice_to_out(detail["invoice"], detail["orders"])
@router.get("")
async def list_invoices(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的发票列表(分页)。"""
invoices, total = await get_user_invoices(db, current_user.id, page, page_size)
# 加载每个发票的关联订单
items = []
for inv in invoices:
result = await db.execute(
select(InvoiceOrder).where(InvoiceOrder.invoice_id == inv.id)
)
orders = result.scalars().all()
items.append(_invoice_to_out(inv, list(orders)))
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.get("/{invoice_id}")
async def get_invoice(
invoice_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取发票详情(含关联订单)。"""
detail = await get_invoice_with_orders(db, invoice_id)
if not detail:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在")
invoice = detail["invoice"]
if invoice.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该发票")
return _invoice_to_out(invoice, detail["orders"])
def _invoice_to_out(invoice: Invoice, orders: list[InvoiceOrder]) -> dict:
"""将 Invoice ORM 对象转换为响应 dict。"""
return {
"id": invoice.id,
"user_id": invoice.user_id,
"invoice_no": invoice.invoice_no,
"header_type": invoice.header_type,
"header_name": invoice.header_name,
"header_tax_no": invoice.header_tax_no,
"header_register_address": invoice.header_register_address,
"header_register_phone": invoice.header_register_phone,
"header_bank_name": invoice.header_bank_name,
"header_bank_account": invoice.header_bank_account,
"email": invoice.email,
"total_amount": round(float(invoice.total_amount), 2),
"total_credits": round(float(invoice.total_credits), 2),
"status": invoice.status,
"failure_reason": invoice.failure_reason,
"issued_at": invoice.issued_at.isoformat() if invoice.issued_at else None,
"created_at": invoice.created_at.isoformat() if invoice.created_at else None,
"updated_at": invoice.updated_at.isoformat() if invoice.updated_at else None,
"orders": [
{
"id": o.id,
"invoice_id": o.invoice_id,
"order_id": o.order_id,
"order_no": o.order_no,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
}
for o in orders
],
}
+52 -3
View File
@@ -289,17 +289,36 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
async def list_orders( async def list_orders(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
status_filter: str | None = Query(None, description="按状态筛选: pending/paid/refunded/failed/cancelled"),
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
invoice_mode: bool = Query(False, description="开票模式:仅返回已支付订单"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
from app.services.payment import _check_and_expire_order from app.services.payment import _check_and_expire_order
from datetime import datetime, timezone, timedelta
count_query = select(func.count(PaymentOrder.id)).where(PaymentOrder.user_id == current_user.id) # 构建筛选条件
conditions = [PaymentOrder.user_id == current_user.id]
if status_filter:
conditions.append(PaymentOrder.status == status_filter)
if invoice_mode:
conditions.append(PaymentOrder.status == "paid")
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at >= start_dt)
if end_date:
end_dt = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at < end_dt)
# 统计总数
count_query = select(func.count(PaymentOrder.id)).where(*conditions)
total = (await db.execute(count_query)).scalar() or 0 total = (await db.execute(count_query)).scalar() or 0
result = await db.execute( result = await db.execute(
select(PaymentOrder) select(PaymentOrder)
.where(PaymentOrder.user_id == current_user.id) .where(*conditions)
.order_by(PaymentOrder.created_at.desc()) .order_by(PaymentOrder.created_at.desc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
@@ -308,7 +327,37 @@ async def list_orders(
for o in orders: for o in orders:
await _check_and_expire_order(db, o) await _check_and_expire_order(db, o)
return {"items": [PaymentOrderOut.model_validate(o) for o in orders], "total": total} # 开票模式:附带订单占用状态
items = []
if invoice_mode:
# 收集当前页订单ID
order_ids = [o.id for o in orders]
# 查询这些订单是否已被占用
from app.models.invoice import Invoice, InvoiceOrder
occupied_map: dict[str, str] = {}
if order_ids:
occ_result = await db.execute(
select(InvoiceOrder.order_id, Invoice.invoice_no)
.join(Invoice, InvoiceOrder.invoice_id == Invoice.id)
.where(
InvoiceOrder.order_id.in_(order_ids),
Invoice.status.in_(["processing", "success"]),
)
)
for row in occ_result.all():
occupied_map[row.order_id] = row.invoice_no
for o in orders:
item = PaymentOrderOut.model_validate(o)
item_dict = item.model_dump()
item_dict["is_occupied"] = o.id in occupied_map
item_dict["occupied_by"] = occupied_map.get(o.id)
items.append(item_dict)
else:
for o in orders:
item = PaymentOrderOut.model_validate(o)
items.append(item.model_dump())
return {"items": items, "total": total}
@router.get("/orders/{order_no}", response_model=PaymentOrderOut) @router.get("/orders/{order_no}", response_model=PaymentOrderOut)
+29 -3
View File
@@ -364,15 +364,41 @@ async def export_team_credit_records(
end_date=end_date, end_date=end_date,
) )
# 生成 CSV(兼容 Excel 打开) # 生成 CSV(兼容 Excel 打开UTF-8 BOM
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:
return str(datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S")) # 情况 1:已经是 datetime
if isinstance(val, _dt):
dt = val
elif isinstance(val, (int, float)):
# 情况 2:Unix 时间戳(极少,兼容旧代码)
dt = _dt.fromtimestamp(val)
elif isinstance(val, str):
# 情况 3ISO 字符串(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)
+12
View File
@@ -0,0 +1,12 @@
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)
+14
View File
@@ -0,0 +1,14 @@
from pydantic import BaseModel
class ApiError(BaseModel):
"""API 错误详情。"""
code: str
message: str
class ApiErrorResponse(BaseModel):
"""API 错误响应(旧格式,保留兼容)。"""
error: ApiError
+55
View File
@@ -0,0 +1,55 @@
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]}",
)
+119
View File
@@ -0,0 +1,119 @@
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
+167
View File
@@ -0,0 +1,167 @@
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,
)
@@ -0,0 +1,485 @@
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_typeImage/VideoVideo 必须提供 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+8naive 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,
)
+14
View File
@@ -346,6 +346,20 @@ 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"
+7
View File
@@ -12,6 +12,7 @@ class CeleryQueue(str, Enum):
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_CREDIT_MAINTENANCE = "gen_credit_maintenance" GEN_CREDIT_MAINTENANCE = "gen_credit_maintenance"
GEN_API_UPSCALE = "gen_api_upscale"
DEFAULT = "default" DEFAULT = "default"
@@ -28,6 +29,7 @@ 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,3 +47,8 @@ class CeleryTaskName(str, Enum):
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"
CREDIT_MAINTENANCE = "credit.maintenance_once" CREDIT_MAINTENANCE = "credit.maintenance_once"
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"
+1 -1
View File
@@ -45,7 +45,7 @@ class GenerationType(str, Enum):
# 生成配置常量 # 生成配置常量
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] 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]
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,6 +74,7 @@ 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"
@@ -101,6 +102,7 @@ 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"
@@ -159,6 +161,9 @@ 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"
+3 -3
View File
@@ -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"
+109 -1
View File
@@ -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 from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException, Request
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
@@ -518,12 +518,120 @@ 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"):
# 非 v3 路径返回标准 HTTPException 响应,保持原始状态码
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
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"}
@@ -3,7 +3,7 @@ import json
import logging import logging
from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import Request, Response from fastapi import HTTPException, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.config import settings from app.config import settings
+6
View File
@@ -41,7 +41,10 @@ from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp 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.invoice import Invoice, InvoiceOrder
from app.models.invoice_header import InvoiceHeader
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",
@@ -62,4 +65,7 @@ __all__ = [
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark", "HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
"PrivatePortraitProject", "PrivatePortraitValidateSession", "PrivatePortraitProject", "PrivatePortraitValidateSession",
"PrivatePortraitAssetGroup", "PrivatePortraitAsset", "PrivatePortraitAssetGroup", "PrivatePortraitAsset",
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing",
"Invoice", "InvoiceOrder", "InvoiceHeader",
] ]
+15
View File
@@ -0,0 +1,15 @@
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",
]
@@ -0,0 +1,125 @@
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)
+57
View File
@@ -0,0 +1,57 @@
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)
@@ -0,0 +1,27 @@
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}]'
)
@@ -0,0 +1,40 @@
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="传入图片每张价(元)")
@@ -0,0 +1,22 @@
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
)
@@ -0,0 +1,61 @@
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="原始请求快照")
+3 -3
View File
@@ -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() DateTime(timezone=True), server_default=func.now(), comment="创建时间"
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="更新时间"
) )
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 DateTime(timezone=True), nullable=True, index=True, comment="软删除时间,NULL表示未删除"
) )
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class Invoice(Base, TimestampMixin):
__tablename__ = "invoices"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
invoice_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
header_type: Mapped[str] = mapped_column(String(16), nullable=False)
header_name: Mapped[str] = mapped_column(String(128), nullable=False)
header_tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True)
header_register_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
header_register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
header_bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
header_bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True)
email: Mapped[str] = mapped_column(String(128), nullable=False)
total_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
total_credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="processing")
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
issued_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index('idx_invoices_user_created', 'user_id', 'created_at'),
Index('idx_invoices_status_created', 'status', 'created_at'),
)
class InvoiceOrder(Base, TimestampMixin):
__tablename__ = "invoice_orders"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
invoice_id: Mapped[str] = mapped_column(
String(32), ForeignKey("invoices.id", ondelete="CASCADE"), index=True
)
order_id: Mapped[str] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), nullable=False)
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
__table_args__ = (
UniqueConstraint('invoice_id', 'order_id', name='uq_invoice_orders'),
Index('idx_invoice_orders_invoice', 'invoice_id'),
Index('idx_invoice_orders_order', 'order_id'),
)
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class InvoiceHeader(Base):
"""发票抬头表"""
__tablename__ = "invoice_headers"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="主键")
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID"
)
type: Mapped[str] = mapped_column(String(16), nullable=False, comment="抬头类型: personal/company")
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="抬头名称")
tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="税号")
register_address: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="注册地址")
register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="注册电话")
bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="开户行")
bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="银行账号")
email: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="接收邮箱")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="更新时间"
)
__table_args__ = (
Index('idx_invoice_headers_user', 'user_id'),
)
@@ -34,6 +34,12 @@ 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")
@@ -0,0 +1,9 @@
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",
]
@@ -0,0 +1,61 @@
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="后台备注")
@@ -0,0 +1,95 @@
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="本地上传后的访问 URLUploadResource 返回的)")
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)
@@ -0,0 +1,86 @@
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 KeyV3 调用方)",
)
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="火山原始响应")
+12
View File
@@ -162,6 +162,18 @@ 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
@@ -0,0 +1,31 @@
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",
]
@@ -0,0 +1,156 @@
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]
@@ -0,0 +1,33 @@
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)
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,39 @@
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]
@@ -0,0 +1,33 @@
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="存储上限 MB0=不可上传文件")
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")
@@ -0,0 +1,31 @@
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",
]
@@ -0,0 +1,14 @@
from pydantic import BaseModel
class ApiError(BaseModel):
"""API 错误详情。"""
code: str
message: str
class ApiErrorResponse(BaseModel):
"""API 错误响应。"""
error: ApiError
+34
View File
@@ -0,0 +1,34 @@
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
+19
View File
@@ -0,0 +1,19 @@
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]
+135
View File
@@ -0,0 +1,135 @@
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="错误信息(失败时返回)")
+135
View File
@@ -0,0 +1,135 @@
import re
from typing import Any
from pydantic import BaseModel, Field, model_validator
from app.schemas.common import NaiveDatetimeOptional
_EMAIL_REGEX = re.compile(r"^[\w.\-]+@[\w.\-]+\.\w+$")
class InvoiceCreateRequest(BaseModel):
"""创建发票请求。"""
header_type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
header_name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
header_tax_no: str | None = Field(None, max_length=32, description="税号")
header_register_address: str | None = Field(None, max_length=256, description="注册地址")
header_register_phone: str | None = Field(None, max_length=32, description="注册电话")
header_bank_name: str | None = Field(None, max_length=128, description="开户行")
header_bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str = Field(..., max_length=128, description="电子邮箱(必填)")
order_ids: list[str] = Field(..., min_length=1, description="订单ID列表")
@model_validator(mode="after")
def validate_email(self) -> "InvoiceCreateRequest":
if not _EMAIL_REGEX.match(self.email):
raise ValueError("邮箱格式不正确")
return self
@model_validator(mode="after")
def validate_company_fields(self) -> "InvoiceCreateRequest":
if self.header_type == "company" and not self.header_tax_no:
raise ValueError("企业抬头必须填写税号")
return self
class InvoiceStatusUpdateRequest(BaseModel):
"""更新发票状态请求。"""
status: str = Field(..., pattern="^(success|failed)$", description="目标状态")
failure_reason: str | None = Field(None, max_length=500, description="失败原因")
@model_validator(mode="after")
def validate_failure_reason(self) -> "InvoiceStatusUpdateRequest":
if self.status == "failed" and not self.failure_reason:
raise ValueError("开具失败时必须填写失败原因")
return self
class InvoiceOrderOut(BaseModel):
"""发票关联订单响应。"""
model_config = {"from_attributes": True}
id: str
invoice_id: str
order_id: str
order_no: str
amount: float
credits: float
class InvoiceOut(BaseModel):
"""发票响应体。"""
model_config = {"from_attributes": True}
id: str
user_id: str
invoice_no: str
header_type: str
header_name: str
header_tax_no: str | None = None
header_register_address: str | None = None
header_register_phone: str | None = None
header_bank_name: str | None = None
header_bank_account: str | None = None
email: str
total_amount: float
total_credits: float
status: str
failure_reason: str | None = None
issued_at: NaiveDatetimeOptional = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
orders: list[InvoiceOrderOut] = []
# ── 发票抬头 ──────────────────────────────────────────────
class InvoiceHeaderCreate(BaseModel):
"""创建发票抬头请求。"""
type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool = Field(False, description="是否设为默认")
@model_validator(mode="after")
def validate_company_fields(self) -> "InvoiceHeaderCreate":
if self.type == "company" and not self.tax_no:
raise ValueError("企业抬头必须填写税号")
return self
class InvoiceHeaderUpdate(BaseModel):
"""更新发票抬头请求。"""
name: str | None = Field(None, min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool | None = Field(None, description="是否设为默认")
class InvoiceHeaderOut(BaseModel):
"""发票抬头响应体。"""
model_config = {"from_attributes": True}
id: str
user_id: str
type: str
name: str
tax_no: str | None = None
register_address: str | None = None
register_phone: str | None = None
bank_name: str | None = None
bank_account: str | None = None
email: str | None = None
is_default: bool = False
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
+2 -2
View File
@@ -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]') 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]')
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, ge=0, le=3, description="最大参考音频数量,0 表示不支持音频参考") max_audio_count: int = Field(default=0, description="最大参考音频数量,0 表示不支持音频参考")
multi_generation_enabled: bool = Field( multi_generation_enabled: bool = Field(
default=False, default=False,
description="是否允许客户端选择生成多个视频;关闭时客户端只能选择 1 份", description="是否允许客户端选择生成多个视频;关闭时客户端只能选择 1 份",
@@ -0,0 +1,37 @@
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",
]
@@ -0,0 +1,106 @@
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)
@@ -0,0 +1,14 @@
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")
@@ -0,0 +1,65 @@
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")
@@ -0,0 +1,21 @@
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="上传存储上限 MB0=不可上传文件")
# 已使用
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 即可)")
@@ -0,0 +1,16 @@
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 上传返回)")
@@ -60,7 +60,9 @@ def _as_date_start(value: str | None) -> datetime | None:
if not value: if not value:
return None return None
try: try:
return datetime.strptime(value, "%Y-%m-%d") # 构造东八区 00:00:00 与 DB timezone-aware created_at 比较,避免 8 小时偏移
naive = datetime.strptime(value, "%Y-%m-%d")
return naive.replace(tzinfo=CST)
except Exception: except Exception:
return None return None
@@ -69,7 +71,11 @@ def _as_date_end(value: str | None) -> datetime | None:
if not value: if not value:
return None return None
try: try:
return datetime.strptime(value, "%Y-%m-%d").replace(hour=23, minute=59, second=59, microsecond=999999) # 构造东八区 23:59:59.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
@@ -264,7 +270,9 @@ 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)
page_size = min(max(int(page_size or 20), 1), 1000) # 列表页默认最多 1000 条;导出接口可传较大值(最多 100000 条),避免月度导出被截断
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,
@@ -326,30 +334,103 @@ async def list_admin_credit_records(
}) })
items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows] items = [_record_to_item(record, user, deleted_map, allocation_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),
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), # 1: 总消费 = total_charge + total_hold(真实扣费 + 预扣占用)
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0), func.coalesce(func.sum(case((and_(_consume_type, _charge_or_hold_action), func.abs(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),
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))), # 4-11: 生成条数 / 尝试次数 / 图片视频条数 / 图片视频提词分析消费(仍按 charge 口径)
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(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(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(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 == "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 == "image", _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.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 == "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 == "image", _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.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.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.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, _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": _round2(s[0]), "total_recharge": _total_recharge,
"total_consume": _round2(s[1]), "total_consume": _total_consume,
"total_refund": _round2(s[2]), "total_refund": _total_refund,
"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),

Some files were not shown because too many files have changed in this diff Show More