From 0c511f3451caca77fe0a291c68efdd3386358192 Mon Sep 17 00:00:00 2001 From: wwwwwwwww <526125649@qq.com> Date: Thu, 6 Aug 2026 13:13:28 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E5=A2=9E=E5=8A=A0=E8=B0=83=E7=94=A8?= =?UTF-8?q?=20AI=20=E8=A7=86=E9=A2=91=E7=94=9F=E6=88=90=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=92=8C=E8=99=9A=E6=8B=9F=E7=B4=A0=E6=9D=90=E5=BA=93=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=9A=84=E5=AF=B9=E5=A4=96api=202=E3=80=81=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=90=8E=E5=8F=B0apikkey=E7=AE=A1=E7=90=86=203?= =?UTF-8?q?=E3=80=81=E5=A2=9E=E5=8A=A0apikey=E5=8D=95=E7=8B=AC=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=AE=9A=E4=BB=B7=204=E3=80=81=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0apikey=E8=B0=83=E7=94=A8=E6=83=85=E5=86=B5=205?= =?UTF-8?q?=E3=80=81=E5=AE=8C=E5=96=84=E6=89=80=E6=9C=89=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=E6=B3=A8=E9=87=8A=E5=A2=9E=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API_v3_接口文档.md | 953 +++++++++++++++++ CODEBASE.md | 68 ++ DEPLOYMENT.md | 123 ++- video-gen-admin/.env | 1 + video-gen-admin/src/App.tsx | 6 + video-gen-admin/src/api/index.ts | 102 ++ video-gen-admin/src/pages/AdminApiKeys.tsx | 605 +++++++++++ .../src/pages/AdminApiModelPricings.tsx | 296 ++++++ video-gen-admin/src/pages/AdminApiUsage.tsx | 210 ++++ .../src/pages/AdminVideoEngines.tsx | 4 +- video-gen-api/.env | 3 +- .../2026073101_add_column_comments.py | 654 ++++++++++++ ...vp_v3_virtual_portrait_tables_and_quota.py | 193 ++++ ...1_add_missing_table_and_column_comments.py | 984 ++++++++++++++++++ .../a1b2c3d4e5f6g_add_api_v3_tables.py | 194 ++++ .../b2c3d4e5f6g7h_add_api_model_pricings.py | 56 + .../c3d4e5f6g7h8_add_api_key_encrypted.py | 33 + ..._add_model_name_to_api_generation_tasks.py | 29 + ...neration_task_id_to_video_upscale_tasks.py | 68 ++ .../e6f7g8h9i0j1_enhance_api_usage_logs.py | 43 + ...ocal_media_json_to_api_generation_tasks.py | 29 + .../app/admin_api/api_keys/__init__.py | 3 + .../app/admin_api/api_keys/routes.py | 376 +++++++ .../admin_api/api_model_pricings/__init__.py | 3 + .../admin_api/api_model_pricings/routes.py | 131 +++ .../app/admin_api/vp_v3_quota/__init__.py | 3 + .../app/admin_api/vp_v3_quota/routes.py | 80 ++ video-gen-api/app/api/admin/__init__.py | 6 + video-gen-api/app/api/v3/__init__.py | 12 + video-gen-api/app/api/v3/common.py | 14 + video-gen-api/app/api/v3/images.py | 55 + video-gen-api/app/api/v3/models.py | 119 +++ video-gen-api/app/api/v3/videos.py | 162 +++ video-gen-api/app/api/v3/virtual_portrait.py | 485 +++++++++ video-gen-api/app/config.py | 14 + video-gen-api/app/enums/celery_queue.py | 6 + video-gen-api/app/enums/private_portrait.py | 5 + video-gen-api/app/enums/upload_resource.py | 6 +- video-gen-api/app/main.py | 106 +- video-gen-api/app/models/__init__.py | 3 + video-gen-api/app/models/api/__init__.py | 15 + .../app/models/api/api_generation_task.py | 125 +++ video-gen-api/app/models/api/api_key.py | 57 + .../app/models/api/api_key_upscale_config.py | 27 + .../app/models/api/api_model_pricing.py | 40 + .../app/models/api/api_upscale_link.py | 22 + video-gen-api/app/models/api/api_usage_log.py | 61 ++ video-gen-api/app/models/base.py | 6 +- .../app/models/video_upscale_task.py | 6 + .../models/virtual_portrait_v3/__init__.py | 9 + .../virtual_portrait_v3/api_key_quota.py | 61 ++ .../app/models/virtual_portrait_v3/asset.py | 95 ++ .../app/models/virtual_portrait_v3/project.py | 86 ++ .../app/schemas/admin_api/__init__.py | 31 + .../app/schemas/admin_api/api_key.py | 140 +++ .../schemas/admin_api/api_model_pricing.py | 33 + .../app/schemas/admin_api/api_upscale.py | 38 + .../app/schemas/admin_api/api_usage.py | 36 + .../app/schemas/admin_api/vp_v3_quota.py | 33 + video-gen-api/app/schemas/api_v3/__init__.py | 31 + video-gen-api/app/schemas/api_v3/common.py | 14 + video-gen-api/app/schemas/api_v3/image.py | 34 + video-gen-api/app/schemas/api_v3/model.py | 19 + video-gen-api/app/schemas/api_v3/video.py | 135 +++ video-gen-api/app/schemas/video_engine.py | 2 +- .../schemas/virtual_portrait_v3/__init__.py | 37 + .../app/schemas/virtual_portrait_v3/asset.py | 106 ++ .../app/schemas/virtual_portrait_v3/common.py | 14 + .../schemas/virtual_portrait_v3/project.py | 65 ++ .../app/schemas/virtual_portrait_v3/quota.py | 21 + .../app/schemas/virtual_portrait_v3/upload.py | 16 + video-gen-api/app/services/api_v3/__init__.py | 59 ++ .../app/services/api_v3/auth_service.py | 99 ++ .../app/services/api_v3/engine_service.py | 132 +++ .../app/services/api_v3/file_service.py | 148 +++ .../app/services/api_v3/generation_service.py | 494 +++++++++ .../app/services/api_v3/key_service.py | 151 +++ .../app/services/api_v3/logging_service.py | 187 ++++ .../app/services/api_v3/pricing_service.py | 173 +++ .../app/services/api_v3/quota_service.py | 68 ++ .../app/services/api_v3/task_service.py | 215 ++++ .../app/services/api_v3/upscale_service.py | 212 ++++ .../app/services/api_v3/usage_log_service.py | 150 +++ video-gen-api/app/services/log_config.py | 12 +- .../app/services/operation_log_service.py | 6 + .../services/video_upscale/owner_service.py | 22 +- .../services/video_upscale/task_service.py | 3 +- .../services/virtual_portrait_v3/__init__.py | 13 + .../virtual_portrait_v3/asset_service.py | 674 ++++++++++++ .../virtual_portrait_v3/logging_service.py | 184 ++++ .../virtual_portrait_v3/project_service.py | 557 ++++++++++ .../virtual_portrait_v3/quota_service.py | 163 +++ .../virtual_portrait_v3/upload_service.py | 547 ++++++++++ video-gen-api/app/tasks/api_celery_app.py | 10 + .../app/tasks/api_generation_tasks.py | 841 +++++++++++++++ video-gen-api/app/tasks/api_recovery_tasks.py | 146 +++ video-gen-api/app/tasks/api_upscale_tasks.py | 364 +++++++ video-gen-api/app/tasks/celery_app.py | 36 +- video-gen-api/app/tasks/vp_v3_asset_tasks.py | 449 ++++++++ video-gen-api/app/utils/security.py | 21 + .../videogen_api.egg-info/SOURCES.txt | 232 ++++- video-gen-app/.env | 1 + 102 files changed, 13986 insertions(+), 41 deletions(-) create mode 100644 API_v3_接口文档.md create mode 100644 video-gen-admin/src/pages/AdminApiKeys.tsx create mode 100644 video-gen-admin/src/pages/AdminApiModelPricings.tsx create mode 100644 video-gen-admin/src/pages/AdminApiUsage.tsx create mode 100644 video-gen-api/alembic/versions/2026073101_add_column_comments.py create mode 100644 video-gen-api/alembic/versions/2026080401_add_vp_v3_virtual_portrait_tables_and_quota.py create mode 100644 video-gen-api/alembic/versions/2026080601_add_missing_table_and_column_comments.py create mode 100644 video-gen-api/alembic/versions/a1b2c3d4e5f6g_add_api_v3_tables.py create mode 100644 video-gen-api/alembic/versions/b2c3d4e5f6g7h_add_api_model_pricings.py create mode 100644 video-gen-api/alembic/versions/c3d4e5f6g7h8_add_api_key_encrypted.py create mode 100644 video-gen-api/alembic/versions/c4d5e6f7g8h9_add_model_name_to_api_generation_tasks.py create mode 100644 video-gen-api/alembic/versions/d5e6f7g8h9i0_add_api_generation_task_id_to_video_upscale_tasks.py create mode 100644 video-gen-api/alembic/versions/e6f7g8h9i0j1_enhance_api_usage_logs.py create mode 100644 video-gen-api/alembic/versions/f7g8h9i0j1k2_add_local_media_json_to_api_generation_tasks.py create mode 100644 video-gen-api/app/admin_api/api_keys/__init__.py create mode 100644 video-gen-api/app/admin_api/api_keys/routes.py create mode 100644 video-gen-api/app/admin_api/api_model_pricings/__init__.py create mode 100644 video-gen-api/app/admin_api/api_model_pricings/routes.py create mode 100644 video-gen-api/app/admin_api/vp_v3_quota/__init__.py create mode 100644 video-gen-api/app/admin_api/vp_v3_quota/routes.py create mode 100644 video-gen-api/app/api/v3/__init__.py create mode 100644 video-gen-api/app/api/v3/common.py create mode 100644 video-gen-api/app/api/v3/images.py create mode 100644 video-gen-api/app/api/v3/models.py create mode 100644 video-gen-api/app/api/v3/videos.py create mode 100644 video-gen-api/app/api/v3/virtual_portrait.py create mode 100644 video-gen-api/app/models/api/__init__.py create mode 100644 video-gen-api/app/models/api/api_generation_task.py create mode 100644 video-gen-api/app/models/api/api_key.py create mode 100644 video-gen-api/app/models/api/api_key_upscale_config.py create mode 100644 video-gen-api/app/models/api/api_model_pricing.py create mode 100644 video-gen-api/app/models/api/api_upscale_link.py create mode 100644 video-gen-api/app/models/api/api_usage_log.py create mode 100644 video-gen-api/app/models/virtual_portrait_v3/__init__.py create mode 100644 video-gen-api/app/models/virtual_portrait_v3/api_key_quota.py create mode 100644 video-gen-api/app/models/virtual_portrait_v3/asset.py create mode 100644 video-gen-api/app/models/virtual_portrait_v3/project.py create mode 100644 video-gen-api/app/schemas/admin_api/__init__.py create mode 100644 video-gen-api/app/schemas/admin_api/api_key.py create mode 100644 video-gen-api/app/schemas/admin_api/api_model_pricing.py create mode 100644 video-gen-api/app/schemas/admin_api/api_upscale.py create mode 100644 video-gen-api/app/schemas/admin_api/api_usage.py create mode 100644 video-gen-api/app/schemas/admin_api/vp_v3_quota.py create mode 100644 video-gen-api/app/schemas/api_v3/__init__.py create mode 100644 video-gen-api/app/schemas/api_v3/common.py create mode 100644 video-gen-api/app/schemas/api_v3/image.py create mode 100644 video-gen-api/app/schemas/api_v3/model.py create mode 100644 video-gen-api/app/schemas/api_v3/video.py create mode 100644 video-gen-api/app/schemas/virtual_portrait_v3/__init__.py create mode 100644 video-gen-api/app/schemas/virtual_portrait_v3/asset.py create mode 100644 video-gen-api/app/schemas/virtual_portrait_v3/common.py create mode 100644 video-gen-api/app/schemas/virtual_portrait_v3/project.py create mode 100644 video-gen-api/app/schemas/virtual_portrait_v3/quota.py create mode 100644 video-gen-api/app/schemas/virtual_portrait_v3/upload.py create mode 100644 video-gen-api/app/services/api_v3/__init__.py create mode 100644 video-gen-api/app/services/api_v3/auth_service.py create mode 100644 video-gen-api/app/services/api_v3/engine_service.py create mode 100644 video-gen-api/app/services/api_v3/file_service.py create mode 100644 video-gen-api/app/services/api_v3/generation_service.py create mode 100644 video-gen-api/app/services/api_v3/key_service.py create mode 100644 video-gen-api/app/services/api_v3/logging_service.py create mode 100644 video-gen-api/app/services/api_v3/pricing_service.py create mode 100644 video-gen-api/app/services/api_v3/quota_service.py create mode 100644 video-gen-api/app/services/api_v3/task_service.py create mode 100644 video-gen-api/app/services/api_v3/upscale_service.py create mode 100644 video-gen-api/app/services/api_v3/usage_log_service.py create mode 100644 video-gen-api/app/services/virtual_portrait_v3/__init__.py create mode 100644 video-gen-api/app/services/virtual_portrait_v3/asset_service.py create mode 100644 video-gen-api/app/services/virtual_portrait_v3/logging_service.py create mode 100644 video-gen-api/app/services/virtual_portrait_v3/project_service.py create mode 100644 video-gen-api/app/services/virtual_portrait_v3/quota_service.py create mode 100644 video-gen-api/app/services/virtual_portrait_v3/upload_service.py create mode 100644 video-gen-api/app/tasks/api_celery_app.py create mode 100644 video-gen-api/app/tasks/api_generation_tasks.py create mode 100644 video-gen-api/app/tasks/api_recovery_tasks.py create mode 100644 video-gen-api/app/tasks/api_upscale_tasks.py create mode 100644 video-gen-api/app/tasks/vp_v3_asset_tasks.py diff --git a/API_v3_接口文档.md b/API_v3_接口文档.md new file mode 100644 index 00000000..b636006a --- /dev/null +++ b/API_v3_接口文档.md @@ -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 字符。
⚠️ 不允许指向内网/本机地址(SSRF 防御) | +| name | string | 否 | 素材展示名,1-100 字符;不传自动从 URL 文件名或响应头 Content-Disposition 推断 | +| asset_type | string | 是 | `Image` / `Video` | +| video_duration | float | Video 可选 | 视频时长秒(1-60)。Video 不传会自动 ffprobe 探测,两者都失败则返回 400 需显式传入 | +| video_cover_url | string | 否(Video) | 视频封面图 URL(可选,仅 Video 用) | + +> 💡 **不再需要**:`upload_resource_id` / `file_size_bytes` / `mime_type` —— 服务端自动探测并写入。 + +**创建流程时序(服务端内部处理步骤):** +1. 校验 URL 格式(http/https + 非内网) → 400 +2. 下载 URL 文件到临时目录 + - HTTP 4xx/5xx → 502(含前 200B 响应片段);连接/读取超时 → 502 + - Content-Type 非法 → 415(application/octet-stream 除外);大小超限 → 413 + - 任一步失败:立即清理临时文件,不占配额 +3. 配额校验(素材数 + 存储 MB,按真实大小)→ 不足则删除刚下载的本地文件,403 +4. Video 时长合并校验(payload 优先,否则 ffprobe 探测) → 非法删本地文件并 400 +5. 写 VpV3Asset(status=Creating)+ 刷新 next_poll_at +6. 调火山 CreateAsset(url=本地公网 URL) → 成功返回 Creating + remote_asset_id;失败则 status=Failed, error_message=错误 + +**响应:VpV3AssetOut**(字段见素材详情) + +创建成功后 `status=Creating`,火山审核 3-30 秒,建议: +- 轮询 GET /assets/{id} 或 POST /assets/{id}/sync 主动刷新 +- 直到 status=Active 才能在 AI 创作中使用 +- 如果 status=Failed,读取 `error_message` / `moderation_json` 查看原因;可选择 DELETE 后重试 + +#### 8.2 查询项目下素材列表 + +``` +GET /api/v3/virtual-portrait/projects/{project_id}/assets +``` + +**Query 参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| page / page_size | int | 分页,默认 1/20 | +| status | string | 素材状态筛选:Creating/Active/Failed/Deleting | +| keyword | string | 素材名称模糊搜索 | +| asset_type | string | Image / Video | + +#### 8.3 素材详情 + +``` +GET /api/v3/virtual-portrait/assets/{asset_id} +``` + +#### 8.4 主动同步素材状态 + +主动调火山 GetAsset 刷新素材状态、URL、审核结果(轮询中断或前端主动刷新时使用)。 + +``` +POST /api/v3/virtual-portrait/assets/{asset_id}/sync +``` + +#### 8.5 删除素材 + +软删素材。本地 commit 时**同步删除本地落盘文件**(自动清理 `/uploads/...` 目录下的文件,带目录穿越防御),再投递 Celery 异步任务删除火山远端 Asset,返回 `remote_delete_status=pending` 表示处理中。 + +``` +DELETE /api/v3/virtual-portrait/assets/{asset_id} +``` + +**响应示例:** `{ "success": true, "remote_delete_status": "pending" }` + +> 说明: +> - 本地文件删除失败只打日志,不会影响素材状态置为 deleting + soft-delete(避免远端删除也回滚) +> - 删除成功后存储/素材数配额会自动返还(`/config` 接口再次查询可见 used 降低) + +--- + +### 9. AI 创作选择器素材 + +只返回当前 API Key 虚拟素材库中 **status=Active** 的图片/视频素材,提供给 AI 创作参考素材选择器使用。 + +``` +GET /api/v3/virtual-portrait/selectable-assets +``` + +**Query 参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| page / page_size | int | 分页,默认 1/20(1-100) | +| project_id | string | 可选,按项目筛选 | +| keyword | string | 可选,素材名称模糊搜索 | +| asset_type | string | 可选,Image / Video | + +**响应字段(VpV3SelectableAssetOut):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| asset_id | string | 素材 ID,供后续带入生成(预留使用:`source=vp_v3_asset, asset_id`) | +| project_id | string | 所属项目 ID | +| name | string | 素材名称 | +| asset_type | string | Image/Video | +| status | string | Active | +| source_url | string | 原始上传 URL | +| preview_url | string | 显示用预览 URL(直接绑定 img/video src) | +| video_duration | float | 视频时长秒(Video 时有值) | +| video_cover_url | string | 视频封面 | +| file_size_bytes | int | 文件大小字节 | +| created_at | datetime | 创建时间 | + +--- + +### 虚拟素材库配额限制错误 + +当 API Key 的虚拟素材库配额不足时,会返回 403: + +```json +{ + "error": { + "code": "quota_exceeded", + "message": "虚拟素材库配额不足:素材总数 上限 50,已使用 50,本次需要 1,超出上限" + } +} +``` + +如果管理员完全没有配置配额(全 0),任何虚拟素材库操作都返回: + +```json +{ + "error": { + "code": "forbidden", + "message": "当前 API Key 未开启虚拟素材库功能,请联系管理员配置配额" + } +} +``` + +--- + +## 价格计算规则 + +API 采用**先扣后退回**策略:任务创建/生成前预扣配额,失败时自动退回。 + +### 视频价格公式 + +``` +基础费用 = (base_price + per_second_price × duration) × price_ratio + +传入视频附加 = (input_video_base_price + input_video_per_second_price × 视频时长) × input_video_ratio +传入图片附加 = (input_image_base_price + input_image_per_image_price × 图片数量) × input_image_ratio + +总价格 = 基础费用 + 传入视频附加 + 传入图片附加 +``` + +### 图片价格公式 + +``` +基础费用 = base_price × price_ratio + +传入图片附加 = (input_image_base_price + input_image_per_image_price × 图片数量) × input_image_ratio + +总价格 = 基础费用 + 传入图片附加 +``` + +### 价格查找优先级 + +1. 引擎专属规则: `gen_type + engine_id + resolution` +2. 降级: `gen_type + resolution`(取 base_price 最高的) +3. 未配置价格的模型不会出现在可用列表中 + +--- + +## 状态码说明 + +| 状态码 | 说明 | +|--------|------| +| 200 | 请求成功 | +| 202 | 任务已创建(视频接口) | +| 401 | API Key 无效或缺失 | +| 403 | API Key 过期或无权限 | +| 404 | 资源不存在 | +| 422 | 请求参数校验失败 | +| 429 | 配额不足或并发超限 | +| 500 | 服务器内部错误 | +| 504 | 生成超时(图片接口) | + +--- + +## 使用示例 + +### cURL 示例 + +```bash +# 1. 获取可用模型 +curl -X GET http://localhost:8000/api/v3/models \ + -H "Authorization: Bearer vk_xxxxxxxxxxxx" + +# 2. 创建视频任务 +curl -X POST http://localhost:8000/api/v3/videos \ + -H "Authorization: Bearer vk_xxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "doubao-seedance-2-0-260128", + "content": [{"type": "text", "text": "一只猫在草地上奔跑"}], + "ratio": "16:9", + "duration": 5, + "resolution": "1080p" + }' + +# 3. 查询视频状态 +curl -X GET http://localhost:8000/api/v3/videos/{task_id} \ + -H "Authorization: Bearer vk_xxxxxxxxxxxx" + +# 4. 生成图片 +curl -X POST http://localhost:8000/api/v3/images \ + -H "Authorization: Bearer vk_xxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "doubao-seedream-5-0-260128", + "prompt": "一只可爱的猫咪", + "size": "2K" + }' +``` + +### Python 示例 + +```python +import requests + +BASE_URL = "http://localhost:8000/api/v3" +API_KEY = "vk_xxxxxxxxxxxx" +HEADERS = {"Authorization": f"Bearer {API_KEY}"} + +# 获取模型列表 +resp = requests.get(f"{BASE_URL}/models", headers=HEADERS) +models = resp.json()["models"] + +# 创建视频任务 +resp = requests.post(f"{BASE_URL}/videos", headers=HEADERS, json={ + "model": "doubao-seedance-2-0-260128", + "content": [{"type": "text", "text": "一只猫在草地上奔跑"}], + "ratio": "16:9", + "duration": 5, + "resolution": "1080p", +}) +task_id = resp.json()["task_id"] + +# 轮询视频状态 +import time +while True: + resp = requests.get(f"{BASE_URL}/videos/{task_id}", headers=HEADERS) + data = resp.json() + if data["status"] == "completed": + print(f"视频URL: {data['video_url']}") + break + elif data["status"] == "failed": + print(f"失败: {data['error']}") + break + time.sleep(30) + +# 生成图片 +resp = requests.post(f"{BASE_URL}/images", headers=HEADERS, json={ + "model": "doubao-seedream-5-0-260128", + "prompt": "一只可爱的猫咪", + "size": "2K", +}) +images = resp.json()["data"] +for img in images: + print(f"图片URL: {img['url']}") +``` + +--- + +## HTTP 状态码 + +所有接口 HTTP 状态码固定返回 **200**,业务结果通过响应体中的 `code` 字段判断: + +| code | 说明 | +|------|------| +| 0 | 成功 | +| 40000 | 请求参数错误 | +| 40001 | 模型+分辨率未配置价格 | +| 40100 | API Key 无效或缺失 | +| 40300 | API Key 过期或无权限 | +| 40400 | 资源不存在 | +| 42200 | 参数校验失败 | +| 42900 | 配额不足或并发超限 | +| 50000 | 服务器内部错误 | +| 50400 | 生成超时(图片接口) | + +--- + +## 统一响应格式 + +**成功响应:** +```json +{ + "code": 0, + "data": { ... }, + "message": "ok" +} +``` + +**错误响应:** +```json +{ + "code": 40001, + "data": null, + "message": "模型 'eng_xxxx' 在分辨率 '1080p' 下未配置,无法生成" +} +``` + +--- + +## 注意事项 + +1. **配额预扣**: 视频任务创建时预扣配额,失败时自动退回 +2. **并发限制**: 每个 API Key 有最大并发视频任务数限制 +3. **轮询间隔**: 视频任务建议轮询间隔 30 秒(前 10 分钟可缩短至 30 秒,之后逐步增加) +4. **超时时间**: 视频任务最长 24 小时,超时自动失败并退回配额 +5. **幂等键**: 视频接口支持 `idempotency_key`,相同键重复请求返回同一任务 +6. **图片同步**: 图片接口为同步阻塞调用,建议设置 300 秒超时 +7. **统一格式**: 所有接口 HTTP 状态码固定 200,通过 `code` 字段判断业务结果 + +--- + +## 部署与运维 + +### Celery Worker 启动命令 + +API v3 视频异步生成依赖以下 3 个 Celery 队列: + +| 队列 | 用途 | 推荐并发 | +|------|------|---------| +| `gen_api_create` | 视频任务创建(调用 Volcano Ark SDK) | 2-4 | +| `gen_api_poll` | 视频状态轮询 | 2-4 | +| `gen_api_download` | 视频下载与超分 | 2-4 | + +```bash +# 启动 API v3 专用 Worker +celery -A app.tasks.celery_app worker -l info \ + -Q gen_api_create,gen_api_poll,gen_api_download \ + --concurrency=4 -n worker_api@%h + +# 启动 Beat 调度器(定时恢复任务) +celery -A app.tasks.celery_app beat -l info +``` + +### 容灾恢复 + +服务重启后,Worker 会自动触发恢复扫描: +- 扫描 48 小时内未完成的 `ApiGenerationTask` +- 重新入队中断的 Celery 任务 +- 每分钟定时扫描(Beat 调度) + +### 日志目录 + +``` +storage/logs/external/ +├── requests/ # 外部 API 请求/响应日志 +├── models/ # 模型调用日志 +├── upscale/ # 超分轮询日志 +└── errors/ # 错误日志 +``` diff --git a/CODEBASE.md b/CODEBASE.md index e0320b0b..729dff96 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -383,6 +383,74 @@ async def _cleanup_urls(): # 实际逻辑写在 async 函数 - 队列路由在 `celery_app.py` 的 `task_routes` 配置 - 必需参数通过 `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 | +| `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,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 \ + --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 配置约定 ```python diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 9acde179..cee52d63 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -234,29 +234,71 @@ sudo systemctl start videogen-api ### 6. Celery Worker(可选) -ChatAPI 异步生成流水线需要 Celery Worker,依赖 Redis 作为 Broker。 +异步任务流水线需要 Celery Worker,依赖 Redis 作为 Broker。 -Celery 使用 **6 个队列**,按功能分离: +Celery 使用 **12 个队列**,按功能分离: | 队列 | 用途 | |------|------| | `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) | | `gen_provider_poll` | 轮询火山引擎生成状态 | | `gen_result_download` | 下载生成的视频/图片结果 | +| `gen_video_upscale_local` | 本地视频超分(FFmpeg) | +| `gen_video_upscale_remote` | 远程视频超分(火山 MediaKit) | | `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) | | `gen_private_portrait` | 真人素材认证与同步 | +| `gen_shot_analysis` | 拆镜分析 | +| `gen_shot_split` | 拆镜切片 | +| `gen_api_create` | **API v3 视频任务创建** | +| `gen_api_poll` | **API v3 视频状态轮询** | +| `gen_api_download` | **API v3 视频下载与超分** | | `default` | 默认队列(用户 OAuth、清理任务等) | +#### 启动命令 + ```bash -# 启动 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 +# ── 启动 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,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 \ + --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 [Unit] -Description=VideoGen Celery Worker +Description=VideoGen Celery Worker (Busy) After=network.target redis.service [Service] @@ -264,7 +306,7 @@ 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_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 RestartSec=5 @@ -272,6 +314,73 @@ RestartSec=5 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 --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 部署(可选) 项目提供 `Dockerfile` 和 `docker-compose.yml`。 diff --git a/video-gen-admin/.env b/video-gen-admin/.env index c08a590d..9c9651ff 100644 --- a/video-gen-admin/.env +++ b/video-gen-admin/.env @@ -1,6 +1,7 @@ # VITE_API_BASE=http://192.168.120.17:8000 #VITE_API_BASE=https://apiforeign.minzhongzc.com VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com +#VITE_API_BASE=http://localhost:8000 VITE_USE_MOCK=false # Encryption disabled for dev — enable in production VITE_ENCRYPTION_KEY= \ No newline at end of file diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index 0d785256..33e82977 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -39,6 +39,9 @@ import AdminPreTestTemplates from './pages/AdminPreTestTemplates'; import AdminOAuthList from './pages/AdminOAuthList'; import AdminMaterialList from './pages/AdminMaterialList'; import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects'; +import AdminApiKeys from './pages/AdminApiKeys'; +import AdminApiModelPricings from './pages/AdminApiModelPricings'; +import AdminApiUsage from './pages/AdminApiUsage'; import { useAdminStore } from './store'; @@ -100,6 +103,9 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index eb283323..503a71ed 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -390,6 +390,108 @@ export async function deleteCreditRatio(id: string): Promise { await api.delete(`/admin/credit-ratios/${id}`); } +// === API 模型价格管理 === + +export async function getApiModelPricings(): Promise { + return api.get('/admin/api-model-pricings'); +} + +export async function saveApiModelPricing(pricing: any): Promise { + 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 { + await api.delete(`/admin/api-model-pricings/${id}`); +} + +// === API Key 管理 === + +export async function getApiKeys(params?: { + skip?: number; + limit?: number; + companyName?: string; + isActive?: boolean; +}): Promise { + 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 { + return api.get(`/admin/api-keys/${id}`); +} + +export async function createApiKey(data: any): Promise { + return api.post('/admin/api-keys', data); +} + +export async function updateApiKey(id: string, data: any): Promise { + return api.put(`/admin/api-keys/${id}`, data); +} + +export async function deleteApiKey(id: string): Promise { + await api.delete(`/admin/api-keys/${id}`); +} + +export async function getApiKeyUsage(id: string, days?: number): Promise { + const qs = days ? `?days=${days}` : ''; + return api.get(`/admin/api-keys/${id}/usage${qs}`); +} + +export async function getApiKeyUpscaleConfig(id: string): Promise { + return api.get(`/admin/api-keys/${id}/upscale`); +} + +export async function saveApiKeyUpscaleConfig(id: string, data: any): Promise { + return api.put(`/admin/api-keys/${id}/upscale`, data); +} + +// === V3 虚拟素材库配额 === +export async function getApiKeyVpV3Quota(id: string): Promise { + 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 { + 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 { + 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 { + 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 { return api.get('/admin/payment-configs'); } diff --git a/video-gen-admin/src/pages/AdminApiKeys.tsx b/video-gen-admin/src/pages/AdminApiKeys.tsx new file mode 100644 index 00000000..cd561d46 --- /dev/null +++ b/video-gen-admin/src/pages/AdminApiKeys.tsx @@ -0,0 +1,605 @@ +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, Tag, Typography, +} from 'antd'; +import { + PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, EyeOutlined, KeyOutlined, CopyOutlined, +} from '@ant-design/icons'; +import { + getApiKeys, createApiKey, updateApiKey, deleteApiKey, getApiKeyUsage, getGenerationAiEngines, revealApiKey, getApiKeyUpscaleConfig, saveApiKeyUpscaleConfig, + getApiKeyVpV3Quota, saveApiKeyVpV3Quota, +} 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([]); + 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 [form] = Form.useForm(); + const [engines, setEngines] = useState([]); + const [upscaleRules, setUpscaleRules] = useState([]); + 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: ( +
+

