122 Commits
Author SHA1 Message Date
root e06a739cdd 1 2026-08-14 16:36:11 +08:00
root fe57416493 1 2026-08-14 16:19:07 +08:00
root c47fac821c 1 2026-08-14 16:16:30 +08:00
root a4417e8976 1 2026-08-14 16:11:07 +08:00
root 3194356859 1 2026-08-14 16:06:07 +08:00
root 978475f0cd 1 2026-08-14 15:59:47 +08:00
sjy 5be40ead52 对公转账信息 2026-08-14 15:55:41 +08:00
sjy 7fc58e62ce 修复对公转账 2026-08-14 15:54:21 +08:00
root 5eed7686c9 1 2026-08-14 15:52:52 +08:00
root 4a2355a902 1 2026-08-14 15:47:51 +08:00
root 7ab5c9a5c9 1 2026-08-14 15:42:12 +08:00
root 556ea4dbd2 1 2026-08-14 15:39:22 +08:00
root 049bbb0d24 Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:36:22 +08:00
root bedf39d4f7 1 2026-08-14 15:35:54 +08:00
sjy abb479c469 Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:34:53 +08:00
GinHa 04fbcc8d8b Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:33:00 +08:00
GinHa c2971f828f 团队积分V2 2026-08-14 15:32:55 +08:00
root 45576e7d97 1 2026-08-14 15:30:29 +08:00
GinHa 1fb75676fc Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:26:21 +08:00
GinHa fac54b5667 团队积分V1 2026-08-14 15:23:46 +08:00
root 1565eeebc5 1 2026-08-14 15:22:13 +08:00
root a754a9e004 1 2026-08-14 15:13:21 +08:00
root 289e458e7c 1 2026-08-14 14:49:35 +08:00
root 3f18b28227 1 2026-08-14 14:46:50 +08:00
root 7c3ef21762 增加银行流水和定时任务程序 2026-08-14 14:35:25 +08:00
root 148b89c3ca 1 2026-08-14 14:03:36 +08:00
root ae92f8b78c 1 2026-08-14 13:56:09 +08:00
sjy 4db879199c 保留修改 2026-08-14 13:29:00 +08:00
root 59116b93d8 123 2026-08-14 13:20:03 +08:00
root 07d476e9ac 1122 2026-08-14 12:51:24 +08:00
root 5afcfe1513 1 2026-08-14 12:16:10 +08:00
sjy 516106c045 对公转账 2026-08-13 17:59:08 +08:00
sjy 5e504eddaf Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-08-13 17:57:38 +08:00
sjy da3f2cac49 冲突 2026-08-13 17:56:58 +08:00
root a31b58316e 1 2026-08-13 16:39:10 +08:00
root 299dc549cd 1 2026-08-13 16:38:15 +08:00
root c091960b87 1 2026-08-13 16:21:40 +08:00
root ee380cfcc1 1 2026-08-13 16:20:55 +08:00
root 9b13968762 1 2026-08-13 16:10:43 +08:00
root 7dcedf974d 1、后台菜单增加定时任务和银行交易
2、增加后台配置银行账户和请求网址
2026-08-13 15:12:16 +08:00
root 33a5cc4f94 1 2026-08-13 11:54:26 +08:00
root b4b629a497 1 2026-08-13 11:50:50 +08:00
root 2aa737edaa 1 2026-08-13 11:34:25 +08:00
root fe855e0fa2 1 2026-08-13 11:31:27 +08:00
root 437be9dac0 “单设备登录(同端互斥)”功能和后台全局用户配置 2026-08-13 10:29:42 +08:00
sjy 780769ddee 冲突解决 2026-08-13 10:02:22 +08:00
sjy b917f19db5 会员充值修改 2026-08-13 09:56:11 +08:00
root 4754151cbb Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-08-13 09:38:36 +08:00
root 58e061c9e7 前台登陆逻辑修改,请帮我实现“单设备登录(同端互斥)”功能 2026-08-13 09:37:39 +08:00
GinHa 6ed82b2e04 积分消耗明细改版V2 build app/admin 2026-08-12 11:53:22 +08:00
GinHa eb7c60684e 积分消耗明细改版V1 2026-08-12 11:49:53 +08:00
GinHa e3719eae09 会员积分改版V10 2026-08-11 15:38:53 +08:00
GinHa 45b9409fe8 会员积分改版V9 2026-08-11 14:27:21 +08:00
GinHa 81ef619ad3 会员积分改版V8 追加订单自动过期celery定时任务 2026-08-11 14:11:59 +08:00
GinHa a49ed802f4 会员积分改版V7 2026-08-11 13:23:22 +08:00
GinHa 809ff47120 会员积分改版V6 修复订单异步时区校验BUG 2026-08-11 11:57:55 +08:00
GinHa 890c1ea83e 会员积分改版V5 版本迁移合并 2026-08-11 10:30:43 +08:00
GinHa ba32d6f55e 会员积分改版V4 | 修复历史素材云兼容BUG | build app/admin 2026-08-11 10:27:06 +08:00
GinHa 392b9f4117 merge main 2026-08-11 10:16:38 +08:00
GinHa a1b82635c7 会员积分改版V3 2026-08-11 10:02:08 +08:00
GinHa 41b7cd395b 会员积分改版V2 2026-08-11 09:43:12 +08:00
GinHa b9fd07f293 会员积分改版V1 2026-08-11 09:24:18 +08:00
root f38f9b09ab 1 2026-08-11 09:24:16 +08:00
root 5bcc7888c2 1 2026-08-10 19:05:07 +08:00
root 0e47719d5c 1 2026-08-10 18:59:31 +08:00
root decabb5c42 1 2026-08-10 18:58:31 +08:00
root 80d0fd26f1 1 2026-08-10 18:47:34 +08:00
root 487c15779d 1 2026-08-10 18:45:26 +08:00
root 190cf54f0f 1 2026-08-10 18:39:29 +08:00
root baf97b5fc5 1 2026-08-10 18:31:45 +08:00
root 7237e8715f 1 2026-08-10 17:59:24 +08:00
root c1092f0deb 1 2026-08-10 17:38:47 +08:00
root e83f963534 1 2026-08-10 17:33:50 +08:00
root 9f7dc731b4 1 2026-08-10 17:23:59 +08:00
root 2bbcb75328 1 2026-08-10 16:53:13 +08:00
root 86c65f9ec8 1 2026-08-10 16:42:47 +08:00
root d1e8eb7316 1 2026-08-10 16:29:26 +08:00
root e364c46ce4 1 2026-08-10 16:11:04 +08:00
root a699c9bab8 1 2026-08-10 16:04:15 +08:00
root 7777454b16 1 2026-08-10 16:00:44 +08:00
root e8e590948c 1 2026-08-10 15:47:07 +08:00
root 6d30347cdb 1、增加订单开发票功能和发票抬头添加功能
2、一个订单只能在一个开票里,不允许多开
2026-08-10 15:39:00 +08:00
sjy e77d78db1e “同步sun分支(开票管理)” 2026-08-10 10:47:21 +08:00
sjy e2a2b6a16f Merge branch 'sun' 2026-08-10 10:42:09 +08:00
sjy e4fe42991d 完成sun分支功能 2026-08-10 10:39:01 +08:00
root ab16456579 1 2026-08-07 14:03:58 +08:00
root 40689de151 1 2026-08-07 13:59:43 +08:00
root c691416434 1 2026-08-07 13:51:46 +08:00
root d0de2f321a 1 2026-08-07 13:50:04 +08:00
root ec4d507890 1 2026-08-07 13:25:49 +08:00
root bffa786d47 1 2026-08-07 12:11:42 +08:00
root f530870370 1 2026-08-07 12:06:56 +08:00
root afac8dac09 1 2026-08-07 12:04:06 +08:00
root a241329ea6 1 2026-08-07 11:52:30 +08:00
root 9f89e186d6 1 2026-08-07 11:49:34 +08:00
root e24465ac39 1 2026-08-07 11:45:10 +08:00
root 1498a11a9b 1 2026-08-07 11:36:02 +08:00
root 9538f6157d 1、增加消息推送的前台横幅显示
2、增加apikey的整体配额增加和修改记录显示
2026-08-07 11:35:41 +08:00
root f5d7cfadb4 1、图片生成同步接口超时风险
2、图片返回的 URL 是本地路径
3、视频生成中媒体文件重复下载
4、幂等性检查无数据库唯一约束
5、幂等键冲突返回 409 改为返回已有任务信息
6、虚拟素材库配额校验 TOCTOU
7、项目级联删除与独立素材删除任务并发冲突
2026-08-06 18:57:14 +08:00
root 44b865e1ca 1 2026-08-06 17:53:06 +08:00
root 61c3761745 1 2026-08-06 17:47:59 +08:00
root 1029555fdc 1 2026-08-06 17:35:13 +08:00
root 166a020bed 1 2026-08-06 17:07:33 +08:00
root 820f601b32 1 2026-08-06 16:50:34 +08:00
root 70f2261d68 1 2026-08-06 16:44:28 +08:00
root 0fb20777f7 1 2026-08-06 14:48:01 +08:00
root 990cdd3ff5 1 2026-08-06 14:23:12 +08:00
root 395d979208 1 2026-08-06 14:18:45 +08:00
root dc3c4f7e4d 1 2026-08-06 14:12:46 +08:00
root 4766bab453 1 2026-08-06 13:55:51 +08:00
root 1e54e3f7f4 1 2026-08-06 13:39:46 +08:00
root 277231339c 1 2026-08-06 13:38:20 +08:00
root d6e6c77278 1 2026-08-06 13:14:37 +08:00
root 0c511f3451 1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理
3、增加apikey单独的模型定价
4、增加apikey调用情况
5、完善所有数据的注释增加
2026-08-06 13:13:28 +08:00
root a55d4d649c 1、修复后台首页数据概览的团队积分消耗排行情况
2、修复后台交易流水数据
2026-08-05 11:59:27 +08:00
root 517bcc3531 1 2026-08-05 10:24:56 +08:00
root ceb930bd5a 1、修复后台首页数据概览的团队积分消耗排行情况
2、修复前台团队的退款统计问题
2026-08-05 10:17:12 +08:00
root 5de637ab6b 1 2026-08-04 18:41:50 +08:00
root 6192aba4b2 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-08-04 18:05:03 +08:00
root ebd22d62a1 1、修改后台首页团队消耗没有算回退的数据
2、修改前台团队积分不可以下载回退积分的数据
2026-08-04 18:05:02 +08:00
sjy fe24e51b97 积分充值修改 2026-08-04 17:33:52 +08:00
root 97edb17062 修改行业提示词带素材库素材未生效的问题 2026-08-03 17:59:45 +08:00
302 changed files with 42783 additions and 9267 deletions
-1
View File
@@ -28,4 +28,3 @@ bak/
# !dir/*.pycnode_modules/
*.tmp.*
*_上线.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` 配置
- 必需参数通过 `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 配置约定
```python
+117 -7
View File
@@ -234,29 +234,72 @@ 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 视频下载** |
| `gen_api_upscale` | **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,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
[Unit]
Description=VideoGen Celery Worker
Description=VideoGen Celery Worker (Busy)
After=network.target redis.service
[Service]
@@ -264,7 +307,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 +315,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,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 部署(可选)
项目提供 `Dockerfile``docker-compose.yml`
+1
View File
@@ -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=
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-BZDhy9nW.js"></script>
<script type="module" crossorigin src="/assets/index-B0W8S7jH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head>
<body>
+18 -2
View File
@@ -21,7 +21,9 @@ import AdminVideoEngines from './pages/AdminVideoEngines';
import AdminImageEngines from './pages/AdminImageEngines';
import AdminCreditRatios from './pages/AdminCreditRatios';
import AdminMenuConfig from './pages/AdminMenuConfig';
import AdminRechargePackages from './pages/AdminRechargePackages';
import AdminCreditProducts from './pages/AdminCreditProducts';
import AdminLlmBillingPolicies from './pages/AdminLlmBillingPolicies';
import AdminLlmBillingExecutions from './pages/AdminLlmBillingExecutions';
import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminOauthAppList from './pages/AdminOauthAppList';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
@@ -39,6 +41,12 @@ 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 AdminInvoices from './pages/AdminInvoices';
import AdminBankTransactions from './pages/AdminBankTransactions';
import AdminScheduledTasks from './pages/AdminScheduledTasks';
import { useAdminStore } from './store';
@@ -94,12 +102,20 @@ const App = () => {
<Route path="image-engines" element={<AdminImageEngines />} />
<Route path="industries" element={<AdminIndustries />} />
<Route path="menu-configs" element={<AdminMenuConfig />} />
<Route path="recharge-packages" element={<AdminRechargePackages />} />
<Route path="credit-products" element={<AdminCreditProducts />} />
<Route path="llm-billing-policies" element={<AdminLlmBillingPolicies />} />
<Route path="llm-billing-executions" element={<AdminLlmBillingExecutions />} />
<Route path="payment" element={<AdminPaymentConfig />} />
<Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} />
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
<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="bank-transactions" element={<AdminBankTransactions />} />
<Route path="scheduled-tasks" element={<AdminScheduledTasks />} />
<Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
+340 -29
View File
@@ -17,7 +17,8 @@ import type {
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct,
BankAccount, ScheduledTask,
} from '../types';
import type {
@@ -208,6 +209,13 @@ export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize
return api.get(`/admin/users?${params.toString()}`);
}
export async function getAdminTeamDetail(teamId: string): Promise<AdminTeam> { return api.get(`/admin/teams/${teamId}`); }
export async function getAdminTeamSubscriptions(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/subscriptions`); }
export async function getAdminTeamMemberUsage(teamId: string, subscriptionId?: string): Promise<any[]> {
const q = subscriptionId ? `?subscription_id=${encodeURIComponent(subscriptionId)}` : ''; return api.get(`/admin/teams/${teamId}/member-usage${q}`);
}
export async function getAdminTeamManagerHistory(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/manager-history`); }
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
await api.post(`/admin/users/${userId}/credits`, { amount, description });
}
@@ -216,6 +224,106 @@ export async function toggleUserStatus(userId: string, isActive: boolean): Promi
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
}
export async function queryBankTransactions(params: {
accountId: string;
startDate: string;
endDate: string;
dcFlag?: number;
page?: number;
pageSize?: number;
}): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
qs.set('account_id', params.accountId);
qs.set('start_date', params.startDate);
qs.set('end_date', params.endDate);
if (params.dcFlag !== undefined) qs.set('dc_flag', String(params.dcFlag));
if (params.page) qs.set('page', String(params.page));
if (params.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/bank/transactions?${qs.toString()}`);
}
// ── Bank Account Management ────────────────────────────────
export async function listBankAccounts(): Promise<{ items: BankAccount[] }> {
return api.get('/admin/bank/accounts');
}
export async function createBankAccount(payload: {
account_name: string;
bank_name: string;
account_no: string;
is_active?: boolean;
is_default?: boolean;
description?: string;
}): Promise<{ id: string; message: string }> {
return api.post('/admin/bank/accounts', payload);
}
export async function updateBankAccount(
accountId: string,
payload: Partial<{
account_name: string;
bank_name: string;
account_no: string;
is_active: boolean;
is_default: boolean;
description: string;
}>,
): Promise<{ message: string }> {
return api.put(`/admin/bank/accounts/${accountId}`, payload);
}
export async function deleteBankAccount(accountId: string): Promise<{ message: string }> {
await api.delete(`/admin/bank/accounts/${accountId}`);
return { message: '删除成功' };
}
// ── Scheduled Tasks ────────────────────────────────────────
export async function listScheduledTasks(): Promise<{ items: ScheduledTask[] }> {
return api.get('/admin/scheduled-tasks');
}
export async function createScheduledTask(payload: {
name: string;
task_type: 'external_api' | 'internal_method';
schedule: string;
config?: Record<string, unknown> | string;
is_active?: boolean;
}): Promise<{ id: string; message: string }> {
return api.post('/admin/scheduled-tasks', payload);
}
export async function updateScheduledTask(
taskId: string,
payload: Partial<{
name: string;
task_type: 'external_api' | 'internal_method';
schedule: string;
config: Record<string, unknown> | string;
is_active: boolean;
}>,
): Promise<{ message: string }> {
return api.put(`/admin/scheduled-tasks/${taskId}`, payload);
}
export async function deleteScheduledTask(taskId: string): Promise<{ message: string }> {
await api.delete(`/admin/scheduled-tasks/${taskId}`);
return { message: '删除成功' };
}
export async function runScheduledTask(taskId: string): Promise<{ message: string }> {
return api.post(`/admin/scheduled-tasks/${taskId}/run`, {});
}
export async function toggleScheduledTask(taskId: string): Promise<{ is_active: boolean; message: string }> {
return api.post(`/admin/scheduled-tasks/${taskId}/toggle`, {});
}
export async function updateSingleDeviceLoginOverride(userId: string, override: boolean | null): Promise<void> {
await api.put(`/admin/users/${userId}/single-device-login-override`, { override });
}
export async function getModelConfigs(): Promise<ModelConfig[]> {
return api.get('/admin/model-configs');
}
@@ -241,6 +349,10 @@ export async function createSystemConfig(key: string, value: string, 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> {
return api.get('/admin/resource-capacity/global');
}
@@ -322,6 +434,7 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'user_type', filters?.userType);
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
setMaybe(params, 'team_id', filters?.teamId);
setMaybe(params, 'subscription_no', filters?.subscriptionNo);
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
setMaybe(params, 'credit_subject', filters?.creditSubject);
setMaybe(params, 'media_type', filters?.mediaType);
@@ -390,6 +503,131 @@ export async function deleteCreditRatio(id: string): Promise<void> {
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[]> {
return api.get('/admin/payment-configs');
}
@@ -402,41 +640,56 @@ export async function batchUpdatePaymentConfigs(configs: Record<string, string>)
await api.put('/admin/payment-configs/batch', configs);
}
export async function getPaymentStats(params?: {
paymentMethod?: string;
status?: string;
startDate?: string;
endDate?: string;
}): Promise<{
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: any[];
}> {
const searchParams = new URLSearchParams();
if (params?.paymentMethod) searchParams.set('payment_method', params.paymentMethod);
if (params?.status) searchParams.set('status', params.status);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
const queryString = searchParams.toString();
const url = queryString ? `/admin/payment-stats?${queryString}` : '/admin/payment-stats';
return api.get(url);
export async function getPaymentStats(params?: { paymentMethod?: string; orderSource?: string; status?: string; startDate?: string; endDate?: string; }): Promise<any> {
const q = new URLSearchParams();
if (params?.paymentMethod) q.set('payment_method', params.paymentMethod); if (params?.orderSource) q.set('order_source', params.orderSource);
if (params?.status) q.set('status', params.status); if (params?.startDate) q.set('start_date', params.startDate); if (params?.endDate) q.set('end_date', params.endDate);
return api.get(`/admin/payment-stats${q.toString() ? `?${q.toString()}` : ''}`);
}
export async function getAdminPaymentOrders(params?: { method?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
export async function getAdminPaymentOrders(params?: { method?: string; orderSource?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.method) qs.set('payment_method', params.method);
if (params?.method) qs.set('payment_method', params.method); if (params?.orderSource) qs.set('order_source', params.orderSource); 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);
if (params?.page) qs.set('page', String(params.page)); if (params?.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/payment-orders?${qs.toString()}`);
}
// 退款接口后端仍保留用于兼容旧调用,但当前版本固定返回“暂未开放订单退款”。
export async function refundPaymentOrder(orderNo: string): Promise<void> { 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);
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/payment-orders?${qs.toString()}`);
return api.get(`/admin/invoices?${qs.toString()}`);
}
export async function refundPaymentOrder(orderNo: string): Promise<void> {
await api.post(`/admin/payment-orders/${orderNo}/refund`);
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[] }> {
@@ -516,8 +769,16 @@ export async function deleteRechargePackage(id: string): Promise<void> {
// ── Operation Logs ──────────────────────────────────────
export async function getOperationLogs(page?: number): Promise<{ total: number; items: any[] }> {
const q = page ? `?page=${page}` : '';
export async function getOperationLogs(params?: {
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}`);
}
@@ -623,6 +884,8 @@ export async function getAdminGenerationRecords(params?: {
status?: string;
engineId?: string;
includeMediaReferences?: boolean;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}): Promise<{ total: number; items: any[] }> {
@@ -633,6 +896,8 @@ export async function getAdminGenerationRecords(params?: {
if (params?.includeMediaReferences !== undefined) {
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?.pageSize) q.set('page_size', String(params.pageSize));
const qs = q.toString();
@@ -1092,3 +1357,49 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
}
// ── Dynamic Credit Products ───────────────────────────────
function normalizeCreditProduct(raw: CreditProduct): CreditProduct {
return { ...raw, renewalEnabled: raw.renewalEnabled === true, tierRank: raw.tierRank == null ? null : Number(raw.tierRank), monthlyGrantCredits: Number(raw.monthlyGrantCredits || 0),
firstPurchasePrice: Number(raw.firstPurchasePrice || 0), regularPrice: Number(raw.regularPrice || 0), activityPrice: raw.activityPrice == null ? null : Number(raw.activityPrice),
price: Number(raw.price || 0), grantCredits: Number(raw.grantCredits || 0), sortOrder: Number(raw.sortOrder || 0), isActive: raw.isActive === true, isDeleted: raw.isDeleted === true };
}
export async function getCreditProducts(productType?: 'subscription' | 'team_subscription' | 'credit_addon'): Promise<CreditProduct[]> {
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : ''; return (await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`)).map(normalizeCreditProduct);
}
export async function createCreditProduct(payload: Record<string, unknown>): Promise<CreditProduct> { return normalizeCreditProduct(await api.post<CreditProduct>('/admin/credit-management/products', payload)); }
export async function updateCreditProduct(id: string, payload: Record<string, unknown>): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}`, payload)); }
export async function setCreditProductRenewal(id: string, renewalEnabled: boolean): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}/renewal`, { renewal_enabled: renewalEnabled })); }
export async function setCreditProductStatus(id: string, isActive: boolean): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}/status`, { is_active: isActive })); }
export async function softDeleteCreditProduct(id: string): Promise<void> { await api.delete(`/admin/credit-management/products/${id}`); }
export async function getAdminUserCreditSummary(userId: string): Promise<any> { return api.get(`/admin/credit-management/users/${userId}/summary`); }
export async function getAdminUserCreditBalances(userId: string, page = 1, pageSize = 50, status?: string): Promise<any[]> { const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); if (status) params.set('status', status); return api.get(`/admin/credit-management/users/${userId}/balances?${params.toString()}`); }
export async function getAdminUserSubscriptions(userId: string): Promise<any[]> { return api.get(`/admin/credit-management/users/${userId}/subscriptions`); }
export async function createAdminOfflineSubscription(userId: string, payload: { productId: string; quantity: number; paymentMethod: 'bank_transfer' | 'cash' | 'other'; actualPaidAmount?: number; offlineTradeNo?: string; offlinePaymentDetail?: string; remark?: string; }): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/offline-subscriptions`, { product_id: payload.productId, quantity: payload.quantity, payment_method: payload.paymentMethod, actual_paid_amount: payload.actualPaidAmount, offline_trade_no: payload.offlineTradeNo || null, offline_payment_detail: payload.offlinePaymentDetail || null, remark: payload.remark || null });
}
export async function adminGrantCredits(userId: string, payload: Record<string, unknown>): Promise<any> { return api.post(`/admin/credit-management/users/${userId}/grant`, payload); }
export async function adminDeductCredits(userId: string, payload: Record<string, unknown>): Promise<any> { return api.post(`/admin/credit-management/users/${userId}/deduct`, payload); }
// ── LLM 场景积分配置 ───────────────────────────────────────
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
return api.get('/admin/llm-billing/policies');
}
export async function createLlmBillingPolicy(payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.post('/admin/llm-billing/policies', payload);
}
export async function updateLlmBillingPolicy(id: string, payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.put(`/admin/llm-billing/policies/${id}`, payload);
}
export async function getLlmBillingExecutions(params?: { page?: number; pageSize?: number; sceneCode?: string; status?: string; userId?: string }): Promise<{ items: import('../types').LlmBillingExecution[]; total: number }> {
const query = new URLSearchParams();
query.set('page', String(params?.page || 1));
query.set('page_size', String(params?.pageSize || 20));
if (params?.sceneCode) query.set('scene_code', params.sceneCode);
if (params?.status) query.set('status', params.status);
if (params?.userId) query.set('user_id', params.userId);
return api.get(`/admin/llm-billing/executions?${query.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;
@@ -0,0 +1,266 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Button, Space, Typography, message, Card, DatePicker, Select } from 'antd';
import dayjs from 'dayjs';
import { BankOutlined, SearchOutlined } from '@ant-design/icons';
import { queryBankTransactions, listBankAccounts } from '../api';
import { formatDate } from '../utils/formatDate';
import type { BankAccount } from '../types';
const { RangePicker } = DatePicker;
interface TransactionRecord {
id?: string;
accountId?: string;
accountNo?: string;
transactionNo?: string;
transactionTime?: string;
transactionAmount?: string;
balanceDirection?: string;
balanceAfter?: string;
counterpartyName?: string;
counterpartyAccount?: string;
counterpartyBank?: string;
remark?: string;
digestCode?: string;
syncBatch?: string;
isSynced?: boolean;
createdAt?: string;
}
const AdminBankTransactions: React.FC = () => {
const [data, setData] = useState<TransactionRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// 银行账户
const [accounts, setAccounts] = useState<BankAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
// 搜索条件
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
const [dcFlag, setDcFlag] = useState<number | undefined>(undefined);
useEffect(() => {
loadAccounts();
}, []);
const loadAccounts = async () => {
try {
const res = await listBankAccounts();
const items = res.items || [];
setAccounts(items);
// 默认选中默认账户(或第一个账户)
const defaultAccount = items.find(a => a.isDefault) || items[0];
if (defaultAccount) {
setSelectedAccountId(defaultAccount.id);
}
// 默认日期范围:今天
const today = new Date().toISOString().slice(0, 10);
setDateRange([today, today]);
} catch (err: any) {
message.error(err?.message || '加载银行账户失败');
}
};
const fetchData = useCallback(async () => {
if (!selectedAccountId || !dateRange[0] || !dateRange[1]) return;
setLoading(true);
try {
const res = await queryBankTransactions({
accountId: selectedAccountId,
startDate: dateRange[0],
endDate: dateRange[1],
dcFlag,
page,
pageSize,
});
setData(res.items || []);
setTotal(res.total || 0);
} catch (err: any) {
message.error(err?.message || '查询失败');
} finally {
setLoading(false);
}
}, [selectedAccountId, dateRange, dcFlag, page, pageSize]);
useEffect(() => {
if (selectedAccountId && dateRange[0] && dateRange[1]) {
fetchData();
}
}, [fetchData]);
const handleSearch = () => {
if (!selectedAccountId) {
message.warning('请选择银行账户');
return;
}
if (!dateRange[0] || !dateRange[1]) {
message.warning('请选择交易时间区间');
return;
}
setPage(1);
fetchData();
};
const selectedAccount = accounts.find(a => a.id === selectedAccountId);
const columns = [
{
title: '交易流水号',
dataIndex: 'transactionNo',
width: 180,
render: (v: string) => v || '-',
},
{
title: '交易时间',
dataIndex: 'transactionTime',
width: 160,
render: (v: string) => v ? formatDate(v) : '-',
},
{
title: '交易金额',
dataIndex: 'transactionAmount',
width: 120,
align: 'right' as const,
render: (v: string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '借贷方向',
dataIndex: 'balanceDirection',
width: 100,
render: (v: string) => v === 'DR' ? '借/出金' : v === 'CR' ? '贷/入金' : '-',
},
{
title: '交易后余额',
dataIndex: 'balanceAfter',
width: 120,
align: 'right' as const,
render: (v: string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '对方户名',
dataIndex: 'counterpartyName',
width: 120,
render: (v: string) => v || '-',
},
{
title: '对方账号',
dataIndex: 'counterpartyAccount',
width: 180,
render: (v: string) => v || '-',
},
{
title: '对方开户行',
dataIndex: 'counterpartyBank',
width: 150,
render: (v: string) => v || '-',
},
{
title: '摘要码',
dataIndex: 'digestCode',
width: 100,
render: (v: string) => v || '-',
},
{
title: '备注',
dataIndex: 'remark',
width: 150,
render: (v: string) => v || '-',
},
{
title: '同步时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => v ? formatDate(v) : '-',
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<BankOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
<Space wrap style={{ marginBottom: 16 }}>
<Select
placeholder="选择银行账户"
value={selectedAccountId}
onChange={setSelectedAccountId}
style={{ width: 260 }}
allowClear
options={accounts.map(a => ({
value: a.id,
label: `${a.accountNo} - ${a.accountName}(${a.bankName})`,
}))}
/>
<RangePicker
value={dateRange[0] && dateRange[1] ? [dayjs(dateRange[0]), dayjs(dateRange[1])] : undefined}
onChange={(dates, dateStrings) => {
if (Array.isArray(dateStrings)) {
setDateRange([dateStrings[0] || null, dateStrings[1] || null]);
}
}}
/>
<Select
placeholder="借贷方向"
value={dcFlag}
onChange={setDcFlag}
style={{ width: 140 }}
allowClear
>
<Select.Option value={0}>/</Select.Option>
<Select.Option value={1}>/</Select.Option>
</Select>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
{selectedAccount && (
<div style={{ padding: '8px 12px', background: '#f8fafc', borderRadius: 8, fontSize: 13, color: '#64748b' }}>
<strong></strong>{selectedAccount.bankName} | {selectedAccount.accountNo} | {selectedAccount.accountName}
</div>
)}
</Card>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
rowKey={(r) => r.id || `${r.transactionNo || ''}-${Math.random()}`}
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</Card>
</div>
);
};
export default AdminBankTransactions;
@@ -0,0 +1,469 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import {
CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createCreditProduct,
getCreditProducts,
setCreditProductRenewal,
setCreditProductStatus,
softDeleteCreditProduct,
updateCreditProduct,
} from '../api';
import type { CreditProduct, CreditProductType } from '../types';
const cycleLabel: Record<string, string> = { monthly: '月', quarterly: '季', yearly: '年' };
const formatMoney = (value?: number | null): string => {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
};
const isSubscriptionType = (value: CreditProductType) => value === 'subscription' || value === 'team_subscription';
const AdminCreditProducts: React.FC = () => {
const [type, setType] = useState<CreditProductType>('subscription');
const [items, setItems] = useState<CreditProduct[]>([]);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState<CreditProduct | null>(null);
const [open, setOpen] = useState(false);
const [renewalEnabled, setRenewalEnabled] = useState(true);
const [form] = Form.useForm();
const replaceItem = (product: CreditProduct) => {
setItems((current) => current.map((item) => (item.id === product.id ? product : item)));
};
const load = async () => {
setLoading(true);
try {
setItems(await getCreditProducts(type));
} catch (error: any) {
message.error(error?.message || '加载积分商品失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [type]);
const openEditor = (item?: CreditProduct) => {
const nextRenewalEnabled = item ? item.renewalEnabled === true : true;
setEditing(item || null);
setRenewalEnabled(nextRenewalEnabled);
setOpen(true);
form.resetFields();
if (item) {
form.setFieldsValue({
productCode: item.productCode,
name: item.name,
description: item.description || undefined,
featuresText: (item.features || []).join('\n'),
tierCode: item.tierCode,
tierRank: item.tierRank,
billingCycle: item.billingCycle,
monthlyGrantCredits: item.monthlyGrantCredits,
firstPurchasePrice: item.firstPurchasePrice,
regularPrice: item.regularPrice,
activityPrice: item.activityPrice,
activityRange: item.activityStartAt && item.activityEndAt
? [dayjs(item.activityStartAt), dayjs(item.activityEndAt)]
: undefined,
renewalEnabled: nextRenewalEnabled,
price: item.price,
grantCredits: item.grantCredits,
validityMonths: item.validityMonths ?? 1,
sortOrder: item.sortOrder,
isActive: item.isActive,
});
return;
}
form.setFieldsValue({
productCode: undefined,
name: undefined,
description: undefined,
featuresText: undefined,
isActive: true,
sortOrder: 0,
billingCycle: 'monthly',
tierRank: 1,
renewalEnabled: true,
validityMonths: 1,
});
};
const save = async () => {
try {
const values = await form.validateFields();
const activityRange = values.activityRange || [];
const payload: Record<string, unknown> = {
name: values.name,
description: values.description || null,
features: String(values.featuresText || '')
.split('\n')
.map((value) => value.trim())
.filter(Boolean),
credit_level: 'general',
currency: 'CNY',
sort_order: values.sortOrder ?? 0,
};
if (!editing) {
payload.product_code = values.productCode;
payload.product_type = type;
payload.is_active = values.isActive ?? true;
}
if (isSubscriptionType(type)) {
if (!editing) {
Object.assign(payload, {
tier_code: values.tierCode,
tier_rank: values.tierRank,
billing_cycle: values.billingCycle,
});
}
Object.assign(payload, {
monthly_grant_credits: values.monthlyGrantCredits,
first_purchase_price: values.firstPurchasePrice,
regular_price: values.regularPrice,
activity_price: values.activityPrice ?? null,
activity_start_at: activityRange[0]?.toISOString() || null,
activity_end_at: activityRange[1]?.toISOString() || null,
renewal_enabled: renewalEnabled,
price: values.regularPrice,
});
} else {
Object.assign(payload, {
price: values.price,
grant_credits: values.grantCredits,
validity_months: values.validityMonths,
});
}
let savedProduct = editing
? await updateCreditProduct(editing.id, payload)
: await createCreditProduct(payload);
if (editing && savedProduct.isActive !== (values.isActive === true)) {
savedProduct = await setCreditProductStatus(editing.id, values.isActive === true);
}
if (isSubscriptionType(type) && savedProduct.renewalEnabled !== renewalEnabled) {
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
}
if (editing) {
replaceItem(savedProduct);
} else {
setItems((current) => [...current, savedProduct].sort((a, b) => {
const sortDiff = Number(a.sortOrder || 0) - Number(b.sortOrder || 0);
return sortDiff !== 0 ? sortDiff : a.id.localeCompare(b.id);
}));
}
message.success(editing ? '商品已更新' : '商品已创建');
setOpen(false);
setEditing(null);
setRenewalEnabled(true);
form.resetFields();
} catch (error: any) {
if (error?.errorFields) return;
message.error(error?.message || '保存失败');
}
};
const toggleRenewal = async (product: CreditProduct, enabled: boolean) => {
const previous = product.renewalEnabled === true;
const optimistic = { ...product, renewalEnabled: enabled };
replaceItem(optimistic);
if (editing?.id === product.id) {
setEditing(optimistic);
setRenewalEnabled(enabled);
form.setFieldValue('renewalEnabled', enabled);
}
try {
const savedProduct = await setCreditProductRenewal(product.id, enabled);
if (savedProduct.renewalEnabled !== enabled) {
throw new Error('续费开关保存结果与提交值不一致');
}
replaceItem(savedProduct);
if (editing?.id === product.id) {
setEditing(savedProduct);
setRenewalEnabled(savedProduct.renewalEnabled === true);
form.setFieldValue('renewalEnabled', savedProduct.renewalEnabled === true);
}
message.success(enabled ? '已开启续费' : '已关闭续费');
} catch (error: any) {
const reverted = { ...product, renewalEnabled: previous };
replaceItem(reverted);
if (editing?.id === product.id) {
setEditing(reverted);
setRenewalEnabled(previous);
form.setFieldValue('renewalEnabled', previous);
}
message.error(error?.message || '更新续费状态失败');
}
};
const toggleStatus = async (row: CreditProduct) => {
try {
const saved = await setCreditProductStatus(row.id, !row.isActive);
replaceItem(saved);
message.success(saved.isActive ? '已上架' : '已下架');
} catch (error: any) {
message.error(error?.message || '商品状态更新失败');
}
};
const removeProduct = async (row: CreditProduct) => {
try {
await softDeleteCreditProduct(row.id);
message.success('商品已软删除,商品编码永久保留且不能恢复');
await load();
} catch (error: any) {
message.error(error?.message || '商品删除失败');
}
};
const columns = useMemo(() => isSubscriptionType(type) ? [
{
title: '套餐',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{
title: '等级',
key: 'tier',
render: (_: unknown, row: CreditProduct) => <Tag>{row.tierCode || '-'} / {row.tierRank ?? '-'}</Tag>,
},
{
title: '周期',
dataIndex: 'billingCycle',
render: (value?: string | null) => value ? cycleLabel[value] || value : '-',
},
{
title: '每月积分',
dataIndex: 'monthlyGrantCredits',
render: (value?: number) => Number(value || 0).toLocaleString(),
},
{
title: '首充价',
dataIndex: 'firstPurchasePrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '原价',
dataIndex: 'regularPrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '活动价',
key: 'activity',
render: (_: unknown, row: CreditProduct) => row.activityPrice == null
? '-'
: <Tag color="red">{formatMoney(row.activityPrice)}</Tag>,
},
{
title: '允许续费',
dataIndex: 'renewalEnabled',
render: (enabled: boolean, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary">-</Typography.Text>
: <Switch
checked={enabled === true}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => void toggleRenewal(row, checked)}
/>,
},
{
title: '状态',
key: 'status',
render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用;历史订单和订阅不受影响。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
] : [
{
title: '增值包',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
{
title: '状态', key: 'status', render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
], [type, items, editing]);
const createButtonLabel = type === 'subscription'
? '新增个人订阅套餐'
: type === 'team_subscription'
? '新增团队订阅套餐'
: '新增积分增值包';
return <Card
title={<Space><ShoppingOutlined /></Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>{createButtonLabel}</Button>}
>
<Tabs
activeKey={type}
onChange={(key) => setType(key as CreditProductType)}
items={[
{ key: 'subscription', label: '个人订阅套餐' },
{ key: 'team_subscription', label: '团队订阅套餐' },
{ key: 'credit_addon', label: '积分增值包' },
]}
/>
<Table rowKey="id" loading={loading} dataSource={items} columns={columns as any} scroll={{ x: 1200 }} />
<Modal
open={open}
title={editing ? '编辑积分商品' : '新增积分商品'}
onOk={save}
onCancel={() => { setOpen(false); setEditing(null); setRenewalEnabled(true); form.resetFields(); }}
width={760}
destroyOnClose
>
<Form form={form} layout="vertical">
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item
name="productCode"
label="商品编码"
rules={[{ required: true }]}
extra={editing ? '商品编码创建后永久不可修改。' : undefined}
>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
<Input style={{ width: 260 }} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
</Space>
{isSubscriptionType(type) ? <>
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input disabled={!!editing} /></Form.Item>
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} disabled={!!editing} /></Form.Item>
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
<Select
disabled={!!editing}
style={{ width: 140 }}
options={[
{ value: 'monthly', label: '月' },
{ value: 'quarterly', label: '季' },
{ value: 'yearly', label: '年' },
]}
/>
</Form.Item>
<Form.Item name="monthlyGrantCredits" label="每月发放积分" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
</Space>
<Space align="start" size={16} wrap>
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="regularPrice" label="原价/续费价" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="activityPrice" label="活动价"><InputNumber min={0} precision={2} /></Form.Item>
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
<Form.Item
label="允许续费"
tooltip="关闭后,已失去对应首购资格的用户不能再次购买该套餐;不影响已创建待付款订单、已持有订阅和季/年卡内部月度发放。"
>
<Switch
checked={renewalEnabled}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => {
setRenewalEnabled(checked);
form.setFieldValue('renewalEnabled', checked);
}}
/>
</Form.Item>
</Space>
</> : <Space align="start" size={16}>
<Form.Item name="grantCredits" label="积分数量" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
<Form.Item name="price" label="价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="validityMonths" label="有效期" rules={[{ required: true, message: '请选择有效期' }]}>
<Select
style={{ width: 140 }}
options={Array.from({ length: 36 }, (_, index) => ({ value: index + 1, label: `${index + 1} 个月` }))}
/>
</Form.Item>
</Space>}
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="featuresText" label="权益说明(每行一项)"><Input.TextArea rows={4} /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminCreditProducts;
@@ -3,12 +3,11 @@ import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
getCreditRatios, saveCreditRatio,
deleteCreditRatio, getSystemConfigs,
updateSystemConfig, getGenerationAiEngines,
deleteCreditRatio, getGenerationAiEngines,
} from '../api';
import type { GenerationAiEngineOption } from '../types';
@@ -54,18 +53,14 @@ const AdminCreditRatios: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
const [form] = Form.useForm<CreditRatioFormValues>();
const [textRate, setTextRate] = useState<number>(10);
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
const [savingTextRate, setSavingTextRate] = useState(false);
const genType = Form.useWatch('genType', form) || 'video';
const selectedEngineId = Form.useWatch('modelConfigId', form);
const load = async () => {
setLoading(true);
try {
const [ratioData, sysConfigs, enginesData] = await Promise.all([
const [ratioData, enginesData] = await Promise.all([
getCreditRatios(),
getSystemConfigs(),
getGenerationAiEngines(),
]);
@@ -81,11 +76,6 @@ const AdminCreditRatios: React.FC = () => {
setRatios(ratioData);
setEngines([...videoEngines, ...imageEngines]);
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
if (textCfg) {
setTextRate(Number(textCfg.value) || 10);
setTextRateConfig({ id: textCfg.id });
}
} catch {
message.error('加载积分比例失败');
} finally {
@@ -171,18 +161,6 @@ const AdminCreditRatios: React.FC = () => {
}
};
const handleSaveTextRate = async () => {
if (!textRateConfig) return;
setSavingTextRate(true);
try {
await updateSystemConfig(textRateConfig.id, String(textRate));
message.success('文字积分费率已更新');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSavingTextRate(false);
}
};
const openEdit = (ratio?: CreditRatio) => {
setModal({ open: true, ratio: ratio || null });
@@ -338,40 +316,6 @@ const AdminCreditRatios: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Text Credit Rate */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Button type="primary" loading={savingTextRate} onClick={handleSaveTextRate} style={{ borderRadius: 8 }}>
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
ceil(token数 x / 1000)1
</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Typography.Text>1000 token消耗积分</Typography.Text>
<Space.Compact style={{ width: 160 }}>
<InputNumber
min={0}
max={1000}
step={0.01}
value={textRate}
onChange={(v) => setTextRate(v || 0)}
size="large"
style={{ width: '100%' }}
/>
<Typography.Text></Typography.Text>
</Space.Compact>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
1000 token = {textRate} 500 token = {(500 * textRate / 1000).toFixed(4)}
</Typography.Text>
</div>
</Card>
{/* Generation Credit Ratios */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
+196 -24
View File
@@ -3,12 +3,12 @@ import {
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
} from '@ant-design/icons';
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import dayjs from 'dayjs';
import { getCreditRecords, getTeamOptions } from '../api';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSubscriptionUsage, AdminTeamOption, AdminCreditRecordSummary } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -17,6 +17,11 @@ const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
totalRecharge: 0,
totalConsume: 0,
totalRefund: 0,
totalCharge: 0,
totalHold: 0,
totalRefundReal: 0,
totalHoldRelease: 0,
netConsume: 0,
transactionCount: 0,
generationCount: 0,
generationAttemptCount: 0,
@@ -35,15 +40,18 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
expire: { text: '过期', color: 'orange', icon: <RollbackOutlined /> },
revoke: { text: '撤销', color: 'volcano', icon: <RollbackOutlined /> },
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
};
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
charge: { text: '真实费', color: 'red' },
charge: { text: '真实费', color: 'red' },
refund: { text: '真实退款', color: 'blue' },
hold: { text: '预扣占用', color: 'gold' },
hold_release: { text: '预扣释放', color: 'green' },
pre_deduct: { text: '历史固定预扣', color: 'default' },
hold: { text: '历史预扣占用', color: 'gold' },
hold_release: { text: '历史预扣释放', color: 'green' },
};
const userScopeOptions = [
@@ -58,6 +66,8 @@ const recordTypeOptions = [
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'expire', label: '过期' },
{ value: 'revoke', label: '撤销' },
{ value: 'team_internal', label: '团队内部' },
];
@@ -96,10 +106,11 @@ const chargeKindOptions = [
const chargeActionOptions = [
{ value: '', label: '全部交易动作' },
{ value: 'charge', label: '真实费' },
{ value: 'charge', label: '真实费' },
{ value: 'refund', label: '真实退款' },
{ value: 'hold', label: '预扣占用' },
{ value: 'hold_release', label: '预扣释放' },
{ value: 'pre_deduct', label: '历史固定预扣' },
{ value: 'hold', label: '历史预扣占用' },
{ value: 'hold_release', label: '历史预扣释放' },
];
const sourceModuleOptions = [
@@ -171,6 +182,44 @@ function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType
return {};
}
function subscriptionTierText(item: AdminCreditRecordSubscriptionUsage): string {
const label = item.tierLabel || item.tierCode || '未知等级';
const code = item.tierCode && item.tierCode !== label ? `${item.tierCode}` : '';
const rank = item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : '';
return `${label}${code}${rank}`;
}
function subscriptionProductText(item: AdminCreditRecordSubscriptionUsage): string {
return [
item.productTypeLabel,
item.productName,
subscriptionTierText(item),
item.billingCycleLabel,
].filter(Boolean).join(' · ');
}
function subscriptionPeriodText(item: AdminCreditRecordSubscriptionUsage): string {
const range = item.periodValidFrom && item.periodExpiresAt
? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}`
: '-';
return `${item.periodLabel || '周期未知'}${range}`;
}
function semanticAllocationText(record: AdminCreditRecord): string {
return (record.allocations || []).map((item) => {
const tier = item.tierLabel || item.tierCode
? `${item.tierLabel || item.tierCode}${item.tierCode && item.tierLabel !== item.tierCode ? `${item.tierCode}` : ''}${item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : ''}`
: '未知等级';
const subscription = item.subscriptionNo
? `${item.subscriptionNo}${[item.productTypeLabel, item.productName, tier, item.billingCycleLabel].filter(Boolean).join('/')}`
: '非订阅资金';
const period = item.periodLabel
? `${item.periodLabel}${item.periodValidFrom && item.periodExpiresAt ? ` ${formatDate(item.periodValidFrom)}${formatDate(item.periodExpiresAt)}` : ''}`
: '无月度周期';
return `${item.creditScopeLabel || '其他资金域'}${item.allocationActionLabel || '资金变动'}${subscription}${period}${n(item.amount)}积分`;
}).join('');
}
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
@@ -183,6 +232,7 @@ const AdminCreditRecords: React.FC = () => {
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [subscriptionNoFilter, setSubscriptionNoFilter] = useState('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [recordType, setRecordType] = useState('');
const [creditSubject, setCreditSubject] = useState('');
@@ -200,6 +250,7 @@ const AdminCreditRecords: React.FC = () => {
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
subscriptionNo: subscriptionNoFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
@@ -211,7 +262,7 @@ const AdminCreditRecords: React.FC = () => {
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope, subscriptionNoFilter]);
const load = async () => {
setLoading(true);
@@ -236,6 +287,7 @@ const AdminCreditRecords: React.FC = () => {
const handleReset = () => {
setUserScope('');
setTeamFilter('');
setSubscriptionNoFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
@@ -274,16 +326,24 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '资金域构成', maxWidth: 22, render: (r) => r.fundingScopeLabel || '无资金分摊' },
{ title: '团队积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.teamAllocationAmount || 0 },
{ title: '个人积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.personalAllocationAmount || 0 },
{ title: '订阅实例', minWidth: 20, maxWidth: 36, render: (r) => (r.subscriptionUsages || []).map((item) => item.subscriptionNo).filter((value, index, arr) => arr.indexOf(value) === index).join('') || '-' },
{ title: '套餐信息', minWidth: 30, maxWidth: 60, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionProductText(item)}`).join('') || '-' },
{ title: '月度周期', minWidth: 28, maxWidth: 64, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionPeriodText(item)}`).join('') || '-' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
{ title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.sceneNameSnapshot || r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '已过期积分', minWidth: 13, maxWidth: 15, align: 'right', numFmt: '#,##0.00', render: (r) => r.expiredAmount || 0 },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ title: 'LLM调用', minWidth: 12, maxWidth: 15, align: 'right', render: (r) => `${r.llmCallCount || 0}(成${r.llmSuccessCallCount || 0}/败${r.llmFailedCallCount || 0}` },
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
@@ -295,6 +355,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: '资金溯源明细', minWidth: 42, maxWidth: 100, render: (r) => semanticAllocationText(r) || '-' },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
@@ -305,22 +366,24 @@ const AdminCreditRecords: React.FC = () => {
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['订阅实例筛选', subscriptionNoFilter || '不限'],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
summaryRows: [
['总充值', exportSummary.totalRecharge],
['总消费', exportSummary.totalConsume],
['总回退', exportSummary.totalRefund],
['总真实消费', exportSummary.totalConsume],
['总真实退款', exportSummary.totalRefund],
['净消耗(总消费 − 总退款,≥ 0)', exportSummary.netConsume],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume],
['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume],
['视频分析积分', exportSummary.analysisConsume],
['图片消费积分(仅真实消费)', exportSummary.imageConsume],
['视频消费积分(仅真实消费)', exportSummary.videoConsume],
['提词消费积分(仅真实消费)', exportSummary.textConsume],
['视频分析积分(仅真实消费)', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
@@ -341,14 +404,55 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{
title: '资金构成', key: 'fundingScope', width: 190,
render: (_: any, r: AdminCreditRecord) => (
<Space size={[4, 4]} wrap>
{(r.teamAllocationAmount || 0) > 0 && <Tag color="geekblue"> {n(r.teamAllocationAmount)}</Tag>}
{(r.personalAllocationAmount || 0) > 0 && <Tag color="purple"> {n(r.personalAllocationAmount)}</Tag>}
{!r.teamAllocationAmount && !r.personalAllocationAmount && <Typography.Text type="secondary"></Typography.Text>}
</Space>
),
},
{
title: '订阅实例 / 套餐', key: 'subscriptionUsages', width: 330,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong copyable={{ text: item.subscriptionNo }}>{item.subscriptionNo}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{subscriptionProductText(item)}</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{item.creditScopeLabel || '-'} · {n(item.amount)} </div>
</div>
))}
</div>
) : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '月度周期', key: 'subscriptionPeriods', width: 300,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-period-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong>{item.periodLabel || '-'}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b' }}>
{item.periodValidFrom && item.periodExpiresAt ? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}` : '-'}
</div>
</div>
))}
</div>
) : '-',
},
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary"></Typography.Text>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.sceneNameSnapshot || r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (value: number) => <Typography.Text strong style={{ color: value > 0 ? '#10b981' : value < 0 ? '#ef4444' : '#64748b' }}>{value > 0 ? '+' : ''}{n(value)}</Typography.Text> },
{ title: '过期积分', dataIndex: 'expiredAmount', width: 110, render: (v: number) => v ? <Tag color="orange">{n(v)}</Tag> : '-' },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ title: 'LLM调用', key: 'llmCalls', width: 130, render: (_: any, r: AdminCreditRecord) => <div>{n(r.llmCallCount || 0)} <div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.llmSuccessCallCount || 0)} / {n(r.llmFailedCallCount || 0)}</div></div> },
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.inputTokens)} / {n(r.outputTokens)}</div></div> },
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
@@ -358,11 +462,50 @@ const AdminCreditRecords: React.FC = () => {
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 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' }}><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>
<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>
<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={{ 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' }}>
<Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}></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' }}>退</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>退</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 style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
@@ -386,6 +529,7 @@ const AdminCreditRecords: React.FC = () => {
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Input placeholder="订阅实例号(PS/TS" value={subscriptionNoFilter} onChange={(e) => { setPage(1); setSubscriptionNoFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
@@ -417,7 +561,35 @@ const AdminCreditRecords: React.FC = () => {
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2160 }}
expandable={{
rowExpandable: (record) => Boolean(record.allocations?.length),
expandedRowRender: (record) => (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={record.allocations || []}
columns={[
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string) => v || '其他动作' },
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string) => v || '其他资金域' },
{ title: '分摊积分', dataIndex: 'amount', width: 120, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '其他积分等级' },
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string) => v || '其他来源' },
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 210, render: (v: string) => v ? <Typography.Text copyable={{ text: v }}>{v}</Typography.Text> : '非订阅资金' },
{ title: '套餐名称', dataIndex: 'productName', width: 170, render: (v: string) => v || '-' },
{ title: '套餐类型', dataIndex: 'productTypeLabel', width: 150, render: (v: string) => v || '-' },
{ title: '套餐等级', key: 'tier', width: 190, render: (_: any, a: any) => a.tierLabel || a.tierCode ? `${a.tierLabel || a.tierCode}${a.tierCode && a.tierLabel !== a.tierCode ? `${a.tierCode}` : ''}${a.tierRank !== undefined && a.tierRank !== null ? ` / ${a.tierRank}` : ''}` : '-' },
{ title: '套餐周期', dataIndex: 'billingCycleLabel', width: 110, render: (v: string) => v || '-' },
{ title: '月度周期', dataIndex: 'periodLabel', width: 120, render: (v: string) => v || '-' },
{ title: '周期起止', key: 'periodRange', width: 330, render: (_: any, a: any) => a.periodValidFrom && a.periodExpiresAt ? `${formatDate(a.periodValidFrom)} ${formatDate(a.periodExpiresAt)}` : '-' },
{ title: '源资金有效期', key: 'balanceRange', width: 330, render: (_: any, a: any) => a.validFrom && a.expiresAt ? `${formatDate(a.validFrom)} ${formatDate(a.expiresAt)}` : '-' },
{ title: '内部来源ID', dataIndex: 'sourceId', width: 220, render: (v: string) => v || '-' },
]}
scroll={{ x: 2390 }}
/>
),
}}
scroll={{ x: 3350 }}
/>
</Card>
</div>
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Card,
DatePicker,
Empty,
Input,
message,
@@ -24,6 +25,7 @@ import {
VideoCameraOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -178,6 +180,9 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
</div>
);
const todayStart = () => dayjs().startOf('day');
const todayEnd = () => dayjs().endOf('day');
const AdminGenerationRecords: React.FC = () => {
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
const [total, setTotal] = useState(0);
@@ -195,6 +200,10 @@ const AdminGenerationRecords: React.FC = () => {
const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
// 时间筛选(默认当天)
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const load = useCallback(async () => {
setLoading(true);
try {
@@ -203,6 +212,8 @@ const AdminGenerationRecords: React.FC = () => {
status: filterStatus || undefined,
engineId: filterEngineId || undefined,
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
page,
pageSize,
});
@@ -246,7 +257,7 @@ const AdminGenerationRecords: React.FC = () => {
} finally {
setLoading(false);
}
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize, queryCreatedRange]);
useEffect(() => {
load();
@@ -300,6 +311,19 @@ const AdminGenerationRecords: React.FC = () => {
}, [preview]);
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);
setReloadKey((v) => v + 1);
};
@@ -870,9 +894,23 @@ const AdminGenerationRecords: React.FC = () => {
onPressEnter={handleSearch}
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>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
</Button>
</Space>
</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;
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { Card, Descriptions, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
import { HistoryOutlined } from '@ant-design/icons';
import { getLlmBillingExecutions } from '../api';
import type { LlmBillingExecution } from '../types';
import { formatDate, formatDatePrecise } from '../utils/formatDate';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
charged: { label: '已消费', color: 'blue' },
processing: { label: '处理中', color: 'processing' },
succeeded: { label: '业务成功', color: 'success' },
final_failed: { label: '最终失败', color: 'error' },
refunded: { label: '已退款', color: 'purple' },
refund_failed: { label: '退款失败', color: 'red' },
};
const AdminLlmBillingExecutions: React.FC = () => {
const [items, setItems] = useState<LlmBillingExecution[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>();
const [userId, setUserId] = useState('');
const [loading, setLoading] = useState(false);
const load = async () => { setLoading(true); try { const data = await getLlmBillingExecutions({ page, pageSize: 20, status, userId: userId || undefined }); setItems(data.items); setTotal(data.total); } finally { setLoading(false); } };
useEffect(() => { load(); }, [page, status]);
return <Card title={<Space><HistoryOutlined />LLM调用与积分审计</Space>} extra={<Space><Input.Search allowClear placeholder="用户ID" value={userId} onChange={(e) => setUserId(e.target.value)} onSearch={() => { setPage(1); load(); }} /><Select allowClear placeholder="最终状态" value={status} onChange={(v) => { setStatus(v); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value, config]) => ({ value, label: config.label }))} /></Space>}>
<Table rowKey="id" loading={loading} dataSource={items} pagination={{ current: page, pageSize: 20, total, onChange: setPage }} expandable={{ expandedRowRender: (record) => <div>
<Timeline items={(record.calls || []).map((call) => ({
color: call.status === 'succeeded' ? 'green' : call.status === 'started' ? 'blue' : 'red',
children: <div>
<Typography.Text strong>{formatDatePrecise(call.requestStartedAt)}  {call.callSequence} {call.status === 'succeeded' ? '成功' : call.status === 'started' ? '处理中' : call.status === 'timeout' ? '超时' : '失败'}</Typography.Text>
<div>{call.responseReceivedAt ? formatDatePrecise(call.responseReceivedAt) : '-'}</div>
<div>{call.modelNameSnapshot || '-'} {call.durationMs == null ? '-' : `${call.durationMs}ms`}</div>
{call.tokenUnavailableReason
? <div>Token{call.tokenUnavailableReason}</div>
: <div> Token{call.inputTokens ?? 0}  Token{call.outputTokens ?? 0}  Token{call.totalTokens ?? 0}</div>}
{call.errorMessage && <Typography.Text type="danger">{call.errorMessage}</Typography.Text>}
{call.postprocessStatus === 'succeeded' && <div></div>}
{call.postprocessError && <div>{call.postprocessError}</div>}
</div>,
}))} />
<Descriptions bordered size="small" column={4} items={[
{ key: 'scene', label: '功能', children: record.sceneNameSnapshot },
{ key: 'credit', label: '消费积分', children: record.chargeCredits },
{ key: 'calls', label: '调用', children: `${record.totalCallCount}(成功 ${record.successfulCallCount} / 失败 ${record.failedCallCount}` },
{ key: 'tokens', label: '累计 Token', children: record.totalTokens },
{ key: 'refundA', label: '有效积分退回', children: record.refundAvailableCredits },
{ key: 'refundE', label: '过期积分退回', children: record.refundExpiredCredits },
{ key: 'error', label: '最终错误', children: record.finalErrorMessage || '-' },
]} />
</div> }} columns={[
{ title: '功能', dataIndex: 'sceneNameSnapshot' },
{ title: '用户', dataIndex: 'userId' },
{ title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` },
{ title: '模型', dataIndex: 'modelNameSnapshot' },
{ title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` },
{ title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return <Tag color={config.color}>{config.label}</Tag>; } },
{ title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) },
]} />
</Card>;
};
export default AdminLlmBillingExecutions;
@@ -0,0 +1,83 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Form, InputNumber, message, Modal, Select, Space, Switch, Table, Tag } from 'antd';
import { EditOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
import { createLlmBillingPolicy, getLlmBillingPolicies, updateLlmBillingPolicy } from '../api';
import type { LlmBillingPolicy } from '../types';
const SCENES: Array<{ value: string; label: string }> = [
{ value: 'generation_record_text_prompt_optimize', label: 'AI创作-提示词优化' },
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻-图片提示词优化' },
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻-视频提示词优化' },
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻-图片提示词优化' },
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻-视频提示词优化' },
{ value: 'shot_original_video_analysis', label: '拆镜复刻-原视频AI分析' },
{ value: 'shot_segment_video_analysis', label: '拆镜复刻-片段视频AI分析' },
];
const AdminLlmBillingPolicies: React.FC = () => {
const [items, setItems] = useState<LlmBillingPolicy[]>([]);
const [editing, setEditing] = useState<LlmBillingPolicy | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
setItems(await getLlmBillingPolicies());
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const edit = (item?: LlmBillingPolicy) => {
setEditing(item || null);
setOpen(true);
form.resetFields();
form.setFieldsValue(item
? { scene_code: item.sceneCode, charge_credits: item.chargeCredits, is_active: item.isActive }
: { is_active: true, charge_credits: 5 });
};
const save = async () => {
try {
const values = await form.validateFields();
if (editing) await updateLlmBillingPolicy(editing.id, values);
else await createLlmBillingPolicy(values);
message.success('已保存');
setOpen(false);
load();
} catch (error: any) {
if (!error?.errorFields) message.error(error?.message || '保存失败');
}
};
return <Card
title={<Space><RobotOutlined />LLM积分配置</Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}></Button>}
>
<Table rowKey="id" loading={loading} dataSource={items} columns={[
{ title: '功能', dataIndex: 'sceneName' },
{ title: '场景编码', dataIndex: 'sceneCode' },
{ title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '版本', dataIndex: 'version' },
{ title: '状态', dataIndex: 'isActive', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> },
{ title: '操作', render: (_: unknown, r: LlmBillingPolicy) => <Button type="link" icon={<EditOutlined />} onClick={() => edit(r)}></Button> },
]} />
<Modal open={open} title={editing ? '编辑LLM积分场景' : '新增LLM积分场景'} onOk={save} onCancel={() => setOpen(false)}>
<Form form={form} layout="vertical">
<Form.Item name="scene_code" label="业务功能" rules={[{ required: true }]}>
<Select disabled={!!editing} options={SCENES} />
</Form.Item>
<Form.Item name="charge_credits" label="消费积分" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminLlmBillingPolicies;
+2 -4
View File
@@ -110,9 +110,8 @@ const AdminModels: React.FC = () => {
const labelMap: Record<string, string> = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
mock: 'Mock模式',
};
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
return <Tag color="blue">{labelMap[v] || v}</Tag>;
},
},
{
@@ -160,7 +159,7 @@ const AdminModels: React.FC = () => {
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{models.length}
{models.length}
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
@@ -197,7 +196,6 @@ const AdminModels: React.FC = () => {
<Select size="large" options={[
{ value: 'sdk', label: 'SDK模式' },
{ value: 'openai_compatible', label: 'OpenAI兼容' },
{ value: 'mock', label: 'Mock模式' },
]} />
</Form.Item>
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
@@ -1,13 +1,13 @@
import React, { useEffect, useState } from 'react';
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';
import {
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined, NotificationOutlined, SaveOutlined,
} from '@ant-design/icons';
import ReactQuill from 'react-quill-new';
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';
interface NotificationRecord {
@@ -31,6 +31,19 @@ interface ReadUser {
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 [notifications, setNotifications] = useState<NotificationRecord[]>([]);
const [loading, setLoading] = useState(false);
@@ -43,6 +56,11 @@ const AdminNotificationManager: React.FC = () => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
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 () => {
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(() => { loadBanner(); }, []);
const handlePageChange = (p: number, ps: number) => {
setPage(p);
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) => {
switch (type) {
case 'system': return 'blue';
@@ -167,8 +239,13 @@ const AdminNotificationManager: React.FC = () => {
},
];
return (
<div>
const tabItems = [
{
key: 'notifications',
label: (
<span><BellOutlined style={{ marginRight: 6 }} /></span>
),
children: (
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
@@ -198,6 +275,112 @@ const AdminNotificationManager: React.FC = () => {
scroll={{ x: 900 }}
/>
</Card>
),
},
{
key: 'banner',
label: (
<span><NotificationOutlined style={{ marginRight: 6 }} /></span>
),
children: (
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#6366f1',
}}>
<NotificationOutlined />
</div>
<div>
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 13, display: 'block' }}>
</Typography.Text>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}></Typography.Text>
{bannerLoading ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: '#94a3b8' }}>...</div>
) : (
<ReactQuill
theme="snow"
value={bannerContent}
onChange={setBannerContent}
modules={editorModules}
placeholder="请输入横幅内容(支持富文本:加粗、变色、链接等)"
style={{ height: 200, marginBottom: 48 }}
/>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<Button
icon={<NotificationOutlined />}
onClick={handleResetBanner}
size="large"
style={{ borderRadius: 8, minWidth: 160 }}
>
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveBanner}
loading={bannerSaving}
size="large"
style={{ borderRadius: 8, minWidth: 140 }}
>
</Button>
</div>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
💡
</Typography.Text>
{/* 预览区域 */}
{bannerContent && (
<div style={{ marginTop: 24 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}></Typography.Text>
<div style={{
borderRadius: 12,
overflow: 'hidden',
background: 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 50%, #e0e7ff 100%)',
border: '1px solid rgba(139, 92, 246, 0.15)',
padding: '10px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{
width: 28, height: 28, borderRadius: 8,
background: 'rgba(139, 92, 246, 0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<NotificationOutlined style={{ color: '#7c3aed', fontSize: 14 }} />
</div>
<div style={{
color: '#5b21b6',
fontSize: 14,
fontWeight: 500,
lineHeight: 1.5,
flex: 1,
}} dangerouslySetInnerHTML={{ __html: bannerContent }} />
</div>
</div>
)}
</Card>
),
},
];
return (
<div>
<Tabs items={tabItems} defaultActiveKey="notifications" />
{/* Send Notification Modal */}
<Modal
@@ -214,7 +397,7 @@ const AdminNotificationManager: React.FC = () => {
</Form.Item>
<Form.Item name="content" label="消息内容"
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>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
@@ -31,7 +31,7 @@ const AdminOperationLogs: React.FC = () => {
const load = async (p?: number) => {
setLoading(true);
try {
const res = await getOperationLogs(p || page);
const res = await getOperationLogs({ page: p || page });
setLogs(res.items || []);
setTotal(res.total || 0);
} catch {
+53 -303
View File
@@ -1,324 +1,74 @@
import React, { useEffect, useState } from 'react';
import {
Card, Col, Input, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
} from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, Col, ConfigProvider, DatePicker, Input, Row, Select, Space, Statistic, Table, Tag, Typography } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
} from '@ant-design/icons';
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate';
import { DollarOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminPaymentOrders, getPaymentStats } from '../api';
const { Option } = Select;
const { RangePicker } = DatePicker;
const SOURCE_LABELS: Record<string, string> = { online_payment: '线上支付', admin_offline: '后台线下成交' };
const METHOD_LABELS: Record<string, string> = { alipay: '支付宝', wechat: '微信支付', bank_transfer: '银行转账', cash: '现金', other: '其他-线下收款' };
const STATUS_LABELS: Record<string, string> = { pending: '待支付', paid: '已支付', refunded: '已退款', cancelled: '已取消', expired: '已过期', failed: '失败' };
const AdminPaymentStats: React.FC = () => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
const [orderPage, setOrderPage] = useState(1);
const [orderPageSize, setOrderPageSize] = useState(10);
const [orderTotal, setOrderTotal] = useState(0);
const [filters, setFilters] = useState<{
paymentMethod?: string;
status?: string;
phone?: string;
startDate: string;
endDate: string;
}>({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
const [orders, setOrders] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filters, setFilters] = useState<any>({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') });
const load = async () => {
try {
const load = useCallback(async () => {
setLoading(true);
const [statsData, ordersData] = await Promise.all([
getPaymentStats(filters),
getAdminPaymentOrders({
...filters,
phone: filters.phone,
page: orderPage,
pageSize: orderPageSize,
}),
try {
const [s, o] = await Promise.all([
getPaymentStats({ paymentMethod: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, startDate: filters.startDate, endDate: filters.endDate }),
getAdminPaymentOrders({ method: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, phone: filters.phone || undefined, startDate: filters.startDate, endDate: filters.endDate, page, pageSize }),
]);
setStats({ ...statsData, recent: ordersData.items || [] });
setOrderTotal(ordersData.total);
} catch {
message.error('加载支付统计失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [filters, orderPage, orderPageSize]);
const handleOrderPageChange = (p: number, ps: number) => {
setOrderPage(p);
setOrderPageSize(ps);
};
const handleReset = () => {
setFilters({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setOrderPage(1);
};
const handleRefund = async (orderNo: string) => {
try {
setLoading(true);
await refundPaymentOrder(orderNo);
message.success('退款成功');
await load();
} catch (e: any) {
message.error(e?.response?.data?.detail || '退款失败');
} finally {
setLoading(false);
}
};
const handleDateChange = (dates: any) => {
if (dates && dates.length === 2) {
setFilters(prev => ({
...prev,
startDate: dates[0].format('YYYY-MM-DD'),
endDate: dates[1].format('YYYY-MM-DD'),
}));
}
};
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
refunded: { color: 'red', label: '已退款', icon: <UndoOutlined /> },
};
const methodConfig: Record<string, { color: string; label: string }> = {
alipay: { color: 'blue', label: '支付宝' },
wechat: { color: 'green', label: '微信' },
};
setStats(s); setOrders(o.items || []); setTotal(Number(o.total || 0));
} finally { setLoading(false); }
}, [filters, page, pageSize]);
useEffect(() => { void load(); }, [load]);
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120, render: (v: string) => v || '-' },
{
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
render: (m: string) => {
const c = methodConfig[m] || { color: 'default', label: m };
return <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (s: string) => {
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: any) => {
if (record.status === 'paid') {
return (
<Popconfirm
title="确认要退款该订单吗?"
description="退款后积分会扣除,金额会原路返回"
onConfirm={() => handleRefund(record.orderNo)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small" icon={<UndoOutlined />}>
退
</Button>
</Popconfirm>
);
}
return null;
},
},
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
{ title: '用户', width: 150, render: (_: any, r: any) => <div>{r.username || '-'}<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '来源', dataIndex: 'orderSource', width: 120, render: (v: string, r: any) => <Tag color={v === 'admin_offline' ? 'purple' : 'blue'}>{r.orderSourceLabel || SOURCE_LABELS[v] || '其他来源'}</Tag> },
{ title: '商品', dataIndex: 'productNameSnapshot', width: 170, ellipsis: true, render: (v: string, r: any) => `${v || '-'}${Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}` },
{ title: '支付方式', dataIndex: 'paymentMethod', width: 135, render: (v: string, r: any) => r.paymentMethodLabel || METHOD_LABELS[v] || '其他支付方式' },
{ title: '系统报价', dataIndex: 'quotedAmountSnapshot', width: 110, align: 'right' as const, render: (v: number) => v == null ? '-' : `¥${Number(v).toFixed(2)}` },
{ title: '实际成交', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => <b>¥{Number(v || 0).toFixed(2)}</b> },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
{ title: '履约', dataIndex: 'fulfillmentStatusLabel', width: 100, render: (v: string) => v || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
// 当前版本退款按钮明确不开放。后端退款 API 仍保留兼容入口并固定返回中文阻止提示,后续重新开放时再恢复此处按钮。
];
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
const reset = () => { setFilters({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') }); setPage(1); };
const today = stats?.today || {};
const month = stats?.month || {};
const bySource = stats?.bySource || {};
const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 };
const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 };
const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 };
const refundedInfo = stats.byStatus?.refunded || { count: 0, amount: 0 };
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count + refundedInfo.count;
const monthInfo = stats.month || { count: 0, amount: 0 };
return (
<ConfigProvider locale={zhCN}>
<div>
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="本月累计"
value={monthInfo.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{monthInfo.paidCount}
</Typography.Text>
</Card>
</Col>
return <ConfigProvider locale={zhCN}><div>
<Row gutter={[16,16]} style={{ marginBottom: 18 }}>
<Col xs={24} md={8}><Card><Statistic title="今日总真实收入" value={Number(today.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(today.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(today.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="本月总真实收入" value={Number(month.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(month.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(month.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="当前筛选总收入" value={Number(stats?.totalIncome?.amount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">{Number(stats?.totalIncome?.count || 0)} </Typography.Text></Card></Col>
</Row>
{/* Status breakdown */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
const info = stats.byStatus?.[s] || { count: 0, amount: 0 };
const c = statusConfig[s];
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
return (
<Col span={6} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
<Card title="收入来源" style={{ marginBottom: 16 }}><Row gutter={16}>{['online_payment','admin_offline'].map((source) => <Col span={12} key={source}><div style={{ padding: 14, background: '#fafafa', borderRadius: 10 }}><Tag color={source === 'online_payment' ? 'blue' : 'purple'}>{bySource[source]?.label || SOURCE_LABELS[source]}</Tag><div style={{ fontSize: 24, fontWeight: 700, marginTop: 8 }}>¥{Number(bySource[source]?.amount || 0).toFixed(2)}</div><div style={{ color: '#94a3b8' }}>{Number(bySource[source]?.count || 0)} </div></div></Col>)}</Row></Card>
<Card title="订单列表">
<Space wrap style={{ marginBottom: 16 }}>
<Select allowClear placeholder="订单来源" style={{ width: 160 }} value={filters.orderSource} onChange={(v) => { setFilters((x:any) => ({...x, orderSource:v})); setPage(1); }} options={Object.entries(SOURCE_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="支付方式" style={{ width: 150 }} value={filters.paymentMethod} onChange={(v) => { setFilters((x:any) => ({...x, paymentMethod:v})); setPage(1); }} options={Object.entries(METHOD_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="订单状态" style={{ width: 130 }} value={filters.status} onChange={(v) => { setFilters((x:any) => ({...x, status:v})); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value,label]) => ({value,label}))} />
<Input allowClear placeholder="手机号" style={{ width: 150 }} value={filters.phone} onChange={(e) => setFilters((x:any) => ({...x, phone:e.target.value}))} onPressEnter={() => { setPage(1); void load(); }} suffix={<SearchOutlined />} />
<RangePicker value={[dayjs(filters.startDate), dayjs(filters.endDate)]} onChange={(v) => { setFilters((x:any) => ({...x, startDate:v?.[0]?.format('YYYY-MM-DD'), endDate:v?.[1]?.format('YYYY-MM-DD')})); setPage(1); }} />
<Button icon={<ReloadOutlined />} onClick={reset}></Button>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
<Table rowKey="id" loading={loading} columns={columns} dataSource={orders} scroll={{ x: 1450 }} pagination={{ current:page, pageSize, total, showSizeChanger:true, showTotal:(t)=>`${t}`, onChange:(p,ps)=>{setPage(p);setPageSize(ps);} }} />
</Card>
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}>
{/* Filters */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: '100%' }}
value={filters.paymentMethod}
onChange={(value) => { setFilters(prev => ({ ...prev, paymentMethod: value })); setOrderPage(1); }}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: '100%' }}
value={filters.status}
onChange={(value) => { setFilters(prev => ({ ...prev, status: value })); setOrderPage(1); }}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
<Option value="cancelled"></Option>
<Option value="refunded">退</Option>
</Select>
</Col>
<Col xs={24} sm={8} md={5}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Input
placeholder="搜索手机号"
allowClear
value={filters.phone}
onChange={(e) => setFilters(prev => ({ ...prev, phone: e.target.value }))}
onPressEnter={() => { setOrderPage(1); load(); }}
suffix={<SearchOutlined style={{ color: '#94a3b8' }} />}
/>
</Col>
<Col xs={24} sm={12} md={7}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<RangePicker
style={{ width: '100%' }}
value={[
dayjs(filters.startDate),
dayjs(filters.endDate),
]}
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Button icon={<ReloadOutlined />} onClick={handleReset} style={{ marginBottom: 0 }}>
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={stats.recent || []}
rowKey="id"
loading={loading}
pagination={{
current: orderPage,
pageSize: orderPageSize,
total: orderTotal,
onChange: handleOrderPageChange,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
</ConfigProvider>
);
</div></ConfigProvider>;
};
export default AdminPaymentStats;
@@ -1,253 +1 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
GiftOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getRechargePackages, saveRechargePackage, deleteRechargePackage } from '../api';
interface PackageItem {
id: string;
name: string;
credits: number;
price: number;
bonusCredits: number;
totalCredits: number;
description: string | null;
packageType: string;
isGift: boolean;
isActive: boolean;
sortOrder: number;
}
const TYPE_COLORS: Record<string, string> = {
normal: 'blue',
gift: 'green',
promo: 'purple',
};
const TYPE_LABELS: Record<string, string> = {
normal: '常规',
gift: '赠送',
promo: '促销',
};
const AdminRechargePackages: React.FC = () => {
const [packages, setPackages] = useState<PackageItem[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: PackageItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getRechargePackages();
setPackages(data.map((item: any) => ({
id: item.id,
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonus_credits ?? item.bonusCredits ?? 0,
totalCredits: item.total_credits ?? item.totalCredits ?? item.credits,
description: item.description,
packageType: item.package_type ?? item.packageType ?? 'normal',
isGift: item.is_gift ?? item.isGift ?? false,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
})));
} catch {
message.error('加载充值套餐失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
name: values.name,
credits: values.credits,
price: values.price,
bonus_credits: values.bonusCredits || 0,
description: values.description || null,
package_type: values.packageType || 'normal',
is_gift: values.isGift || false,
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (modal.item?.id) {
await saveRechargePackage({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveRechargePackage(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleDelete = async (id: string) => {
try {
await deleteRechargePackage(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: PackageItem) => {
setModal({ open: true, item: item || null });
if (item) {
form.setFieldsValue({
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonusCredits,
description: item.description,
packageType: item.packageType,
isGift: item.isGift,
isActive: item.isActive,
sortOrder: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true, sortOrder: 0, packageType: 'normal', bonusCredits: 0, isGift: false });
}
};
const columns = [
{
title: '套餐名称', key: 'name', width: 160,
render: (_: any, r: PackageItem) => (
<div>
<Typography.Text strong>{r.name}</Typography.Text>
{r.description && <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.description}</div>}
</div>
),
},
{
title: '基础积分', dataIndex: 'credits', width: 100,
render: (v: number) => <Typography.Text>{v.toLocaleString()}</Typography.Text>,
},
{
title: '赠送积分', dataIndex: 'bonusCredits', width: 100,
render: (v: number) => v > 0
? <Tag color="green">+{v.toLocaleString()}</Tag>
: <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '总积分', key: 'total', width: 100,
render: (_: any, r: PackageItem) => (
<Typography.Text strong style={{ color: '#6366f1' }}>
{(r.credits + r.bonusCredits).toLocaleString()}
</Typography.Text>
),
},
{
title: '价格(元)', dataIndex: 'price', width: 100,
render: (v: number) => <Typography.Text strong>¥{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'packageType', width: 80,
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: PackageItem) => (
<Space size={4}>
<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 (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{packages.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={packages}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><GiftOutlined />{modal.item ? '编辑套餐' : '添加套餐'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="套餐名称" rules={[{ required: true, message: '请输入套餐名称' }]}>
<Input placeholder="例如:进阶包" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="credits" label="基础积分" rules={[{ required: true, message: '请输入积分' }]} style={{ flex: 1 }}>
<InputNumber min={1} placeholder="2000" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="price" label="价格(元)" rules={[{ required: true, message: '请输入价格' }]} style={{ flex: 1 }}>
<InputNumber min={0.01} step={1} placeholder="168" size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="bonusCredits" label="赠送积分" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} placeholder="0" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="packageType" label="套餐类型" initialValue="normal" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 'normal', label: '常规' },
{ value: 'gift', label: '赠送' },
{ value: 'promo', label: '促销' },
]} />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input placeholder="套餐描述(可选)" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="isGift" label="是否赠送" valuePropName="checked" initialValue={false} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0} style={{ flex: 1 }}>
<InputNumber size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminRechargePackages;
export { default } from './AdminCreditProducts';
@@ -0,0 +1,300 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Button, Space, Typography, message, Card, Modal, Form, Input, Select, Switch, Tag, Popconfirm, Tabs,
} from 'antd';
import {
ClockCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PlayCircleOutlined, StopOutlined, CheckCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons';
import {
listScheduledTasks,
createScheduledTask,
updateScheduledTask,
deleteScheduledTask,
runScheduledTask,
toggleScheduledTask,
} from '../api';
import type { ScheduledTask } from '../types';
const { TextArea } = Input;
const SCHEDULE_TYPE_OPTIONS = [
{ value: 'internal_method', label: '内部方法执行' },
];
const TASK_TYPE_LABELS: Record<string, string> = {
internal_method: '内部方法',
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
success: { label: '成功', color: 'green' },
error: { label: '失败', color: 'red' },
};
const AdminScheduledTasks: React.FC = () => {
const [data, setData] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState(false);
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<'basic' | 'config'>('basic');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listScheduledTasks();
setData(res.items || []);
} catch (err: any) {
message.error(err?.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const openModal = (task?: ScheduledTask) => {
if (task) {
setEditing(task);
let configStr = '';
if (task.config) {
try {
configStr = typeof task.config === 'string' ? JSON.stringify(JSON.parse(task.config), null, 2) : JSON.stringify(task.config, null, 2);
} catch {
configStr = task.config;
}
}
form.setFieldsValue({
name: task.name,
task_type: task.taskType,
schedule: task.schedule,
config: configStr,
is_active: task.isActive,
});
} else {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_active: true, task_type: 'internal_method' });
}
setActiveTab('basic');
setModal(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
let configVal = values.config;
if (configVal && typeof configVal === 'string') {
try {
configVal = JSON.stringify(JSON.parse(configVal));
} catch {
message.warning('配置 JSON 格式不合法,将按原样保存');
}
}
setSaving(true);
if (editing) {
await updateScheduledTask(editing.id, { ...values, config: configVal });
message.success('更新成功');
} else {
await createScheduledTask({ ...values, config: configVal });
message.success('创建成功');
}
setModal(false);
await fetchData();
} catch (err: any) {
message.error(err?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (taskId: string) => {
try {
await deleteScheduledTask(taskId);
message.success('删除成功');
await fetchData();
} catch (err: any) {
message.error(err?.message || '删除失败');
}
};
const handleRun = async (taskId: string) => {
try {
await runScheduledTask(taskId);
message.success('任务已提交执行');
setTimeout(fetchData, 1500);
} catch (err: any) {
message.error(err?.message || '执行失败');
}
};
const handleToggle = async (task: ScheduledTask) => {
try {
const res = await toggleScheduledTask(task.id);
message.success(res.message);
await fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
};
const columns = [
{ title: '任务名称', dataIndex: 'name', width: 160, ellipsis: true },
{
title: '类型',
dataIndex: 'taskType',
width: 100,
render: (v: string) => <Tag color="purple">{TASK_TYPE_LABELS[v] || v}</Tag>,
},
{ title: '调度表达式', dataIndex: 'schedule', width: 140, render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code> },
{
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '最后执行',
dataIndex: 'lastRunAt',
width: 160,
render: (v: string, r: ScheduledTask) => {
if (!v) return '-';
const status = r.lastStatus ? STATUS_LABELS[r.lastStatus] : null;
return (
<span>
{v.includes('T') ? v.replace('T', ' ').slice(0, 19) : v}
{status && <Tag color={status.color} style={{ marginLeft: 6 }}>{status.label}</Tag>}
</span>
);
},
},
{
title: '操作',
width: 220,
render: (_: any, record: ScheduledTask) => (
<Space size="small">
<Button type="link" size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(record.id)}></Button>
<Button type="link" size="small" icon={record.isActive ? <StopOutlined /> : <CheckCircleOutlined />} onClick={() => handleToggle(record)}>
{record.isActive ? '禁用' : '启用'}
</Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openModal(record)}></Button>
<Popconfirm title="确定删除该任务?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
const scheduleHelp = (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
<div> 3600 = </div>
<div> Cron 5 </div>
<div> <code>0 * * * *</code> = | <code>0 2 * * *</code> = 2</div>
</div>
);
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<ClockCircleOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openModal()}>
</Button>
</div>
</Card>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
/>
</Card>
{/* 新增/编辑弹窗 */}
<Modal
title={editing ? '编辑定时任务' : '新增定时任务'}
open={modal}
onOk={handleSave}
onCancel={() => setModal(false)}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnClose
width={600}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Tabs
activeKey={activeTab}
onChange={(k) => setActiveTab(k as 'basic' | 'config')}
items={[
{
key: 'basic',
label: '基本设置',
children: (
<>
<Form.Item name="name" label="任务名称" rules={[{ required: true, message: '请输入任务名称' }]}>
<Input placeholder="例如:每小时同步银行交易" />
</Form.Item>
<Form.Item name="task_type" label="任务类型" rules={[{ required: true, message: '请选择任务类型' }]}>
<Select options={SCHEDULE_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="schedule"
label="调度表达式"
rules={[{ required: true, message: '请输入调度表达式' }]}
extra={scheduleHelp}
>
<Input placeholder="纯数字(秒)或 Cron 表达式:* * * * *" />
</Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</>
),
},
{
key: 'config',
label: '任务配置',
children: (
<Form.Item
name="config"
label="配置 JSON"
extra='module 和 function 指定调用的函数,其余字段作为参数传入。服务会自动翻页拉取全部流水:'
>
<TextArea
rows={10}
placeholder={'{\n "module": "app.services.bank.sync_service",\n "function": "sync_bank_transactions",\n "url": "http://ceshi.web.minzhong.cn/api/api/v1/internal/get-blank-transfer-acct-time",\n "api_key": "jixekCxm8piLFi0AlfA24bDtCKJ82bfu",\n "acct_no": "110972289710001",\n "start_date": "2026-08-01",\n "end_date": "2026-08-14"\n}'}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
),
},
]}
/>
</Form>
</Modal>
</div>
);
};
export default AdminScheduledTasks;
+231 -109
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
Button, Card, Form, Input, InputNumber, message, Modal, Select, Space, Switch, Tabs, Typography, Upload, Table, Tag, Popconfirm,
} from 'antd';
import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined, BankOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
createSystemConfig,
@@ -14,8 +14,12 @@ import {
uploadLogo,
uploadPdf,
uploadLoginVideo,
listBankAccounts,
createBankAccount,
updateBankAccount,
deleteBankAccount,
} from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types';
import type { ResourceCapacityUnit, SystemConfig, BankAccount } from '../types';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' },
@@ -30,10 +34,116 @@ const AdminSettings: React.FC = () => {
const [uploading, setUploading] = useState('');
const [form] = Form.useForm();
// 银行账户管理
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(false);
const [accountModal, setAccountModal] = useState(false);
const [accountEditing, setAccountEditing] = useState<BankAccount | null>(null);
const [accountForm] = Form.useForm();
const [accountSaving, setAccountSaving] = useState(false);
useEffect(() => {
load();
loadBankAccounts();
}, []);
const loadBankAccounts = async () => {
setLoadingAccounts(true);
try {
const res = await listBankAccounts();
setBankAccounts(res.items || []);
} catch (e: any) {
message.error(e?.message || '加载银行账户失败');
} finally {
setLoadingAccounts(false);
}
};
const openAccountModal = (account?: BankAccount) => {
if (account) {
setAccountEditing(account);
accountForm.setFieldsValue({
account_name: account.accountName,
bank_name: account.bankName,
account_no: account.accountNo,
is_active: account.isActive,
is_default: account.isDefault,
description: account.description,
});
} else {
setAccountEditing(null);
accountForm.resetFields();
accountForm.setFieldsValue({ is_active: true, is_default: false });
}
setAccountModal(true);
};
const handleAccountSave = async () => {
try {
const values = await accountForm.validateFields();
setAccountSaving(true);
if (accountEditing) {
await updateBankAccount(accountEditing.id, values);
message.success('更新成功');
} else {
await createBankAccount(values);
message.success('创建成功');
}
setAccountModal(false);
await loadBankAccounts();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setAccountSaving(false);
}
};
const handleAccountDelete = async (accountId: string) => {
try {
await deleteBankAccount(accountId);
message.success('删除成功');
await loadBankAccounts();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const accountColumns = [
{ title: '账户名称', dataIndex: 'accountName', width: 150 },
{ title: '开户银行', dataIndex: 'bankName', width: 150 },
{
title: '银行账号',
dataIndex: 'accountNo',
width: 180,
render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code>,
},
{
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '默认',
dataIndex: 'isDefault',
width: 80,
render: (v: boolean) => v && <Tag color="blue"></Tag>,
},
{ title: '备注', dataIndex: 'description', ellipsis: true, render: (v: string) => v || '-' },
{
title: '操作',
width: 140,
render: (_: any, record: BankAccount) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openAccountModal(record)}></Button>
<Popconfirm title="确定删除该账户?" onConfirm={() => handleAccountDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
const load = async () => {
setLoading(true);
try {
@@ -45,17 +155,13 @@ const AdminSettings: React.FC = () => {
if (!data.some(c => c.key === 'llm_media_as_base64')) {
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
}
// 确保 single_device_login_enabled 配置存在
if (!data.some(c => c.key === 'single_device_login_enabled')) {
data.push({ id: 'cfg_single_device_login_enabled', key: 'single_device_login_enabled', value: 'false', description: '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话' });
}
setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
// LLM 预扣积分默认值
if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5';
if (!formValues.llm_billing_enabled) formValues.llm_billing_enabled = 'true';
if (!formValues.llm_hold_credits_default) formValues.llm_hold_credits_default = '5';
if (!formValues.llm_hold_credits_generation_record_prompt) formValues.llm_hold_credits_generation_record_prompt = '5';
if (!formValues.llm_hold_credits_module_image_prompt) formValues.llm_hold_credits_module_image_prompt = '5';
if (!formValues.llm_hold_credits_module_video_prompt) formValues.llm_hold_credits_module_video_prompt = '10';
if (!formValues.llm_hold_credits_shot_video_analysis) formValues.llm_hold_credits_shot_video_analysis = '10';
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
@@ -70,80 +176,14 @@ const AdminSettings: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes(
String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(),
);
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);
const llmManagedKeys = new Set([
'optimize_hold_credits',
'llm_billing_enabled',
'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',
]);
for (const config of configs) {
if (llmManagedKeys.has(config.key)) continue;
const newVal = values[config.key];
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
const saveManagedConfig = async (key: string, value: unknown, description: string) => {
if (value === undefined || value === null || value === '') return;
const normalizedValue = String(value);
const existing = configs.find(c => c.key === key);
if (existing) {
if (normalizedValue !== existing.value) await updateSystemConfig(existing.id, normalizedValue);
} else {
await createSystemConfig(key, normalizedValue, description);
}
};
const enabledConfig = [
'llm_billing_enabled',
values.llm_billing_enabled,
'是否启用 LLM 统一预扣与真实扣费结算',
] as const;
const llmHoldConfigs = [
['optimize_hold_credits', values.optimize_hold_credits, '提示词理解预扣积分数量(防止并发超卖)'],
['llm_hold_credits_default', values.llm_hold_credits_default, 'LLM 默认预扣积分数量'],
['llm_hold_credits_generation_record_prompt', values.llm_hold_credits_generation_record_prompt, 'AI创作提示词优化预扣积分数量'],
['llm_hold_credits_module_image_prompt', values.llm_hold_credits_module_image_prompt, '模块图片 AI 提词优化预扣积分数量'],
['llm_hold_credits_module_video_prompt', values.llm_hold_credits_module_video_prompt, '模块视频 AI 提词优化预扣积分数量'],
['llm_hold_credits_shot_video_analysis', values.llm_hold_credits_shot_video_analysis, '拆镜视频分析预扣积分数量'],
] as const;
// 关闭时先关开关,随后允许保存 0;启用时先保存正数预扣,最后再打开开关。
if (!llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
for (const [key, value, description] of llmHoldConfigs) {
await saveManagedConfig(key, value, description);
}
if (llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
@@ -228,6 +268,26 @@ const AdminSettings: React.FC = () => {
message.success('已移除登录背景视频');
};
const handleToggleSingleDevice = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'single_device_login_enabled');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('single_device_login_enabled', checked ? 'true' : 'false', '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'single_device_login_enabled');
if (exists) return prev.map(c => c.key === 'single_device_login_enabled' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`${checked ? '开启' : '关闭'}单设备登录限制`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleToggleBase64 = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'llm_media_as_base64');
@@ -249,12 +309,12 @@ const AdminSettings: React.FC = () => {
};
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'),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'收款银行': [],
'其他配置': configs.filter(c => c.key === 'operation_manual'),
'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits' || c.key.startsWith('llm_')),
};
const getFieldDescription = (config: SystemConfig): string => {
@@ -270,13 +330,6 @@ const AdminSettings: React.FC = () => {
user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
optimize_hold_credits: '兼容旧配置。新 LLM 配置为空时回退使用该值',
llm_billing_enabled: '是否启用 LLM 统一预扣、释放预扣和真实扣费结算',
llm_hold_credits_default: 'LLM 场景默认预扣积分,场景配置为空时使用',
llm_hold_credits_generation_record_prompt: 'AI创作提示词优化发起前预扣积分',
llm_hold_credits_module_image_prompt: '爆款开头/拆镜复刻图片 AI 提词优化发起前预扣积分',
llm_hold_credits_module_video_prompt: '爆款开头/拆镜复刻视频 AI 提词优化发起前预扣积分',
llm_hold_credits_shot_video_analysis: '拆镜原视频/片段视频分析发起前预扣积分',
};
return descMap[config.key] || config.description || '';
};
@@ -406,7 +459,7 @@ const AdminSettings: React.FC = () => {
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) {
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
@@ -467,30 +520,38 @@ const AdminSettings: React.FC = () => {
{getFieldComponent(config)}
</Form.Item>
))}
{/* AI创作预扣积分 - 固定显示 */}
<Form.Item
name="optimize_hold_credits"
label={<span style={{ fontWeight: 500 }}></span>}
extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值"
>
<Input type="number" min={0} placeholder="默认5" size="large" />
</Form.Item>
{[
['llm_billing_enabled', '启用 LLM 统一计费', 'true 表示启用,false 表示关闭'],
['llm_hold_credits_default', 'LLM 默认预扣积分', '默认5'],
['llm_hold_credits_generation_record_prompt', 'AI创作提词预扣积分', '默认5'],
['llm_hold_credits_module_image_prompt', '模块图片提词预扣积分', '默认5'],
['llm_hold_credits_module_video_prompt', '模块视频提词预扣积分', '默认10'],
['llm_hold_credits_shot_video_analysis', '拆镜视频分析预扣积分', '默认10'],
].map(([name, label, extra]) => (
<Form.Item key={name} name={name} label={<span style={{ fontWeight: 500 }}>{label}</span>} extra={extra}>
{name === 'llm_billing_enabled' ? <Input placeholder="true / false" size="large" /> : <Input type="number" min={1} placeholder={extra} size="large" />}
</Form.Item>
))}
</div>
</Form>
),
},
{
key: 'integration',
label: '收款银行',
children: (
<div>
{/* 银行账户管理 */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
<Typography.Text strong style={{ fontSize: 14 }}>
<BankOutlined style={{ marginRight: 8, color: '#6366f1' }} />
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} size="small" onClick={() => openAccountModal()}>
</Button>
</div>
<Table
rowKey="id"
dataSource={bankAccounts}
loading={loadingAccounts}
pagination={false}
size="middle"
columns={accountColumns}
/>
</div>
</div>
),
},
{
key: 'other',
label: '其他配置',
@@ -595,13 +656,39 @@ const AdminSettings: React.FC = () => {
</div>
</div>
</div>
{/* 单设备登录限制 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'single_device_login_enabled') || {}).value === 'true'}
onChange={handleToggleSingleDevice}
checkedChildren="已启用"
unCheckedChildren="已禁用"
/>
</div>
</div>
</div>
</Form>
),
},
];
return (
<div style={{ maxWidth: 720 }}>
<div style={{ maxWidth: 1080 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
@@ -627,6 +714,41 @@ const AdminSettings: React.FC = () => {
</Button>
</div>
{/* 银行账户新增/编辑弹窗 */}
<Modal
title={accountEditing ? '编辑银行账户' : '新增银行账户'}
open={accountModal}
onOk={handleAccountSave}
onCancel={() => setAccountModal(false)}
confirmLoading={accountSaving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={accountForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="account_name" label="账户名称" rules={[{ required: true, message: '请输入账户名称' }]}>
<Input placeholder="例如:民众普康科技有限公司" />
</Form.Item>
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true, message: '请输入开户银行' }]}>
<Input placeholder="例如:中国工商银行北京分行" />
</Form.Item>
<Form.Item name="account_no" label="银行账号" rules={[{ required: true, message: '请输入银行账号' }]}>
<Input placeholder="银行账号" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={2} placeholder="可选备注信息" />
</Form.Item>
<div style={{ display: 'flex', gap: 24 }}>
<Form.Item name="is_active" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item name="is_default" label="设为默认" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
+61 -360
View File
@@ -1,378 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined, UserSwitchOutlined } from '@ant-design/icons';
import {
deleteAdminTeam, getAdminTeamManagerHistory, getAdminTeamMemberUsage, getAdminTeams, getAdminTeamSubscriptions,
getTeamMembersForAdmin, saveAdminTeam, setTeamManager,
} from '../api';
import type { AdminTeam, AdminUser } from '../types';
import { formatDate } from '../utils/formatDate';
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
const editStatusOptions = [
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
function statusLabel(status?: string): string {
if (status === 'active') return '启用';
if (status === 'disabled') return '禁用';
return status || '-';
}
const STATUS = { active: '启用', disabled: '禁用' } as Record<string,string>;
const fmt = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-';
const AdminTeams: React.FC = () => {
const [items, setItems] = useState<AdminTeam[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
const [form] = Form.useForm();
const [items, setItems] = useState<AdminTeam[]>([]); const [loading,setLoading]=useState(false); const [page,setPage]=useState(1); const [pageSize,setPageSize]=useState(20); const [total,setTotal]=useState(0);
const [keyword,setKeyword]=useState(''); const [status,setStatus]=useState<string>(); const [form]=Form.useForm(); const [edit,setEdit]=useState<AdminTeam|null>(null); const [editOpen,setEditOpen]=useState(false);
const [detail,setDetail]=useState<AdminTeam|null>(null); const [members,setMembers]=useState<AdminUser[]>([]); const [subscriptions,setSubscriptions]=useState<any[]>([]); const [usage,setUsage]=useState<any[]>([]); const [history,setHistory]=useState<any[]>([]); const [detailLoading,setDetailLoading]=useState(false);
const query=useMemo(()=>({page,pageSize,keyword:keyword||undefined,status}),[page,pageSize,keyword,status]);
const load=useCallback(async()=>{setLoading(true);try{const r=await getAdminTeams(query);setItems(r.items||[]);setTotal(Number(r.total||0));}catch(e:any){message.error(e?.message||'团队列表加载失败');}finally{setLoading(false);}},[query]);
useEffect(()=>{void load();},[load]);
// 管理人弹窗状态
const [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
const [managerLoading, setManagerLoading] = useState(false);
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
const openEdit=(team?:AdminTeam)=>{const item=team||null;setEdit(item);form.resetFields();form.setFieldsValue(item?{name:item.name,code:item.code,description:item.description,status:item.status,sortOrder:item.sortOrder}:{status:'active',sortOrder:0});setEditOpen(true);};
const save=async()=>{try{const v=await form.validateFields();await saveAdminTeam({id:edit?.id,name:v.name,code:v.code||null,description:v.description||null,status:v.status,sort_order:Number(v.sortOrder||0)});message.success(edit?'团队已更新':'团队已创建');setEditOpen(false);await load();}catch(e:any){if(!e?.errorFields)message.error(e?.message||'保存失败');}};
const openDetail=async(team:AdminTeam)=>{setDetail(team);setDetailLoading(true);try{const [m,s,u,h]=await Promise.all([getTeamMembersForAdmin(team.id,1,500),getAdminTeamSubscriptions(team.id),getAdminTeamMemberUsage(team.id),getAdminTeamManagerHistory(team.id)]);setMembers(m.items||[]);setSubscriptions(s||[]);setUsage(u||[]);setHistory(h||[]);}catch(e:any){message.error(e?.message||'团队详情加载失败');}finally{setDetailLoading(false);}};
// 查看成员弹窗状态
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const query = useMemo(() => ({
page,
pageSize,
keyword: keyword || undefined,
status: status || undefined,
}), [page, pageSize, keyword, status]);
const load = async () => {
setLoading(true);
try {
const res = await getAdminTeams(query);
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载团队列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
const openCreate = () => {
form.resetFields();
form.setFieldsValue({ status: 'active', sortOrder: 0 });
setModal({ open: true, item: null });
};
const openEdit = (item: AdminTeam) => {
form.setFieldsValue({
name: item.name,
code: item.code || '',
description: item.description || '',
status: item.status || 'active',
sortOrder: item.sortOrder || 0,
});
setModal({ open: true, item });
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await saveAdminTeam({
id: modal.item?.id,
name: values.name,
code: values.code || null,
description: values.description || null,
status: values.status || 'active',
sort_order: values.sortOrder || 0,
});
message.success(modal.item ? '团队已更新' : '团队已创建');
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (item: AdminTeam) => {
try {
await deleteAdminTeam(item.id);
message.success('团队已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const loadTeamMembers = async (teamId: string): Promise<AdminUser[]> => {
try {
const res = await getTeamMembersForAdmin(teamId);
return res.items || [];
} catch (e: any) {
message.error(e?.message || '加载成员失败');
return [];
}
};
const openManagerModal = async (team: AdminTeam) => {
setManagerModal({ open: true, team });
setSelectedManagerId(team.managerId || null);
setManagerLoading(true);
const data = await loadTeamMembers(team.id);
setManagerMembers(data);
setManagerLoading(false);
};
const openMembersModal = async (team: AdminTeam) => {
setMembersModal({ open: true, team });
setMembersLoading(true);
const data = await loadTeamMembers(team.id);
setTeamMembers(data);
setMembersLoading(false);
};
const handleSetManager = async () => {
if (!managerModal.team) return;
try {
setManagerLoading(true);
await setTeamManager(managerModal.team.id, selectedManagerId);
message.success(selectedManagerId ? '已设置管理人' : '已取消管理人');
setManagerModal({ open: false, team: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setManagerLoading(false);
}
};
const columns = [
{
title: '团队名称',
dataIndex: 'name',
width: 220,
render: (v: string, r: AdminTeam) => (
<Space>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(99,102,241,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6366f1' }}>
<TeamOutlined />
</div>
<div>
<Typography.Text strong>{v}</Typography.Text>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.code || '-'}</div>
</div>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{statusLabel(v)}</Tag>,
},
{
title: '成员数',
dataIndex: 'memberCount',
width: 100,
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
},
{
title: '管理人',
key: 'manager',
width: 140,
render: (_: any, r: AdminTeam) => (
<Typography.Text style={{ fontSize: 13 }}>
{r.managerName || <span style={{ color: '#94a3b8' }}></span>}
</Typography.Text>
),
},
{
title: '排序',
dataIndex: 'sortOrder',
width: 90,
render: (v: number) => v ?? 0,
},
{
title: '备注',
dataIndex: 'description',
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作',
key: 'action',
width: 230,
fixed: 'right' as const,
render: (_: any, r: AdminTeam) => (
<Space size={4}>
<Button type="link" size="small" icon={<UserOutlined />} onClick={() => openMembersModal(r)}></Button>
<Button type="link" size="small" icon={<SettingOutlined />} onClick={() => openManagerModal(r)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm
title="确定删除该团队?"
description={r.memberCount > 0 ? '该团队下仍有成员,后端会拒绝删除。' : '删除后团队不再出现在设置下拉中。'}
onConfirm={() => handleDelete(r)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
const columns=[
{title:'团队',dataIndex:'name',width:220,render:(v:string,r:AdminTeam)=><Space><TeamOutlined/><div><b>{v}</b><div style={{fontSize:12,color:'#94a3b8'}}>{r.code||'-'}</div></div></Space>},
{title:'状态',dataIndex:'status',width:100,render:(v:string,r:AdminTeam)=><Tag color={v==='active'?'green':'red'}>{r.statusLabel||STATUS[v]||'其他状态'}</Tag>},
{title:'成员数',dataIndex:'memberCount',width:90,align:'right' as const},
{title:'当前队长',dataIndex:'managerName',width:140,render:(v:string)=>v||'-'},
{title:'团队首购时间',dataIndex:'firstSubscriptionPaidAt',width:180,render:(v:string)=>fmt(v)},
{title:'排序',dataIndex:'sortOrder',width:80},
{title:'备注',dataIndex:'description',ellipsis:true,render:(v:string)=>v||'-'},
{title:'操作',fixed:'right' as const,width:220,render:(_:any,r:AdminTeam)=><Space><Button size="small" icon={<EyeOutlined/>} onClick={()=>openDetail(r)}></Button><Button size="small" icon={<EditOutlined/>} onClick={()=>openEdit(r)}>{r.status==='disabled'?'重新启用':'编辑'}</Button><Popconfirm title="确认软删除团队?" description="仅在无有效团队订阅、无未完成团队订单且无成员时允许删除。" onConfirm={async()=>{try{await deleteAdminTeam(r.id);message.success('团队已删除');await load();}catch(e:any){message.error(e?.message||'删除失败');}}}><Button size="small" danger icon={<DeleteOutlined/>}></Button></Popconfirm></Space>},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Input
placeholder="搜索团队名称/编码/备注"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={keyword}
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
onPressEnter={load}
style={{ width: 260 }}
allowClear
/>
<Select value={status} onChange={(v) => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={statusOptions} />
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</div>
const subColumns=[
{title:'订阅实例',width:190,render:(_:any,r:any)=><Typography.Text copyable={r.subscription?.subscriptionNo?{text:r.subscription.subscriptionNo}:false}>{r.subscription?.subscriptionNo||'-'}</Typography.Text>},
{title:'套餐',render:(_:any,r:any)=>r.subscription?.productNameSnapshot||'-'}, {title:'状态',render:(_:any,r:any)=><Tag>{r.subscription?.statusLabel||'其他状态'}</Tag>},
{title:'数量N',render:(_:any,r:any)=>r.subscription?.quantitySnapshot||1}, {title:'席位',render:(_:any,r:any)=>`${r.activeSeatCount||0}/${r.seatLimit||0}`},
{title:'本期总积分',dataIndex:'periodTotalCredits',align:'right' as const},{title:'本期剩余',dataIndex:'periodUnspentCredits',align:'right' as const},{title:'未分配',dataIndex:'periodUnallocatedCredits',align:'right' as const},
{title:'订阅到期',render:(_:any,r:any)=>fmt(r.subscription?.expiresAt)},
];
const memberColumns=[{title:'用户名',dataIndex:'username'},{title:'手机号',dataIndex:'phone',render:(v:string)=>v||'-'},{title:'个人积分',dataIndex:'personalCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队可用',dataIndex:'teamAvailableCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队冻结',dataIndex:'teamFrozenCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'设为队长',render:(_:any,r:AdminUser)=>r.id===detail?.managerId? <Tag color="purple"></Tag>:<Popconfirm title="确认更换队长?" description="只有团队启用、所有团队订阅结束且没有未完成团队订单时才能更换。" onConfirm={async()=>{if(!detail)return;try{await setTeamManager(detail.id,r.id);message.success('队长已更换');await openDetail({...detail,managerId:r.id,managerName:r.username});await load();}catch(e:any){message.error(e?.message||'更换队长失败');}}}><Button size="small" disabled={detail?.status==='disabled'} icon={<UserSwitchOutlined/>}></Button></Popconfirm>}];
<Table
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 个团队`,
}}
scroll={{ x: 1100 }}
/>
</Card>
return <div><Card><Space wrap style={{marginBottom:16}}><Input allowClear value={keyword} onChange={e=>{setKeyword(e.target.value);setPage(1);}} placeholder="团队名称/编码/备注" prefix={<SearchOutlined/>} style={{width:260}}/><Select allowClear value={status} onChange={v=>{setStatus(v);setPage(1);}} placeholder="全部状态" style={{width:130}} options={[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/><Button icon={<ReloadOutlined/>} onClick={load}></Button><Button type="primary" icon={<PlusOutlined/>} onClick={()=>openEdit()}></Button></Space><Table rowKey="id" loading={loading} columns={columns} dataSource={items} scroll={{x:1250}} pagination={{current:page,pageSize,total,showSizeChanger:true,onChange:(p,ps)=>{setPage(p);setPageSize(ps);}}}/></Card>
<Modal
title={<Space><TeamOutlined />{modal.item ? '编辑团队' : '新增团队'}</Space>}
open={modal.open}
confirmLoading={saving}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存"
cancelText="取消"
width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="团队名称" rules={[{ required: true, message: '请输入团队名称' }]}>
<Input maxLength={128} placeholder="请输入团队名称" size="large" />
</Form.Item>
<Form.Item name="code" label="团队编码" extra="选填,用于内部标识或后续外部系统对接。">
<Input maxLength={64} placeholder="例如 sales_a" size="large" />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select size="large" options={editStatusOptions} />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}>
<InputNumber min={0} max={999999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={3} maxLength={512} placeholder="选填" />
</Form.Item>
</Form>
<Modal width={560} title={edit?'编辑团队':'新增团队'} open={editOpen} onCancel={()=>setEditOpen(false)} onOk={save} okText={edit?.status==='disabled'?'重新启用':'保存'} cancelText="取消">
{edit?.status==='disabled'&&<Alert style={{marginBottom:14}} type="warning" showIcon message="禁用团队当前仅允许重新启用" description="团队禁用期间所有团队业务保持只读;Subscription、Period、Seat 和积分仍正常滚期、发放与过期。"/>}
<Form form={form} layout="vertical"><Form.Item name="name" label="团队名称" rules={[{required:true}]}><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="code" label="团队编码"><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="description" label="备注"><Input.TextArea rows={3} disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="status" label="状态" rules={[{required:true}]}><Select options={edit?.status==='disabled'?[{value:'active',label:'重新启用'}]:[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/></Form.Item><Form.Item name="sortOrder" label="排序"><InputNumber min={0} max={999999} disabled={edit?.status==='disabled'} style={{width:'100%'}}/></Form.Item></Form>
</Modal>
{/* 设置管理人弹窗 */}
<Modal
title={<Space><UserOutlined /></Space>}
open={managerModal.open}
confirmLoading={managerLoading}
onOk={handleSetManager}
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ marginTop: 16 }}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
{managerModal.team?.name || '-'}
</Typography.Text>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="选择管理人(可清空取消)"
value={selectedManagerId}
onChange={(v) => setSelectedManagerId(v || null)}
allowClear
loading={managerLoading}
optionFilterProp="label"
options={managerMembers.map((m) => ({
value: m.id,
label: `${m.username}${m.phone ? ` (${m.phone})` : ''}`,
}))}
showSearch
/>
</div>
</Modal>
{/* 查看成员弹窗 */}
<Modal
title={<Space><UserOutlined /> - {membersModal.team?.name}</Space>}
open={membersModal.open}
onCancel={() => setMembersModal({ open: false, team: null })}
footer={null}
width={600}
>
<div style={{ marginTop: 8 }}>
{membersModal.team && (
<Table
size="small"
rowKey="id"
loading={membersLoading}
dataSource={teamMembers}
<Modal width={1100} footer={null} title={detail?`团队详情:${detail.name}`:'团队详情'} open={!!detail} onCancel={()=>setDetail(null)}>
{detail?.status==='disabled'&&<Alert type="warning" showIcon style={{marginBottom:14}} message="团队已禁用:当前仅允许只读查看" description="团队积分处于冻结状态,但团队订阅仍继续正常发放和到期。"/>}
<Tabs items={[
{key:'subscriptions',label:'订阅与席位',children:<Table loading={detailLoading} rowKey={(r:any)=>r.subscription?.id} dataSource={subscriptions} columns={subColumns} pagination={false} scroll={{x:1120}} expandable={{expandedRowRender:(r:any)=><Table rowKey="id" size="small" pagination={false} dataSource={r.seats||[]} columns={[{title:'席位用户',dataIndex:'username'},{title:'月额度',dataIndex:'monthlyAllocatedCredits',align:'right' as const},{title:'本期净消耗',dataIndex:'currentPeriodUsedCredits',align:'right' as const},{title:'本期剩余',dataIndex:'currentPeriodRemainingCredits',align:'right' as const},{title:'状态',dataIndex:'statusLabel'}]}/>}}/>},
{key:'members',label:'成员与队长',children:<Table loading={detailLoading} rowKey="id" dataSource={members} columns={memberColumns} pagination={false} scroll={{x:850}}/>},
{key:'usage',label:'成员月净消耗',children:<Table
loading={detailLoading}
rowKey={(r:any)=>`${r.userId}-${r.subscriptionPeriodId}`}
dataSource={usage}
pagination={false}
scroll={{ y: 400 }}
scroll={{x:1400}}
columns={[
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>,
},
{title:'成员',dataIndex:'username',width:120},
{title:'订阅实例',dataIndex:'subscriptionNo',width:190,render:(v:string)=><Typography.Text copyable={v&&v!=='历史订阅'?{text:v}:false}>{v||'历史订阅'}</Typography.Text>},
{title:'套餐名称',dataIndex:'subscriptionName',width:180,render:(v:string)=><Typography.Text strong>{v||'历史团队订阅'}</Typography.Text>},
{title:'等级编码',width:190,render:(_:any,r:any)=><span>{r.tierLabel||'未知等级'}{r.tierCode?`${r.tierCode}`:''}{r.tierRank?` / ${r.tierRank}`:''}</span>},
{title:'套餐周期',dataIndex:'billingCycleLabel',width:110,render:(v:string)=><Tag>{v||'未知周期'}</Tag>},
{title:'月度周期',dataIndex:'periodLabel',width:110,render:(v:string,r:any)=>v||(Number(r.periodSequence)>0?`${r.periodSequence}个月`:'历史周期')},
{title:'周期时间',width:320,render:(_:any,r:any)=>`${fmt(r.periodStartAt)} ${fmt(r.periodExpiresAt)}`},
{title:'本期净消耗',dataIndex:'consumedCredits',width:130,align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},
]}
locale={{ emptyText: '该团队暂无成员' }}
/>
)}
</div>
/>},
{key:'history',label:'队长任期历史',children:<Table loading={detailLoading} rowKey="id" dataSource={history} pagination={false} columns={[{title:'队长',dataIndex:'managerName'},{title:'开始',dataIndex:'startedAt',render:(v:string)=>fmt(v)},{title:'结束',dataIndex:'endedAt',render:(v:string)=>v?fmt(v):<Tag color="green"></Tag>}]}/>},
]}/>
</Modal>
</div>
);
</div>;
};
export default AdminTeams;
+364 -46
View File
@@ -1,17 +1,22 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Radio, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, SafetyOutlined,
} from '@ant-design/icons';
import {
adjustCredits,
adminDeductCredits,
adminGrantCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
createAdminOfflineSubscription,
deleteUserResourceCapacity,
getAdminUsers,
getCreditProducts,
getAdminUserCreditBalances,
getAdminUserCreditSummary,
getMenuConfigs,
getTeamOptions,
getSystemConfigs,
@@ -20,12 +25,13 @@ import {
saveUserResourceCapacity,
toggleUserStatus,
updateFrontendUserKind,
updateSingleDeviceLoginOverride,
updateUserTeam,
updateSystemConfig,
updateUserMenus,
updateUserAdminStatus,
} from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, CreditProduct, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -66,6 +72,15 @@ const AdminUsers: React.FC = () => {
const [teamFilter, setTeamFilter] = useState<string>('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [creditDetailModal, setCreditDetailModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineModal, setOfflineModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineProducts, setOfflineProducts] = useState<CreditProduct[]>([]);
const [offlineSaving, setOfflineSaving] = useState(false);
const [creditDetailLoading, setCreditDetailLoading] = useState(false);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditBalances, setCreditBalances] = useState<any[]>([]);
const [creditBalanceStatus, setCreditBalanceStatus] = useState<string>('');
const [creditOperation, setCreditOperation] = useState<'grant' | 'deduct'>('grant');
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState<string>('frontend');
const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
@@ -75,6 +90,10 @@ const AdminUsers: React.FC = () => {
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
const [singleDeviceModal, setSingleDeviceModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [singleDeviceValue, setSingleDeviceValue] = useState<boolean | null>(null);
const [singleDeviceSaving, setSingleDeviceSaving] = useState(false);
const [globalSingleDeviceEnabled, setGlobalSingleDeviceEnabled] = useState(false);
const [capacityLoading, setCapacityLoading] = useState(false);
const [capacitySaving, setCapacitySaving] = useState(false);
const [teamSaving, setTeamSaving] = useState(false);
@@ -86,6 +105,9 @@ const AdminUsers: React.FC = () => {
const [capacityForm] = Form.useForm();
const [teamForm] = Form.useForm();
const [portraitForm] = Form.useForm();
const [offlineForm] = Form.useForm();
const offlineProductId = Form.useWatch('productId', offlineForm);
const selectedOfflineProduct = offlineProducts.find((item) => item.id === offlineProductId);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -127,6 +149,9 @@ const AdminUsers: React.FC = () => {
const formValues: Record<string, string> = {};
credit.forEach(c => { formValues[c.key] = c.value; });
configForm.setFieldsValue(formValues);
// 获取全局单设备登录开关状态
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
} catch { /* auth error handled by client */ }
};
loadCreditConfigs();
@@ -164,12 +189,94 @@ const AdminUsers: React.FC = () => {
const values = await form.validateFields();
const { user } = creditModal;
if (!user) return;
await adjustCredits(user.id, values.amount, values.description);
message.success(`${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
if (creditOperation === 'grant') {
await adminGrantCredits(user.id, {
amount: values.amount,
description: values.description,
validity_unit: values.validityUnit || 'month',
validity_value: values.validityValue || 1,
credit_level: 'general',
});
} else {
await adminDeductCredits(user.id, { amount: values.amount, description: values.description });
}
message.success(`${creditOperation === 'grant' ? '增加' : '扣除'} ${values.amount} 积分`);
setCreditModal({ open: false, user: null });
setCreditOperation('grant');
form.resetFields();
load();
} catch { /* validation */ }
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '积分操作失败');
}
};
const openOfflineModal = async (user: AdminUser) => {
try {
const products = await getCreditProducts();
const available = products.filter((item) =>
(item.productType === 'subscription' || item.productType === 'team_subscription')
&& item.isActive
&& !item.isDeleted
);
setOfflineProducts(available);
offlineForm.resetFields();
offlineForm.setFieldsValue({ quantity: 1, paymentMethod: 'bank_transfer' });
setOfflineModal({ open: true, user });
} catch (e: any) {
message.error(e?.message || '加载可成交套餐失败');
}
};
const handleOfflineSubscription = async () => {
const user = offlineModal.user;
if (!user) return;
try {
const values = await offlineForm.validateFields();
const product = offlineProducts.find((item) => item.id === values.productId);
if (!product) {
message.error('请选择有效套餐');
return;
}
const quantity = product.productType === 'team_subscription' ? Number(values.quantity || 2) : 1;
setOfflineSaving(true);
await createAdminOfflineSubscription(user.id, {
productId: product.id,
quantity,
paymentMethod: values.paymentMethod,
actualPaidAmount: values.actualPaidAmount === undefined || values.actualPaidAmount === null ? undefined : Number(values.actualPaidAmount),
offlineTradeNo: values.offlineTradeNo,
offlinePaymentDetail: values.offlinePaymentDetail,
remark: values.remark,
});
message.success('线下订阅成交已完成,订单、套餐与首期权益已同步生效');
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '线下订阅成交失败');
} finally {
setOfflineSaving(false);
}
};
const openCreditDetailModal = async (user: AdminUser, status = '') => {
setCreditDetailModal({ open: true, user });
setCreditBalanceStatus(status);
setCreditDetailLoading(true);
try {
const [summary, balances] = await Promise.all([
getAdminUserCreditSummary(user.id),
getAdminUserCreditBalances(user.id, 1, 200, status || undefined),
]);
setCreditSummary(summary);
setCreditBalances(balances || []);
} catch (e: any) {
message.error(e?.message || '加载用户积分明细失败');
} finally {
setCreditDetailLoading(false);
}
};
const handleToggleStatus = async (user: AdminUser) => {
@@ -390,6 +497,20 @@ const AdminUsers: React.FC = () => {
}
};
const handleUpdateSingleDeviceOverride = async (user: AdminUser, value: boolean | null) => {
try {
setSingleDeviceSaving(true);
await updateSingleDeviceLoginOverride(user.id, value);
message.success('已更新单设备登录设置');
setSingleDeviceModal({ open: false, user: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setSingleDeviceSaving(false);
}
};
const isAdminTab = activeTab === 'admin';
const columns = [
@@ -417,11 +538,15 @@ const AdminUsers: React.FC = () => {
),
},
...(!isAdminTab ? [{
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v.toLocaleString()}
</Typography.Text>
title: '积分余额', dataIndex: 'credits', width: 210, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number, r: AdminUser) => (
<div style={{ lineHeight: 1.7 }}>
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}> {Number(v || 0).toLocaleString()}</Typography.Text>
<div style={{ color: '#64748b', fontSize: 12 }}>
{Number(r.personalCredits || 0).toLocaleString()} / {Number(r.teamAvailableCredits || 0).toLocaleString()}
</div>
{Number(r.teamFrozenCredits || 0) > 0 && <div style={{ color: '#f59e0b', fontSize: 12 }}> {Number(r.teamFrozenCredits || 0).toLocaleString()}</div>}
</div>
),
}] : []),
{
@@ -488,15 +613,30 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
title: '操作', key: 'action', width: 650, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4} wrap>
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
onClick={() => {
setCreditOperation('grant');
setCreditModal({ open: true, user: r });
form.setFieldsValue({ amount: undefined, description: '', validityUnit: 'month', validityValue: 1 });
}}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => openCreditDetailModal(r)}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => openOfflineModal(r)}>
线
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
@@ -540,6 +680,15 @@ const AdminUsers: React.FC = () => {
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
</Button>
{!isAdminTab && (
<Button type="link" size="small" icon={<SafetyOutlined />}
onClick={() => {
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
setSingleDeviceModal({ open: true, user: r });
}}>
</Button>
)}
<Popconfirm
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
onConfirm={() => handleToggleStatus(r)}
@@ -685,50 +834,174 @@ const AdminUsers: React.FC = () => {
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
onOk={handleAdjustCredits}
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={440}
onCancel={() => { setCreditModal({ open: false, user: null }); setCreditOperation('grant'); form.resetFields(); }}
okText="确认" cancelText="取消" width={480}
>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#64748b' }}></span>
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
{/* 快捷操作 */}
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}></Typography.Text>
<Space wrap>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
+1000 /
</Button>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
+500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
-500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
-1000 /
</Button>
</Space>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
placeholder="正数增加,负数扣除"
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
<Form form={form} layout="vertical" initialValues={{ validityUnit: 'month', validityValue: 1 }}>
<Form.Item label="操作类型">
<Select
value={creditOperation}
onChange={(value) => setCreditOperation(value)}
options={[
{ value: 'grant', label: '增加积分' },
{ value: 'deduct', label: '扣除积分' },
]}
/>
</Form.Item>
<Form.Item name="description" label="原因"
rules={[{ required: true, message: '请输入调整原因' }]}>
<Form.Item name="amount" label="积分数量" rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} size="large" placeholder="请输入正数积分数量" />
</Form.Item>
{creditOperation === 'grant' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="validityUnit" label="有效期单位" rules={[{ required: true }]}>
<Select options={[{ value: 'day', label: '天' }, { value: 'month', label: '自然月' }]} />
</Form.Item>
<Form.Item name="validityValue" label="有效期数值" rules={[{ required: true }]}>
<InputNumber min={1} max={120} precision={0} style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="description" label="原因" rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined />线 - {offlineModal.user?.username}</Space>}
open={offlineModal.open}
onOk={handleOfflineSubscription}
confirmLoading={offlineSaving}
okText="确认成交"
cancelText="取消"
width={620}
onCancel={() => {
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
}}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
线 使 0
</Typography.Paragraph>
<Form form={offlineForm} layout="vertical" initialValues={{ quantity: 1, paymentMethod: 'bank_transfer' }}>
<Form.Item name="productId" label="成交套餐" rules={[{ required: true, message: '请选择成交套餐' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="请选择个人订阅或团队订阅套餐"
options={offlineProducts.map((item) => ({
value: item.id,
label: `${item.productType === 'team_subscription' ? '团队订阅' : '个人订阅'}${item.name}`,
}))}
onChange={(value) => {
const product = offlineProducts.find((item) => item.id === value);
offlineForm.setFieldValue('quantity', product?.productType === 'team_subscription' ? 2 : 1);
}}
/>
</Form.Item>
{selectedOfflineProduct?.productType === 'team_subscription' && (
<Form.Item
name="quantity"
label="团队席位数量"
rules={[{ required: true, message: '请输入团队席位数量' }]}
extra="后台单张团队订阅允许 2~1000 席;成交后数量永久按订单快照固定。"
>
<InputNumber min={2} max={1000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)}
<Form.Item
name="actualPaidAmount"
label="实际成交总额(元)"
extra="留空使用后端定价器计算出的系统报价;填写后以该整单总额作为实际收入。0 元属于合法真实成交,不按赠送处理。"
>
<InputNumber min={0} precision={2} stringMode style={{ width: '100%' }} placeholder="留空则使用系统报价" />
</Form.Item>
<Form.Item name="paymentMethod" label="线下收款方式" rules={[{ required: true, message: '请选择收款方式' }]}>
<Select options={[
{ value: 'bank_transfer', label: '银行转账' },
{ value: 'cash', label: '现金' },
{ value: 'other', label: '其他' },
]} />
</Form.Item>
<Form.Item name="offlineTradeNo" label="线下流水/凭证号(选填)">
<Input maxLength={128} placeholder="可填写银行流水号、收款凭证号等" />
</Form.Item>
<Form.Item name="offlinePaymentDetail" label="具体收款方式(选填)">
<Input maxLength={128} placeholder="未填写且选择“其他”时,系统显示“其他-线下收款”" />
</Form.Item>
<Form.Item name="remark" label="成交备注(选填)">
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写商务成交、售后置换等必要说明" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined /> - {creditDetailModal.user?.username}</Space>}
open={creditDetailModal.open}
footer={null}
width={1180}
onCancel={() => {
setCreditDetailModal({ open: false, user: null });
setCreditSummary(null);
setCreditBalances([]);
setCreditBalanceStatus('');
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.personalCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamAvailableCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamFrozenCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.availableCredits || creditSummary?.credits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.nextExpiringCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 15, fontWeight: 600 }}>{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}</div></Card>
</div>
<Space>
<Typography.Text strong></Typography.Text>
<Select
value={creditBalanceStatus}
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'scheduled', label: '未生效' },
{ value: 'active', label: '有效' },
{ value: 'consumed', label: '已消费' },
{ value: 'expired', label: '已过期' },
{ value: 'revoked', label: '已撤销' },
]}
onChange={(value) => creditDetailModal.user && openCreditDetailModal(creditDetailModal.user, value)}
/>
</Space>
<Table
size="small"
loading={creditDetailLoading}
rowKey="id"
pagination={false}
dataSource={creditBalances}
columns={[
{ title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string, r: any) => v || r.sourceType || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 100, render: (v: string, r: any) => v || r.creditLevel || '-' },
{ title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已过期', dataIndex: 'expiredAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || '其他状态'}</Tag> },
]}
scroll={{ x: 1410, y: 460 }}
/>
</Space>
</Modal>
<Modal
title={<Space><TeamOutlined /> - {teamModal.user?.username}</Space>}
open={teamModal.open}
@@ -893,7 +1166,7 @@ const AdminUsers: React.FC = () => {
<Input.Password placeholder="请输入密码(至少6位)" size="large" />
</Form.Item>
{createType === 'frontend' && (
<Form.Item name="credits" label="初始积分" initialValue={0}>
<Form.Item name="credits" label="初始积分(一个自然月有效)" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
@@ -980,6 +1253,51 @@ const AdminUsers: React.FC = () => {
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><SafetyOutlined /> - {singleDeviceModal.user?.username}</Space>}
open={singleDeviceModal.open}
onOk={() => {
if (singleDeviceModal.user) {
handleUpdateSingleDeviceOverride(singleDeviceModal.user, singleDeviceValue);
}
}}
onCancel={() => setSingleDeviceModal({ open: false, user: null })}
okText="保存" cancelText="取消" width={420}
confirmLoading={singleDeviceSaving}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<Tag color={globalSingleDeviceEnabled ? 'green' : 'default'}>{globalSingleDeviceEnabled ? '已开启' : '已关闭'}</Tag>
</Typography.Text>
</div>
<Radio.Group
value={singleDeviceValue}
onChange={e => setSingleDeviceValue(e.target.value)}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value={null}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{globalSingleDeviceEnabled ? '当前受单设备登录限制' : '当前不受限制'}
</Typography.Text>
</Radio>
<Radio value={true}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
</Typography.Text>
</Radio>
<Radio value={false}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
</Typography.Text>
</Radio>
</Space>
</Radio.Group>
</Modal>
</div>
);
};
@@ -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 = () => {
</Form.Item>
<Form.Item name="supportedDurations" label="支持时长(秒)" style={{ flex: 1 }}>
<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>
</div>
@@ -306,14 +306,14 @@ const AdminVideoEngines: React.FC = () => {
name="maxAudioCount"
label="最大参考音频数"
style={{ flex: 1 }}
extra="0 表示不支持音频参考,最大 3 段"
extra="0 表示不支持音频参考"
rules={[
{
validator: (_, value) => {
const n = Number(value ?? 0);
if (!Number.isInteger(n) || n < 0 || n > 3) {
return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
}
// if (!Number.isInteger(n) || n < 0 || n > 3) {
// return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
// }
return Promise.resolve();
},
},
@@ -1,5 +1,5 @@
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 WatermarkPreview from './WatermarkPreview';
@@ -87,7 +87,15 @@ const WatermarkEditor: React.FC<WatermarkEditorProps> = ({ value, onChange, wate
</Col>
<Col span={12}>
<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>
</Col>
<Col span={12}>
+262 -17
View File
@@ -143,12 +143,16 @@ export interface AdminTeam {
code?: string | null;
description?: string | null;
status: AdminTeamStatus;
statusLabel?: string;
isReadOnly?: boolean;
teamCreditFrozen?: boolean;
sortOrder: number;
memberCount: number;
createdAt: string;
updatedAt?: string | null;
managerId?: string | null;
managerName?: string | null;
firstSubscriptionPaidAt?: string | null;
}
export interface AdminTeamOption {
@@ -186,6 +190,9 @@ export interface AdminUser {
email: string;
phone?: string;
credits: number;
personalCredits?: number;
teamAvailableCredits?: number;
teamFrozenCredits?: number;
isActive: boolean;
isAdmin: boolean;
userType: string;
@@ -197,6 +204,7 @@ export interface AdminUser {
allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitAssetLimit: number;
singleDeviceLoginOverride?: boolean | null;
}
export interface DailyCredit {
@@ -248,26 +256,21 @@ export interface AdminStats {
}
export interface PaymentStats {
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: PaymentOrder[];
byStatus: Record<string, { label?: string; count: number; amount: number }>;
bySource: Record<string, { label?: string; count: number; amount: number }>;
totalIncome: { label?: string; count: number; amount: number };
today: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
month: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
recent?: PaymentOrder[];
}
export interface PaymentOrder {
id: string;
orderNo: string;
userId: string;
username?: string;
amount: number;
credits: number;
paymentMethod: string;
status: string;
tradeNo?: string;
paidAt?: string;
createdAt: string;
refundedAt?: string;
refundAmount?: number;
id: string; orderNo: string; userId: string; username?: string; phone?: string; amount: number; credits: number; quantity: number;
paymentMethod: string; paymentMethodLabel?: string; orderSource: 'online_payment' | 'admin_offline' | string; orderSourceLabel?: string;
status: string; statusLabel?: string; productId?: string | null; productType?: string | null; productNameSnapshot?: string | null;
quotedUnitPriceSnapshot?: number | null; quotedAmountSnapshot?: number | null; actualUnitPriceSnapshot?: number | null;
fulfillmentStatus?: string | null; fulfillmentStatusLabel?: string | null; tradeNo?: string | null; offlineTradeNo?: string | null; offlinePaymentDetail?: string | null; remark?: string | null;
paidAt?: string | null; createdAt: string; refundedAt?: string | null; refundAmount?: number | null; refundTradeNo?: string | null; refundEntitlementStatus?: string | null;
}
export interface ModelConfig {
@@ -868,22 +871,99 @@ export interface VideoPromptSchemaPreviewOut {
export interface AdminCreditRecordSummary {
totalRecharge: number;
/** 总消费:仅真实消费,不含历史 hold/团队内部转账;兼容历史 LLM pre_deduct 真实消费 */
totalConsume: number;
/** 总退款:仅真实退款,不含历史 hold_release */
totalRefund: number;
/** 兼容字段:与 totalConsume 同真实消费口径 */
totalCharge: number;
/** 历史兼容字段:hold 占用,不计入 totalConsume */
totalHold: number;
/** 兼容字段:与 totalRefund 同真实退款口径 */
totalRefundReal: number;
/** 历史兼容字段:hold_release,不计入 totalRefund */
totalHoldRelease: number;
/** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */
netConsume: number;
transactionCount: number;
generationCount: number;
generationAttemptCount: number;
imageGenerationCount: number;
videoGenerationCount: number;
/** 子分类消费(图片)仅真实消费 charge 口径 */
imageConsume: number;
/** 子分类消费(视频)仅真实消费 charge 口径 */
videoConsume: number;
/** 子分类消费(提词)仅真实消费 charge 口径 */
textConsume: number;
/** 子分类消费(分析)仅真实消费 charge 口径 */
analysisConsume: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
}
export interface AdminCreditRecordAllocation {
id: string;
creditBalanceId: string;
sourceAllocationId?: string | null;
allocationAction: string;
allocationActionLabel?: string;
amount: number;
creditLevel?: string;
creditLevelLabel?: string;
creditScope?: string;
creditScopeLabel?: string;
teamId?: string | null;
teamManagerId?: string | null;
subscriptionId?: string | null;
subscriptionNo?: string | null;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
seatId?: string | null;
sourceType?: string;
sourceTypeLabel?: string;
sourceId?: string;
validFrom?: string;
expiresAt?: string;
unspentBefore?: number;
unspentAfter?: number;
consumedBefore?: number;
consumedAfter?: number;
}
export interface AdminCreditRecordSubscriptionUsage {
creditScope?: string | null;
creditScopeLabel?: string | null;
subscriptionId?: string | null;
subscriptionNo: string;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
amount: number;
}
export interface AdminCreditRecord {
id: string;
userId: string;
@@ -901,6 +981,8 @@ export interface AdminCreditRecord {
recordTypeLabel?: string;
amount: number;
balanceAfter: number;
balanceDelta?: number;
expiredAmount?: number;
description?: string;
relatedId?: string;
bizKey?: string;
@@ -920,6 +1002,17 @@ export interface AdminCreditRecord {
mediaTypeLabel?: string;
billingScene?: string;
billingSceneLabel?: string;
sceneNameSnapshot?: string;
requestTime?: string;
llmCallCount?: number;
llmSuccessCallCount?: number;
llmFailedCallCount?: number;
fundingScope?: string;
fundingScopeLabel?: string;
teamAllocationAmount?: number;
personalAllocationAmount?: number;
subscriptionUsages?: AdminCreditRecordSubscriptionUsage[];
allocations?: AdminCreditRecordAllocation[];
sourceModule?: string;
sourceModuleLabel?: string;
sourceProjectId?: string;
@@ -952,6 +1045,7 @@ export interface AdminCreditRecordQueryParams {
userType?: string;
frontendUserKind?: string;
teamId?: string;
subscriptionNo?: string;
recordType?: string;
type?: string;
creditSubject?: string;
@@ -1426,3 +1520,154 @@ export interface VideoUpscaleConfigSavePayload {
}>;
};
}
// ── Dynamic Credit Products / LLM Billing ─────────────────
export type CreditProductType = 'subscription' | 'team_subscription' | 'credit_addon';
export type SubscriptionBillingCycle = 'monthly' | 'quarterly' | 'yearly';
export interface CreditProduct {
id: string; productCode: string; productType: CreditProductType; productTypeLabel?: string; name: string; description?: string | null; features?: string[];
tierCode?: string | null; tierLabel?: string | null; tierRank?: number | null; billingCycle?: SubscriptionBillingCycle | null; billingCycleLabel?: string | null;
monthlyGrantCredits?: number; grantCount?: number; firstPurchasePrice?: number; regularPrice?: number; activityPrice?: number | null; activityStartAt?: string | null; activityEndAt?: string | null; renewalEnabled: boolean;
price: number; currentPrice?: number; grantCredits?: number; validityMonths?: number | null; creditLevel: 'promotional' | 'general'; creditLevelLabel?: string; currency: string;
isActive: boolean; isDeleted?: boolean; deletedAt?: string | null; statusLabel?: string; sortOrder: number;
}
export interface LlmBillingPolicy {
id: string;
sceneCode: string;
sceneName: string;
chargeCredits: number;
isActive: boolean;
version: number;
createdBy?: string | null;
updatedBy?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface LlmCallAttempt {
id: string;
callSequence: number;
retrySequence: number;
modelConfigId?: string | null;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
providerRequestId?: string | null;
requestStartedAt: string;
responseReceivedAt?: string | null;
durationMs?: number | null;
status: string;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
httpStatus?: number | null;
errorMessage?: string | null;
tokenUnavailableReason?: string | null;
postprocessStatus?: string | null;
postprocessError?: string | null;
}
export interface LlmBillingExecution {
id: string;
userId: string;
sceneCode: string;
sceneNameSnapshot: string;
ownerType: string;
ownerId: string;
businessAttemptNo: number;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
requestTime: string;
chargeCredits: number;
status: string;
totalCallCount: number;
successfulCallCount: number;
failedCallCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
refundAvailableCredits: number;
refundExpiredCredits: number;
finalErrorMessage?: string | null;
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[];
}
// ── Bank Account ───────────────────────────────────────
export interface BankAccount {
id: string;
accountName: string;
bankName: string;
accountNo: string;
isActive: boolean;
isDefault: boolean;
description?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
// ── Scheduled Task ─────────────────────────────────────
export interface ScheduledTask {
id: string;
name: string;
taskType: 'external_api' | 'internal_method';
schedule: string;
config?: string | null;
isActive: boolean;
lastRunAt?: string | null;
lastStatus?: string | null;
lastError?: string | null;
createdBy?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
+20
View File
@@ -36,3 +36,23 @@ export function formatDate(iso: string | null | undefined): string {
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
export function formatDatePrecise(iso: string | null | undefined): string {
if (!iso) return '-';
const fraction = iso.match(/\.(\d{1,6})/)?.[1]?.padEnd(6, '0') || '000000';
const s = iso.trim();
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
if (!m) return s.replace('T', ' ');
const [, year, month, day, hour, min, sec, tz] = m;
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
let target = utcMs;
if (tz === 'Z') target += CST_OFFSET * 60000;
else if (tz) {
const sign = tz[0] === '+' ? 1 : -1;
const compact = tz.slice(1).replace(':', '');
const offset = sign * (+compact.slice(0, 2) * 60 + +compact.slice(2, 4));
target = utcMs - offset * 60000 + CST_OFFSET * 60000;
}
const d = new Date(target);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${fraction}`;
}
+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/adminbanktransactions.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditproducts.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/adminllmbillingexecutions.tsx","./src/pages/adminllmbillingpolicies.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/adminscheduledtasks.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
# 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://postgres:123456@localhost:5432/videogen_cs
# Redis (leave empty to disable - rate limiting and captcha will use in-memory fallback)
REDIS_URL=redis://127.0.0.1:6379/0
@@ -44,6 +44,7 @@ CAPTCHA_ENABLED=true
CORS_ORIGINS=["*"]
# Base URL (用于 favicon、回调地址等)
#BASE_URL=http://localhost:8000
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
# RESOURCE
@@ -0,0 +1,25 @@
"""merge changes from remote
Revision ID: 1a4d1f095fa1
Revises: 20260811_20260811, 8a6d2f4c9b10
Create Date: 2026-08-11 10:30:10.431672
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1a4d1f095fa1'
down_revision: Union[str, None] = ('20260811_20260811', '8a6d2f4c9b10')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -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,30 @@
"""单设备登录拆分设备类型 + 用户级覆盖
Revision ID: 20260813_split_device_type
Revises: 20260813_token_version
Create Date: 2026-08-13 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_split_device_type'
down_revision = '20260813_token_version'
branch_labels = None
depends_on = None
def upgrade():
# 拆分为按设备类型管理
op.add_column('users', sa.Column('pc_token_version', sa.Integer(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('mobile_token_version', sa.Integer(), nullable=False, server_default='0'))
op.drop_column('users', 'token_version')
# 用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
op.add_column('users', sa.Column('single_device_login_override', sa.Boolean(), nullable=True))
def downgrade():
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
op.drop_column('users', 'single_device_login_override')
op.drop_column('users', 'mobile_token_version')
op.drop_column('users', 'pc_token_version')
@@ -0,0 +1,22 @@
"""增加用户token_version字段 — 单设备登录
Revision ID: 20260813_token_version
Revises:b7e2c4d91a63
Create Date: 2026-08-13 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_token_version'
down_revision = 'b7e2c4d91a63' # 设置为上一个迁移的 revision
branch_labels = None
depends_on = None
def upgrade():
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
def downgrade():
op.drop_column('users', 'token_version')
@@ -0,0 +1,53 @@
"""银行账户与定时任务
Revision ID: 20260813_bank_scheduled
Revises: 20260813_split_device_type
Create Date: 2026-08-13 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_bank_scheduled'
down_revision = '20260813_split_device_type'
branch_labels = None
depends_on = None
def upgrade():
# 创建银行账户表
op.create_table(
'bank_accounts',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('account_name', sa.String(128), nullable=False, comment='账户名称'),
sa.Column('bank_name', sa.String(128), nullable=False, comment='开户银行'),
sa.Column('account_no', sa.String(64), unique=True, nullable=False, comment='银行账号'),
sa.Column('is_active', sa.Boolean, default=True, comment='是否启用'),
sa.Column('is_default', sa.Boolean, default=False, comment='是否默认账户'),
sa.Column('description', sa.String(256), nullable=True, comment='备注'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), comment='更新时间'),
)
# 创建定时任务表
op.create_table(
'scheduled_tasks',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('name', sa.String(128), nullable=False, comment='任务名称'),
sa.Column('task_type', sa.String(32), nullable=False, comment='类型: external_api / internal_method'),
sa.Column('schedule', sa.String(128), nullable=False, comment='Cron 表达式或间隔秒数'),
sa.Column('config', sa.Text, nullable=True, comment='任务配置 JSON'),
sa.Column('is_active', sa.Boolean, default=True, comment='是否启用'),
sa.Column('last_run_at', sa.String(64), nullable=True, comment='最后执行时间 ISO'),
sa.Column('last_status', sa.String(16), nullable=True, comment='最后执行状态'),
sa.Column('last_error', sa.Text, nullable=True, comment='最后执行错误信息'),
sa.Column('created_by', sa.String(32), nullable=True, comment='创建者管理员 ID'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), comment='更新时间'),
)
def downgrade():
# 删除新表
op.drop_table('scheduled_tasks')
op.drop_table('bank_accounts')
@@ -0,0 +1,51 @@
"""银行交易流水表
Revision ID: 20260814_bank_tx
Revises: 20260813_bank_scheduled
Create Date: 2026-08-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260814_bank_tx'
down_revision = '20260813_bank_scheduled'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'bank_transactions',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('account_id', sa.String(32), nullable=False, comment='银行账户ID'),
sa.Column('account_no', sa.String(64), nullable=False, comment='银行账号'),
sa.Column('transaction_no', sa.String(128), unique=True, nullable=True, comment='交易流水号(唯一)'),
sa.Column('transaction_time', sa.DateTime(timezone=True), nullable=True, comment='交易时间'),
sa.Column('transaction_amount', sa.String(32), nullable=False, comment='交易金额'),
sa.Column('balance_direction', sa.String(8), nullable=True, comment='借贷方向: DR/CR'),
sa.Column('balance_after', sa.String(32), nullable=True, comment='交易后余额'),
sa.Column('counterparty_name', sa.String(128), nullable=True, comment='对方户名'),
sa.Column('counterparty_account', sa.String(64), nullable=True, comment='对方账号'),
sa.Column('counterparty_bank', sa.String(128), nullable=True, comment='对方开户行'),
sa.Column('remark', sa.Text, nullable=True, comment='摘要/备注'),
sa.Column('digest_code', sa.String(64), nullable=True, comment='摘要码'),
sa.Column('purpose', sa.String(256), nullable=True, comment='用途'),
sa.Column('raw_data', sa.Text, nullable=True, comment='接口返回原始JSON'),
sa.Column('sync_batch', sa.String(32), nullable=True, comment='同步批次号'),
sa.Column('is_synced', sa.Boolean, default=True, comment='是否同步成功'),
)
op.create_index('ix_bank_transactions_account_id', 'bank_transactions', ['account_id'])
op.create_index('ix_bank_transactions_account_no', 'bank_transactions', ['account_no'])
op.create_index('ix_bank_transactions_transaction_time', 'bank_transactions', ['transaction_time'])
op.create_index('ix_bank_transactions_sync_batch', 'bank_transactions', ['sync_batch'])
op.create_index('ix_bank_transactions_account_time', 'bank_transactions', ['account_no', 'transaction_time'])
def downgrade():
op.drop_index('ix_bank_transactions_account_time', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_sync_batch', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_transaction_time', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_account_no', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_account_id', table_name='bank_transactions')
op.drop_table('bank_transactions')
@@ -0,0 +1,27 @@
"""银行流水表加 created_at 字段
Revision ID: 20260814_bank_tx_ca
Revises: 20260814_bank_tx
Create Date: 2026-08-14 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, use by Alembic.
revision = '20260814_bank_tx_ca'
down_revision = 'e7f2527691bb'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'bank_transactions',
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('ix_bank_transactions_created_at', 'bank_transactions', ['created_at'])
def downgrade():
op.drop_index('ix_bank_transactions_created_at', table_name='bank_transactions')
op.drop_column('bank_transactions', 'created_at')
@@ -0,0 +1,444 @@
"""个人/团队订阅多实例、团队席位与统一线下订单
Revision ID: 20260814_multi_sub_team
Revises: 20260813_split_device_type
Create Date: 2026-08-14 11:31:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "20260814_multi_sub_team"
down_revision = "20260813_bank_scheduled"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_indexes(table_name)}
def _constraints(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
names = {item.get("name") for item in inspector.get_check_constraints(table_name)}
names |= {item.get("name") for item in inspector.get_unique_constraints(table_name)}
names |= {item.get("name") for item in inspector.get_foreign_keys(table_name)}
return {name for name in names if name}
def _add_column(table: str, column: sa.Column) -> None:
if column.name not in _columns(table):
op.add_column(table, column)
def _drop_column(table: str, name: str) -> None:
if name in _columns(table):
op.drop_column(table, name)
def _drop_index(table: str, name: str) -> None:
if name in _indexes(table):
op.drop_index(name, table_name=table)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
# Product:上下架与软删除分离;保留续费开关;product_code 保持原全局唯一索引。
_add_column("credit_products", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
if "uq_credit_products_code" not in _indexes("credit_products"):
op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True)
if "ix_credit_products_deleted_at" not in _indexes("credit_products"):
op.create_index("ix_credit_products_deleted_at", "credit_products", ["deleted_at"], unique=False)
# 旧版本同名索引不含 deleted_at,必须重建,不能只按名称判断存在。
_drop_index("credit_products", "ix_credit_products_public")
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "deleted_at", "is_active", "sort_order"])
_add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'")
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
op.create_check_constraint(
"ck_credit_products_type_required_fields",
"credit_products",
"(product_type IN ('subscription', 'team_subscription') "
"AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
# Subscription:个人/团队共表,多实例独立,不再保留升级链。
_add_column("user_credit_subscriptions", sa.Column("team_id", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("product_type_snapshot", sa.String(24), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("product_name_snapshot", sa.String(96), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("monthly_total_credits_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("quantity_snapshot", sa.Integer(), nullable=True, server_default="1"))
_add_column("user_credit_subscriptions", sa.Column("first_purchase_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
_add_column("user_credit_subscriptions", sa.Column("regular_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
_add_column("user_credit_subscriptions", sa.Column("activity_price_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True, server_default="0"))
if "fk_user_credit_subscriptions_team" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_team", "user_credit_subscriptions", "teams", ["team_id"], ["id"], ondelete="RESTRICT"
)
op.execute(
"UPDATE user_credit_subscriptions s SET "
"product_type_snapshot = COALESCE(product_type_snapshot, 'subscription'), "
"product_name_snapshot = COALESCE(product_name_snapshot, NULLIF(s.product_snapshot_json->>'name',''), '历史订阅套餐'), "
"monthly_total_credits_snapshot = COALESCE(monthly_total_credits_snapshot, monthly_grant_credits_snapshot), "
"quantity_snapshot = COALESCE(quantity_snapshot, 1), "
"first_purchase_price_snapshot = COALESCE(first_purchase_price_snapshot, NULLIF(s.product_snapshot_json->>'first_purchase_price','')::numeric, 0), "
"regular_price_snapshot = COALESCE(regular_price_snapshot, NULLIF(s.product_snapshot_json->>'regular_price','')::numeric, paid_amount_snapshot), "
"activity_price_snapshot = COALESCE(activity_price_snapshot, NULLIF(s.product_snapshot_json->>'activity_price','')::numeric), "
"actual_unit_price_snapshot = COALESCE(actual_unit_price_snapshot, paid_amount_snapshot)"
)
for col in (
"product_type_snapshot", "product_name_snapshot", "monthly_total_credits_snapshot",
"quantity_snapshot", "first_purchase_price_snapshot", "regular_price_snapshot", "actual_unit_price_snapshot",
):
if col in _columns("user_credit_subscriptions"):
op.alter_column("user_credit_subscriptions", col, nullable=False)
_drop_column("user_credit_subscriptions", "source_subscription_id")
_drop_column("user_credit_subscriptions", "upgrade_order_id")
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_current")
if "ix_user_credit_subscriptions_user_active" not in _indexes("user_credit_subscriptions"):
op.create_index("ix_user_credit_subscriptions_user_active", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
if "ix_user_credit_subscriptions_team_active" not in _indexes("user_credit_subscriptions"):
op.create_index("ix_user_credit_subscriptions_team_active", "user_credit_subscriptions", ["team_id", "status", "expires_at"])
# Period:删除升级预留字段,补充窗口索引。
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_upgrade")
_drop_column("user_credit_subscription_periods", "upgrade_order_id")
_drop_column("user_credit_subscription_periods", "reserved_at")
_drop_column("user_credit_subscription_periods", "revoked_at")
if "ix_user_credit_subscription_periods_window" not in _indexes("user_credit_subscription_periods"):
op.create_index(
"ix_user_credit_subscription_periods_window",
"user_credit_subscription_periods",
["subscription_id", "valid_from", "expires_at"],
)
# Balance:增加个人/团队资金域。
_add_column("user_credit_balances", sa.Column("credit_scope", sa.String(16), nullable=True, server_default="personal"))
_add_column("user_credit_balances", sa.Column("team_id", sa.String(32), nullable=True))
op.execute("UPDATE user_credit_balances SET credit_scope='personal' WHERE credit_scope IS NULL")
op.alter_column("user_credit_balances", "credit_scope", nullable=False, server_default="personal")
if "fk_user_credit_balances_team" not in _constraints("user_credit_balances"):
op.create_foreign_key("fk_user_credit_balances_team", "user_credit_balances", "teams", ["team_id"], ["id"], ondelete="RESTRICT")
if "ck_user_credit_balances_scope_fields" not in _constraints("user_credit_balances"):
op.create_check_constraint(
"ck_user_credit_balances_scope_fields",
"user_credit_balances",
"(credit_scope='personal' AND team_id IS NULL) OR "
"(credit_scope='team' AND team_id IS NOT NULL AND subscription_id IS NOT NULL AND subscription_period_id IS NOT NULL)",
)
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id"],
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
if "ix_user_credit_balances_team_spendable" not in _indexes("user_credit_balances"):
op.create_index(
"ix_user_credit_balances_team_spendable",
"user_credit_balances",
["team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id"],
postgresql_where=sa.text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"),
)
# Allocation:资金来源、团队任期、Subscription/Period/Seat 全部冷备。
allocation_columns = [
sa.Column("credit_scope_snapshot", sa.String(16), nullable=True, server_default="personal"),
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True),
sa.Column("subscription_id_snapshot", sa.String(32), nullable=True),
sa.Column("subscription_period_id_snapshot", sa.String(32), nullable=True),
sa.Column("seat_id_snapshot", sa.String(32), nullable=True),
]
for column in allocation_columns:
_add_column("credit_record_allocations", column)
op.execute("UPDATE credit_record_allocations SET credit_scope_snapshot='personal' WHERE credit_scope_snapshot IS NULL")
op.alter_column("credit_record_allocations", "credit_scope_snapshot", nullable=False, server_default="personal")
for name, cols in (
("ix_credit_record_allocations_team_time", ["team_id_snapshot", "created_at", "id"]),
("ix_credit_record_allocations_team_manager_time", ["team_id_snapshot", "team_manager_id_snapshot", "created_at", "id"]),
("ix_credit_record_allocations_team_period_user", ["subscription_period_id_snapshot", "user_id", "allocation_action"]),
):
if name not in _indexes("credit_record_allocations"):
op.create_index(name, "credit_record_allocations", cols)
# PaymentOrder:线上/线下统一主表,删除升级价格字段。
payment_columns = [
sa.Column("order_source", sa.String(24), nullable=True, server_default="online_payment"),
sa.Column("quantity", sa.Integer(), nullable=True, server_default="1"),
sa.Column("quoted_unit_price_snapshot", sa.Numeric(20, 2), nullable=True),
sa.Column("quoted_amount_snapshot", sa.Numeric(20, 2), nullable=True),
sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True),
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
sa.Column("operator_admin_id", sa.String(32), nullable=True),
sa.Column("offline_trade_no", sa.String(128), nullable=True),
sa.Column("offline_payment_detail", sa.String(128), nullable=True),
sa.Column("remark", sa.String(512), nullable=True),
sa.Column("refund_entitlement_status", sa.String(32), nullable=True),
]
for column in payment_columns:
_add_column("payment_orders", column)
op.execute(
"UPDATE payment_orders SET order_source=COALESCE(order_source,'online_payment'), quantity=COALESCE(quantity,1), "
"quoted_amount_snapshot=COALESCE(quoted_amount_snapshot, amount), "
"quoted_unit_price_snapshot=COALESCE(quoted_unit_price_snapshot, amount), "
"actual_unit_price_snapshot=COALESCE(actual_unit_price_snapshot, amount)"
)
op.alter_column("payment_orders", "order_source", nullable=False, server_default="online_payment")
op.alter_column("payment_orders", "quantity", nullable=False, server_default="1")
for col in (
"source_subscription_id", "upgrade_period_ids_json", "target_price_snapshot",
"deduction_amount_snapshot", "payable_amount_snapshot",
):
_drop_column("payment_orders", col)
if "fk_payment_orders_team_snapshot" not in _constraints("payment_orders"):
op.create_foreign_key("fk_payment_orders_team_snapshot", "payment_orders", "teams", ["team_id_snapshot"], ["id"], ondelete="RESTRICT")
if "fk_payment_orders_operator_admin" not in _constraints("payment_orders"):
op.create_foreign_key("fk_payment_orders_operator_admin", "payment_orders", "users", ["operator_admin_id"], ["id"], ondelete="SET NULL")
if "ix_payorder_source_status_created" not in _indexes("payment_orders"):
op.create_index("ix_payorder_source_status_created", "payment_orders", ["order_source", "status", "created_at"])
if "ix_payorder_team_status_created" not in _indexes("payment_orders"):
op.create_index("ix_payorder_team_status_created", "payment_orders", ["team_id_snapshot", "status", "created_at"])
_add_column("teams", sa.Column("first_subscription_paid_at", sa.DateTime(timezone=True), nullable=True))
if "ix_teams_first_subscription_paid_at" not in _indexes("teams"):
op.create_index("ix_teams_first_subscription_paid_at", "teams", ["first_subscription_paid_at"])
if bind.dialect.name == "postgresql":
op.execute("CREATE SEQUENCE IF NOT EXISTS team_auto_name_seq START WITH 1 INCREMENT BY 1")
# 避免历史上已经存在“团队000N”时从1开始产生重名;is_called=false 让下一次 nextval 直接返回 max+1。
op.execute(
"SELECT setval('team_auto_name_seq', "
"GREATEST(COALESCE(MAX((substring(name from '^团队([0-9]+)$'))::bigint), 0) + 1, 1), false) "
"FROM teams WHERE name ~ '^团队[0-9]+$'"
)
if "team_manager_history" not in tables:
op.create_table(
"team_manager_history",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
sa.Column("manager_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_team_manager_history_team_time", "team_manager_history", ["team_id", "started_at", "ended_at"])
op.create_index("ix_team_manager_history_manager_time", "team_manager_history", ["manager_user_id", "started_at", "ended_at"])
op.create_index(
"uq_team_manager_history_current",
"team_manager_history",
["team_id"],
unique=True,
postgresql_where=sa.text("ended_at IS NULL"),
)
op.execute(
"INSERT INTO team_manager_history(id, team_id, manager_user_id, started_at, created_at, updated_at) "
"SELECT md5(random()::text || clock_timestamp()::text), id, manager_id, COALESCE(created_at, now()), now(), now() "
"FROM teams WHERE manager_id IS NOT NULL AND deleted_at IS NULL"
)
if "team_subscription_seats" not in tables:
op.create_table(
"team_subscription_seats",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("monthly_allocated_credits", sa.Numeric(20, 2), nullable=False),
sa.Column("created_by_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"),
)
op.create_index("ix_team_subscription_seats_subscription", "team_subscription_seats", ["subscription_id", "created_at"])
op.create_index("ix_team_subscription_seats_team_id", "team_subscription_seats", ["team_id"])
op.create_index("ix_team_subscription_seats_deleted_at", "team_subscription_seats", ["deleted_at"])
op.create_index("ix_team_subscription_seats_user", "team_subscription_seats", ["user_id", "subscription_id"])
op.create_index(
"uq_team_subscription_seats_active_user",
"team_subscription_seats",
["subscription_id", "user_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL AND cancelled_at IS NULL"),
)
if "team_subscription_seat_usages" not in tables:
op.create_table(
"team_subscription_seat_usages",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("seat_id", sa.String(32), sa.ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_period_id", sa.String(32), sa.ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("used_credits", sa.Numeric(20, 2), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"),
)
op.create_index(
"uq_team_subscription_seat_usage_period",
"team_subscription_seat_usages",
["seat_id", "subscription_period_id"],
unique=True,
)
op.create_index("ix_team_subscription_seat_usage_member", "team_subscription_seat_usages", ["subscription_period_id", "user_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "team_subscription_seat_usages" in tables:
op.drop_table("team_subscription_seat_usages")
if "team_subscription_seats" in tables:
op.drop_table("team_subscription_seats")
if "team_manager_history" in tables:
op.drop_table("team_manager_history")
if bind.dialect.name == "postgresql":
op.execute("DROP SEQUENCE IF EXISTS team_auto_name_seq")
_drop_index("teams", "ix_teams_first_subscription_paid_at")
_drop_column("teams", "first_subscription_paid_at")
for name in ("ix_payorder_team_status_created", "ix_payorder_source_status_created"):
_drop_index("payment_orders", name)
for fk in ("fk_payment_orders_operator_admin", "fk_payment_orders_team_snapshot"):
if fk in _constraints("payment_orders"):
op.drop_constraint(fk, "payment_orders", type_="foreignkey")
for col in (
"refund_entitlement_status", "remark", "offline_payment_detail", "offline_trade_no",
"operator_admin_id", "team_id_snapshot", "actual_unit_price_snapshot", "quoted_amount_snapshot",
"quoted_unit_price_snapshot", "quantity", "order_source",
):
_drop_column("payment_orders", col)
_add_column("payment_orders", sa.Column("source_subscription_id", sa.String(32), nullable=True))
if "ix_payment_orders_source_subscription_id" not in _indexes("payment_orders"):
op.create_index("ix_payment_orders_source_subscription_id", "payment_orders", ["source_subscription_id"])
_add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True))
_add_column("payment_orders", sa.Column("target_price_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("payment_orders", sa.Column("deduction_amount_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("payment_orders", sa.Column("payable_amount_snapshot", sa.Numeric(20, 2), nullable=True))
for name in (
"ix_credit_record_allocations_team_period_user",
"ix_credit_record_allocations_team_manager_time",
"ix_credit_record_allocations_team_time",
):
_drop_index("credit_record_allocations", name)
for col in (
"seat_id_snapshot", "subscription_period_id_snapshot", "subscription_id_snapshot",
"team_manager_id_snapshot", "team_id_snapshot", "credit_scope_snapshot",
):
_drop_column("credit_record_allocations", col)
_drop_index("user_credit_balances", "ix_user_credit_balances_team_spendable")
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
if "ck_user_credit_balances_scope_fields" in _constraints("user_credit_balances"):
op.drop_constraint("ck_user_credit_balances_scope_fields", "user_credit_balances", type_="check")
if "fk_user_credit_balances_team" in _constraints("user_credit_balances"):
op.drop_constraint("fk_user_credit_balances_team", "user_credit_balances", type_="foreignkey")
_drop_column("user_credit_balances", "team_id")
_drop_column("user_credit_balances", "credit_scope")
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_level_rank", "expires_at", "valid_from", "id"],
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_window")
_add_column("user_credit_subscription_periods", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
if "fk_user_credit_subscription_periods_upgrade_order" not in _constraints("user_credit_subscription_periods"):
op.create_foreign_key(
"fk_user_credit_subscription_periods_upgrade_order",
"user_credit_subscription_periods", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
)
_add_column("user_credit_subscription_periods", sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True))
_add_column("user_credit_subscription_periods", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_user_credit_subscription_periods_upgrade", "user_credit_subscription_periods", ["upgrade_order_id", "status"])
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_team_active")
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_user_active")
if "fk_user_credit_subscriptions_team" in _constraints("user_credit_subscriptions"):
op.drop_constraint("fk_user_credit_subscriptions_team", "user_credit_subscriptions", type_="foreignkey")
for col in (
"actual_unit_price_snapshot", "activity_price_snapshot", "regular_price_snapshot",
"first_purchase_price_snapshot", "quantity_snapshot", "monthly_total_credits_snapshot",
"product_name_snapshot", "product_type_snapshot", "team_manager_id_snapshot", "team_id",
):
_drop_column("user_credit_subscriptions", col)
_add_column("user_credit_subscriptions", sa.Column("source_subscription_id", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
if "fk_user_credit_subscriptions_source_subscription" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_source_subscription",
"user_credit_subscriptions", "user_credit_subscriptions", ["source_subscription_id"], ["id"], ondelete="SET NULL"
)
if "fk_user_credit_subscriptions_upgrade_order" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_upgrade_order",
"user_credit_subscriptions", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
)
op.create_index("ix_user_credit_subscriptions_current", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
# 旧版本不认识 team_subscription。降级时保留商品与永久唯一 product_code
# 但将团队套餐转换为旧版可识别的 subscription 并强制下架,避免旧代码误售。
op.execute(
"UPDATE credit_products "
"SET product_type='subscription', is_active=false "
"WHERE product_type='team_subscription'"
)
op.create_check_constraint(
"ck_credit_products_type_required_fields",
"credit_products",
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
_add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'")
_drop_index("credit_products", "ix_credit_products_public")
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "is_active", "sort_order"])
_drop_index("credit_products", "ix_credit_products_deleted_at")
_drop_column("credit_products", "deleted_at")
@@ -0,0 +1,50 @@
"""兼容恢复积分套餐续费开关
Revision ID: 20260814_restore_renewal
Revises: 20260814_multi_sub_team
Create Date: 2026-08-14 13:25:00
说明:
- 修正版 20260814_multi_sub_team 已不再删除 renewal_enabled;新环境执行到本迁移时为 no-op。
- 如果数据库已经执行过上一版会删除 renewal_enabled 的同 revision 迁移,本迁移负责安全补回字段。
- 已经被旧迁移删除的历史 false 值无法从数据库自身恢复,补回时订阅套餐默认 true,积分增值包统一 false。
"""
from alembic import op
import sqlalchemy as sa
revision = "20260814_restore_renewal"
down_revision = "20260814_multi_sub_team"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def upgrade() -> None:
if "renewal_enabled" not in _columns("credit_products"):
op.add_column(
"credit_products",
sa.Column(
"renewal_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
op.execute(
"UPDATE credit_products "
"SET renewal_enabled=false "
"WHERE product_type='credit_addon'"
)
def downgrade() -> None:
# 当前修正版上一个 revision 本身就保留 renewal_enabled,降级到它时字段也应继续存在。
pass
@@ -0,0 +1,92 @@
"""订阅实例业务流水号
Revision ID: 20260814_subscription_no
Revises: 20260814_restore_renewal
Create Date: 2026-08-14 14:23:00
说明:
- 每张 user_credit_subscriptions 增加永久唯一 subscription_no。
- 个人订阅前缀 PS,团队订阅前缀 TS。
- 全局共用 PostgreSQL Sequence,避免 COUNT/MAX 并发冲突。
- 历史记录按原 created_at 日期生成业务编号;编号只用于展示/客服定位,不参与业务排序和结算。
"""
from alembic import op
import sqlalchemy as sa
revision = "20260814_subscription_no"
down_revision = "20260814_restore_renewal"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
raise RuntimeError("订阅实例流水号迁移当前仅支持 PostgreSQL")
op.execute("CREATE SEQUENCE IF NOT EXISTS credit_subscription_no_seq START WITH 1 INCREMENT BY 1")
if "subscription_no" not in _columns("user_credit_subscriptions"):
op.add_column(
"user_credit_subscriptions",
sa.Column(
"subscription_no",
sa.String(length=32),
nullable=True,
comment="订阅业务实例编号,供用户/客服/开发定位",
),
)
# 历史数据一次性补号。日期使用东八区业务日期;Sequence 只保证唯一,不要求无跳号。
op.execute(
"""
UPDATE user_credit_subscriptions
SET subscription_no =
CASE
WHEN product_type_snapshot = 'team_subscription' THEN 'TS'
ELSE 'PS'
END
|| to_char(COALESCE(created_at, start_at, now()) AT TIME ZONE 'Asia/Shanghai', 'YYYYMMDD')
|| lpad(nextval('credit_subscription_no_seq')::text, 6, '0')
WHERE subscription_no IS NULL OR btrim(subscription_no) = ''
"""
)
op.alter_column(
"user_credit_subscriptions",
"subscription_no",
existing_type=sa.String(length=32),
nullable=False,
)
if "uq_user_credit_subscriptions_no" not in _indexes("user_credit_subscriptions"):
op.create_index(
"uq_user_credit_subscriptions_no",
"user_credit_subscriptions",
["subscription_no"],
unique=True,
)
def downgrade() -> None:
if "uq_user_credit_subscriptions_no" in _indexes("user_credit_subscriptions"):
op.drop_index("uq_user_credit_subscriptions_no", table_name="user_credit_subscriptions")
if "subscription_no" in _columns("user_credit_subscriptions"):
op.drop_column("user_credit_subscriptions", "subscription_no")
op.execute("DROP SEQUENCE IF EXISTS credit_subscription_no_seq")
@@ -0,0 +1,650 @@
"""add credit product renewal enabled
Revision ID: 8a6d2f4c9b10
Revises: 7f3c8a2d9e41
Create Date: 2026-08-06 17:20:00
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from alembic import op
import sqlalchemy as sa
revision = "8a6d2f4c9b10"
down_revision = "7f3c8a2d9e41"
branch_labels = None
depends_on = None
_PRODUCT_UPSERT_SQL = sa.text(
"""
INSERT INTO credit_products (
id,
product_code,
product_type,
name,
description,
features_json,
tier_code,
tier_rank,
billing_cycle,
monthly_grant_credits,
first_purchase_price,
regular_price,
activity_price,
activity_start_at,
activity_end_at,
grant_credits,
validity_months,
price,
credit_level,
currency,
is_active,
sort_order,
created_at,
updated_at,
renewal_enabled
) VALUES (
:id,
:product_code,
:product_type,
:name,
:description,
CAST(:features_json AS JSON),
:tier_code,
:tier_rank,
:billing_cycle,
:monthly_grant_credits,
:first_purchase_price,
:regular_price,
:activity_price,
:activity_start_at,
:activity_end_at,
:grant_credits,
:validity_months,
:price,
:credit_level,
:currency,
:is_active,
:sort_order,
:created_at,
:updated_at,
:renewal_enabled
)
ON CONFLICT (product_code) DO UPDATE SET
product_type = EXCLUDED.product_type,
name = EXCLUDED.name,
description = EXCLUDED.description,
features_json = EXCLUDED.features_json,
tier_code = EXCLUDED.tier_code,
tier_rank = EXCLUDED.tier_rank,
billing_cycle = EXCLUDED.billing_cycle,
monthly_grant_credits = EXCLUDED.monthly_grant_credits,
first_purchase_price = EXCLUDED.first_purchase_price,
regular_price = EXCLUDED.regular_price,
activity_price = EXCLUDED.activity_price,
activity_start_at = EXCLUDED.activity_start_at,
activity_end_at = EXCLUDED.activity_end_at,
grant_credits = EXCLUDED.grant_credits,
validity_months = EXCLUDED.validity_months,
price = EXCLUDED.price,
credit_level = EXCLUDED.credit_level,
currency = EXCLUDED.currency,
is_active = EXCLUDED.is_active,
sort_order = EXCLUDED.sort_order,
updated_at = EXCLUDED.updated_at,
renewal_enabled = EXCLUDED.renewal_enabled
"""
)
_POLICY_UPSERT_SQL = sa.text(
"""
INSERT INTO llm_billing_policies (
id,
scene_code,
scene_name,
pre_deduct_credits,
is_active,
version,
created_by,
updated_by,
created_at,
updated_at
) VALUES (
:id,
:scene_code,
:scene_name,
:pre_deduct_credits,
:is_active,
:version,
:created_by,
:updated_by,
:created_at,
:updated_at
)
ON CONFLICT (scene_code) DO UPDATE SET
scene_name = EXCLUDED.scene_name,
pre_deduct_credits = EXCLUDED.pre_deduct_credits,
is_active = EXCLUDED.is_active,
version = EXCLUDED.version,
updated_by = EXCLUDED.updated_by,
updated_at = EXCLUDED.updated_at
"""
)
def _dt(value: str) -> datetime:
return datetime.fromisoformat(value)
def _seed_credit_products() -> None:
rows = [
{
"id": "0019fd653e4680767ab",
"product_code": "1",
"product_type": "subscription",
"name": "入门",
"description": None,
"features_json": "[]",
"tier_code": "入门",
"tier_rank": 1,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("1000.00"),
"first_purchase_price": Decimal("79.00"),
"regular_price": Decimal("122.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("122.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 1,
"created_at": _dt("2026-08-06T09:07:30.020043+08:00"),
"updated_at": _dt("2026-08-06T09:52:15.579827+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd67e8a786dda0e",
"product_code": "2",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("2000.00"),
"first_purchase_price": Decimal("154.00"),
"regular_price": Decimal("239.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("239.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 2,
"created_at": _dt("2026-08-06T09:54:05.049302+08:00"),
"updated_at": _dt("2026-08-06T09:54:05.049302+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd67f4c8bf75dd1",
"product_code": "3",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("3000.00"),
"first_purchase_price": Decimal("221.00"),
"regular_price": Decimal("353.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("353.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 3,
"created_at": _dt("2026-08-06T09:54:54.768868+08:00"),
"updated_at": _dt("2026-08-06T09:55:10.408471+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd680784c16dd15",
"product_code": "4",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("4000.00"),
"first_purchase_price": Decimal("280.00"),
"regular_price": Decimal("476.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("476.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 4,
"created_at": _dt("2026-08-06T09:56:11.442596+08:00"),
"updated_at": _dt("2026-08-06T09:56:11.442596+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68270abc3dcbd",
"product_code": "5",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("5000.00"),
"first_purchase_price": Decimal("1029.00"),
"regular_price": Decimal("1499.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("1499.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 5,
"created_at": _dt("2026-08-06T09:58:20.601505+08:00"),
"updated_at": _dt("2026-08-06T09:58:20.601505+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68373d24dbab0",
"product_code": "6",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("6000.00"),
"first_purchase_price": Decimal("1197.00"),
"regular_price": Decimal("1791.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("1791.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 6,
"created_at": _dt("2026-08-06T09:59:26.972669+08:00"),
"updated_at": _dt("2026-08-06T09:59:26.972669+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68469706aa522",
"product_code": "7",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("7000.00"),
"first_purchase_price": Decimal("1367.00"),
"regular_price": Decimal("2079.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("2079.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 7,
"created_at": _dt("2026-08-06T10:00:29.843562+08:00"),
"updated_at": _dt("2026-08-06T10:00:29.843562+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd6904f72f59505",
"product_code": "8",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("10000.00"),
"first_purchase_price": Decimal("7308.00"),
"regular_price": Decimal("8399.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("8399.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 8,
"created_at": _dt("2026-08-06T10:13:29.614018+08:00"),
"updated_at": _dt("2026-08-06T10:13:29.614018+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd692bc5e42b8be",
"product_code": "9",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("15000.00"),
"first_purchase_price": Decimal("10710.00"),
"regular_price": Decimal("12586.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("12586.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 9,
"created_at": _dt("2026-08-06T10:16:08.545547+08:00"),
"updated_at": _dt("2026-08-06T10:16:08.545547+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd69417fabda8c6",
"product_code": "10",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("20000.00"),
"first_purchase_price": Decimal("13440.00"),
"regular_price": Decimal("16685.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("16685.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 10,
"created_at": _dt("2026-08-06T10:17:37.548293+08:00"),
"updated_at": _dt("2026-08-06T10:17:37.548293+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd69a07e363c7b5",
"product_code": "ZLB-A",
"product_type": "credit_addon",
"name": "增量包A",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("500.00"),
"validity_months": 1,
"price": Decimal("0.01"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 1,
"created_at": _dt("2026-08-06T10:24:06.646673+08:00"),
"updated_at": _dt("2026-08-06T10:24:06.646673+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69a640d7ccc92",
"product_code": "ZLB-B",
"product_type": "credit_addon",
"name": "增量包B",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("1000.00"),
"validity_months": 1,
"price": Decimal("0.02"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 2,
"created_at": _dt("2026-08-06T10:24:30.256294+08:00"),
"updated_at": _dt("2026-08-06T10:24:35.878389+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69ae039d3ba69",
"product_code": "ZLB-C",
"product_type": "credit_addon",
"name": "增量包C",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("1500.00"),
"validity_months": 1,
"price": Decimal("0.03"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 3,
"created_at": _dt("2026-08-06T10:25:02.043620+08:00"),
"updated_at": _dt("2026-08-06T10:25:02.043620+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69b3166a6a71c",
"product_code": "ZLB-D",
"product_type": "credit_addon",
"name": "增量包D",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("2000.00"),
"validity_months": 1,
"price": Decimal("0.04"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 4,
"created_at": _dt("2026-08-06T10:25:22.811498+08:00"),
"updated_at": _dt("2026-08-06T10:25:29.647103+08:00"),
"renewal_enabled": False,
},
]
connection = op.get_bind()
for row in rows:
connection.execute(_PRODUCT_UPSERT_SQL, row)
def _seed_llm_billing_policies() -> None:
admin_user_id = "0019e0a44895b6d837d"
rows = [
{
"id": "0019fd6aa5084a85a96",
"scene_code": "generation_record_text_prompt_optimize",
"scene_name": "AI创作-提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:41:53.809940+08:00"),
"updated_at": _dt("2026-08-06T10:41:53.809940+08:00"),
},
{
"id": "0019fd6aa834169f61d",
"scene_code": "hot_opening_image_prompt_optimize",
"scene_name": "爆款开头复刻-图片提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:06.818840+08:00"),
"updated_at": _dt("2026-08-06T10:42:06.818840+08:00"),
},
{
"id": "0019fd6aac862f821ca",
"scene_code": "shot_image_prompt_optimize",
"scene_name": "拆镜复刻-图片提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:24.496938+08:00"),
"updated_at": _dt("2026-08-06T10:42:24.496938+08:00"),
},
{
"id": "0019fd6aaf2fcae0765",
"scene_code": "shot_video_prompt_optimize",
"scene_name": "拆镜复刻-视频提示词优化",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:35.404414+08:00"),
"updated_at": _dt("2026-08-06T10:42:35.404414+08:00"),
},
{
"id": "0019fd6aaaf31d7c7c7",
"scene_code": "hot_opening_video_prompt_optimize",
"scene_name": "爆款开头复刻-视频提示词优化",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:18.003357+08:00"),
"updated_at": _dt("2026-08-06T10:42:39.440194+08:00"),
},
{
"id": "0019fd6abba149ef7e9",
"scene_code": "shot_original_video_analysis",
"scene_name": "拆镜复刻-原视频AI分析",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:43:26.390337+08:00"),
"updated_at": _dt("2026-08-06T10:43:26.390337+08:00"),
},
{
"id": "0019fd6abdbc4182c17",
"scene_code": "shot_segment_video_analysis",
"scene_name": "拆镜复刻-片段视频AI分析",
"pre_deduct_credits": Decimal("10.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:43:35.017213+08:00"),
"updated_at": _dt("2026-08-06T10:43:35.017213+08:00"),
},
]
connection = op.get_bind()
for row in rows:
connection.execute(_POLICY_UPSERT_SQL, row)
def upgrade() -> None:
op.add_column(
"credit_products",
sa.Column(
"renewal_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
# 积分增值包不参与订阅续费,统一标记为关闭;已有订阅套餐默认保持开启。
op.execute(
"UPDATE credit_products "
"SET renewal_enabled = false "
"WHERE product_type = 'credit_addon'"
)
_seed_credit_products()
_seed_llm_billing_policies()
def downgrade() -> None:
# 产品和计费策略属于运营配置,降级仅撤销本次新增字段,不主动删除业务配置数据。
op.drop_column("credit_products", "renewal_enabled")
@@ -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,75 @@
"""expand credit addon validity months to 1-36
Revision ID: b7e2c4d91a63
Revises: 1a4d1f095fa1
Create Date: 2026-08-11 13:15:00
"""
from alembic import op
revision = "b7e2c4d91a63"
down_revision = "1a4d1f095fa1"
branch_labels = None
depends_on = None
CONSTRAINT_NAME = "ck_credit_products_type_required_fields"
def _drop_type_constraint_if_exists() -> None:
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'credit_products'
AND c.conname = '{CONSTRAINT_NAME}'
) THEN
ALTER TABLE credit_products DROP CONSTRAINT {CONSTRAINT_NAME};
END IF;
END
$$;
"""
)
def _create_type_constraint(*, addon_condition: str) -> None:
op.create_check_constraint(
CONSTRAINT_NAME,
"credit_products",
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
f"AND {addon_condition} AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
def upgrade() -> None:
# 当前版本中增值包被旧约束固定为 validity_months = 1。
# 先移除旧约束,再开放为1-36个自然月;不修改任何现有商品数据。
_drop_type_constraint_if_exists()
_create_type_constraint(addon_condition="validity_months BETWEEN 1 AND 36")
def downgrade() -> None:
# 回退到旧版本时,旧约束只允许1个月。为保证 downgrade 可执行,
# 将现有增值包有效期恢复为旧版本唯一合法值1个月。
_drop_type_constraint_if_exists()
op.execute(
"UPDATE credit_products "
"SET validity_months = 1 "
"WHERE product_type = 'credit_addon' "
"AND validity_months IS DISTINCT FROM 1"
)
_create_type_constraint(addon_condition="validity_months = 1")
@@ -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,25 @@
"""merge changes from remote
Revision ID: e7f2527691bb
Revises: 20260814_bank_tx, 20260814_subscription_no
Create Date: 2026-08-14 15:32:14.351079
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7f2527691bb'
down_revision: Union[str, None] = ('20260814_bank_tx', '20260814_subscription_no')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -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.bank.routes import router
__all__ = ["router"]
+222
View File
@@ -0,0 +1,222 @@
"""银行账户管理与交易查询后台路由。"""
from datetime import datetime, time
from fastapi import APIRouter, Depends, HTTPException, Path, Query
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.bank_account import BankAccount
from app.models.bank_transaction import BankTransaction
from app.models.user import User
from app.services.bank.service import query_transactions_with_log
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/bank", tags=["admin-bank"])
# ============================================================
# 银行账户 CRUD
# ============================================================
@router.get("/accounts")
async def list_accounts(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有银行账户。"""
result = await db.execute(select(BankAccount).order_by(BankAccount.is_default.desc(), BankAccount.created_at.desc()))
accounts = result.scalars().all()
return {"items": [
{
"id": a.id,
"account_name": a.account_name,
"bank_name": a.bank_name,
"account_no": a.account_no,
"is_active": a.is_active,
"is_default": a.is_default,
"description": a.description,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
for a in accounts
]}
@router.post("/accounts")
async def create_account(
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""新增银行账户。"""
account_name = (body.get("account_name") or "").strip()
bank_name = (body.get("bank_name") or "").strip()
account_no = (body.get("account_no") or "").strip()
if not account_name or not bank_name or not account_no:
raise HTTPException(status_code=400, detail="账户名称、开户银行、银行账号不能为空")
# 检查账号唯一性
existing = await db.execute(select(BankAccount).where(BankAccount.account_no == account_no))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该银行账号已存在")
is_default = bool(body.get("is_default", False))
# 如果设为默认,取消其他默认
if is_default:
await db.execute(
BankAccount.__table__.update().where(BankAccount.is_default.is_(True)).values(is_default=False)
)
account = BankAccount(
id=generate_id(),
account_name=account_name,
bank_name=bank_name,
account_no=account_no,
is_active=bool(body.get("is_active", True)),
is_default=is_default,
description=body.get("description"),
)
db.add(account)
await db.commit()
return {"id": account.id, "message": "创建成功"}
@router.put("/accounts/{account_id}")
async def update_account(
account_id: str = Path(..., description="账户 ID"),
body: dict = ...,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""编辑银行账户。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="账户不存在")
if "account_name" in body:
account.account_name = str(body["account_name"]).strip()
if "bank_name" in body:
account.bank_name = str(body["bank_name"]).strip()
if "account_no" in body:
new_no = str(body["account_no"]).strip()
if new_no != account.account_no:
existing = await db.execute(select(BankAccount).where(BankAccount.account_no == new_no))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该银行账号已存在")
account.account_no = new_no
if "is_active" in body:
account.is_active = bool(body["is_active"])
if "description" in body:
account.description = body.get("description")
if body.get("is_default"):
await db.execute(
BankAccount.__table__.update()
.where(BankAccount.is_default.is_(True))
.where(BankAccount.id != account_id)
.values(is_default=False)
)
account.is_default = True
await db.commit()
return {"message": "更新成功"}
@router.delete("/accounts/{account_id}")
async def delete_account(
account_id: str = Path(..., description="账户 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除银行账户。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="账户不存在")
await db.delete(account)
await db.commit()
return {"message": "删除成功"}
# ============================================================
# 银行交易查询
# ============================================================
@router.get("/transactions")
async def list_transactions(
account_id: str = Query(..., description="银行账户 ID"),
start_date: str = Query(..., description="开始日期 (YYYY-MM-DD)"),
end_date: str = Query(..., description="结束日期 (YYYY-MM-DD)"),
dc_flag: int | None = Query(None, description="借贷方向: 0-借/出金, 1-贷/入金, 不传返回全部"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""查询本地已同步的银行交易流水。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="银行账户不存在")
# 构建查询条件(日期字符串转为 datetime,避免 PostgreSQL 类型错误)
conditions = [BankTransaction.account_id == account_id]
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
conditions.append(BankTransaction.transaction_time >= start_dt)
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59
)
conditions.append(BankTransaction.transaction_time <= end_dt)
if dc_flag is not None:
direction = "DR" if dc_flag == 0 else "CR"
conditions.append(BankTransaction.balance_direction == direction)
# 查总数
count_result = await db.execute(
select(sa.func.count()).where(*conditions)
)
total = count_result.scalar() or 0
# 查分页数据
offset = (page - 1) * page_size
data_result = await db.execute(
select(BankTransaction)
.where(*conditions)
.order_by(BankTransaction.transaction_time.desc())
.offset(offset)
.limit(page_size)
)
transactions = data_result.scalars().all()
return {
"items": [
{
"id": t.id,
"account_id": t.account_id,
"account_no": t.account_no,
"transaction_no": t.transaction_no,
"transaction_time": t.transaction_time.isoformat() if t.transaction_time else None,
"transaction_amount": t.transaction_amount,
"balance_direction": t.balance_direction,
"balance_after": t.balance_after,
"counterparty_name": t.counterparty_name,
"counterparty_account": t.counterparty_account,
"counterparty_bank": t.counterparty_bank,
"remark": t.remark,
"digest_code": t.digest_code,
"sync_batch": t.sync_batch,
"is_synced": t.is_synced,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in transactions
],
"total": total,
}
@@ -0,0 +1,3 @@
from app.admin_api.scheduled_tasks.routes import router
__all__ = ["router"]
@@ -0,0 +1,171 @@
"""定时任务管理后台路由。"""
import json
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.scheduled_task import ScheduledTask
from app.models.user import User
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/scheduled-tasks", tags=["admin-scheduled-tasks"])
def _task_to_dict(task: ScheduledTask) -> dict:
return {
"id": task.id,
"name": task.name,
"task_type": task.task_type,
"schedule": task.schedule,
"config": task.config,
"is_active": task.is_active,
"last_run_at": task.last_run_at,
"last_status": task.last_status,
"last_error": task.last_error,
"created_by": task.created_by,
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
}
@router.get("")
async def list_tasks(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有定时任务。"""
result = await db.execute(select(ScheduledTask).order_by(ScheduledTask.created_at.desc()))
tasks = result.scalars().all()
return {"items": [_task_to_dict(t) for t in tasks]}
@router.post("")
async def create_task(
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""创建定时任务。"""
name = (body.get("name") or "").strip()
task_type = (body.get("task_type") or "internal_method").strip()
schedule = (body.get("schedule") or "").strip()
if not name or not schedule:
raise HTTPException(status_code=400, detail="任务名称、调度表达式不能为空")
if task_type != "internal_method":
raise HTTPException(status_code=400, detail="任务类型仅支持 internal_method")
config = body.get("config")
if isinstance(config, dict):
config = json.dumps(config, ensure_ascii=False)
elif isinstance(config, str):
# 验证 JSON 合法性
try:
json.loads(config)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="config 不是合法的 JSON")
task = ScheduledTask(
id=generate_id(),
name=name,
task_type=task_type,
schedule=schedule,
config=config,
is_active=bool(body.get("is_active", True)),
created_by=admin.id,
)
db.add(task)
await db.commit()
return {"id": task.id, "message": "创建成功"}
@router.put("/{task_id}")
async def update_task(
task_id: str = Path(..., description="任务 ID"),
body: dict = ...,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if "name" in body:
task.name = str(body["name"]).strip()
if "task_type" in body:
t = body["task_type"]
if t != "internal_method":
raise HTTPException(status_code=400, detail="任务类型仅支持 internal_method")
task.task_type = t
if "schedule" in body:
task.schedule = str(body["schedule"]).strip()
if "is_active" in body:
task.is_active = bool(body["is_active"])
if "config" in body:
config = body["config"]
if isinstance(config, dict):
config = json.dumps(config, ensure_ascii=False)
elif isinstance(config, str):
try:
json.loads(config)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="config 不是合法的 JSON")
task.config = config
await db.commit()
return {"message": "更新成功"}
@router.delete("/{task_id}")
async def delete_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
await db.delete(task)
await db.commit()
return {"message": "删除成功"}
@router.post("/{task_id}/run")
async def run_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""手动执行一次定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
from app.tasks.celery_app import RECOVERY_QUEUE
from app.tasks.scheduled_tasks import execute_scheduled_task
execute_scheduled_task.apply_async(args=[task_id], queue=RECOVERY_QUEUE)
return {"message": "任务已提交执行"}
@router.post("/{task_id}/toggle")
async def toggle_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""启用/禁用定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
task.is_active = not task.is_active
await db.commit()
return {"is_active": task.is_active, "message": "已启用" if task.is_active else "已禁用"}
@@ -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)
+14
View File
@@ -7,9 +7,16 @@ from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router
from app.api.admin.private_portrait import router as private_portrait_router
from app.api.admin.recharge_package import router as recharge_package_router
from app.api.admin.credit_management import router as credit_management_router
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.upload import router as admin_upload_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
from app.admin_api.bank import router as bank_admin_router
from app.admin_api.scheduled_tasks import router as scheduled_tasks_admin_router
router = APIRouter()
router.include_router(video_prompt_schema_config_router)
@@ -19,6 +26,13 @@ router.include_router(team_router)
router.include_router(home_material_router)
router.include_router(private_portrait_router)
router.include_router(recharge_package_router)
router.include_router(credit_management_router)
router.include_router(llm_billing_router)
router.include_router(menu_config_router)
router.include_router(admin_upload_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)
router.include_router(bank_admin_router)
router.include_router(scheduled_tasks_admin_router)
@@ -0,0 +1,421 @@
from __future__ import annotations
import json
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditBalanceSourceType,
CreditScope,
)
from app.enums.credit_product import CreditProductType
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.user import User
from app.schemas.credit_balance import AdminCreditDeductRequest, AdminCreditGrantRequest
from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductStatusUpdate, CreditProductUpdate
from app.schemas.credit_subscription import AdminOfflineSubscriptionCreate
from app.services.credit.ledger_service import deduct_credits, grant_credits
from app.services.credit.offline_subscription_service import create_offline_subscription_order
from app.services.credit.product_service import product_to_dict
from app.services.credit.query_service import apply_balance_status_filter, effective_balance_status, get_balance_summary
from app.services.credit.time_policy import add_natural_months, last_usable_at
from app.services.credit.utils import utc_now
from app.services.notification import create_notification
from app.services.operation_log import log_operation
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/credit-management", tags=["admin-credit-management"])
def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
mapping = {"features": "features_json"}
for key, value in payload.items():
setattr(product, mapping.get(key, key), value)
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
product.price = product.regular_price or 0
product.grant_credits = None
product.validity_months = None
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
product.renewal_enabled = False
if product.validity_months is None:
product.validity_months = 1
product.tier_code = None
product.tier_rank = None
product.billing_cycle = None
product.monthly_grant_credits = None
product.first_purchase_price = None
product.regular_price = None
product.activity_price = None
product.activity_start_at = None
product.activity_end_at = None
def _validate_product_entity(product: CreditProduct) -> None:
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
required = {
"套餐等级编码": product.tier_code,
"套餐等级顺序": product.tier_rank,
"订阅周期": product.billing_cycle,
"每月积分": product.monthly_grant_credits,
"首购价格": product.first_purchase_price,
"常规价格": product.regular_price,
}
missing = [label for label, value in required.items() if value is None]
if missing:
raise HTTPException(status_code=400, detail=f"订阅套餐缺少字段:{''.join(missing)}")
if product.activity_price is None:
if product.activity_start_at is not None or product.activity_end_at is not None:
raise HTTPException(status_code=400, detail="未配置活动价时不能单独配置活动周期")
elif product.activity_start_at is None or product.activity_end_at is None:
raise HTTPException(status_code=400, detail="配置活动价时必须同时配置活动开始和结束时间")
elif product.activity_end_at <= product.activity_start_at:
raise HTTPException(status_code=400, detail="活动结束时间必须晚于开始时间")
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
if product.grant_credits is None:
raise HTTPException(status_code=400, detail="积分增值包必须配置积分数量")
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
else:
raise HTTPException(status_code=400, detail="不支持的积分商品类型")
@router.get("/products")
async def list_products(
product_type: str | None = Query(default=None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(CreditProduct)
if product_type:
stmt = stmt.where(CreditProduct.product_type == product_type)
result = await db.execute(
stmt.order_by(CreditProduct.deleted_at.asc(), CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id)
)
return [product_to_dict(item) for item in result.scalars().all()]
@router.post("/products")
async def create_product(
data: CreditProductCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
exists = await db.execute(
select(CreditProduct.id).where(CreditProduct.product_code == data.product_code).limit(1)
)
if exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="商品编码已被使用,商品编码永久唯一且不可复用")
product = CreditProduct(id=generate_id())
_apply_product_payload(product, data.model_dump())
_validate_product_entity(product)
db.add(product)
await db.flush()
snapshot = product_to_dict(product)
await log_operation(
db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST",
"/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED",
user_id=admin.id, message="积分商品创建成功", detail={"product_id": product.id},
)
await db.commit()
return snapshot
@router.put("/products/{product_id}")
async def update_product(
product_id: str,
data: CreditProductUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能恢复或继续编辑")
before = product_to_dict(product)
_apply_product_payload(product, data.model_dump(exclude_unset=True))
_validate_product_entity(product)
await db.flush()
after = product_to_dict(product)
await log_operation(
db, admin.id, admin.username, f"更新积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}",
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False, default=str),
)
await db.commit()
return after
@router.put("/products/{product_id}/renewal")
async def update_product_renewal(
product_id: str,
data: CreditProductRenewalUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能修改续费开关")
if product.product_type not in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
before = bool(product.renewal_enabled)
product.renewal_enabled = bool(data.renewal_enabled)
await db.flush()
after = product_to_dict(product)
await log_operation(
db, admin.id, admin.username,
f"{'开启' if product.renewal_enabled else '关闭'}积分套餐续费 {product.name}",
"PUT", f"/admin/credit-management/products/{product_id}/renewal",
detail=json.dumps({"before": before, "after": bool(product.renewal_enabled)}, ensure_ascii=False),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_RENEWAL_UPDATED",
user_id=admin.id, message="积分套餐续费开关已更新",
detail={"product_id": product.id, "renewal_enabled": bool(product.renewal_enabled)},
)
await db.commit()
return after
@router.put("/products/{product_id}/status")
async def update_product_status(
product_id: str,
data: CreditProductStatusUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="已软删除商品不能重新上架")
product.is_active = bool(data.is_active)
await db.flush()
await log_operation(
db, admin.id, admin.username,
f"{'上架' if product.is_active else '下架'}积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}/status",
)
await db.commit()
return product_to_dict(product)
@router.delete("/products/{product_id}")
async def soft_delete_product(
product_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is None:
product.is_active = False
product.deleted_at = utc_now()
await db.flush()
await log_operation(
db, admin.id, admin.username, f"软删除积分商品 {product.name}", "DELETE",
f"/admin/credit-management/products/{product_id}",
)
await db.commit()
return {"ok": True, "message": "商品已软删除,商品编码永久保留且不能恢复"}
@router.post("/users/{user_id}/offline-subscriptions")
async def create_offline_subscription(
user_id: str,
data: AdminOfflineSubscriptionCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
order = await create_offline_subscription_order(
db,
target_user_id=user_id,
product_id=data.product_id,
operator_admin_id=admin.id,
payment_method=data.payment_method,
quantity=data.quantity,
actual_paid_amount=data.actual_paid_amount,
offline_trade_no=data.offline_trade_no,
offline_payment_detail=data.offline_payment_detail,
remark=data.remark,
)
order_no = str(order.order_no)
subscription_id = order.subscription_id
amount = float(order.amount)
await log_operation(
db, admin.id, admin.username, f"为用户 {user_id} 创建线下真实订阅成交", "POST",
f"/admin/credit-management/users/{user_id}/offline-subscriptions",
detail=json.dumps({"order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}, ensure_ascii=False),
)
await db.commit()
return {"ok": True, "order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}
except Exception:
await db.rollback()
raise
@router.get("/users/{user_id}/subscriptions")
async def list_user_subscriptions(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.user_id == user_id)
.order_by(UserCreditSubscription.created_at.desc(), UserCreditSubscription.id.desc())
)
return [
{
"id": item.id,
"product_name": item.product_name_snapshot,
"product_type": item.product_type_snapshot,
"product_type_label": "团队订阅套餐" if item.product_type_snapshot == "team_subscription" else "个人订阅套餐",
"team_id": item.team_id,
"status": item.status,
"status_label": {"active": "有效", "expired": "已过期", "cancelled": "已取消", "pending": "待生效"}.get(item.status, "其他状态"),
"quantity": item.quantity_snapshot,
"monthly_total_credits": float(item.monthly_total_credits_snapshot),
"paid_amount": float(item.paid_amount_snapshot),
"start_at": item.start_at,
"expires_at": item.expires_at,
}
for item in result.scalars().all()
]
@router.get("/users/{user_id}/summary")
async def get_user_credit_summary(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
return (await get_balance_summary(db, user_id)).to_dict()
@router.get("/users/{user_id}/balances")
async def list_user_credit_balances(
user_id: str,
status: str | None = Query(default=None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == user_id)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
result = await db.execute(
stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
.offset((page - 1) * page_size).limit(page_size)
)
return [
{
"id": item.id,
"credit_scope": item.credit_scope,
"credit_scope_label": CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
"team_id": item.team_id,
"credit_level": item.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
"source_type": item.source_type,
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
"source_id": item.source_id,
"grant_amount": float(item.grant_amount),
"unspent_amount": float(item.unspent_amount),
"consumed_amount": float(item.consumed_amount),
"expired_amount": float(item.expired_amount),
"revoked_amount": float(item.revoked_amount),
"valid_from": item.valid_from,
"expires_at": item.expires_at,
"last_usable_at": last_usable_at(item.expires_at),
"status": (status_value := effective_balance_status(item, request_time=checked_at)),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, "其他状态"),
}
for item in result.scalars().all()
]
@router.post("/users/{user_id}/grant")
async def admin_grant_credit(
user_id: str,
data: AdminCreditGrantRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
starts_at = data.valid_from or utc_now()
ends_at = (
starts_at + timedelta(days=data.validity_value)
if data.validity_unit == "day"
else add_natural_months(starts_at, data.validity_value)
)
result = await grant_credits(
db, user_id=user_id, amount=data.amount, description=data.description,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value, source_id=admin.id,
valid_from=starts_at, expires_at=ends_at, credit_level=data.credit_level,
related_id=admin.id, biz_key=f"admin-grant:{admin.id}:{generate_id()}",
)
await create_notification(db, user_id, "积分变动通知", f"您的积分已增加{data.amount}积分。原因:{data.description}", "credit")
await log_operation(db, admin.id, admin.username, f"给用户 {user_id} 增加积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/grant")
return {"ok": True, "credits": result.balance_after}
@router.post("/users/{user_id}/deduct")
async def admin_deduct_credit(
user_id: str,
data: AdminCreditDeductRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
result = await deduct_credits(
db, user_id=user_id, amount=data.amount, description=data.description,
related_id=admin.id, biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
allowed_scopes={CreditScope.PERSONAL.value},
)
except Exception as exc:
if exc.__class__.__name__ == "InsufficientCreditsError":
raise HTTPException(status_code=400, detail="用户有效积分不足") from exc
raise
await create_notification(db, user_id, "积分变动通知", f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit")
await log_operation(db, admin.id, admin.username, f"扣除用户 {user_id} 积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/deduct")
return {"ok": True, "credits": result.balance_after}
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.llm_billing import LLM_BILLING_SCENE_LABELS
from app.models.llm_billing.policy import LlmBillingPolicyModel
from app.models.user import User
from app.schemas.llm_billing import LlmBillingPolicyCreate, LlmBillingPolicyUpdate
from app.services.llm_billing.query_service import list_executions_with_calls
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"])
def _policy_out(policy: LlmBillingPolicyModel) -> dict:
# 数据库 pre_deduct_credits 是历史物理字段名;Admin API 统一输出 charge_credits。
return {
"id": policy.id,
"scene_code": policy.scene_code,
"scene_name": policy.scene_name,
"charge_credits": float(policy.pre_deduct_credits),
"is_active": policy.is_active,
"version": policy.version,
"created_by": policy.created_by,
"updated_by": policy.updated_by,
"created_at": policy.created_at,
"updated_at": policy.updated_at,
}
@router.get("/policies")
async def list_policies(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code))
return [_policy_out(item) for item in result.scalars().all()]
@router.post("/policies")
async def create_policy(
data: LlmBillingPolicyCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if data.scene_code not in LLM_BILLING_SCENE_LABELS:
raise HTTPException(status_code=400, detail="不支持的LLM业务场景")
existing = await db.execute(
select(LlmBillingPolicyModel.id)
.where(LlmBillingPolicyModel.scene_code == data.scene_code)
.limit(1)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该LLM业务场景已存在")
policy = LlmBillingPolicyModel(
id=generate_id(),
scene_code=data.scene_code,
scene_name=LLM_BILLING_SCENE_LABELS[data.scene_code],
# 保留历史物理字段,不做无业务价值的数据库重命名迁移。
pre_deduct_credits=data.charge_credits,
is_active=data.is_active,
version=1,
created_by=admin.id,
updated_by=admin.id,
)
db.add(policy)
await db.flush()
policy_id = policy.id
scene_code = policy.scene_code
charge_credits = float(policy.pre_deduct_credits)
log_operation_event(
domain="llm_billing",
module="admin",
event_type="LLM_BILLING_POLICY_CREATED",
user_id=admin.id,
detail={"scene_code": scene_code, "charge_credits": charge_credits, "policy_id": policy_id},
)
return _policy_out(policy)
@router.put("/policies/{policy_id}")
async def update_policy(
policy_id: str,
data: LlmBillingPolicyUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(LlmBillingPolicyModel)
.where(LlmBillingPolicyModel.id == policy_id)
.limit(1)
.with_for_update()
)
policy = result.scalar_one_or_none()
if not policy:
raise HTTPException(status_code=404, detail="LLM积分场景不存在")
payload = data.model_dump(exclude_unset=True)
payload.pop("scene_name", None)
if "charge_credits" in payload:
policy.pre_deduct_credits = payload.pop("charge_credits")
if "is_active" in payload:
policy.is_active = payload["is_active"]
policy.scene_name = LLM_BILLING_SCENE_LABELS.get(policy.scene_code, policy.scene_name)
policy.version += 1
policy.updated_by = admin.id
await db.flush()
log_operation_event(
domain="llm_billing",
module="admin",
event_type="LLM_BILLING_POLICY_UPDATED",
user_id=admin.id,
detail={
"scene_code": policy.scene_code,
"policy_id": policy.id,
"version": policy.version,
"charge_credits": float(policy.pre_deduct_credits),
"is_active": policy.is_active,
},
)
return _policy_out(policy)
@router.get("/executions")
async def list_executions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
scene_code: str | None = Query(default=None),
status: str | None = Query(default=None),
user_id: str | None = Query(default=None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
items, total = await list_executions_with_calls(
db,
page=page,
page_size=page_size,
scene_code=scene_code,
status=status,
user_id=user_id,
)
return {"items": items, "total": total}
+12 -133
View File
@@ -1,148 +1,27 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user
from app.dependencies import get_admin_user, get_db
from app.enums.credit_product import CreditProductType
from app.models.credit.product import CreditProduct
from app.models.user import User
from app.models.recharge_package import RechargePackage
from app.schemas.recharge_package import (
RechargePackageCreate,
RechargePackageUpdate,
RechargePackageOut,
)
from app.services.operation_log import log_operation
from app.utils.id_gen import generate_id
from app.services.credit.product_service import product_to_dict
router = APIRouter(prefix="/admin/recharge-packages", tags=["admin-recharge-packages"])
def _to_out(pkg: RechargePackage) -> dict:
return {
"id": pkg.id,
"name": pkg.name,
"credits": round(pkg.credits, 2),
"price": round(pkg.price, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
"description": pkg.description,
"package_type": pkg.package_type,
"is_gift": pkg.is_gift,
"is_active": pkg.is_active,
"sort_order": pkg.sort_order,
}
@router.get("", response_model=list[RechargePackageOut])
@router.get("")
async def admin_list_packages(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).order_by(RechargePackage.sort_order)
select(CreditProduct)
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.deleted_at.is_(None),
)
return [_to_out(p) for p in result.scalars().all()]
@router.post("", response_model=RechargePackageOut)
async def create_package(
data: RechargePackageCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
pkg = RechargePackage(id=generate_id(), **data.model_dump())
db.add(pkg)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建充值套餐 {pkg.name}",
"POST",
"/admin/recharge-packages",
detail=json.dumps(
{
"id": pkg.id,
"name": pkg.name,
"price": round(pkg.price, 2),
"credits": round(pkg.credits, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
},
ensure_ascii=False,
),
.order_by(CreditProduct.sort_order, CreditProduct.id)
)
await db.commit()
return _to_out(pkg)
@router.put("/{pkg_id}", response_model=RechargePackageOut)
async def update_package(
pkg_id: str,
data: RechargePackageUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
before = _to_out(pkg)
for k, v in data.model_dump(exclude_unset=True).items():
setattr(pkg, k, v)
await db.flush()
after = _to_out(pkg)
await log_operation(
db,
admin.id,
admin.username,
f"更新充值套餐 {pkg.name}",
"PUT",
f"/admin/recharge-packages/{pkg_id}",
detail=json.dumps(
{
"id": pkg_id,
"before": before,
"after": after,
},
ensure_ascii=False,
),
)
await db.commit()
return _to_out(pkg)
@router.delete("/{pkg_id}")
async def delete_package(
pkg_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
pkg_name = pkg.name
await db.delete(pkg)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"删除充值套餐 {pkg_name}",
"DELETE",
f"/admin/recharge-packages/{pkg_id}",
detail=json.dumps(
{
"id": pkg_id,
"name": pkg_name,
},
ensure_ascii=False,
),
)
await db.commit()
return {"ok": True}
return [product_to_dict(item) for item in result.scalars().all()]
+85 -39
View File
@@ -3,54 +3,57 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
from app.schemas.team_manager import SetManagerRequest
from app.services.credit.team_subscription_service import (
list_member_period_usage,
list_team_subscriptions_for_management,
)
from app.services.operation_log import log_operation
from app.services.team_manager_service import set_team_manager
from app.services.team_manager_service import get_manager_history, set_team_manager
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
"""构造返回团队详情,包含 manager_name。"""
payload = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", "active"),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": None,
}
# 查询成员数和管理人用户名
from sqlalchemy import func
from app.enums.user import UserType
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
payload["member_count"] = int(member_count)
if getattr(team, "manager_id", None):
mgr = await db.execute(
manager_name = None
if team.manager_id:
manager_name = (await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
payload["manager_name"] = mgr.scalar_one_or_none()
return payload
)).scalar_one_or_none()
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"sort_order": team.sort_order or 0,
"member_count": int(member_count),
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
@router.get("", response_model=TeamListOut)
@@ -62,6 +65,7 @@ async def list_admin_teams(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
@@ -71,10 +75,11 @@ async def list_admin_team_options(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_options(db, include_disabled=include_disabled)
@router.post("", )
@router.post("")
async def create_admin_team(
req: TeamCreate,
admin: User = Depends(get_admin_user),
@@ -93,7 +98,22 @@ async def create_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}", )
@router.get("/{team_id}")
async def get_admin_team(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
result = await db.execute(select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1))
team = result.scalar_one_or_none()
if not team:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="团队不存在")
return await _team_detail_payload(db, team)
@router.put("/{team_id}")
async def update_admin_team(
team_id: str,
req: TeamUpdate,
@@ -113,7 +133,7 @@ async def update_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}/manager", )
@router.put("/{team_id}/manager")
async def set_team_manager_endpoint(
team_id: str,
req: SetManagerRequest = Body(...),
@@ -121,19 +141,14 @@ async def set_team_manager_endpoint(
db: AsyncSession = Depends(get_db),
):
team = await set_team_manager(db, team_id, req.user_id)
manager_name = None
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
if req.user_id:
mgr = await db.execute(
manager_name = (await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)
manager_name = mgr.scalar_one_or_none()
team_name = team.name
)).scalar_one_or_none()
await log_operation(
db,
admin.id,
admin.username,
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
f"更换团队队长 {team.name}: {manager_name or req.user_id}",
"PUT",
f"/admin/teams/{team_id}/manager",
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
@@ -141,6 +156,37 @@ async def set_team_manager_endpoint(
return await _team_detail_payload(db, team)
@router.get("/{team_id}/subscriptions")
async def list_admin_team_subscriptions(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_subscriptions_for_management(db, team_id=team_id)
@router.get("/{team_id}/member-usage")
async def list_admin_team_member_usage(
team_id: str,
subscription_id: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_member_period_usage(db, team_id=team_id, subscription_id=subscription_id)
@router.get("/{team_id}/manager-history")
async def list_admin_team_manager_history(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await get_manager_history(db, team_id)
@router.delete("/{team_id}")
async def delete_admin_team(
team_id: str,
@@ -157,4 +203,4 @@ async def delete_admin_team(
f"/admin/teams/{team_id}",
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
)
return {"message": "ok"}
return {"message": "团队已删除"}
+8
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.bank import router as bank_router
from app.api.v1.projects import router as projects_router
from app.api.v1.generation import router as generation_router
from app.api.v1.credits import router as credits_router
@@ -12,6 +13,7 @@ from app.api.v1.sms import router as sms_router
from app.api.v1.industries import router as industries_router
from app.api.v1.menu_configs import router as menu_configs_router
from app.api.v1.recharge_packages import router as recharge_packages_router
from app.api.v1.credit_products import router as credit_products_router
from app.api.v1.video_engines import router as video_engines_router
from app.api.v1.image_engines import router as image_engines_router
from app.api.v1.generation_ai import router as generation_ai_router
@@ -35,9 +37,12 @@ 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_virtual import router as private_portrait_virtual_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.include_router(auth_router)
api_router.include_router(bank_router)
api_router.include_router(projects_router)
api_router.include_router(generation_router)
api_router.include_router(credits_router)
@@ -49,6 +54,7 @@ api_router.include_router(sms_router)
api_router.include_router(industries_router)
api_router.include_router(menu_configs_router)
api_router.include_router(recharge_packages_router)
api_router.include_router(credit_products_router)
api_router.include_router(video_engines_router)
api_router.include_router(image_engines_router)
api_router.include_router(generation_ai_router)
@@ -72,3 +78,5 @@ api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router)
api_router.include_router(private_portrait_virtual_router)
api_router.include_router(upload_resource_router)
api_router.include_router(invoices_router)
api_router.include_router(invoice_headers_router)
+498 -164
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
import json
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 app.dependencies import get_db, get_admin_user
@@ -24,6 +24,8 @@ from app.models.credit_ratio import CreditRatio
from app.models.operation_log import OperationLog
from app.enums.user import FrontendUserKind, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.enums.common import PAYMENT_ORDER_SOURCE_LABELS
from app.schemas.payment import PAYMENT_METHOD_LABELS, PAYMENT_STATUS_LABELS, FULFILLMENT_STATUS_LABELS
from app.schemas.admin import (
CreditAdjustRequest,
ModelConfigCreate,
@@ -48,18 +50,21 @@ from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.query_service import attach_credit_snapshot, get_balance_summary, get_user_credit_summary_map
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import utc_now
from app.services.credit_record_meta_service import build_admin_adjust_meta
from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.system_config_cache import invalidate_system_config_cache
from app.services.llm_billing.config import validate_llm_system_config_value
from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url
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.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
@@ -130,6 +135,10 @@ async def list_users(
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
team_name_map = await batch_get_team_name_map(db, team_ids)
credit_summary_map = await get_user_credit_summary_map(db, user_ids)
for item in users:
summary = credit_summary_map.get(item.id)
attach_credit_snapshot(item, summary.available_credits if summary else 0)
return {
"items": [
AdminUserOut.model_validate(user)
@@ -137,6 +146,9 @@ async def list_users(
update={
"resource_capacity": capacity_map.get(user.id),
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary_map[user.id].personal_credits) if user.id in credit_summary_map else 0.0,
"team_available_credits": float(credit_summary_map[user.id].team_available_credits) if user.id in credit_summary_map else 0.0,
"team_frozen_credits": float(credit_summary_map[user.id].team_frozen_credits) if user.id in credit_summary_map else 0.0,
}
)
.model_dump(mode="json")
@@ -178,16 +190,29 @@ async def create_user(
hashed_password=hash_password(req.password),
email=req.email,
phone=req.phone,
credits=req.credits,
is_admin=req.is_admin if req.user_type == "admin" else False,
user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus,
private_portrait_asset_limit=req.private_portrait_asset_limit,
)
user.credits = round(user.credits, 2)
db.add(user)
await db.flush()
if req.credits > 0:
now = utc_now()
await add_credits(
db, user.id, req.credits, "管理员创建用户初始积分",
record_meta=build_admin_adjust_meta(),
valid_from=now,
expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.GENERAL.value,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
related_id=admin.id,
biz_key=f"admin-create-user-credit:{user.id}",
)
else:
attach_credit_snapshot(user, 0)
await log_operation(
db,
admin.id,
@@ -257,13 +282,17 @@ async def get_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.credits = round(user.credits, 2)
credit_summary = await get_balance_summary(db, user.id)
attach_credit_snapshot(user, credit_summary.available_credits)
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
return AdminUserOut.model_validate(user).model_copy(
update={
"resource_capacity": resource_capacity,
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary.personal_credits),
"team_available_credits": float(credit_summary.team_available_credits),
"team_frozen_credits": float(credit_summary.team_frozen_credits),
}
)
@@ -276,9 +305,26 @@ async def adjust_credits(
db: AsyncSession = Depends(get_db),
):
if req.amount > 0:
await add_credits(db, user_id, req.amount, f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
now = utc_now()
await add_credits(
db, user_id, req.amount, f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
valid_from=now, expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.GENERAL.value,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
related_id=admin.id,
biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
)
else:
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
await deduct_credits(
db,
user_id,
abs(req.amount),
f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
allowed_scopes={"personal"},
)
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
@@ -369,6 +415,45 @@ async def update_user_admin_status(
return {"message": "ok"}
@router.put("/users/{user_id}/single-device-login-override")
async def update_single_device_login_override(
user_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""设置用户级单设备登录覆盖。
body.override: True=强制启用, False=强制禁用, None=跟随全局
"""
override = body.get("override")
if override is not None:
override = bool(override)
# None 表示跟随全局
await db.execute(
update(User).where(User.id == user_id).values(single_device_login_override=override)
)
await db.flush()
label = "跟随全局" if override is None else ("强制启用" if override else "强制禁用")
await log_operation(
db,
admin.id,
admin.username,
f"单设备登录设置: {label}",
"PUT",
f"/admin/users/{user_id}/single-device-login-override",
detail=json.dumps(
{
"user_id": user_id,
"override": override,
},
ensure_ascii=False,
),
)
await db.commit()
return {"message": "ok"}
@router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut)
async def update_user_frontend_kind(
user_id: str,
@@ -499,6 +584,7 @@ async def list_credit_records(
user_type: str | None = Query(None),
frontend_user_kind: str | None = Query(None),
team_id: str | None = Query(None),
subscription_no: str | None = Query(None),
record_type: str | None = Query(None),
type: str | None = Query(None),
credit_subject: str | None = Query(None),
@@ -523,6 +609,7 @@ async def list_credit_records(
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
subscription_no=subscription_no,
record_type=record_type or type,
credit_subject=credit_subject,
media_type=media_type,
@@ -737,107 +824,112 @@ async def batch_update_payment_configs(
@router.get("/payment-stats")
async def get_payment_stats(
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return payment statistics for admin dashboard with filters."""
from sqlalchemy import func
# Ensure by_status has all expected statuses with defaults
"""支付统计:线上、后台线下和总真实收入可分别统计。"""
del admin
by_status = {
"pending": {"count": 0, "amount": 0.0},
"paid": {"count": 0, "amount": 0.0},
"cancelled": {"count": 0, "amount": 0.0},
"refunded": {"count": 0, "amount": 0.0},
"pending": {"label": "待支付", "count": 0, "amount": 0.0},
"paid": {"label": "已支付", "count": 0, "amount": 0.0},
"cancelled": {"label": "已取消", "count": 0, "amount": 0.0},
"expired": {"label": "已过期", "count": 0, "amount": 0.0},
"failed": {"label": "失败", "count": 0, "amount": 0.0},
"refunded": {"label": "已退款", "count": 0, "amount": 0.0},
}
# Parse dates and build base query filters
now_cst = datetime.now(CST)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
# Default to today if no date range provided
query_start = today_start
query_end = today_end
if start_date:
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
if end_date:
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
# Build filter list for status breakdown
breakdown_filters = []
if payment_method:
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
if status:
breakdown_filters.append(PaymentOrder.status == status)
# Always apply date range to breakdown
breakdown_filters.append(PaymentOrder.created_at >= query_start)
breakdown_filters.append(PaymentOrder.created_at < query_end)
# Status breakdown
status_result = await db.execute(
select(
PaymentOrder.status,
func.count().label("count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST) if start_date else today_start
query_end = (
(datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
if end_date else today_end
)
.where(*breakdown_filters)
filters = [PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
filters.append(PaymentOrder.status == status)
status_result = await db.execute(
select(PaymentOrder.status, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*filters)
.group_by(PaymentOrder.status)
)
for row in status_result.all():
if row.status in by_status:
if row.status not in by_status:
by_status[row.status] = {
"count": row.count,
"amount": round(float(row.amount), 2)
"label": PAYMENT_STATUS_LABELS.get(row.status, "其他状态"),
"count": 0,
"amount": 0.0,
}
else:
# Map any unexpected status to cancelled
by_status["cancelled"]["count"] += row.count
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
by_status[row.status]["count"] = int(row.count or 0)
by_status[row.status]["amount"] = round(float(row.amount or 0), 2)
# Today's stats (CST time zone) - independent of filter
today_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
source_filters = [PaymentOrder.status == "paid", PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
source_filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
source_filters.append(PaymentOrder.order_source == order_source)
source_result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*source_filters)
.group_by(PaymentOrder.order_source)
)
by_source = {
"online_payment": {"label": "线上支付", "count": 0, "amount": 0.0},
"admin_offline": {"label": "后台线下成交", "count": 0, "amount": 0.0},
}
for row in source_result.all():
target = by_source.setdefault(
row.order_source,
{"label": PAYMENT_ORDER_SOURCE_LABELS.get(row.order_source, "其他订单来源"), "count": 0, "amount": 0.0},
)
target["count"] = int(row.count or 0)
target["amount"] = round(float(row.amount or 0), 2)
total_income = {
"label": "总真实收入",
"count": sum(int(item["count"]) for item in by_source.values()),
"amount": round(sum(float(item["amount"]) for item in by_source.values()), 2),
}
async def _period_income(start_at: datetime, end_at: datetime) -> dict:
result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= today_start,
PaymentOrder.paid_at < today_end,
PaymentOrder.paid_at >= start_at,
PaymentOrder.paid_at < end_at,
)
.group_by(PaymentOrder.order_source)
)
today_row = today_result.one()
source_map = {row.order_source: (int(row.count or 0), round(float(row.amount or 0), 2)) for row in result.all()}
online_count, online_amount = source_map.get("online_payment", (0, 0.0))
offline_count, offline_amount = source_map.get("admin_offline", (0, 0.0))
return {
"paid_count": online_count + offline_count,
"paid_amount": round(online_amount + offline_amount, 2),
"online_paid_count": online_count,
"online_paid_amount": online_amount,
"offline_paid_count": offline_count,
"offline_paid_amount": offline_amount,
}
# Monthly cumulative stats
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= month_start,
PaymentOrder.paid_at < month_end,
)
)
month_row = month_result.one()
return {
"by_status": by_status,
"today": {
"paid_count": today_row.paid_count,
"paid_amount": round(float(today_row.paid_amount), 2),
},
"month": {
"paid_count": month_row.paid_count,
"paid_amount": round(float(month_row.paid_amount), 2),
},
"by_source": by_source,
"total_income": total_income,
"today": await _period_income(today_start, today_end),
"month": await _period_income(month_start, month_end),
}
@@ -846,6 +938,7 @@ async def list_payment_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
@@ -853,13 +946,15 @@ async def list_payment_orders(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated payment orders for admin dashboard."""
"""管理后台统一订单列表;线上和后台线下成交均来自 payment_orders"""
del admin
query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
count_query = select(func.count(PaymentOrder.id))
count_query = select(func.count(PaymentOrder.id)).join(User, PaymentOrder.user_id == User.id)
filters = []
if payment_method:
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
filters.append(PaymentOrder.status == status)
if phone:
@@ -868,43 +963,68 @@ async def list_payment_orders(
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date:
filters.append(PaymentOrder.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST))
if filters:
query = query.where(*filters)
count_query = count_query.where(*filters)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total = int((await db.execute(count_query)).scalar() or 0)
rows = (await db.execute(
query.order_by(PaymentOrder.created_at.desc(), PaymentOrder.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)).all()
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(PaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
rows = result.all()
items = [
items = []
for order, username, user_phone in rows:
payment_label = PAYMENT_METHOD_LABELS.get(order.payment_method, "其他支付方式")
if order.payment_method == "other" and order.offline_payment_detail:
payment_label = f"其他-线下收款({order.offline_payment_detail}"
items.append(
{
"id": o.id,
"orderNo": o.order_no,
"order_no": o.order_no,
"userId": o.user_id,
"user_id": o.user_id,
"id": order.id,
"orderNo": order.order_no,
"order_no": order.order_no,
"userId": order.user_id,
"user_id": order.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
"paymentMethod": o.payment_method,
"payment_method": o.payment_method,
"status": o.status,
"tradeNo": o.trade_no,
"trade_no": o.trade_no,
"paidAt": o.paid_at.isoformat() if o.paid_at else None,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"createdAt": o.created_at.isoformat() if o.created_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None,
"amount": round(float(order.amount), 2),
"credits": round(float(order.credits), 2),
"quantity": int(order.quantity or 1),
"quoted_unit_price_snapshot": float(order.quoted_unit_price_snapshot) if order.quoted_unit_price_snapshot is not None else None,
"quoted_amount_snapshot": float(order.quoted_amount_snapshot) if order.quoted_amount_snapshot is not None else None,
"actual_unit_price_snapshot": float(order.actual_unit_price_snapshot) if order.actual_unit_price_snapshot is not None else None,
"paymentMethod": order.payment_method,
"payment_method": order.payment_method,
"payment_method_label": payment_label,
"order_source": order.order_source,
"order_source_label": PAYMENT_ORDER_SOURCE_LABELS.get(order.order_source, "其他订单来源"),
"status": order.status,
"status_label": PAYMENT_STATUS_LABELS.get(order.status, "其他状态"),
"product_id": order.product_id,
"product_type": order.product_type,
"product_name_snapshot": order.product_name_snapshot,
"team_id_snapshot": order.team_id_snapshot,
"fulfillment_status": order.fulfillment_status,
"fulfillment_status_label": FULFILLMENT_STATUS_LABELS.get(order.fulfillment_status, "其他履约状态") if order.fulfillment_status else None,
"tradeNo": order.trade_no,
"trade_no": order.trade_no,
"offline_trade_no": order.offline_trade_no,
"offline_payment_detail": order.offline_payment_detail,
"remark": order.remark,
"refund_amount": float(order.refund_amount) if order.refund_amount is not None else None,
"refund_trade_no": order.refund_trade_no,
"refund_entitlement_status": order.refund_entitlement_status,
"paidAt": order.paid_at.isoformat() if order.paid_at else None,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"refunded_at": order.refunded_at.isoformat() if order.refunded_at else None,
"createdAt": order.created_at.isoformat() if order.created_at else None,
"created_at": order.created_at.isoformat() if order.created_at else None,
}
for o, username, user_phone in rows
]
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.put("/payment-configs/{config_id}")
async def update_payment_config(
config_id: str,
@@ -955,25 +1075,14 @@ async def refund_payment_order(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Refund a paid payment order."""
result = await process_refund(db, order_no)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
await log_operation(
db,
admin.id,
admin.username,
f"订单退款: {order_no}",
"POST",
f"/admin/payment-orders/{order_no}/refund",
detail=json.dumps(
{
"order_no": order_no,
},
ensure_ascii=False,
),
)
return result
"""保留退款 API 路由兼容旧客户端,但本版本明确不开放主动订单退款。"""
del admin
exists = (await db.execute(
select(PaymentOrder.id).where(PaymentOrder.order_no == order_no).limit(1)
)).scalar_one_or_none()
if not exists:
raise HTTPException(status_code=404, detail="订单不存在")
raise HTTPException(status_code=409, detail="当前版本暂未开放订单退款")
# ── Industry Config ──────────────────────────────────────
@@ -1526,6 +1635,8 @@ async def create_model_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.provider == "mock":
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止新增Mock模型")
config = ModelConfig(id=generate_id(), **req.model_dump())
db.add(config)
await db.flush()
@@ -1554,6 +1665,8 @@ async def update_model_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.provider == "mock":
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止使用Mock模型")
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
config = result.scalar_one_or_none()
if not config:
@@ -1629,10 +1742,6 @@ async def create_system_config(
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
try:
await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config = SystemConfig(
id=generate_id(),
key=req.key,
@@ -1667,10 +1776,6 @@ async def update_system_config(
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
try:
await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config.value = str(req.value)
await db.flush()
await log_operation(
@@ -1696,17 +1801,62 @@ async def update_system_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 ──────────────────────────────────────
@router.get("/operation-logs")
async def list_operation_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
action: str | None = Query(None, description="按 action 过滤(前缀匹配)"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(OperationLog).order_by(OperationLog.created_at.desc())
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
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = result.scalars().all()
@@ -1749,17 +1899,26 @@ async def get_stats(
):
today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
date_start: datetime
date_end: datetime
try:
if start_date:
date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
else:
date_start = today_start
if end_date:
date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST)
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
# 先构造完整的 naive 日期时刻,再一次性 attach tzinfo(避免分步 replace 丢 tzinfo
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:
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_end = datetime.now(CST)
@@ -1802,20 +1961,45 @@ async def get_stats(
)
)).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_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
)
credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
real_credit_charge_filter,
# 核心数据「消耗积分」= 净消耗 = 真实消费 + 预扣占用 - 真实退款 - 预扣释放。
# 说明:
# hold(预扣占用):type=consumecharge_action='hold'amount<0
# 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_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(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
@@ -1879,21 +2063,31 @@ async def get_stats(
)
)).scalar() or 0
last_period_credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
real_credit_charge_filter,
last_period_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 >= last_period_start,
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天,便于图表展示)
# created_at 为 timestamptz,数据库 session 时区已是东八区(CST),
# 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时。
from sqlalchemy import Date, cast as sa_cast
_day_expr = sa_cast(CreditRecord.created_at, Date)
# 图表固定展示 [date_end - 6天, date_end] 共7天
_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 = (
select(
_day_expr.label('date'),
@@ -1904,7 +2098,7 @@ async def get_stats(
CreditRecord.type == "consume",
real_credit_charge_filter,
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)
.subquery()
@@ -1948,23 +2142,41 @@ async def get_stats(
]
# ── 各团队积分消耗(有团队 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(
select(
func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'),
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(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.type.in_(["consume", "refund"]),
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.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()
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
]
@@ -2102,6 +2314,8 @@ async def admin_list_generation_records(
status: str | None = Query(None),
engine_id: str | 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_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user),
@@ -2124,6 +2338,10 @@ async def admin_list_generation_records(
query = query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
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_query = (
@@ -2139,6 +2357,10 @@ async def admin_list_generation_records(
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
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 = total_result.scalar() or 0
@@ -2379,6 +2601,118 @@ async def upload_login_video(
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,
}
+86 -39
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,8 +11,9 @@ from app.dependencies import (
get_current_user,
get_current_user_allow_password_pending,
get_db,
security,
)
from app.models.credit_record import CreditRecord
from fastapi.security import HTTPAuthorizationCredentials
from app.models.system_config import SystemConfig
from app.models.user import User
from app.schemas.auth import (
@@ -32,8 +33,13 @@ from app.services.auth import (
hash_password,
verify_password,
)
from app.utils.device import detect_device_type
from app.services.sms import verify_sms_code
from app.services.resource_capacity_service import get_user_resource_capacity_usage
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.ledger_service import grant_credits
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.credit.time_policy import add_natural_months
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -56,9 +62,16 @@ def _validate_captcha_if_needed(captcha_token: str | None) -> None:
)
def _token_response(user: User, remember_me: bool = False) -> dict:
def _token_response(user: User, remember_me: bool = False, device_type: str = "pc") -> dict:
user.credits = round(user.credits, 2)
token = create_access_token(user.id, remember_me)
# 按设备类型递增对应版本号 — 单设备登录(同端互斥)
if device_type == "mobile":
user.mobile_token_version = (user.mobile_token_version or 0) + 1
version = user.mobile_token_version
else:
user.pc_token_version = (user.pc_token_version or 0) + 1
version = user.pc_token_version
token = create_access_token(user.id, remember_me, version, device_type)
return {
"access_token": token,
"token_type": "bearer",
@@ -93,50 +106,57 @@ async def _get_register_credits(db: AsyncSession) -> int:
async def _add_register_credit_record(db: AsyncSession, user: User, credits: int) -> None:
if credits <= 0:
attach_credit_snapshot(user, 0)
return
record = CreditRecord(
id=generate_id(),
now = datetime.now(timezone.utc)
result = await grant_credits(
db,
user_id=user.id,
type="recharge",
amount=credits,
balance_after=user.credits,
description=f"注册赠送 {credits} 积分",
source_type=CreditBalanceSourceType.REGISTER_GIFT.value,
source_id=user.id,
valid_from=now,
expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.PROMOTIONAL.value,
related_id=user.id,
biz_key=f"register-gift:{user.id}",
request_time=now,
)
db.add(record)
attach_credit_snapshot(user, result.balance_after)
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
enabled_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
)
enabled = enabled_result.scalar_one_or_none() == "true"
if not enabled:
if enabled_result.scalar_one_or_none() != "true":
attach_credit_snapshot(user, await get_available_credits(db, user.id))
return
credits_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits").limit(1)
)
credits = int(credits_result.scalar_one_or_none() or "0")
if credits <= 0:
return
today = datetime.now(CST).date()
if user.last_login_at:
last_login_date = user.last_login_at.date()
if last_login_date >= today:
return
user.credits += credits
record = CreditRecord(
id=generate_id(),
now_cst = datetime.now(CST)
if credits > 0:
next_midnight_cst = datetime.combine(now_cst.date() + timedelta(days=1), datetime.min.time(), tzinfo=CST)
result = await grant_credits(
db,
user_id=user.id,
type="recharge",
amount=credits,
balance_after=user.credits,
description=f"每日登录赠送 {credits} 积分",
source_type=CreditBalanceSourceType.DAILY_LOGIN.value,
source_id=now_cst.date().isoformat(),
valid_from=now_cst,
expires_at=next_midnight_cst,
credit_level=CreditLevel.PROMOTIONAL.value,
related_id=user.id,
biz_key=f"daily-login:{user.id}:{now_cst.date().isoformat()}",
request_time=now_cst,
)
db.add(record)
attach_credit_snapshot(user, result.balance_after)
else:
attach_credit_snapshot(user, await get_available_credits(db, user.id))
@router.post(
@@ -144,7 +164,7 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
summary="客户端密码登录",
description="保留原有用户名/手机号 + 密码登录。仅允许 frontend 用户登录;管理员仍使用 /auth/admin-login。",
)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
_validate_captcha_if_needed(req.captcha_token)
user = await authenticate_user(db, req.username, req.password)
@@ -164,7 +184,8 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now(CST)
await db.flush()
return _token_response(user, req.remember_me)
device_type = detect_device_type(request.headers.get("user-agent"))
return _token_response(user, req.remember_me, device_type)
@router.post(
@@ -172,7 +193,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
summary="客户端短信验证码登录",
description="新增兼容登录方式:手机号 + 短信验证码登录。不覆盖 /auth/login 密码登录。仅允许 frontend 用户登录。",
)
async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
async def sms_login(req: SmsLoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
ok = await verify_sms_code(req.phone, req.code, "login")
if not ok:
raise HTTPException(
@@ -195,7 +216,8 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now(CST)
await db.flush()
return _token_response(user, req.remember_me)
device_type = detect_device_type(request.headers.get("user-agent"))
return _token_response(user, req.remember_me, device_type)
@router.post(
@@ -203,7 +225,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
summary="客户端手机号短信注册",
description="手机号 + 注册短信验证码注册。注册成功后 username 默认等于手机号,不生成密码;前端需根据 must_set_password 引导用户设置密码。",
)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
async def register(req: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)):
ok = await verify_sms_code(req.phone, req.code, "register")
if not ok:
raise HTTPException(
@@ -226,7 +248,6 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
phone=req.phone,
hashed_password=hash_password(req.password),
password_set_at=datetime.now(CST),
credits=register_credits,
is_admin=False,
user_type="frontend",
)
@@ -237,7 +258,15 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
await _assign_default_frontend_menus(db, user)
user.credits = round(user.credits, 2)
token = create_access_token(user.id)
# 按设备类型递增对应版本号
device_type = detect_device_type(request.headers.get("user-agent"))
if device_type == "mobile":
user.mobile_token_version = (user.mobile_token_version or 0) + 1
version = user.mobile_token_version
else:
user.pc_token_version = (user.pc_token_version or 0) + 1
version = user.pc_token_version
token = create_access_token(user.id, False, version, device_type)
return {
"access_token": token,
"token_type": "bearer",
@@ -247,7 +276,22 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user_allow_password_pending)):
async def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
current_user: User = Depends(get_current_user_allow_password_pending),
db: AsyncSession = Depends(get_db),
):
# 按设备类型递增对应版本号 — 使当前 token 失效
device_type = "pc"
if credentials:
payload = decode_access_token(credentials.credentials)
if payload:
device_type = payload.get("dev", "pc")
if device_type == "mobile":
current_user.mobile_token_version = (current_user.mobile_token_version or 0) + 1
else:
current_user.pc_token_version = (current_user.pc_token_version or 0) + 1
await db.flush()
return {"message": "ok"}
@@ -342,7 +386,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name, logo, agreement and copyright info."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits"
"site_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()
@@ -367,11 +411,13 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"operation_manual": info.get("operation_manual", ""),
"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),
}
@router.post("/admin-login")
async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
async def admin_login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
"""Admin-only login endpoint."""
user = await authenticate_user(db, req.username, req.password)
if not user:
@@ -389,6 +435,7 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
user.last_login_at = datetime.now(CST)
await db.flush()
token = create_access_token(user.id, req.remember_me)
device_type = detect_device_type(request.headers.get("user-agent"))
token = create_access_token(user.id, req.remember_me, 0, device_type)
user.credits = round(user.credits, 2)
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
+83
View File
@@ -0,0 +1,83 @@
"""银行账户前端查询接口。"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.bank_account import BankAccount
from app.models.user import User
router = APIRouter(prefix="/bank", tags=["bank"])
@router.get("/default-account")
async def get_default_account(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取默认银行账户信息(前端展示用)。
优先返回标记为默认的启用账户如果没有默认账户返回第一个启用账户
"""
# 先查默认账户
result = await db.execute(
select(BankAccount).where(
BankAccount.is_active.is_(True),
BankAccount.is_default.is_(True),
)
)
account = result.scalar_one_or_none()
# 没有默认账户则取第一个启用账户
if account is None:
result = await db.execute(
select(BankAccount).where(
BankAccount.is_active.is_(True),
).order_by(BankAccount.created_at.asc())
)
account = result.scalar_one_or_none()
if account is None:
return {
"has_account": False,
"account": None,
}
return {
"has_account": True,
"account": {
"id": account.id,
"accountName": account.account_name,
"bankName": account.bank_name,
"accountNo": account.account_no,
"description": account.description,
},
}
@router.get("/accounts")
async def list_active_accounts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取所有启用的银行账户列表(前端展示用)。"""
result = await db.execute(
select(BankAccount).where(
BankAccount.is_active.is_(True),
).order_by(BankAccount.is_default.desc(), BankAccount.created_at.asc())
)
accounts = result.scalars().all()
return {
"items": [
{
"id": a.id,
"accountName": a.account_name,
"bankName": a.bank_name,
"accountNo": a.account_no,
"isDefault": a.is_default,
"description": a.description,
}
for a in accounts
]
}
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.credit_product import CreditProductCatalogOut
from app.services.credit.product_service import build_product_catalog
router = APIRouter(prefix="/credit-products", tags=["credit-products"])
@router.get("/catalog", response_model=CreditProductCatalogOut)
async def get_credit_product_catalog(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await build_product_catalog(db, user=current_user)
+143 -21
View File
@@ -1,20 +1,115 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.dependencies import get_current_user, get_db
from app.models.credit.balance import UserCreditBalance
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit_record import CreditRecord
from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditScope,
)
from app.enums.credit_record import CREDIT_RECORD_BILLING_SCENE_LABELS, CREDIT_RECORD_TYPE_LABELS
from app.models.credit_ratio import CreditRatio
from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
from app.schemas.credit import CreditBalanceOut, CreditRecordOut
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.schemas.credit import CreditRecordOut
from app.schemas.credit_balance import CreditBalanceItemOut
from app.schemas.credit_ratio import CreditRatioOut
from app.services.credit.query_service import (
apply_balance_status_filter,
effective_balance_status,
get_balance_summary,
)
from app.services.credit.utils import utc_now
from app.services.credit.time_policy import last_usable_at
from app.services.credit_ratio_service import list_all_credit_ratios
from app.services.credits import get_records
router = APIRouter(prefix="/credits", tags=["credits"])
def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
return CreditBalanceItemOut(
id=item.id,
credit_scope=item.credit_scope,
credit_scope_label=CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
team_id=item.team_id,
credit_level=item.credit_level,
credit_level_label=CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
source_type=item.source_type,
source_type_label=CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
source_id=item.source_id,
product_id=item.product_id,
payment_order_id=item.payment_order_id,
subscription_id=item.subscription_id,
subscription_period_id=item.subscription_period_id,
grant_amount=float(item.grant_amount),
unspent_amount=float(item.unspent_amount),
consumed_amount=float(item.consumed_amount),
expired_amount=float(item.expired_amount),
revoked_amount=float(item.revoked_amount),
valid_from=item.valid_from,
expires_at=item.expires_at,
last_usable_at=last_usable_at(item.expires_at),
status=effective_balance_status(item, request_time=checked_at),
status_label=CREDIT_BALANCE_STATUS_LABELS.get(
effective_balance_status(item, request_time=checked_at), "其他状态"
),
created_at=item.created_at,
)
async def _records_with_scope_amounts(db: AsyncSession, records: list[CreditRecord]) -> list[dict]:
if not records:
return []
ids = [item.id for item in records]
result = await db.execute(
select(
CreditRecordAllocation.credit_record_id,
CreditRecordAllocation.credit_scope_snapshot,
func.coalesce(func.sum(CreditRecordAllocation.amount), 0).label("amount"),
)
.where(CreditRecordAllocation.credit_record_id.in_(ids))
.group_by(CreditRecordAllocation.credit_record_id, CreditRecordAllocation.credit_scope_snapshot)
)
scope_map: dict[str, dict[str, float]] = {}
for row in result.all():
scope_map.setdefault(str(row.credit_record_id), {})[str(row.credit_scope_snapshot)] = float(row.amount or 0)
output = []
for record in records:
parts = scope_map.get(record.id, {})
sign = -1.0 if float(record.amount or 0) < 0 else 1.0
output.append({
"id": record.id,
"type": record.type,
"type_label": CREDIT_RECORD_TYPE_LABELS.get(record.type, "其他"),
"amount": float(record.amount),
"personal_amount": sign * float(parts.get("personal", 0)),
"team_amount": sign * float(parts.get("team", 0)),
"balance_delta": float(record.balance_delta or 0),
"expired_amount": float(record.expired_amount or 0),
"balance_after": float(record.balance_after or 0),
"description": record.description,
"billing_scene": record.billing_scene,
"billing_scene_label": CREDIT_RECORD_BILLING_SCENE_LABELS.get(record.billing_scene, "其他场景") if record.billing_scene else None,
"scene_name_snapshot": record.scene_name_snapshot,
"input_tokens": record.input_tokens,
"output_tokens": record.output_tokens,
"total_tokens": record.total_tokens,
"llm_call_count": record.llm_call_count,
"llm_success_call_count": record.llm_success_call_count,
"llm_failed_call_count": record.llm_failed_call_count,
"created_at": record.created_at,
})
return output
@router.get("")
async def get_credits(
page: int = Query(1, ge=1),
@@ -23,18 +118,52 @@ async def get_credits(
db: AsyncSession = Depends(get_db),
):
records, total = await get_records(db, current_user.id, page, page_size)
summary = await get_balance_summary(db, current_user.id)
totals_result = await db.execute(
select(
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "expire", CreditRecord.expired_amount), else_=0)), 0),
).where(CreditRecord.user_id == current_user.id)
)
total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
return {
"credits": round(current_user.credits, 2),
"records": [CreditRecordOut.model_validate(r) for r in records],
**summary.to_dict(),
"records": await _records_with_scope_amounts(db, records),
"total": total,
"total_granted": float(total_granted or 0),
"total_consumed": float(total_consumed or 0),
"total_refunded": float(total_refunded or 0),
"total_expired": float(total_expired or 0),
}
@router.get("/balances")
async def list_credit_balances(
status: str | None = Query(default=None),
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),
):
checked_at = utc_now()
# 通用积分页只展示用户自己的个人积分批次。团队资金池归属成交时队长,
# 不能因为 Balance.owner 是队长就在这里展示整个团队资金池;团队席位与资金池明细统一在团队管理页查看。
stmt = select(UserCreditBalance).where(
UserCreditBalance.user_id == current_user.id,
UserCreditBalance.credit_scope == CreditScope.PERSONAL.value,
)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
return [_balance_to_out(item, checked_at=checked_at) for item in result.scalars().all()]
@router.get(
"/credit-ratios",
response_model=list[CreditRatioOut],
summary="获取积分比例列表",
description="客户端获取当前系统配置的积分计费规则列表。普通登录用户可访问,只读返回 credit_ratios 表中的图片/视频积分比例配置。",
)
async def list_client_credit_ratios(
current_user: User = Depends(get_current_user),
@@ -60,28 +189,21 @@ async def get_credit_ratios(
return [CreditRatioOut.model_validate(r) for r in ratios]
return []
video_engines_result = await db.execute(
video_result = await db.execute(
select(VideoEngine.id)
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.where(VideoEngine.is_active.is_(True), VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc())
)
video_engine_ids = video_engines_result.scalars().all()
image_engines_result = await db.execute(
image_result = await db.execute(
select(ImageEngine.id)
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.where(ImageEngine.is_active.is_(True), ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc())
)
image_engine_ids = image_engines_result.scalars().all()
grouped = {}
video_ratios = await get_ratios_for_engine_type("video", video_engine_ids)
video_ratios = await get_ratios_for_engine_type("video", list(video_result.scalars().all()))
image_ratios = await get_ratios_for_engine_type("image", list(image_result.scalars().all()))
if video_ratios:
grouped["video"] = video_ratios
image_ratios = await get_ratios_for_engine_type("image", image_engine_ids)
if image_ratios:
grouped["image"] = image_ratios
return grouped
+39 -1
View File
@@ -27,7 +27,11 @@ from app.services.generation.pipeline.db_lock_service import (
execute_with_lock_timeout,
)
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.private_portrait.reference_resolver import (
batch_resolve_private_portrait_reference_display_urls,
resolve_private_portrait_reference_display_urls,
resolve_private_portrait_references,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
@@ -405,6 +409,24 @@ async def generate_record_resource(
else:
await get_image_engine(db, record.engine_id)
frozen_engine = _frozen_engine_view(record)
# 触发生成前兜底:私域素材在 GenerationRecord 创建时可能未走 resolve_private_portrait_references
# 导致入库 url 存的是前端预览地址而非供应商需要的 asset://。这里强制重新解析,
# 确保供应商侧拿到正确的 remote_asset_id / asset:// URI。
try:
raw_refs = json.loads(record.media_references) if record.media_references else None
except (TypeError, ValueError):
raw_refs = None
if raw_refs:
resolved_refs = await resolve_private_portrait_references(
db,
user_id=user_id_snapshot,
media_references=raw_refs,
gen_type=record.gen_type,
)
if resolved_refs is not None:
record.media_references = json.dumps(resolved_refs, ensure_ascii=False)
reference_usage = calculate_media_reference_usage(
record.media_references,
include=bool(record.include_media_references),
@@ -547,6 +569,22 @@ async def retry_generation(
else:
await get_image_engine(db, record.engine_id)
frozen_engine = _frozen_engine_view(record)
# 重试前兜底:私域素材 url 可能仍然是预览地址,重新解析确保供应商拿到 asset://
try:
raw_refs = json.loads(record.media_references) if record.media_references else None
except (TypeError, ValueError):
raw_refs = None
if raw_refs:
resolved_refs = await resolve_private_portrait_references(
db,
user_id=user_id_snapshot,
media_references=raw_refs,
gen_type=record.gen_type,
)
if resolved_refs is not None:
record.media_references = json.dumps(resolved_refs, ensure_ascii=False)
reference_usage = calculate_media_reference_usage(
record.media_references,
include=bool(record.include_media_references),
@@ -17,7 +17,6 @@ from app.enums.credit_record import (
CreditRecordChargeKind,
CreditRecordOwnerType,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.schemas.hot_opening_replicate import (
HotOpeningActionOut,
@@ -36,7 +35,6 @@ from app.schemas.hot_opening_replicate import (
)
from app.services.hot_opening_replicate_service import (
_get_project_for_user,
create_hot_opening_project,
delete_hot_opening_project,
generate_image_from_prompt,
generate_video_from_prompt,
@@ -66,8 +64,8 @@ from app.services.module_async_recovery_service import (
remove_active_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
@@ -181,11 +179,6 @@ def _prompt_dispatch_billing_context(
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"爆款开头复刻图片AI提词优化"
if is_image
@@ -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
],
}
+139 -48
View File
@@ -9,17 +9,16 @@ logger = logging.getLogger("payment")
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.payment_order import PaymentOrder
from app.models.recharge_package import RechargePackage
from app.models.credit.product import CreditProduct
from app.services.credit.utils import utc_now
from app.schemas.payment import RechargeRequest, PaymentOrderOut
from app.services.payment import (
create_recharge_order,
verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
process_refund,
_get_payment_configs,
_close_alipay_order,
_get_order_expire_seconds,
PaymentCloseResult,
_resolve_order_for_cancellation,
)
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -57,27 +56,25 @@ async def recharge(
raise HTTPException(status_code=400, detail="该支付方式未启用")
result = await db.execute(
select(RechargePackage).where(
RechargePackage.id == req.plan,
RechargePackage.is_active == True,
select(CreditProduct).where(
CreditProduct.id == req.plan,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
).limit(1)
)
.limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=400, detail="无效的套餐")
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail="无效或已下架的积分商品")
try:
order = await create_recharge_order(
db,
current_user.id,
credits=pkg.credits,
price=pkg.price,
label=pkg.name,
bonus_credits=pkg.bonus_credits,
method=req.method,
product_id=product.id,
quantity=req.quantity,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return order
@@ -222,34 +219,64 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
total_amount = amount_info.get("total", 0) / 100 # 转换为元
if order_no:
await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount)
try:
processed = await process_payment_success_by_order_no(
db, order_no, transaction_id, total_amount
)
if processed:
logger.info(
f"WeChat callback processed: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
else:
logger.warning(
f"WeChat payment success not fulfilled locally: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
except Exception as e:
# 微信已经通过验签、解密并明确通知支付成功。
# 本地履约异常属于项目内部故障:回滚本地事务,但仍向微信返回 SUCCESS,
# 后续由 pending 主动查单重试或日志人工对账处理。
await db.rollback()
logger.exception(
f"WeChat payment fulfillment error: order_no={order_no}, "
f"transaction_id={transaction_id}, error={e}"
)
return {"code": "SUCCESS", "message": "OK"}
# 处理退款回调
# 处理退款回调:本版本只记录渠道退款事实,不撤销订阅、Period、Seat、积分或首购资格。
elif event_type == "REFUND.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
refund_id = decrypted_data.get("refund_id", "")
refund_status = decrypted_data.get("status", "")
refund_amount_info = decrypted_data.get("amount", {}) or {}
refund_amount = refund_amount_info.get("refund", 0) / 100
if order_no and refund_status == "SUCCESS":
# 更新订单状态为已退款
from app.models import PaymentOrder
from sqlalchemy import select
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.order_no == order_no)
.with_for_update()
.limit(1)
)
order = result.scalar_one_or_none()
if order and order.status == "refunding":
if order:
# 幂等记录渠道退款事实:即便升级前本地已经写成 refunded,
# 也要补齐渠道退款号/金额/时间;本版本绝不触碰任何订阅或积分权益。
order.status = "refunded"
order.transaction_id = refund_id
if refund_id:
order.refund_trade_no = refund_id
if refund_amount > 0:
order.refund_amount = refund_amount
elif order.refund_amount is None:
order.refund_amount = order.amount
if order.refunded_at is None:
order.refunded_at = utc_now()
order.refund_entitlement_status = "record_only"
await db.commit()
logger.info(
f"WeChat refund callback processed: order_no={order_no}, "
f"refund_id={refund_id}, status={refund_status}"
f"WECHAT_REFUND_CALLBACK_RECORDED order_no={order_no} "
f"refund_id={refund_id} amount={refund_amount} entitlement=record_only"
)
return {"code": "SUCCESS", "message": "OK"}
@@ -294,17 +321,37 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
async def list_orders(
page: int = Query(1, ge=1),
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),
db: AsyncSession = Depends(get_db),
):
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")
conditions.append(PaymentOrder.order_source == "online_payment")
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
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.user_id == current_user.id)
.where(*conditions)
.order_by(PaymentOrder.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
@@ -313,7 +360,37 @@ async def list_orders(
for o in orders:
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)
@@ -350,7 +427,7 @@ async def cancel_order(
select(PaymentOrder).where(
PaymentOrder.order_no == order_no,
PaymentOrder.user_id == current_user.id,
).limit(1)
).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
if not order:
@@ -358,19 +435,33 @@ async def cancel_order(
if order.status != "pending":
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
# If it's an Alipay or WeChat order, call close API first
# Resolve the authoritative gateway state before local cancellation.
# If WeChat explicitly reports ORDERPAID, the payment service queries only
# this order once and runs the normal atomic payment-success fulfillment.
db_configs = await _get_payment_configs(db)
if order.payment_method == "alipay":
try:
await _close_alipay_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {order_no}: {e}")
elif order.payment_method == "wechat":
try:
from app.services.payment import _close_wechat_order
await _close_wechat_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close WeChat order {order_no}: {e}")
log_user_id = str(current_user.id)
payment_method = str(order.payment_method)
close_result = await _resolve_order_for_cancellation(db, order, db_configs)
if close_result == PaymentCloseResult.PAID:
logger.info(
f"ORDER_CANCEL_PAID_RECOVERED order_no={order_no} "
f"user={log_user_id} method={payment_method}"
)
raise HTTPException(
status_code=409,
detail="订单已支付并完成支付处理,无法取消",
)
if close_result != PaymentCloseResult.CLOSED:
logger.warning(
f"ORDER_CANCEL_CLOSE_PENDING order_no={order_no} "
f"user={log_user_id} method={payment_method}"
)
raise HTTPException(
status_code=409,
detail="支付渠道暂未确认订单关闭,请稍后重试",
)
order.status = "cancelled"
await db.flush()
+26 -23
View File
@@ -1,39 +1,42 @@
"""Legacy endpoint retained for old clients; only active credit add-ons are returned."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.dependencies import get_current_user, get_db
from app.enums.credit_product import CreditProductType
from app.models.credit.product import CreditProduct
from app.models.user import User
from app.models.recharge_package import RechargePackage
from app.services.credit.product_service import product_to_dict
router = APIRouter(tags=["recharge-packages"])
def _to_out(pkg: RechargePackage) -> dict:
return {
"id": pkg.id,
"name": pkg.name,
"credits": round(pkg.credits, 2),
"price": round(pkg.price, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
"description": pkg.description,
"package_type": pkg.package_type,
"is_gift": pkg.is_gift,
"is_active": pkg.is_active,
"sort_order": pkg.sort_order,
}
@router.get("/recharge-packages")
async def list_active_packages(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Public: list active recharge packages."""
result = await db.execute(
select(RechargePackage)
.where(RechargePackage.is_active == True)
.order_by(RechargePackage.sort_order)
select(CreditProduct)
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
)
return [_to_out(p) for p in result.scalars().all()]
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
items = []
for product in result.scalars().all():
payload = product_to_dict(product, user_price=product.price, price_type="regular", can_purchase=True)
payload.update(
{
"credits": payload["grant_credits"],
"bonus_credits": 0.0,
"total_credits": payload["grant_credits"],
"package_type": "credit_addon",
"is_gift": False,
}
)
items.append(payload)
return items
+1 -8
View File
@@ -20,7 +20,6 @@ from app.enums.credit_record import (
CreditRecordOwnerType,
CreditRecordSourceStepCode,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
@@ -235,11 +234,6 @@ def _prompt_dispatch_billing_context(
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化"
),
@@ -277,7 +271,6 @@ def _analysis_dispatch_billing_context(
source_step_id=owner_id,
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=owner_id,
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
description_prefix=(
"拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析"
),
@@ -539,7 +532,7 @@ async def create_shot_task_set(
task_set, created_new = await create_task_set(db, current_user=current_user, req=req)
task_set_id = str(task_set.id)
if not created_new:
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。
# 幂等重复请求不重复消费积分和投递;已有 pending 任务由原投递或恢复任务继续处理。
await db.rollback()
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
+265 -195
View File
@@ -1,45 +1,74 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
import csv
import io
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.config import settings
from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
from app.schemas.team_join_request import (
JoinByCodeRequest,
JoinRequestHandle,
JoinRequestOut,
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
ManagedTeamOut,
ManagerTransferRequest,
SetManagerRequest,
TeamMemberOut,
)
from app.schemas.team_join_request import JoinByCodeRequest, JoinRequestHandle, JoinRequestOut, JoinTeamInfoOut
from app.schemas.team_manager import SetManagerRequest
from app.schemas.team_subscription import TeamSeatCreateRequest, TeamSeatUpdateRequest
from app.services import team_invitation_service
from app.services.credit.team_subscription_service import (
cancel_seat,
create_seat,
list_member_period_usage,
list_team_subscriptions_for_management,
update_seat,
)
from app.services.team_credit_record_service import list_team_credit_records as query_team_credit_records
from app.services.team_manager_service import (
get_managed_team,
get_manager_history,
get_team_members,
is_team_manager,
transfer_credits_to_member,
list_manager_access_teams,
set_team_manager,
)
router = APIRouter(prefix="/team", tags=["team"])
CST = timezone(timedelta(hours=8))
def _team_payload(team: Team, *, manager_name: str | None, member_count: int) -> dict:
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
async def _resolve_flow_team_id(db: AsyncSession, *, current_user: User, team_id: str | None) -> str:
if team_id:
# 真正的当前/历史队长权限由流水 Service 根据 TeamManagerHistory 再校验。
return team_id
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=400, detail="请指定需要查看的历史团队")
return team.id
# ── 获取当前用户管理的团队 ──────────────────────────────
@router.get("/managed")
async def get_managed_team_info(
current_user: User = Depends(get_current_user),
@@ -47,30 +76,25 @@ async def get_managed_team_info(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=404, detail="您不是任何团队的管理人")
from sqlalchemy import func
from app.enums.user import UserType
raise HTTPException(status_code=404, detail="当前不是任何团队的队长")
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
return _team_payload(team, manager_name=current_user.username, member_count=int(member_count))
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": current_user.username,
}
@router.get("/manager-access")
async def list_manager_access(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""当前及历史队长可访问的团队列表,用于历史团队流水入口。"""
return await list_manager_access_teams(db, current_user.id)
# ── 团队成员列表 ──────────────────────────────────────
@router.get("/members")
async def list_team_members(
page: int = Query(1, ge=1),
@@ -80,31 +104,123 @@ async def list_team_members(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员列表")
return await get_team_members(db, team.id, page=page, page_size=page_size)
# ── 转账积分给成员 ────────────────────────────────────
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: ManagerTransferRequest,
@router.put("/manager")
async def transfer_manager(
req: SetManagerRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await transfer_credits_to_member(
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以转让队长")
await set_team_manager(db, team.id, req.user_id)
return {"message": "团队队长已更换"}
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: dict = Body(default_factory=dict),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
del member_id, req, current_user, db
raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度")
@router.get("/subscriptions")
async def list_team_subscriptions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
return await list_team_subscriptions_for_management(db, team_id=team.id)
@router.post("/subscriptions/{subscription_id}/seats")
async def create_subscription_seat(
subscription_id: str,
req: TeamSeatCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
seat = await create_seat(
db,
current_user.id,
req.target_user_id,
req.amount,
req.direction or "increase",
req.description,
team_id=team.id,
subscription_id=subscription_id,
manager_user_id=current_user.id,
user_id=req.user_id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "ok"}
return {"message": "席位已创建", "seat_id": seat.id}
# ── 邀请码管理 ────────────────────────────────────────
@router.post("/invitations", )
@router.put("/seats/{seat_id}")
async def update_subscription_seat(
seat_id: str,
req: TeamSeatUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
seat = await update_seat(
db,
team_id=team.id,
seat_id=seat_id,
manager_user_id=current_user.id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "席位额度已更新", "seat_id": seat.id}
@router.delete("/seats/{seat_id}")
async def cancel_subscription_seat(
seat_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
await cancel_seat(db, team_id=team.id, seat_id=seat_id, manager_user_id=current_user.id)
return {"message": "席位已取消"}
@router.get("/member-usage")
async def get_member_usage(
subscription_id: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员团队积分消耗")
return await list_member_period_usage(db, team_id=team.id, subscription_id=subscription_id)
@router.get("/manager-history")
async def manager_history(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看完整队长任期历史")
return await get_manager_history(db, team.id)
@router.post("/invitations")
async def create_invitation(
req: TeamInvitationCreate,
current_user: User = Depends(get_current_user),
@@ -112,15 +228,13 @@ async def create_invitation(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
raise HTTPException(status_code=403, detail="只有团队队长可创建邀请码")
expires_at = None
if req.expires_at:
try:
expires_at = datetime.fromisoformat(req.expires_at)
except ValueError:
raise HTTPException(status_code=400, detail="过期时间格式错误")
except ValueError as exc:
raise HTTPException(status_code=400, detail="过期时间格式错误") from exc
invitation = await team_invitation_service.create_invitation(
db, team.id, current_user.id, req.max_uses, expires_at
)
@@ -143,7 +257,7 @@ async def list_invitations(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有团队队长可查看邀请码")
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
return [
{
@@ -167,10 +281,9 @@ async def revoke_invitation(
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
return {"message": "ok"}
return {"message": "邀请码已撤销"}
# ── 加入申请 ──────────────────────────────────────────
@router.post("/join")
async def join_by_code(
req: JoinByCodeRequest,
@@ -178,42 +291,43 @@ async def join_by_code(
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
return {"message": "申请已提交,请等待团队管理人审批"}
return {"message": "申请已提交,请等待团队队长审批"}
@router.get("/join-info", )
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
async def _join_info_payload(
db: AsyncSession,
*,
code: str,
current_user: User | None,
) -> JoinTeamInfoOut:
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
team_result = await db.execute(
select(Team).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
already_in_team = current_user and current_user.team_id == invitation.team_id
team = team_result.scalar_one_or_none()
if not team or team.status != TeamStatus.ACTIVE.value:
return JoinTeamInfoOut(
team_name=team.name if team else "",
team_id=invitation.team_id,
valid=False,
already_in_team=False,
has_pending_request=False,
)
already_in_team = bool(current_user and current_user.team_id == invitation.team_id)
has_pending_request = False
if current_user:
from app.models.team_join_request import TeamJoinRequest
pending = await db.execute(
select(TeamJoinRequest).where(
select(TeamJoinRequest.id).where(
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
has_pending = pending.scalar_one_or_none()
has_pending_request = has_pending is not None
has_pending_request = pending.scalar_one_or_none() is not None
return JoinTeamInfoOut(
team_name=team_name,
team_name=team.name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
@@ -221,31 +335,21 @@ async def get_join_info(
)
@router.get("/join-info/public", )
async def get_join_info_public(
@router.get("/join-info")
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return {"team_name": "", "team_id": "", "valid": False, "already_in_team": False, "has_pending_request": False}
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
return {
"team_name": team_name,
"team_id": invitation.team_id,
"valid": True,
"already_in_team": False,
"has_pending_request": False,
}
return await _join_info_payload(db, code=code, current_user=current_user)
@router.get("/join-requests", )
@router.get("/join-info/public")
async def get_join_info_public(code: str = Query(...), db: AsyncSession = Depends(get_db)):
return await _join_info_payload(db, code=code, current_user=None)
@router.get("/join-requests")
async def list_join_requests(
status: str | None = Query(None),
current_user: User = Depends(get_current_user),
@@ -253,29 +357,22 @@ async def list_join_requests(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有团队队长可查看加入申请")
requests = await team_invitation_service.get_all_requests(db, team.id, status)
# 获取团队名
team_name_result = await db.execute(
select(Team.name).where(Team.id == team.id).limit(1)
)
team_name = team_name_result.scalar_one_or_none() or ""
return [
JoinRequestOut(
id=r["id"],
team_id=r["team_id"],
team_name=team_name,
user_id=r["user_id"],
username=r["username"],
phone=r.get("phone"),
status=r["status"],
note=r.get("note"),
created_at=r["created_at"],
handled_at=r.get("handled_at"),
id=item["id"],
team_id=item["team_id"],
team_name=team.name,
user_id=item["user_id"],
username=item["username"],
phone=item.get("phone"),
status=item["status"],
note=item.get("note"),
created_at=item["created_at"],
handled_at=item.get("handled_at"),
)
for r in requests
for item in requests
]
@@ -286,131 +383,104 @@ async def handle_join_request(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.handle_join_request(
db, request_id, current_user.id, req.action, req.note
)
return {"message": "ok"}
await team_invitation_service.handle_join_request(db, request_id, current_user.id, req.action, req.note)
return {"message": "申请已处理"}
# ── 团队积分变动记录 ────────────────────────────────────
@router.get("/credit-records")
async def list_team_credit_records(
team_id: str | None = Query(None, description="历史队长查看旧团队时传团队ID"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
phone: str | None = Query(None, description="按手机号搜索"),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查看团队所有成员的积分变动记录(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
# 如果传了 phone,先找到对应的 user_id
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
if not resolved_user_id:
return {"items": [], "total": 0, "summary": {}}
return await list_admin_credit_records(
return {"items": [], "total": 0, "page": page, "page_size": page_size}
return await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=page,
page_size=page_size,
team_id=team.id,
user_id=resolved_user_id,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# ── 团队积分导出 Excel ──────────────────────────────────
@router.get("/credit-records/export")
async def export_team_credit_records(
team_id: str | None = Query(None),
user_id: str | None = Query(None),
phone: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""导出团队积分变动记录为 Excel(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
# 拉取全部记录(不分页)
result = await list_admin_credit_records(
if not resolved_user_id:
# 导出筛选手机号不存在时必须返回空结果,不能因为 user_id=None 退化成导出整个团队流水。
resolved_user_id = "__not_found__"
result = await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=1,
page_size=10000,
team_id=team.id,
user_id=resolved_user_id,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# 生成 CSV(兼容 Excel 打开)
import csv
import io
def _format_dt(val):
if val is None:
return "-"
return str(datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S"))
team_result = await db.execute(select(Team).where(Team.id == resolved_team_id).limit(1))
team = team_result.scalar_one_or_none()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
writer.writerow(["用户名", "流水类型", "团队积分变动", "说明", "订阅实例", "周期ID", "席位ID", "时间"])
for item in result.get("items", []):
created_at = item.get("created_at")
if isinstance(created_at, datetime):
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=CST)
else:
created_at = created_at.astimezone(CST)
created_at = created_at.strftime("%Y-%m-%d %H:%M:%S")
writer.writerow([
item.get("username") or "-",
item.get("phone") or "-",
item.get("record_type_label") or item.get("type") or "-",
item.get("amount", 0),
item.get("balance_after", 0),
item.get("record_type_label") or "-",
item.get("team_amount", 0),
item.get("description") or "-",
_format_dt(item.get("created_at")),
item.get("subscription_no") or "历史订阅",
item.get("subscription_period_id") or "-",
item.get("seat_id") or "-",
created_at or "-",
])
from starlette.responses import StreamingResponse
from urllib.parse import quote
filename = f"团队积分_{(team.name if team else resolved_team_id)}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
output.seek(0)
safe_team_name = team.name or "team"
filename = f"团队积分_{safe_team_name}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
encoded_filename = quote(filename)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8-sig",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
+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,
)
@@ -0,0 +1,66 @@
from __future__ import annotations
import asyncio
import json
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
_UNRESOLVED_HOLDS_SQL = text(
"""
SELECT
h.id,
h.user_id,
h.biz_key,
h.amount,
h.created_at
FROM credit_records AS h
WHERE h.type = 'hold'
AND NOT EXISTS (
SELECT 1
FROM credit_records AS x
WHERE x.refund_for_biz_key = h.biz_key
OR x.biz_key IN (
REPLACE(h.biz_key, :hold_suffix, :hold_release_suffix),
REPLACE(h.biz_key, :hold_suffix, :charge_suffix)
)
)
ORDER BY h.created_at ASC
LIMIT 5000
"""
)
async def amain() -> int:
"""维护窗口检查脚本:列出旧 HOLD 未形成 RELEASE/CHARGE 的业务,禁止带病切换。"""
async with async_session() as db:
result = await db.execute(
_UNRESOLVED_HOLDS_SQL,
{
"hold_suffix": ":hold",
"hold_release_suffix": ":hold_release",
"charge_suffix": ":charge",
},
)
rows = result.mappings().all()
print(
json.dumps(
{
"checked_at": utc_now().isoformat(),
"unresolved_count": len(rows),
"items": [dict(row) for row in rows],
},
ensure_ascii=False,
indent=2,
default=str,
)
)
return 0 if not rows else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
@@ -0,0 +1,96 @@
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from decimal import Decimal
from sqlalchemy import text
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.enums.credit_record import CreditRecordType
from app.models.base import async_session
from app.services.credit.ledger_service import grant_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_credit_decimal, utc_now
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="将 users.credits 一次性迁移到动态积分余额表。")
parser.add_argument("--batch-size", type=int, default=500)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--commit", action="store_true")
parser.add_argument("--after-id", default="")
return parser
async def amain(argv: list[str]) -> int:
args = build_parser().parse_args(argv)
if args.dry_run and args.commit:
raise SystemExit("--dry-run 和 --commit 不能同时使用")
do_commit = bool(args.commit)
batch_size = max(1, min(int(args.batch_size or 500), 5000))
migration_time = utc_now()
cursor = str(args.after_id or "")
stats = {"positive_users": 0, "zero_users": 0, "negative_reset_users": 0, "migrated_credits": "0.00", "last_id": cursor}
total = Decimal("0.00")
while True:
async with async_session() as db:
rows = (await db.execute(
text("SELECT id, credits FROM users WHERE id > :cursor ORDER BY id ASC LIMIT :limit"),
{"cursor": cursor, "limit": batch_size},
)).mappings().all()
if not rows:
break
try:
for row in rows:
user_id = str(row["id"])
legacy = to_credit_decimal(row.get("credits") or 0)
cursor = user_id
stats["last_id"] = cursor
if legacy > 0:
stats["positive_users"] += 1
total += legacy
if do_commit:
await grant_credits(
db,
user_id=user_id,
amount=legacy,
description="历史用户积分迁移",
source_type=CreditBalanceSourceType.LEGACY_MIGRATION.value,
valid_from=migration_time,
expires_at=add_natural_months(migration_time, 12),
credit_level=CreditLevel.GENERAL.value,
source_id=user_id,
related_id=user_id,
record_type=CreditRecordType.RECHARGE.value,
biz_key=f"legacy-user-credits:{user_id}",
metadata_json={"legacy_credits": str(legacy), "migration_time": migration_time.isoformat()},
request_time=migration_time,
)
elif legacy < 0:
stats["negative_reset_users"] += 1
else:
stats["zero_users"] += 1
if do_commit:
await db.commit()
else:
await db.rollback()
except Exception:
await db.rollback()
raise
stats["migrated_credits"] = str(total.quantize(Decimal("0.01")))
stats["migration_time"] = migration_time.isoformat()
stats["mode"] = "commit" if do_commit else "dry-run"
print(json.dumps(stats, ensure_ascii=False, indent=2))
return 0
def main() -> int:
return asyncio.run(amain(sys.argv[1:]))
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,38 @@
from __future__ import annotations
import asyncio
import json
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
async def amain() -> int:
async with async_session() as db:
rows = (await db.execute(text("""
SELECT e.id, e.total_call_count, e.successful_call_count, e.failed_call_count,
e.total_input_tokens, e.total_output_tokens, e.total_tokens,
COUNT(a.id) AS actual_calls,
COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0) AS actual_success,
COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0) AS actual_failed,
COALESCE(SUM(a.input_tokens), 0) AS actual_input,
COALESCE(SUM(a.output_tokens), 0) AS actual_output,
COALESCE(SUM(a.total_tokens), 0) AS actual_total
FROM llm_billing_executions e
LEFT JOIN llm_call_attempts a ON a.billing_execution_id = e.id
GROUP BY e.id
HAVING e.total_call_count <> COUNT(a.id)
OR e.successful_call_count <> COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0)
OR e.failed_call_count <> COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0)
OR e.total_tokens <> COALESCE(SUM(a.total_tokens), 0)
ORDER BY e.id
LIMIT 1000
"""))).mappings().all()
print(json.dumps({"checked_at": utc_now().isoformat(), "mismatch_count": len(rows), "items": [dict(r) for r in rows]}, ensure_ascii=False, indent=2, default=str))
return 0 if not rows else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))

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