请妥善保存以下 API Key,此信息仅显示一次:

+ + {result.apiKey} + +
+ ), + }); + } + } + // 保存超分配置 + 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); + if (result?.apiKey) { + await navigator.clipboard.writeText(result.apiKey); + message.success('API Key 已复制到剪贴板'); + } else { + message.error('获取 API Key 失败'); + } + } catch (e: any) { + const msg = e?.response?.data?.detail || '复制失败'; + message.error(msg); + } + }; + + const viewUsage = async (key: ApiKey) => { + try { + const usage = await getApiKeyUsage(key.id, 30); + setUsageModal({ open: true, key, usage }); + } 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 = { daily: '每日', monthly: '每月', one_time: '一次性' }; + return cycle ? map[cycle] || cycle : '无限'; + }; + + const columns = [ + { title: '公司', dataIndex: 'companyName', width: 120, ellipsis: true }, + { + title: 'Key 前缀', + dataIndex: 'apiKeyPrefix', + width: 110, + render: (v: string) => {v}****, + }, + { + title: '配额(元)', + dataIndex: 'quotaLimit', + width: 130, + render: (_v: number, r: ApiKey) => { + if (!r.quotaLimit) return 无限; + const used = r.quotaUsed || 0; + const limit = r.quotaLimit || 1; + const pct = Math.min(100, Math.round((used / limit) * 100)); + return ( +
+ `${used.toFixed(1)}/${limit}`} /> +
+ ); + }, + }, + { + title: '周期', + dataIndex: 'quotaCycle', + width: 70, + render: (v: string | null) => {cycleLabel(v)}, + }, + { + title: '状态', + dataIndex: 'isActive', + width: 70, + render: (v: boolean) => {v ? '启用' : '停用'}, + }, + { + 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) => ( + + + + + handleDelete(r.id)}> + + + + ), + }, + ]; + + return ( + + +
+ +
+ +
+ API Key 管理 + {total} 个 +
+ +
+
+ + + + + + {/* 创建/编辑弹窗 */} + { setModal({ open: false, key: null }); form.resetFields(); }} + okText="保存" cancelText="取消" width={760} + > +
+ + + + + + +
+ + + + + + +
+
+ + + + + + +
+ + handleUpscaleRuleChange(idx, 'targetResolution', v)} + style={{ width: 100 }} + options={['480p', '720p', '1080p', '2K', '4K'].map(r => ({ label: r, value: r }))} + /> + + 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' }, + ]} + /> + handleRemoveUpscaleRule(idx)}> + + + + + + {/* 虚拟素材库配额(仅编辑模式显示,新建时没ID) */} + {modal.key?.id && ( + <> + +
+ 🧩 V3 虚拟素材库配额 + + {vpV3Quota.enabled ? '已启用' : '未启用(全0=不可用)'} + +
+
+ + 默认 0 = 该 API Key 不可使用虚拟素材库功能。项目数或素材数任一上限 > 0 即启用(存储空间不再设置上限)。 + +
+
+
+ 项目数上限 + 已使用 {vpV3Quota.projectUsed || 0} / {vpV3Quota.projectLimit || 0} +
+ setVpV3Quota(q => ({ ...q, projectLimit: Number(v) || 0 }))} + addonBefore="上限" addonAfter="个" + /> + 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'} + /> +
+
+
+ 素材数上限 + 已使用 {vpV3Quota.assetUsed || 0} / {vpV3Quota.assetLimit || 0} +
+ setVpV3Quota(q => ({ ...q, assetLimit: Number(v) || 0 }))} + addonBefore="上限" addonAfter="张" + /> + 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'} + /> +
+
+ {vpV3Quota.storageMbUsed > 0 && ( +
+ 已使用存储空间:{Number(vpV3Quota.storageMbUsed || 0).toFixed(2)} MB(无上限限制,仅供参考) +
+ )} +
+ 备注(仅后台可见): + setVpV3Quota(q => ({ ...q, remark: e.target.value || null }))} + style={{ marginTop: 4 }} + /> +
+
+ + )} +
+ + {/* 使用统计弹窗 */} + setUsageModal({ open: false, key: null, usage: null })} + footer={null} width={640} + > + {usageModal.usage && ( +
+
+ 总请求数{usageModal.usage.totalRequests} + 总消耗(元){usageModal.usage.totalCreditsCost?.toFixed(2)} + 成功率{usageModal.usage.totalRequests ? ((usageModal.usage.successCount / usageModal.usage.totalRequests) * 100).toFixed(1) : 0}% +
+
new Date(v).toLocaleString() }, + { title: '类型', dataIndex: 'requestType' }, + { title: '模型', dataIndex: 'modelName' }, + { title: '消耗(元)', dataIndex: 'creditsCost' }, + { title: '状态', dataIndex: 'status', render: (v: string) => {v} }, + ]} + dataSource={usageModal.usage.items || []} + rowKey="id" + pagination={false} + size="small" + /> + + )} + + + ); +}; + +export default AdminApiKeys; diff --git a/video-gen-admin/src/pages/AdminApiModelPricings.tsx b/video-gen-admin/src/pages/AdminApiModelPricings.tsx new file mode 100644 index 00000000..51f8e5b8 --- /dev/null +++ b/video-gen-admin/src/pages/AdminApiModelPricings.tsx @@ -0,0 +1,296 @@ +import React, { useEffect, useState } from 'react'; +import { + Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography, +} from 'antd'; +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([]); + const [engines, setEngines] = useState([]); + 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 = genType === 'video' + ? (selectedEngine?.supportedResolutions?.length ? selectedEngine.supportedResolutions : DEFAULT_VIDEO_RESOLUTIONS) + : (selectedEngine?.supportedSizes?.length ? 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 = [ + { + title: '类型', + dataIndex: 'genType', + width: 80, + render: (v: string) => {v === 'video' ? '视频' : '图片'}, + }, + { + 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) => = 2 ? '#f5222d' : v >= 1.5 ? '#faad14' : '#52c41a' }}>{v}, + }, + { + 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) => ( + + + handleDelete(r.id)}> + + + + ), + }, + ]; + + return ( + + +
+ +
+ +
+ API 模型价格配置 + {pricings.length} 条 +
+ +
+
+ + +
+ + + { setModal({ open: false, pricing: null }); form.resetFields(); }} + okText="保存" cancelText="取消" width={560} + > + +
+ + + + + + +
+ + + +
+ + + + + + +
+ {genType === 'video' && ( + + + + )} + 传入媒体附加费 +
+ + + + + + +
+ +
+ + ); +}; + +export default AdminApiModelPricings; diff --git a/video-gen-admin/src/pages/AdminApiUsage.tsx b/video-gen-admin/src/pages/AdminApiUsage.tsx new file mode 100644 index 00000000..0b846759 --- /dev/null +++ b/video-gen-admin/src/pages/AdminApiUsage.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useState } from 'react'; +import { + Button, Card, DatePicker, Input, Select, Space, Table, Tag, Typography, +} from 'antd'; +import { + TableOutlined, ReloadOutlined, +} 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; + status: string; + errorMessage: string | null; + errorCode: string | null; + createdAt: string | null; +} + +const AdminApiUsage: React.FC = () => { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [searchCompany, setSearchCompany] = useState(''); + const [filterGenType, setFilterGenType] = useState(undefined); + const [filterStatus, setFilterStatus] = useState(undefined); + const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]); + + const load = 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(); + + const data = await getApiUsageAll(params); + setItems(data?.items || []); + setTotal(data?.total || 0); + } catch { + message.error('加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [page, pageSize, filterGenType, filterStatus, dateRange]); + + const handleSearch = () => { + setPage(1); + load(); + }; + + 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: 'Key 前缀', + dataIndex: 'apiKeyPrefix', + width: 110, + render: (v: string) => v ? {v} : '-', + }, + { + title: '类型', + dataIndex: 'genType', + width: 70, + render: (v: string) => {v === 'video' ? '视频' : '图片'}, + }, + { + title: '模型', + dataIndex: 'modelName', + width: 160, + ellipsis: true, + }, + { + title: '消耗(元)', + dataIndex: 'creditsCost', + width: 90, + render: (v: number) => 0 ? '#f5222d' : '#52c41a', fontWeight: 500 }}>{v?.toFixed(2) || '0.00'}, + }, + { + 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) => {v === 'success' ? '成功' : '失败'}, + }, + { + title: '错误信息', + dataIndex: 'errorMessage', + width: 200, + ellipsis: true, + render: (v: string) => v ? {v} : '-', + }, + ]; + + // 统计 + 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 ( + + +
+ +
+ +
+ API 消耗列表 + {total} 条 +
+ + 本页消耗: {totalCost.toFixed(2)} 元 + 成功: {successCount} + 失败: {failedCount} + + +
+
+ + {/* 筛选栏 */} + +
+ + + { setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]); setPage(1); }} + /> + +
+
+ + +
{ setPage(p); setPageSize(ps || 50); }, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条`, + }} + scroll={{ x: 1300 }} + /> + + + ); +}; + +export default AdminApiUsage; diff --git a/video-gen-admin/src/pages/AdminVideoEngines.tsx b/video-gen-admin/src/pages/AdminVideoEngines.tsx index c606a5f3..08553688 100644 --- a/video-gen-admin/src/pages/AdminVideoEngines.tsx +++ b/video-gen-admin/src/pages/AdminVideoEngines.tsx @@ -121,7 +121,7 @@ const AdminVideoEngines: React.FC = () => { form.setFieldsValue({ isActive: true, priority: 0, multiGenerationEnabled: false, maxGenerationCount: 1, - maxDuration: 30, + maxDuration: 15, maxImageCount: 2, maxVideoCount: 0, maxAudioCount: 0, @@ -286,7 +286,7 @@ const AdminVideoEngines: React.FC = () => {