213 Commits
Author SHA1 Message Date
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
root d2c85b03df 1 2026-07-31 09:51:11 +08:00
root 33d5bfd997 1 2026-07-31 09:49:41 +08:00
sjy 15deed1cf4 修改缩略图 2026-07-28 17:33:48 +08:00
root 7077d05d6a 1 2026-07-28 14:22:56 +08:00
root 570292266e 修复图片生成积分后台计算逻辑不对没有加上每张积分 2026-07-28 11:43:15 +08:00
root 9b3541264f 修复行业提示词优化最终不使用附件还是传到模型的错误 2026-07-28 10:34:48 +08:00
root 1f711fed7f 1 2026-07-27 20:52:47 +08:00
root 2d9abd5218 1 2026-07-27 20:52:39 +08:00
root cfcec301d2 1 2026-07-27 20:49:20 +08:00
root 3d144a4640 1 2026-07-27 20:45:43 +08:00
root 8d5c900411 1 2026-07-27 20:15:59 +08:00
root 8fbf239e01 Merge branch 'online' of https://gitee.com/wg123/video-gen 2026-07-27 19:28:27 +08:00
root 9114b45841 Merge branch 'online' 2026-07-27 19:18:38 +08:00
sjy 0576169773 爆款切换缩略图 2026-07-27 17:44:17 +08:00
sjy 1bdafa25cb 爆款拆镜修改 2026-07-27 17:22:59 +08:00
root 4eb0360134 1 2026-07-24 18:26:20 +08:00
sjy ac73de70b4 编辑提示词修改 2026-07-24 17:58:55 +08:00
sjy 0195175231 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-24 17:56:56 +08:00
sjy af1dd20789 本地修改 2026-07-24 17:56:42 +08:00
root da08fc43cb 1 2026-07-24 17:27:39 +08:00
GinHa acac39c73c Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-24 16:12:48 +08:00
GinHa 8325ae1f71 视频提词API超时600秒 | 错误信息重新捕获 2026-07-24 16:12:41 +08:00
root 03ed98ce9b 1 2026-07-24 16:07:05 +08:00
root bc7eee681d 爆款和拆镜error消息正则中文 2026-07-24 15:56:15 +08:00
GinHa 663c4fa9db 拆镜 AI拆片 API 可重复执行 2026-07-24 14:30:28 +08:00
GinHa 4685b475af 修复冻结积分BUG | 拆镜状态异常BUG 2026-07-24 14:00:37 +08:00
GinHa 357657d7cd Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-24 12:06:22 +08:00
GinHa e55077a833 max_token save 10000 2026-07-24 12:06:13 +08:00
sjy 0e20981383 页面样式优化 2026-07-24 11:29:04 +08:00
GinHa a445198ecb 版本迁移兼容修复 2026-07-24 09:38:12 +08:00
GinHa 42f103a945 积分冻结释放 merge | build admin 2026-07-24 09:30:50 +08:00
GinHa 68e902b4a4 积分冻结释放 2026-07-24 09:18:05 +08:00
root 053dde7546 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-23 18:37:05 +08:00
root cc0f8e5fd8 1 2026-07-23 18:37:04 +08:00
sjy d5cb11dc60 行业提词优化,积分不足判断 2026-07-23 17:41:44 +08:00
sjy 7ea612d374 暂存修改 2026-07-23 13:07:33 +08:00
sjy 19d6580704 暂存修改 2026-07-23 13:06:11 +08:00
GinHa 920d884e92 celery 升级优化V2 | 日志调整 | 前端BUG修复 | build admin/app 2026-07-23 12:10:55 +08:00
GinHa 9463b94a3e Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-23 12:07:34 +08:00
GinHa 2931e67226 celery 升级优化V2 | 日志调整 | 前端BUG修复 2026-07-23 12:07:23 +08:00
root 06c2159b15 1 2026-07-23 09:45:16 +08:00
root 7ecae0eb61 1 2026-07-23 09:15:25 +08:00
root 54b8e1a360 消息添加增加富文本 2026-07-23 09:11:04 +08:00
root 4fdbe0c946 1 2026-07-22 16:56:42 +08:00
root ed2e1715d1 1 2026-07-22 16:47:19 +08:00
GinHa 06bf5c4db7 celery 升级容灾移除冗余文件 2026-07-22 15:15:37 +08:00
sjy 73243ff497 状态修改 2026-07-22 15:13:22 +08:00
sjy 966d1ff5d3 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-22 15:12:50 +08:00
root cb682b4867 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-22 14:53:18 +08:00
root 6b934b07dc 1 2026-07-22 14:53:03 +08:00
sjy 6db0e2d3d5 合并修改 2026-07-22 14:52:08 +08:00
GinHa 69e7dec807 celery 容灾升级 2026-07-22 14:48:29 +08:00
sjy d0ccb04b07 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-22 14:34:47 +08:00
sjy eee8105d0e 合并修改 2026-07-22 14:34:07 +08:00
root 3f1c4063b0 修复。image_batch_service.py 中的 claim.task_snapshot 是 SimpleNamespace(ChatGenerationTask 的快照),传给 owner_include_media_references() 时因为类型不匹配报错 2026-07-22 14:33:19 +08:00
sjy eec5862465 爆款,拆镜流程修改 2026-07-22 14:26:57 +08:00
root 6b78d5830d 1 2026-07-22 14:04:54 +08:00
root d111977023 1 2026-07-22 13:44:37 +08:00
GinHa b41e1d6607 移除提词fps参数 2026-07-22 12:10:54 +08:00
root def054c147 1 2026-07-22 12:01:46 +08:00
root 1cb957d20d 1 2026-07-22 11:10:32 +08:00
root a63039c146 1 2026-07-22 10:02:54 +08:00
root d8c5258822 1 2026-07-22 10:01:21 +08:00
root 7afd5b1d5e 1 2026-07-22 09:59:27 +08:00
root 04fad1af9d 1 2026-07-22 09:44:08 +08:00
root b32ed4129c 1 2026-07-22 09:34:21 +08:00
root 9b13d01d72 增加预扣积分 2026-07-22 09:27:33 +08:00
root 4afda8992c 项目积分不足直接返回 2026-07-21 19:28:02 +08:00
root 6f37134c8c Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-21 19:22:55 +08:00
root 7683ebb186 1 2026-07-21 19:22:50 +08:00
GinHa 0aa884304d 爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制 build app admin 2026-07-21 14:04:26 +08:00
GinHa 4f1ccd3f37 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-21 14:01:22 +08:00
GinHa 79c09151ba 爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制 2026-07-21 14:01:08 +08:00
root b374a541ea 1 2026-07-21 10:46:26 +08:00
root 56f380aa33 1 2026-07-21 10:43:58 +08:00
root 65fc424583 1 2026-07-21 10:40:54 +08:00
root 5538ddaa43 1 2026-07-21 10:34:23 +08:00
root ea06edec3e 1 2026-07-21 10:26:10 +08:00
root 6283bb8ceb 1 2026-07-21 10:25:30 +08:00
root fb3b95b687 1 2026-07-21 10:12:11 +08:00
root 3c6deed786 1 2026-07-21 10:10:16 +08:00
root 2a9c2d868c 1 2026-07-21 10:08:00 +08:00
root 76d03e7de1 后台概览页面修改 2026-07-21 10:03:32 +08:00
root 880e7ed515 1 2026-07-20 18:03:53 +08:00
sjy 40efcf55cf 项目生成模块选择器闪动修改 2026-07-20 15:41:00 +08:00
GinHa 1aaef451b8 项目图片生成提词拦截开放 2026-07-20 15:37:51 +08:00
GinHa 2403b38cae Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-20 14:11:52 +08:00
GinHa c0a58fdac3 项目/AI生成链路合并 版本迁移修复 2026-07-20 14:11:45 +08:00
root c025303ef8 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-20 14:01:28 +08:00
root a2e98ef3a3 1 2026-07-20 14:01:22 +08:00
GinHa 4af74acf25 项目/AI生成链路合并 admin build 2026-07-20 13:50:39 +08:00
GinHa d5a964a4d9 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-20 13:48:27 +08:00
GinHa fe5a59d725 项目/AI生成链路合并 2026-07-20 13:48:17 +08:00
root 53e01f6f74 1、修改联系我们后台接口程序和界面
2、修改提示词优化使用模型规则,改为按照后台设置排序倒序使用对应模型,如果失败使用下一个
2026-07-20 10:28:17 +08:00
root 46b59ff76c 1 2026-07-20 09:26:18 +08:00
root c145efd0b1 1 2026-07-17 19:36:22 +08:00
root 4104774174 1 2026-07-17 19:35:35 +08:00
root 4563b686e0 1 2026-07-17 19:30:47 +08:00
root e264214b77 1 2026-07-17 19:21:16 +08:00
root a7d8bd25bb 1 2026-07-17 19:10:39 +08:00
root bbc9bd95bd 1 2026-07-17 19:09:45 +08:00
sjy d202316708 merge main 2026-07-17 18:11:02 +08:00
sjy 519c1e58b9 样式优化 2026-07-17 18:02:39 +08:00
root bba29d79a2 1 2026-07-17 17:15:04 +08:00
GinHa 34ca98f9eb 修复LLM 爆款/拆镜返回中文提词 2026-07-17 16:04:41 +08:00
GinHa 1aa7ff02f4 修复LLM 爆款/拆镜重复资源BUG 2026-07-17 15:39:06 +08:00
GinHa 2d3d096fd7 修复LLM base64资源定位BUG 2026-07-17 14:58:05 +08:00
root 8ca6f0fedb Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-17 09:14:36 +08:00
root 5b4449fe97 修改行业智造名字 2026-07-17 09:14:21 +08:00
sjy 71ec6f7d47 媒体生成百分比显示 2026-07-16 17:58:16 +08:00
sjy f0ff573650 媒体生成百分比显示 2026-07-16 17:52:49 +08:00
GinHa c5292fcf03 超分独立cli 2026-07-16 17:50:42 +08:00
GinHa edfeea6f98 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-16 16:55:27 +08:00
GinHa 395702fbd5 修复行业智造图片生成BUG 2026-07-16 16:54:42 +08:00
root 098a639be6 1 2026-07-16 15:38:48 +08:00
GinHa a7c99839de 超分 build admin 2026-07-16 15:04:28 +08:00
GinHa 6a7de32b3a Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-16 15:02:14 +08:00
GinHa 3db586cd60 超分功能完成 2026-07-16 15:02:03 +08:00
root 52f53a0c63 1 2026-07-16 14:47:36 +08:00
root c80e2150f5 1 2026-07-16 14:43:20 +08:00
root 4d872036b2 调整后台文字模型请求发送图片或者视频的方式base64,而不是现在的链接形式,增加后台配置 2026-07-16 13:36:37 +08:00
root 38040dfb0d 1 2026-07-16 09:40:40 +08:00
root e44885461b 1 2026-07-16 09:39:19 +08:00
root 01c55721ca 1 2026-07-16 09:33:13 +08:00
root 085e85967a 1 2026-07-16 09:29:15 +08:00
root 41feb480ee 1、修改前台登陆样式背景可使用视频
2、增加后台配置前台背景样式
2026-07-16 09:28:43 +08:00
sjy 5036efe9aa Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-15 17:15:13 +08:00
sjy 3188d77b88 生成数量添加 2026-07-15 17:10:49 +08:00
root 3e900a43f0 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-15 13:51:36 +08:00
root 6db989dc42 1 2026-07-15 13:51:21 +08:00
sjy b1a8c3ed5a “爆款复刻/拆镜复刻积分不足提示” 2026-07-15 13:38:33 +08:00
GinHa 51673453d8 AI创作批量生成任务 V4 修复客户端AI创作排序BUG 2026-07-15 13:30:59 +08:00
GinHa 7d9c50a74d AI创作批量生成任务 V3 修复AI创作客户端排序 2026-07-15 13:23:02 +08:00
GinHa 0ffb35e2df AI创作批量生成任务 main V2 build push 2026-07-15 13:10:15 +08:00
GinHa 9756a86304 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-15 13:03:23 +08:00
GinHa 51f9deecde AI创作批量生成任务 main V1 init 2026-07-15 13:03:13 +08:00
root a9190ba4e1 修改复制的问题 2026-07-15 10:11:53 +08:00
root b4166ecadb 修改用户积分修改默认 2026-07-14 17:58:02 +08:00
sjy 4ade6bb347 “修改用户名/验证码滑动发送提示” 2026-07-14 17:23:28 +08:00
root 9637e5ba79 修改时区显示问题 2026-07-14 14:13:29 +08:00
root 21ff42653c 增加修改用户名 2026-07-14 11:18:28 +08:00
sjy 5b937f652b “真人人像/虚拟素材批量上传” 2026-07-13 17:54:01 +08:00
GinHa b2b322bcfb Merge branch 'main' into online 2026-07-13 17:34:26 +08:00
root dc2e527977 1 2026-07-13 17:32:21 +08:00
root 6ff8866ca3 修改后台权限的问题 2026-07-13 17:23:56 +08:00
sjy 6039b5ee75 “重新编辑修改” 2026-07-11 17:24:41 +08:00
GinHa adfc2aa4f4 Merge branch 'main' into online 2026-07-11 17:15:44 +08:00
sjy 399aefe61f “重新编辑修改” 2026-07-11 17:07:47 +08:00
sjy 4ab2baae43 “重新编辑修改” 2026-07-11 15:57:34 +08:00
root fed6ba2e19 1 2026-07-11 15:41:23 +08:00
root 18fe13dd9f 支付统计增加手机号 2026-07-11 15:35:54 +08:00
sjy 36e4bec90e “重新编辑修改” 2026-07-11 15:32:57 +08:00
root af60e6720b 1 2026-07-11 13:49:36 +08:00
GinHa 21208e68d8 Merge branch 'main' into online 2026-07-11 13:25:53 +08:00
root b79162654d 1 2026-07-11 13:23:01 +08:00
sjy 778da67b34 “拆镜状态修改” 2026-07-11 13:18:33 +08:00
GinHa a7cd5a634c celery 2小时结果留存 2026-07-11 13:16:21 +08:00
GinHa 0962fa7820 Merge branch 'main' into online 2026-07-11 13:05:21 +08:00
root 344fb789a4 1 2026-07-11 13:00:06 +08:00
root 6cc1655c69 1 2026-07-11 12:48:29 +08:00
root c64cf06c33 1 2026-07-11 12:38:03 +08:00
root cba3450596 1 2026-07-11 12:18:37 +08:00
root 39160822f7 1、修改素材云样式
2、修改用户私域人像素材默认上限PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT为50
2026-07-11 12:16:13 +08:00
sjy 32673f0f25 “历史素材” 2026-07-11 11:35:18 +08:00
sjy 742c8688bc “历史素材” 2026-07-11 11:32:20 +08:00
root 37588d0f05 前台素材云页面样式统一修改 2026-07-11 11:31:30 +08:00
root f5f4be2986 取消 2026-07-11 11:05:19 +08:00
root d2a30c58ca 1、后台系统设置settings页面增加一个开启关闭网站的按钮
2、如果网站关闭,前台页面所有请求暂时跳转单独的页如果网站关闭,前台页面所有请求暂时跳转单独的页面,页面内容 系统正在升级相关信息
3、要提供一个开发人员可以查看真实网站内容的入口
2026-07-11 10:58:48 +08:00
root 111c74c7c6 增加架构md跟使用md优化 2026-07-11 09:17:07 +08:00
root 8641d507e6 1 2026-07-10 19:28:39 +08:00
root 330ac27b1e 1 2026-07-10 19:27:17 +08:00
root 508732bc76 1 2026-07-10 19:23:52 +08:00
root 614ae1b4d6 1 2026-07-10 19:20:53 +08:00
root 63e1a43b2a Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-10 18:33:35 +08:00
root 8270dc6500 1 2026-07-10 18:33:27 +08:00
root d0bd171ab5 1 2026-07-10 18:24:07 +08:00
389 changed files with 62006 additions and 20228 deletions
+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/ # 错误日志
```
+882
View File
@@ -0,0 +1,882 @@
# VideoGen 代码架构指南
> 本文档面向开发者或 AI 模型,帮助快速理解代码风格、架构约定和修改规则。
# AI 工作规则(最高优先级)
当你执行开发任务时:
1. 不要扫描整个 Repository。
2. 不要执行全量目录探索。
3. 仅阅读本任务涉及的目录。
4. 优先按照本文档中的"快速定位表"寻找文件。
5. 如果无法定位文件,再进行有限范围搜索。
6. 单次最多搜索一级目录。
7. 不允许重复搜索已经访问过的目录。
8. 阅读完目标文件后立即开始修改代码。
---
## 一、整体架构
```
video_item/
├── video-gen-api/ # 后端 (Python FastAPI)
├── video-gen-app/ # 前台/用户端 (React + Vite) ──┤
└── video-gen-admin/ # 后台管理端 (React + Vite) ──┘ 两者共用同一个后端 API
```
**核心原则:** 三个项目完全分离,前台和后台是两个独立的 SPA,部署在不同的子域名上,但调用同一套后端接口。
---
## 二、后端架构 (video-gen-api)
### 2.1 目录结构
```
app/
├── api/ # API 路由层(薄层,仅做参数解析和调用 service)
│ ├── v1/ # 前台 + 部分管理接口(FastAPI 意义上的 v1 版本)
│ │ ├── __init__.py # 聚合所有 v1 router
│ │ ├── auth.py
│ │ ├── projects.py
│ │ ├── generation.py
│ │ ├── admin.py # prefix="/admin"(管理接口混入 v1
│ │ └── ...
│ └── admin/ # 额外的管理端细分路由
│ ├── __init__.py
│ ├── team.py
│ ├── home_material.py
│ └── ...
├── schemas/ # Pydantic 模型(请求入参 + 响应序列化)
│ ├── common.py # 公共基类:NaiveDatetime, PaginatedResponse
│ ├── generation.py
│ ├── auth.py
│ └── ...
├── models/ # SQLAlchemy ORM 模型(41 个)
│ ├── base.py # Base, TimestampMixin, SoftDeleteMixin
│ ├── user.py
│ └── ...
├── services/ # 业务逻辑层(核心业务写在这里)
│ ├── auth.py # JWT 认证、密码哈希
│ ├── credits.py # 积分扣减
│ ├── operation_log.py # 操作日志
│ └── ...
├── enums/ # 枚举定义(全部用 str, Enum
│ ├── generation_status.py
│ ├── celery_queue.py
│ └── ...
├── tasks/ # Celery 异步任务
│ ├── celery_app.py # Celery 实例 + 配置
│ ├── cleanup.py
│ └── ...
├── middleware/ # FastAPI 中间件
│ ├── logging.py
│ ├── rate_limit.py
│ ├── request_encrypt.py
│ ├── anti_crawler.py
│ └── ...
├── utils/ # 通用工具
│ ├── id_gen.py # generate_id(), generate_order_no()
│ ├── exceptions.py # 自定义 HTTPException
│ └── security.py # AES 加密、HMAC
├── dependencies.py # FastAPI 依赖注入(get_db, get_current_user 等)
├── config.py # Pydantic Settings,读取 .env
└── main.py # FastAPI 应用入口、lifespan、种子数据
```
### 2.2 分层约定(重要)
请求处理严格遵循 **路由 → 服务 → 模型** 三层:
```
Route (api/) → Service (services/) → Model (models/)
解析参数 业务逻辑 数据库操作
调用 service 编排调用 纯 CRUD
返回序列化结果 不直接操作 ORM 无业务规则
```
**禁止:**
- 路由中直接写复杂 SQL/ORM 操作 → 应抽到 service
- Model 文件中写业务逻辑 → model 只定义字段和关系
- Service 中混入 HTTP 相关代码 → 不 import FastAPI 对象
### 2.3 SQLAlchemy 模型约定
使用 SQLAlchemy 2.0 风格的 `mapped_column`
```python
# app/models/project.py — 标准模板
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class Project(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(128))
industry: Mapped[str] = mapped_column(String(32))
```
**约定:**
- 主键统一用 `String(32)`,由 `generate_id()` 生成(13 位时间戳 hex + 6 位随机 hex
- 继承 `TimestampMixin` 自动获得 `created_at` / `updated_at`
- 需要软删除的继承 `SoftDeleteMixin` 获得 `deleted_at`
- 外键统一加 `index=True``ondelete="CASCADE"` 按需
- 所有 `str` 类型指定长度:`String(32)` / `String(128)` / `String(255)`
- 时间统一用 `DateTime(timezone=True)`
- **新增 model 后**必须在 `app/models/__init__.py` 中 import
```python
# app/models/base.py — 公共 Mixin
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class SoftDeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
```
### 2.4 Pydantic Schema 约定
```python
# app/schemas/project.py
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class ProjectCreate(BaseModel): # 请求入参(客户端 → 服务端)
name: str = Field(..., max_length=128)
industry: str = Field(..., max_length=64)
class ProjectOut(BaseModel): # 响应序列化(服务端 → 客户端)
id: str
name: str
industry: str
created_at: NaiveDatetime
updated_at: NaiveDatetime
model_config = {"from_attributes": True} # ← 必加!允许从 ORM 对象直接构造
```
**命名约定:**
- `XxxCreate` — POST 创建请求
- `XxxUpdate` — PUT 更新请求
- `XxxOut` / `XxxResponse` — 响应
- `XxxListOut` — 列表响应(含 items + total
- `XxxRequest` — 通用请求
**公共工具:**
- `NaiveDatetime` / `NaiveDatetimeOptional` — 自动将带时区的 datetime 转为北京时间 naive datetime
- `PaginatedResponse` — 分页响应基类
### 2.5 API 路由约定
```python
# app/api/v1/projects.py — 标准模板
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectOut
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"]) # prefix 不含 /api
@router.get("", response_model=list[ProjectOut]) # GET 列表用 list[XxxOut]
async def list_projects(
current_user: User = Depends(get_current_user), # 鉴权依赖
db: AsyncSession = Depends(get_db), # 数据库 session
):
result = await db.execute(
select(Project).where(
Project.user_id == current_user.id,
Project.deleted_at.is_(None), # 软删除过滤
).order_by(Project.created_at.desc())
)
return result.scalars().all() # 直接返回 ORM 对象,Pydantic 自动序列化
@router.post("", response_model=ProjectOut)
async def create_project(
req: ProjectCreate, # 请求体自动校验
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
project = Project(
id=generate_id(), # 主键手工生成
user_id=current_user.id,
name=req.name,
industry=req.industry,
)
db.add(project)
await db.flush() # flush 获取自增字段但不出事务
return project
```
**关键约定:**
- `prefix` 不含 `/api``/api``main.py``include_router` 统一加)
- 受保护接口用 `Depends(get_current_user)` 获取当前用户
- 管理员接口用 `Depends(get_admin_user)`
- 可选登录用 `Depends(get_optional_current_user)`
- 查询过滤软删除: `.where(Model.deleted_at.is_(None))`
- 返回 ORM 对象时 Schema 必须有 `model_config = {"from_attributes": True}`
- 错误抛 `HTTPException` 或用自定义异常(见 `app/utils/exceptions.py`
### 2.6 依赖注入约定
```python
# app/dependencies.py
get_db # 生成 AsyncSession,自动 commit/rollback
get_current_user # 验证 JWT → 返回 User,要求密码已设置
get_current_user_allow_password_pending # 验证 JWT → 返回 User(允许未设置密码)
get_optional_current_user # 验证 JWT → User | None(不强制登录)
get_admin_user # 验证 JWT + is_admin + user_type=="admin"
get_backend_user # 验证 JWT + user_type=="admin"(不要求 is_admin
```
### 2.7 服务层约定
```python
# app/services/team_service.py — 标准模板
from __future__ import annotations # 前向引用必需的 import
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.schemas.team import TeamCreate, TeamUpdate
from app.utils.id_gen import generate_id
async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
team = Team(
id=generate_id(),
name=req.name,
code=req.code,
description=req.description,
)
db.add(team)
await db.flush()
return team
```
**约定:**
- 函数签名统一 `(db: AsyncSession, ...) → Model | list[Model] | dict`
- 不处理 HTTP 异常,HTTP 相关处理留给 route 层
-`from __future__ import annotations` 支持前向引用
- 纯查询类服务可以接收 `db` 作为第一个参数
### 2.8 枚举约定
```python
# app/enums/generation_status.py
from enum import Enum
class GenerationStatus(str, Enum): # 继承 str 使值可直接序列化
prompt_optimized = "prompt_optimized"
generating = "generating"
completed = "completed"
failed = "failed"
class GenerationType(str, Enum):
video = "video"
image = "image"
```
**约定:**
- 所有枚举继承 `str, Enum`(可序列化为 JSON
- 枚举值用 snake_case
- 枚举文件统一放 `app/enums/`
- 在 schema 中引用枚举做校验: `gen_type: GenerationType = Field(...)`
### 2.9 异常处理约定
```python
# app/utils/exceptions.py — 预定义异常
InsufficientCreditsError # 402 积分不足
CaptchaFailedError # 400 验证码失败
RecordNotFoundError # 404 记录不存在
ProjectNotFoundError # 404 项目不存在
InvalidStatusError # 400 状态不允许操作
# 在 service 或 route 中抛出
raise InsufficientCreditsError()
```
### 2.10 认证约定
```python
# Token 创建
create_access_token(user_id, remember_me=False) str # JWT 令牌
# Token 解码
decode_access_token(token) str | None # 返回 user_id 或 None
# 密码
hash_password(plain) str # bcrypt 哈希
verify_password(plain, hashed) bool # bcrypt 校验
```
**鉴权流程:**
1. 前端 Authorization header: `Bearer <token>`
2. `HTTPBearer` 提取 token → `decode_access_token` 获取 `user_id`
3. 查 User 表验证 `is_active`
4. 检查 `user_must_set_password`(短信注册用户首次需设置密码)
### 2.11 数据库 Session 模式
```python
# dependencies.py 中的 get_db — 自动管理事务
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit() # 成功自动提交
except Exception:
await session.rollback() # 异常自动回滚
raise
finally:
await session.close()
```
**注意:** 在 route 中如果只读查询不需要手动 commit(get_db 自动处理)。在 service 中做写入时需要调用者负责 commit,或由 get_db 处理。
### 2.12 中间件约定
中间件按**注册顺序**从外到内执行(`main.py`):
```python
application.add_middleware(RequestLoggingMiddleware) # 1. 请求日志
application.add_middleware(AntiCrawlerMiddleware) # 2. 反爬虫
application.add_middleware(RateLimitMiddleware) # 3. 限流 (Redis)
application.add_middleware(RequestEncryptMiddleware) # 4. 加密/解密
application.add_middleware(CORSMiddleware, ...) # 5. CORS
```
**加密中间件行为:**
- 请求有 `X-Encrypted: true` header → 解密请求体
- GET 请求无 body 但**响应仍加密**
- 支付回调路径白名单跳过加密 (`/payments/alipay/callback`, `/payments/wechat/callback`)
### 2.13 Celery 任务约定
```python
# app/tasks/cleanup.py — 标准模板
from app.tasks.celery_app import celery_app
@celery_app.task # 装饰器注册任务
def cleanup_expired_video_urls():
"""Run hourly. Clear expired video URL tokens."""
asyncio.run(_cleanup_urls()) # 同步任务内跑异步代码
async def _cleanup_urls(): # 实际逻辑写在 async 函数里
from app.models.base import async_session # 延迟导入避免循环
async with async_session() as db:
...
await db.commit()
```
**约定:**
- 任务装饰器: `@celery_app.task`
- 同步入口 → `asyncio.run()` 包异步逻辑
- 任务名自动生成: `文件名.函数名`(如 `app.tasks.cleanup.cleanup_expired_video_urls`
- 队列路由在 `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
# app/config.py — 基于 pydantic-settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
SECRET_KEY: str = "change-me"
# ... 所有配置项有默认值,生产通过 .env 覆盖
settings = Settings() # 全局单例
```
**约定:**
- 所有环境变量在 `config.py` 中有类型注解和默认值
- 通过 `settings.XXX` 全局访问
- 布尔值用 `bool` 类型(pydantic 自动解析 "true"/"false" 字符串)
- 列表值用 `list[str]`pydantic 自动解析 JSON 数组字符串)
### 2.15 操作日志约定
```python
from app.services.operation_log import log_operation
# 在路由中调用(通常在变更操作后)
await log_operation(
db,
admin.id,
admin.username,
f"创建团队 {team.name}", # action 描述
"POST", # HTTP method
"/admin/teams", # path
detail=None, # 可选详情
ip=None, # 可选 IP
)
```
---
## 三、前端架构 (video-gen-app & video-gen-admin)
### 3.1 相同点
两个前端项目遵循完全相同的架构约定:
- React 19 + TypeScript + Vite 8 + Ant Design 6 + Tailwind CSS 3
- Zustand 状态管理
- 统一的 API 调用层(crypto/client/index 三层)
- 路由结构 SPAreact-router-dom v7
### 3.2 目录结构(以 video-gen-app 为例)
```
src/
├── api/ # API 调用层
│ ├── client.ts # 核心:apiRequest<T>(加密/解密、错误处理)
│ ├── crypto.ts # AES-GCM 加密/解密(Web Crypto API
│ ├── index.ts # 所有业务 API 函数(按业务域分组)
│ └── mock.ts # Mock 数据(VITE_USE_MOCK=true 时使用)
├── store/ # Zustand 状态
│ ├── useAuthStore.ts # 认证状态(登录/登出/当前用户)
│ └── useAppStore.ts # 应用状态
├── types/ # TypeScript 类型定义
│ └── index.ts # 所有 interface/type
├── pages/ # 页面组件(路由对应)
│ ├── LoginPage.tsx
│ ├── ProjectsPage.tsx
│ └── ...
├── components/ # 可复用组件
│ ├── Layout/
│ │ ├── AppLayout.tsx # 主布局(Header + Sider + Content
│ │ └── AppLayout.css
│ ├── privatePortrait/
│ └── ...
├── utils/ # 工具函数
│ ├── formatDate.ts
│ └── ...
├── App.tsx # 路由定义 + 全局配置
└── main.tsx # 入口
```
### 3.3 API 调用约定(核心)
```typescript
// ── client.ts — 核心请求函数 ──
// 所有请求统一走 apiRequest<T>,自动处理加密和响应解析
async function apiRequest<T>(path: string, options: RequestOptions): Promise<T>
// 调用参数:
interface RequestOptions {
method?: string // GET/POST/PUT/DELETE,默认 GET
body?: unknown // 请求体(自动 JSON 序列化 + 加密)
auth?: boolean // 是否带 Authorization header,默认 true
encryptBody?: boolean // 是否加密,默认跟随全局开关
signal?: AbortSignal // 取消请求
skipAuthRedirect?: boolean // 401 时不跳转登录页
}
```
```typescript
// ── index.ts — 业务 API 函数 ──
// 命名约定:动词 + 业务名,camelCase
export async function login(username: string, password: string): Promise<User> {
if (USE_MOCK) return mock.mockLogin({ username, password });
const res = await api.post<{ accessToken: string; user: User }>(
'/auth/login',
{ username, password },
false, // auth: false(登录不需要 token
true // encryptBody: true(登录凭证需要加密)
);
setToken(res.accessToken);
return res.user;
}
// GET 列表
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects();
return api.get<Project[]>('/projects');
}
// POST 创建
export async function createProject(name: string, industry: Industry): Promise<Project> {
if (USE_MOCK) return mock.mockCreateProject(name, industry);
return api.post<Project>('/projects', { name, industry });
}
// 带查询参数的 GET
export async function getRecordsPage(params): Promise<GenerationRecordPageListOut> {
const query = new URLSearchParams();
if (params.projectId) query.set('project_id', params.projectId);
query.set('page', String(page));
return api.get<GenerationRecordPageListOut>(`/generation-records?${query.toString()}`);
}
```
**关键约定:**
- `api.get/post/put/delete<T>(path, body?, auth?, encryptBody?)` — 返回**已解析的 T 类型数据**(不含响应包裹层)
- 后端返回 `{ data: ... }` 的结构,`apiRequest` 会自动解包
- 所有 id 字段前端用 `string`(不转 number
- 后端 `snake_case` 字段在 `client.ts` 自动转 `camelCase`user_id → userId
- 文件上传不走 `apiRequest`,直接用 `fetch` + `FormData`(见 `uploadAudio/uploadImage` 等)
- 上传相关 API 直接读 `import.meta.env.VITE_API_BASE` 拼 URL
### 3.4 状态管理约定(Zustand
```typescript
// store/useAuthStore.ts — 标准模板
import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api';
interface AuthState {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>; // 启动时验证 token 有效性
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
loading: true,
login: async (username, password) => {
const user = await api.login(username, password);
set({ user });
},
// ...
}));
```
**约定:**
- 全局状态用 Zustand `create`,不用 Redux
- API 调用写在 store actions 或页面中,**不要**写在 components 里
- 状态更新用 `set()`,异步操作加 `async/await`
- 组件中消费: `const { user, login } = useAuthStore()`
### 3.5 页面组件约定
```typescript
// pages/ProjectsPage.tsx — 标准模板
import React, { useEffect, useState } from 'react';
import { Button, Empty, Form, Input, message, Modal, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import { getIndustries } from '../api';
import type { IndustryConfig } from '../types';
const ProjectsPage: React.FC = () => {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const loadProjects = async () => {
setLoading(true);
try {
const data = await getProjects(); // 调 API
setProjects(data);
} catch (e: any) {
message.error(e?.message || '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => { loadProjects(); }, []);
return (
<div>
<Typography.Title level={4}></Typography.Title>
{/* ... */}
</div>
);
};
export default ProjectsPage;
```
**约定:**
- 页面组件是 `React.FC`,默认导出
- 业务数据类型从 `../types` 导入(`import type`
- API 函数从 `../api` 导入
- 不用 `axios`,所有请求走 `api/index.ts`
-`message.success/error/warning` 显示操作反馈
-`useNavigate()` 编程式导航
- 列表加载用 `loading` 状态 + `Spin` 组件
- 错误统一 `catch (e: any)` + `message.error(e?.message || '默认消息')`
### 3.6 路由约定
```typescript
// App.tsx — 路由结构
// 公开路由
<Route path="/login" element={<LoginPage />} />
<Route path="/join-team" element={<JoinTeamPage />} />
// 受保护路由(需要登录)
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/projects" replace />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
<Route path="*" element={<Navigate to="/projects" replace />} />
</Route>
```
**约定:**
- 路由定义集中在 `App.tsx`
- 登录保护通过 `ProtectedRoute` 包装器实现(检查 `useAuthStore.user`
- 未登录用户访问受保护路由 → 跳 `/login`
- 动态路由参数用 `:paramName`
- 嵌套路由用 `<Outlet />` 渲染子页面
### 3.7 TypeScript 类型约定
```typescript
// types/index.ts — 全局类型定义
export interface User {
id: string; // 所有 ID 都是 string
username: string;
credits: number;
// ...
}
```
**约定:**
- 全局共享类型在 `types/index.ts` 定义
- 类型/接口名 PascalCase + 业务含义
- 所有 ID、外键字段类型为 `string`
- 可选字段用 `?:`,不可选但不一定传的用联合类型 `string | null`
- 从其他文件导入类型: `import type { ... } from '../types'`(用 `import type` 优化打包)
### 3.8 加密约定
```typescript
// crypto.ts
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
// 前端加密流程:
// 1. apiRequest 中如果 encryptBody=true:
// body → JSON.stringify → encrypt() → { data: "<密文>" }
// headers['X-Encrypted'] = 'true'
// 2. 收到响应如果 encryptBody=true 且 parsed.data 是字符串:
// decrypt(parsed.data) → JSON.parse → 返回
//
// 自动判断: USE_ENCRYPTION 全局开关控制是否启用加密
// 加密算法: AES-256-GCM (12 字节 IV + 128 位认证标签)
// 密钥来源: VITE_ENCRYPTION_KEY (32 字节 base64)
```
### 3.9 Mock 模式约定
`VITE_USE_MOCK=true` 时,前端走本地假数据,无需后端。
```typescript
// api/index.ts 中每个 API 函数都检查 USE_MOCK
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects(); // ← 先看 mock
return api.get<Project[]>('/projects'); // ← 后走真实请求
}
```
**新增 API 函数时**必须同时提供 mock 数据函数(在 `mock.ts` 中)。
---
## 四、跨项目约定(前后端协作)
### 4.1 字段名转换
| 后端 (Python) | 前端 (TypeScript) | 转换 |
|--------------|-------------------|------|
| `snake_case` | `camelCase` | client.ts 自动转 |
| `created_at` | `createdAt` | 自动 |
| `user_id` | `userId` | 自动 |
**新增 API 时**:
- 后端 request schema 字段用 `snake_case`
- 前端 types/interface 字段用 `camelCase`
- **不需要**手动转,`client.ts``keysToCamel()` 递归转换所有 key
### 4.2 API 路径约定
```
/api/auth/login → 登录(公开)
/api/projects → 项目管理(需登录)
/api/admin/users → 用户管理(需管理员)
/admin/teams → 团队管理(需管理员,二级路由)
```
- `/api/` 前缀统一
- 管理接口路径含 `/admin/`
- 路径用小写 + 短横线(kebab-case
### 4.3 分页约定
后端返回:
```python
@router.get("", response_model=GenerationRecordPageListOut)
# { items: [...], total: 100, page: 1, page_size: 20 }
```
前端类型:
```typescript
export interface GenerationRecordPageListOut {
page: number;
pageSize: number;
total: number;
items: GenerationRecord[];
}
```
### 4.4 错误处理约定
后端抛 `HTTPException(status_code, detail)` → 前端 `apiRequest` 捕获后抛 `Error` → 页面 `catch` + `message.error(e.message)`
常见错误码:
- `401` — token 无效/过期 → 自动跳 `/login`
- `403` — 权限不足
- `404` — 资源不存在
- `402` — 积分不足(`InsufficientCreditsError`
- `400` — 参数错误
---
## 五、修改检查清单
### 新增后端接口时 ✅
- [ ]`app/schemas/` 中定义 Request/Response Schema(含 `from_attributes`
- [ ]`app/services/` 中实现业务逻辑函数
- [ ]`app/api/v1/` (或 `app/api/admin/`) 中定义路由(`APIRouter` + `Depends`
- [ ] 确认 `__init__.py` 中注册了新 router
- [ ] 复杂操作记录 `log_operation()`
- [ ] 写入操作后正确 `flush()` + `commit()`
### 新增后端 Model 时 ✅
- [ ]`app/models/` 中新建或编辑 model 文件
- [ ] 继承 `Base` + 按需加 `TimestampMixin` / `SoftDeleteMixin`
- [ ] 主键用 `generate_id()`,外键加 `index=True`
- [ ]`app/models/__init__.py` 中 import
- [ ] 执行 `alembic revision --autogenerate -m "描述"` 生成迁移
- [ ] 执行 `alembic upgrade head` 应用迁移
### 新增前端页面/功能时 ✅
- [ ]`types/index.ts` 中定义/补充类型
- [ ]`api/index.ts` 中添加 API 函数(含 mock 分支)
- [ ]`pages/` 中新建页面组件(`React.FC` + 默认导出)
- [ ]`App.tsx` 中注册路由
- [ ] 受保护路由包裹 `<ProtectedRoute>`
- [ ]`mock.ts` 中添加对应 mock 数据函数
---
## 六、常见反模式(应避免)
| ❌ 反模式 | ✅ 正确做法 |
|----------|-----------|
| Route 中写复杂 ORM 查询 | 抽到 `services/` 函数 |
| Model 中有业务逻辑 | Model 只定义字段和关系 |
| 前端直接用 `fetch` 调后端 API | 走 `api/index.ts` + `apiRequest` |
| 新增 Model 不加到 `__init__.py` | 立即 import |
| 主键用数据库自增 int | 用 `generate_id()` 生成 string |
| Schema 不加 `from_attributes` | 响应 Schema 必须加 |
| 前端用 `any` 不定义类型 | 在 `types/index.ts` 定义 |
| 密码明文存储 | bcrypt 哈希(通过 `hash_password` |
---
## 七、快速定位表
| 想修改什么 | 去哪里 |
|-----------|--------|
| 新增/改表字段 | `app/models/xxx.py` + `alembic revision` |
| 新增接口 | `app/api/v1/xxx.py` + `app/schemas/xxx.py` + `app/services/xxx.py` |
| 改业务规则 | `app/services/xxx.py` |
| 改枚举值 | `app/enums/xxx.py` |
| 加新页面 | `src/pages/XxxPage.tsx` + `App.tsx` 路由 + `src/types/index.ts` |
| 加新 API 调用 | `src/api/index.ts` + `src/api/mock.ts` |
| 改全局状态 | `src/store/useAuthStore.ts` / `useAppStore.ts` |
| 改配置项 | `app/config.py` + `.env.example` |
| 改中间件行为 | `app/middleware/xxx.py` |
| 加异步任务 | `app/tasks/xxx.py` + `celery_app.py``task_routes` |
+357 -72
View File
@@ -5,11 +5,13 @@
```
video_item/
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
├── video-gen-app/ # 前台 (React 19 + Vite 8 + Ant Design 6 + Tailwind)
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
├── video-gen-app/ # 前台/用户端 (React 19 + Vite 8 + Ant Design 6)
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
└── DEPLOYMENT.md # 本文档
```
**三个前端/后端的关系:** `video-gen-app` (用户前台) 和 `video-gen-admin` (管理后台) 都连接同一个 `video-gen-api` 后端。
---
## 一、环境要求
@@ -18,14 +20,13 @@ video_item/
|------|---------|------|
| Python | >= 3.10 | 推荐 3.12 |
| Node.js | >= 18 | 推荐 20+ |
| PostgreSQL | >= 14 | 推荐 16 |
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
| wechatpayv3 | >=2.0.2 | 可选,用于支付 |
| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 |
| PostgreSQL | >= 14 | 推荐 16**必须** |
| Redis | >= 6 | 推荐用于: 限流/验证码/Celery/任务状态 |
| FFmpeg | 任意 | 用于视频封面截帧,留空时从 PATH 自动查找 |
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
| alipay-sdk-python | >=3.7.1160 | 可选,支付宝支付 |
| wechatpayv3 | >=2.0.2 | 可选,微信支付 |
| volcengine-python-sdk | >=1.1.0 | 可选,视频生成/短信 |
---
@@ -34,7 +35,7 @@ video_item/
### 1. 安装依赖
```bash
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS(支付宝/火山等)全部失败
# CentOS/RHEL
sudo yum install -y ca-certificates
# Ubuntu/Debian
@@ -49,7 +50,10 @@ python -m venv .venv
# Linux/Mac
source .venv/bin/activate
# 安装基础依赖 + PostgreSQL 驱动
# 安装基础依赖
pip install -e .
# 安装 PostgreSQL 驱动(生产环境必须)
pip install -e ".[pg]"
# 如需 Redis 支持(限流、验证码、Celery)
@@ -58,21 +62,19 @@ pip install -e ".[pg,redis]"
# 如需 Celery 异步任务(ChatAPI 生成流水线)
pip install -e ".[pg,redis,celery]"
#安装阿里支付sdk
# 如需支付宝
pip install -e ".[pg,redis,celery,alipay]"
# 安装微信支付sdk
pip install -e ".[pg,redis,celery,alipay,wechatpayv3]"
#安装火山sdk
pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]"
# 如需微信支付
pip install -e ".[pg,redis,celery,alipay,wxpay]"
# 如需火山引擎 SDK(短信等)
pip install -e ".[pg,redis,celery,alipay,wxpay,volc]"
```
### 2. 配置环境变量
复制 `.env.example``.env`,修改以下关键配置:
复制 `.env.example``.env`,修改关键配置:
```bash
cp .env.example .env
@@ -81,13 +83,14 @@ cp .env.example .env
```ini
# ── 基础配置 ──
APP_NAME=VideoGen API
APP_VERSION=1.0.0
DEBUG=false
SECRET_KEY=改成一个随机的长字符串
SECRET_KEY=改成一个随机的长字符串JWT 签名密钥)
# ── 数据库 ──
# ── 数据库(必须) ──
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
# ── Redis(可选,留空则禁用限流验证码) ──
# ── Redis(可选,留空则禁用限流/验证码/Celery ──
REDIS_URL=redis://localhost:6379/0
# ── JWT ──
@@ -101,24 +104,30 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
# ── LLM 提示词优化 ──
LLM_MOCK=false
LLM_API_BASE=https://api.openai.com/v1
LLM_API_KEY=
LLM_MODEL=gpt-4o
LLM_MOCK=true # true=使用 mock 响应,不调用真实 LLM
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
ENCRYPTION_KEY=你的32字节base64密钥
# ── 存储路径 ──
# ── 存储路径(本地存储) ──
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./storage/generate/videos
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
UPLOAD_LOCAL_PATH=./storage/uploads
# ── 跨域(生产环境务必限制域名) ──
# ── 跨域(生产环境务必限制域名,默认 ["*"] 允许所有 ──
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
# ── 回调基础地址 ──
BASE_URL=https://你的域名
# ── 验证码 ──
CAPTCHA_ENABLED=true
# ── 短信(火山引擎 SDKSMS_MOCK=false 时生效) ──
SMS_MOCK=true
VOLC_SMS_ACCESS_KEY_ID=
@@ -129,6 +138,12 @@ VOLC_SMS_SIGN=短信签名
# ── 支付(PAYMENT_MOCK=false 时生效) ──
PAYMENT_MOCK=true
WECHAT_MCH_ID=
WECHAT_API_KEY=
ALIPAY_APP_ID=
ALIPAY_PRIVATE_KEY=
ALIPAY_PUBLIC_KEY=
ALIPAY_NOTIFY_URL=
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
CELERY_BROKER_URL=redis://localhost:6379/5
@@ -138,27 +153,30 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/6
FFMPEG_BIN=/usr/bin/ffmpeg
```
> **关于 ENCRYPTION_KEY 的生成:** 需要 32 字节(256 位)的 base64 编码字符串。生成方式:`openssl rand -base64 32`。前后端必须使用**完全相同**的密钥。
### 3. 初始化数据库
```bash
# 创建 PostgreSQL 数据库
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
# 启动后端(首次启动自动建表 + 填充种子数据
# 启动后端(首次启动自动建表)
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
首次启动会自动完成:
- 创建所有数据表(23 个 model)
- 创建管理员账号:`admin` / `123456`
- 创建演示用户:`demo` / `123456`(手机号 `13888888888`
- 填充系统配置、视频引擎(Seedance 2.0 / 2.0 fast)、图片引擎(Seedream 5.0)、模型配置、积分比例、菜单配置、充值套餐(4 档)、行业配置
首次启动时会**自动创建所有数据表**(41 个 model)。
**首次部署后务必修改默认密码。**
> ⚠️ **关于种子数据:** 代码中包含 `_seed_data()` 函数(创建管理员/演示用户、系统配置、引擎配置等),但当前在 `main.py` 中被**注释掉了**`# await _seed_data()`)。因此首次启动**不会**自动创建管理员账号。
>
> **如果你需要种子数据,**有以下选择:
> 1. 在 `main.py` 中取消注释 `# await _seed_data()` 后重启
> 2. 手动通过 API 或数据库脚本创建管理员账号
> 3. 自行编写独立的种子脚本调用 `_seed_data()`
### 4. 数据库迁移 (Alembic)
项目使用 Alembic 管理数据库结构变更`env.py` 已导入全部 20 个 model
项目使用 Alembic 管理数据库结构变更。
```bash
cd video-gen-api
@@ -179,17 +197,13 @@ python -m alembic history
python -m alembic downgrade -1
```
**部署流程** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
**新增 model 时:** 需要在 `alembic/env.py` 中添加对应的 import。
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
### 5. 生产运行
```bash
# 方式一:直接运行(推荐 4 workers
# 直接运行(推荐 4 workers
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
# 方式二:使用 systemd (Linux)
```
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`
@@ -220,18 +234,72 @@ sudo systemctl start videogen-api
### 6. Celery Worker(可选)
ChatAPI 异步生成流水线需要 Celery Worker。需要 Redis 作为 Broker。
异步任务流水线需要 Celery Worker,依赖 Redis 作为 Broker。
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处理 3 个队列:gen_chatapi_create, gen_provider_poll, gen_result_download
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,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]
@@ -239,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,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
@@ -247,25 +315,94 @@ 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`,一键启动完整环境:
项目提供 `Dockerfile``docker-compose.yml`
> ⚠️ **注意:** 默认 `Dockerfile` 只安装基础依赖(`pip install .`),生产使用需改为 `".[pg,redis,celery]"`。
```bash
cd video-gen-api
# 注意:Dockerfile 默认只装基础依赖,需修改为安装 pg+redis+celery
# 将 Dockerfile 中的 RUN pip install --no-cache-dir . 改为:
# 使用前需修改 Dockerfile 第 6 行为:
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
docker compose up -d
```
启动的服务:
| 服务 | 端口 | 说明 |
|------|------|------|
| api | 8000 | FastAPI 应用(`--reload`开发模式) |
| worker | - | Celery Worker |
| api | 8000 | FastAPI 应用(`--reload` 开发模式) |
| worker | - | Celery Worker(消费所有队列) |
| postgres | 5432 | PostgreSQL 16 |
| redis | 6379 | Redis 7 |
@@ -276,7 +413,7 @@ server {
listen 80;
server_name api.yourdomain.com;
# 上传文件大小限制
# 上传文件大小限制(视频上传需要较大值)
client_max_body_size 100M;
location / {
@@ -300,6 +437,8 @@ server {
## 三、前台部署 (video-gen-app)
用户前台,面向最终用户。
### 1. 安装依赖 & 构建
```bash
@@ -310,8 +449,8 @@ npm install
# 配置 API 地址(创建 .env.production
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
# 如需前后端加密通信
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
# 如需前后端加密通信(与后端 ENCRYPTION_KEY 相同)
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
# 构建
npm run build
@@ -319,7 +458,15 @@ npm run build
构建产物在 `dist/` 目录。
### 2. Nginx 配置
### 2. 前端环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
| `VITE_USE_MOCK` | 是否使用 mock 数据(无需后端) | `false` |
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥(需与后端一致) | 空(不加密) |
### 3. Nginx 配置
```nginx
server {
@@ -328,12 +475,12 @@ server {
root /opt/video-gen-app/dist;
index index.html;
# SPA 路由
# SPA 路由:所有页面请求回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
# 静态资源缓存(带 hash 的文件名可长期缓存)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
@@ -345,6 +492,8 @@ server {
## 四、后台管理部署 (video-gen-admin)
管理员后台,面向运营/管理人员。
### 1. 安装依赖 & 构建
```bash
@@ -356,13 +505,21 @@ npm install
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
# 如需前后端加密通信
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
# 构建
npm run build
```
### 2. Nginx 配置
### 2. 后台环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
| `VITE_USE_MOCK` | 是否使用 mock 数据 | `false` |
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥 | 空(不加密) |
### 3. Nginx 配置
```nginx
server {
@@ -456,6 +613,8 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
## 七、默认账号
> ⚠️ 默认账号仅在种子数据被执行后存在(见第三节第 3 点说明)。
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|------|--------|--------|------|------|
| 管理员 | admin | 13800000000 | 123456 | 10000 |
@@ -467,32 +626,84 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
## 八、架构说明
### 异步任务处理
### 后端启动时的后台任务
系统有两套异步任务机制
后端启动时(`lifespan`)会自动启动以下**进程内**后台任务
| 机制 | 说明 | 依赖 |
|------|------|------|
| 内置 TaskQueue | asyncio 实现,运行在 uvicorn 进程内,轮询视频/图片生成状态 | 无额外依赖 |
| Celery Worker | 处理 ChatAPI 异步生成流水线,3 个队列分优先级 | Redis 作为 Broker |
| 任务 | 说明 |
|------|------|
| `task_queue` (video_queue) | 内置 asyncio 任务队列,轮询视频/图片生成状态 |
| `upload_queue` | 上传任务队列 |
| `material_consumption_queue` | 素材消耗队列 |
| `token_refresh_scheduler` | 每 5 分钟检查并刷新即将过期的 token |
| `poll_pre_test_results` | 每分钟轮询前测结果 |
| `schedule_daily_sync` | 每天 9 点自动同步素材消耗 |
| `_order_expiry_loop` | 每分钟同步待支付订单状态 + 自动过期订单 |
### 中间件栈(从外到内)
加上 **Celery Worker**(可选,处理 ChatAPI 异步流水线)。
### 中间件栈(从外到内,即请求到达的顺序)
1. `RequestLoggingMiddleware` — 请求/响应日志记录
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
5. `CORSMiddleware` — 跨域
5. `CORSMiddleware` — 跨域expose `X-Encrypted` 响应头)
### 日志系统
### 前后端通信加密
| 日志类型 | 目录 | 控制方式 |
**机制:** AES-256-GCM 对称加密,前后端共享同一个 `ENCRYPTION_KEY`。使用 Web Crypto API(前端)和 `cryptography` 库(后端)。
**生效条件:** 同时满足以下两个条件才启用加密:
- 后端 `.env``ENCRYPTION_KEY` 非空
- 前端 `.env``VITE_ENCRYPTION_KEY` 非空
- 运行在安全上下文(HTTPS 或 localhost
**加密范围:**
| 请求类型 | 请求体 | 响应体 |
|---------|--------|--------|
| GET(无 body) | 不加密(无内容) | **加密** |
| POST/PUT/DELETE(有 body | **加密** | **加密** |
**工作流:**
1. 前端发送 POST 请求时,将 JSON 请求体加密为 `{ data: "<密文>" }`,并加 `X-Encrypted: true` 请求头
2. 后端中间件检测到 `X-Encrypted: true` 时解密请求体,处理完后加密响应体
3. 支付回调接口(`/payments/alipay/callback`, `/payments/wechat/callback`)白名单跳过加密
> **GET 请求注意:** GET 没有请求体,但响应仍会被加密。前端会自动检测并解密。
### 文件日志加密(独立机制)
与通信加密不同,**文件日志存储**使用另一套独立的 AES-CBC-256 加密:
| 日志类型 | 目录 | 加密方式 |
|---------|------|---------|
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | 始终开启 |
| AI 模型日志 | `log/AiModel/{日期}.log` | `AI_LOG_ENABLED` 环境变量 |
| Python 控制台日志 | stderr | `DEBUG=true` 时输出 INFO,否则 WARNING |
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | AES-CBC-256,密钥硬编码 |
| AI 模型日志 | `log/AiModel/{日期}.log` | AES-CBC-256,密钥硬编码 |
文件日志使用 AES-CBC 加密存储,解密工具:`/internal/decrypt-data` 端点
日志加密密钥与 `ENCRYPTION_KEY` 无关,是代码中硬编码的值。解密工具:`/internal/decrypt-data` 页面
### 内部管理端点
| 端点 | 用途 |
|------|------|
| `/internal/` | 后端入口导航页 |
| `/internal/health` | 健康检查 (`{"status": "ok"}`) |
| `/internal/status` | 服务状态监控页 (Celery/Redis 连接状态) |
| `/internal/decrypt-data` | 日志数据解密工具(AES-CBC) |
| `/internal/api-docs` | Swagger API 文档 |
| `/internal/api-redoc` | ReDoc API 文档 |
| `/uploads/` | 用户上传的静态文件(挂载为静态目录) |
| `/api/decrypt` | 解密接口(POST,供解密工具调用) |
### 前端 Mock 模式
前端支持 Mock 数据模式,无需后端即可开发:
- 设置 `VITE_USE_MOCK=true` 时,所有 API 调用返回本地假数据
- 适用场景:纯前端开发、演示、无后端环境
- 注意:mock 模式下仍会调用部分真实 API(如站点信息、验证码)
### 存储路径
@@ -522,6 +733,15 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
│ ├── .env # 环境变量
│ ├── .venv/ # Python 虚拟环境
│ ├── app/ # 应用代码
│ │ ├── api/ # API 路由
│ │ │ ├── v1/ # v1 版本接口(前端用户端 + 部分管理接口)
│ │ │ └── admin/ # 管理端接口
│ │ ├── middleware/ # 中间件
│ │ ├── models/ # 数据模型 (41 个)
│ │ ├── services/ # 业务服务
│ │ ├── tasks/ # Celery 任务
│ │ ├── enums/ # 枚举定义
│ │ └── utils/ # 工具函数
│ ├── alembic/ # 数据库迁移文件
│ ├── storage/
│ │ ├── generate/
@@ -565,4 +785,69 @@ cd /opt/video-gen-api && python -m alembic upgrade head
# 生成迁移文件
cd /opt/video-gen-api && python -m alembic revision --autogenerate -m "描述"
# 查看 Celery 活动任务
celery -A app.tasks.celery_app inspect active
```
---
## 十一、环境变量完整参考
### 后端 (video-gen-api/.env)
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `SECRET_KEY` | **是** | `change-me` | JWT 签名密钥 |
| `DATABASE_URL` | **是** | sqlite | PostgreSQL 连接串 |
| `REDIS_URL` | 否 | 空 | Redis 连接串,留空禁用限流/验证码 |
| `JWT_EXPIRE_MINUTES` | 否 | 1440 | Token 有效期(分钟) |
| `JWT_EXPIRE_REMEMBER_MINUTES` | 否 | 10080 | 记住登录 Token 有效期 |
| `SEEDANCE_API_KEY` | **是** | 空 | 火山引擎 API Key |
| `SEEDANCE_API_BASE` | 否 | 火山地址 | API 基础 URL |
| `SEEDANCE_CALLBACK_URL` | 否 | 空 | 生成结果回调 URL |
| `LLM_API_BASE` | 否 | OpenAI | LLM API 地址 |
| `LLM_API_KEY` | 否 | 空 | LLM API Key |
| `LLM_MODEL` | 否 | gpt-4o | LLM 模型名称 |
| `LLM_MOCK` | 否 | true | 是否 mock LLM 响应 |
| `ENCRYPTION_KEY` | 否 | 占位符 | 前后端通信加密密钥 |
| `SMS_MOCK` | 否 | true | 是否 mock 短信 |
| `PAYMENT_MOCK` | 否 | false | 是否 mock 支付 |
| `CORS_ORIGINS` | 否 | `["*"]` | 允许的跨域来源 |
| `BASE_URL` | 否 | 测试地址 | 站点基础 URL,用于回调拼接 |
| `STORAGE_TYPE` | 否 | local | 存储类型 |
| `UPLOAD_LOCAL_PATH` | 否 | `./storage/uploads` | 上传文件存储路径 |
| `FFMPEG_BIN` | 否 | 空 | FFmpeg 路径(留空自动查找) |
| `CAPTCHA_ENABLED` | 否 | true | 是否启用验证码 |
| `CELERY_BROKER_URL` | 否 | 空 | Celery Broker(留空禁用 Celery |
| `CELERY_RESULT_BACKEND` | 否 | 空 | Celery 结果后端 |
| `RATE_LIMIT_ENABLED` | 否 | true | 是否启用限流 |
### 前台 (video-gen-app/.env.production)
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `VITE_API_BASE` | **是** | localhost:8000 | 后端 API 地址 |
| `VITE_USE_MOCK` | 否 | false | 是否使用 mock 数据 |
| `VITE_ENCRYPTION_KEY` | 否 | 空 | 通信加密密钥(与后端一致) |
### 后台管理 (video-gen-admin/.env.production)
同前台。
---
## 十二、故障排查
| 现象 | 可能原因 | 解决方案 |
|------|---------|---------|
| 前端登录后 401 | Token 过期或 SECRET_KEY 不一致 | 检查后端 `SECRET_KEY` 是否变更 |
| 上传文件失败 413 | Nginx 上传大小限制 | 增大 `client_max_body_size` |
| 加密请求报错"解密失败" | 前后端密钥不一致 | 确保 `VITE_ENCRYPTION_KEY` = 后端 `ENCRYPTION_KEY` |
| 非 HTTPS 环境加密无效 | Web Crypto API 需要安全上下文 | 本地开发用 localhost,生产用 HTTPS |
| Celery 任务不执行 | Redis 未启动或地址错误 | 检查 `CELERY_BROKER_URL` 和 Redis |
| 限流不生效 | Redis 未配置 | 检查 `REDIS_URL` |
| 短信发送失败 | 火山配置缺失或 `SMS_MOCK=true` | 填入 `VOLC_SMS_*` 变量并设 `SMS_MOCK=false` |
| 视频封面无法生成 | FFmpeg 未安装 | 安装 FFmpeg 或设置 `FFMPEG_BIN` |
| HTTPS 请求支付宝/火山失败 | 缺少 CA 证书 | 安装 `ca-certificates` |
| 数据库迁移失败 | Model 定义与迁移不一致 | 重新生成迁移文件后执行 |
+3 -1
View File
@@ -1,5 +1,7 @@
# VITE_API_BASE=http://192.168.120.17:8000
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
#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
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
@import "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap";:root{--font-sans:"Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--nav-bg:#08080c;--nav-surface:#ffffff0a;--nav-border:#ffffff0f;--nav-text:#e8e8ec;--nav-text-muted:#8b8fa3;--nav-hover:#ffffff14;--nav-active:#ffffff1f}*,:before,:after{box-sizing:border-box}html,body{font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#1a1a2e;letter-spacing:-.01em;background:#f8f9fc;margin:0;padding:0}#root{min-height:100vh}
+2 -2
View File
@@ -28,8 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-3wUbVp5v.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
<script type="module" crossorigin src="/assets/index-CfFryG8z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head>
<body>
<div id="root"></div>
+82
View File
@@ -13,6 +13,7 @@
"dayjs": "^1.11.21",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-quill-new": "^3.8.3",
"react-router-dom": "^7.15.0",
"xlsx-js-style": "^1.2.0",
"zustand": "^5.0.13"
@@ -1428,6 +1429,12 @@
"node": ">=8"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
@@ -1437,6 +1444,12 @@
"node": ">=0.8"
}
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1761,6 +1774,25 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -1780,6 +1812,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1841,6 +1879,35 @@
"node": ">=0.8"
}
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -1868,6 +1935,21 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-quill-new": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21",
"quill": "~2.0.3"
},
"peerDependencies": {
"quill-delta": "^5.1.0",
"react": "^16 || ^17 || ^18 || ^19",
"react-dom": "^16 || ^17 || ^18 || ^19"
}
},
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"dayjs": "^1.11.21",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-quill-new": "^3.8.3",
"react-router-dom": "^7.15.0",
"xlsx-js-style": "^1.2.0",
"zustand": "^5.0.13"
+8 -2
View File
@@ -14,7 +14,6 @@ import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
import AdminNotificationManager from './pages/AdminNotificationManager';
import AdminCreditRecords from './pages/AdminCreditRecords';
import AdminModelPricingRules from './pages/AdminModelPricingRules';
import AdminPaymentConfig from './pages/AdminPaymentConfig';
import AdminPaymentStats from './pages/AdminPaymentStats';
import AdminIndustries from './pages/AdminIndustries';
@@ -33,12 +32,16 @@ import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import AdminVideoUpscale from './pages/AdminVideoUpscale';
import AdminContactRequests from './pages/AdminContactRequests';
import AdminHomeMaterials from './pages/AdminHomeMaterials';
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
import AdminOAuthList from './pages/AdminOAuthList';
import AdminMaterialList from './pages/AdminMaterialList';
import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
import AdminApiKeys from './pages/AdminApiKeys';
import AdminApiModelPricings from './pages/AdminApiModelPricings';
import AdminApiUsage from './pages/AdminApiUsage';
import { useAdminStore } from './store';
@@ -88,7 +91,6 @@ const App = () => {
<Route path="users" element={<AdminUsers />} />
<Route path="teams" element={<AdminTeams />} />
<Route path="credit-records" element={<AdminCreditRecords />} />
<Route path="model-pricing" element={<AdminModelPricingRules />} />
<Route path="models" element={<AdminModels />} />
<Route path="credit-ratios" element={<AdminCreditRatios />} />
<Route path="video-engines" element={<AdminVideoEngines />} />
@@ -100,6 +102,10 @@ const App = () => {
<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="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
+205 -66
View File
@@ -13,11 +13,11 @@ import type {
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
ModelPricingRule, ModelPricingRuleListResponse, ModelPricingRulePayload, ModelPricingPreviewResponse,
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
} from '../types';
import type {
@@ -42,6 +42,18 @@ import type {
HomeMaterialWatermarkQueryParams,
} from '../types';
// ── Video Upscale ────────────────────────────────────────
export async function getVideoUpscaleConfig(): Promise<VideoUpscaleConfigOut> {
return api.get<VideoUpscaleConfigOut>('/admin/video-upscale/config');
}
export async function saveVideoUpscaleConfig(payload: VideoUpscaleConfigSavePayload): Promise<VideoUpscaleConfigOut> {
return api.put<VideoUpscaleConfigOut>('/admin/video-upscale/config', payload);
}
// ── Auth ──────────────────────────────────────────────────
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
@@ -225,6 +237,14 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
await api.put(`/admin/system-configs/${id}`, { value });
}
export async function createSystemConfig(key: string, value: string, description?: string): Promise<SystemConfig> {
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');
}
@@ -277,6 +297,22 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
return { url: res.url };
}
export async function uploadLoginVideo(file: File): Promise<{ url: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/admin/upload-login-video`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || '上传失败');
}
return res.json();
}
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
}
@@ -294,57 +330,16 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'credit_subject', filters?.creditSubject);
setMaybe(params, 'media_type', filters?.mediaType);
setMaybe(params, 'charge_kind', filters?.chargeKind);
setMaybe(params, 'charge_action', filters?.chargeAction);
setMaybe(params, 'source_module', filters?.sourceModule);
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
setMaybe(params, 'billing_scene', filters?.billingScene);
setMaybe(params, 'engine_provider', filters?.engineProvider);
setMaybe(params, 'engine_model_name', filters?.engineModelName);
setMaybe(params, 'pricing_version_code', filters?.pricingVersionCode);
setMaybe(params, 'provider_cost_status', filters?.providerCostStatus);
setMaybe(params, 'provider_cost_is_estimated', filters?.providerCostIsEstimated);
setMaybe(params, 'has_attachment', filters?.hasAttachment);
setMaybe(params, 'start_date', filters?.startDate);
setMaybe(params, 'end_date', filters?.endDate);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/credit-records${q}`);
}
export async function getModelPricingRules(filters?: {
page?: number; pageSize?: number; provider?: string; modelName?: string; modelCategory?: string; publishStatus?: string;
}): Promise<ModelPricingRuleListResponse> {
const params = new URLSearchParams();
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'provider', filters?.provider);
setMaybe(params, 'model_name', filters?.modelName);
setMaybe(params, 'model_category', filters?.modelCategory);
setMaybe(params, 'publish_status', filters?.publishStatus);
return api.get(`/admin/model-pricing/rules${params.toString() ? `?${params}` : ''}`);
}
export async function createModelPricingRule(payload: ModelPricingRulePayload): Promise<ModelPricingRule> {
return api.post('/admin/model-pricing/rules', payload);
}
export async function updateModelPricingRule(id: string, payload: Partial<ModelPricingRulePayload>): Promise<ModelPricingRule> {
return api.put(`/admin/model-pricing/rules/${id}`, payload);
}
export async function publishModelPricingRule(id: string): Promise<ModelPricingRule> {
return api.post(`/admin/model-pricing/rules/${id}/publish`);
}
export async function disableModelPricingRule(id: string): Promise<ModelPricingRule> {
return api.post(`/admin/model-pricing/rules/${id}/disable`);
}
export async function previewModelPricing(payload: {
billing_mode: string; calculator_version: string; rule_json: Record<string, any>; usage: Record<string, any>; currency?: string;
}): Promise<ModelPricingPreviewResponse> {
return api.post('/admin/model-pricing/preview', payload);
}
export async function getIndustryConfigs(): Promise<any[]> {
return api.get('/admin/industry-configs');
}
@@ -358,8 +353,9 @@ export async function deleteIndustryConfig(id: string): Promise<void> {
await api.delete(`/admin/industry-configs/${id}`);
}
export async function getVideoEngines(): Promise<any[]> {
return api.get('/admin/video-engines');
export async function getVideoEngines(options?: { includeDeleted?: boolean }): Promise<any[]> {
const query = options?.includeDeleted ? '?include_deleted=true' : '';
return api.get(`/admin/video-engines${query}`);
}
export async function saveVideoEngine(engine: any): Promise<any> {
@@ -371,8 +367,9 @@ export async function deleteVideoEngine(id: string): Promise<void> {
await api.delete(`/admin/video-engines/${id}`);
}
export async function getImageEngines(): Promise<any[]> {
return api.get('/admin/image-engines');
export async function getImageEngines(options?: { includeDeleted?: boolean }): Promise<any[]> {
const query = options?.includeDeleted ? '?include_deleted=true' : '';
return api.get(`/admin/image-engines${query}`);
}
export async function saveImageEngine(engine: any): Promise<any> {
@@ -397,6 +394,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');
}
@@ -430,10 +552,11 @@ export async function getPaymentStats(params?: {
return api.get(url);
}
export async function getAdminPaymentOrders(params?: { method?: string; status?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
export async function getAdminPaymentOrders(params?: { method?: 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?.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));
@@ -522,8 +645,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}`);
}
@@ -625,28 +756,26 @@ export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
// ── Generation Records (Admin) ─────────────────────────────
export async function getAdminGenerationRecords(params?: {
userId?: string; status?: string; page?: number; pageSize?: number;
userId?: string;
status?: string;
engineId?: string;
includeMediaReferences?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ total: number; items: any[] }> {
const q = new URLSearchParams();
if (params?.userId) q.set('user_id', params.userId);
if (params?.status) q.set('status', params.status);
if (params?.engineId) q.set('engine_id', params.engineId);
if (params?.includeMediaReferences !== undefined) {
q.set('include_media_references', String(params.includeMediaReferences));
}
if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('page_size', String(params.pageSize));
const qs = q.toString();
return api.get(`/admin/generation-records${qs ? `?${qs}` : ''}`);
}
export async function adminUpdateGenerationStatus(
recordId: string, status: string, videoUrl?: string
): Promise<void> {
await api.put(`/admin/generation-records/${recordId}/status`, { status, video_url: videoUrl });
}
export async function adminGenerateVideo(
recordId: string, aspectRatio: string, resolution: string, image_size: string
): Promise<void> {
await api.post(`/admin/generation-records/${recordId}/generate`, { aspect_ratio: aspectRatio, resolution, image_size });
}
// ── Generation AI Engines (Admin) ─────────────────────────────
@@ -692,8 +821,13 @@ export async function getAdminHotOpeningTasks(params?: AdminHotOpeningTaskQueryP
return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`);
}
export async function getAdminHotOpeningTaskDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
export async function getAdminHotOpeningTaskDetail(
projectId: string,
flowVersion: 'v1' | 'v2',
): Promise<ReplicationProjectDetailOut> {
return flowVersion === 'v2'
? api.get<ReplicationProjectDetailOut>(`/v2/hot-opening-replications/tasks/${projectId}`)
: api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
}
export async function getAdminShotTaskSets(params?: AdminShotTaskSetQueryParams): Promise<ShotTaskSetListOut> {
@@ -732,8 +866,13 @@ export async function getAdminShotSegmentDetail(segmentId: string): Promise<Shot
return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`);
}
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
export async function getAdminShotProjectDetail(
projectId: string,
flowVersion: 'v1' | 'v2',
): Promise<ReplicationProjectDetailOut> {
return flowVersion === 'v2'
? api.get<ReplicationProjectDetailOut>(`/v2/shot-replications/projects/${projectId}`)
: api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
}
@@ -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;
@@ -0,0 +1,59 @@
import React from 'react';
import { Empty, Spin, Tag, Typography } from 'antd';
import { PlayCircleFilled } from '@ant-design/icons';
import type { GenerationAITaskOut } from '../../types';
import { resolveGenerationUiState } from '../../utils/generationTaskStatus';
interface Props {
task: GenerationAITaskOut;
resolveUrl: (url?: string | null) => string;
onPreview: (url: string, type: 'image' | 'video', title: string) => void;
}
const spanByCount = (count: number, index: number): number => {
if (count <= 1) return 6;
if (count === 2 || count === 4) return 3;
if (count === 3) return index < 2 ? 3 : 6;
return index < 3 ? 2 : 3;
};
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, resolveUrl, onPreview }) => {
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
const sortedChildren = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
const items: GenerationAITaskOut[] = sortedChildren.length
? sortedChildren
: (count > 1
? Array.from({ length: count }, (_, index) => ({ ...task, id: `${task.id}-${index + 1}`, generationIndex: index + 1, childItems: [] }))
: [task]);
return (
<div style={{ width: '100%', height: 430, display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0,1fr))', gridAutoRows: 'minmax(0,1fr)', gap: items.length > 1 ? 8 : 0 }}>
{items.map((item, index) => {
const uiState = resolveGenerationUiState(item);
const isVideo = item.genType === 'video';
const resultUrl = resolveUrl(isVideo ? item.videoUrl : item.imageUrl);
const coverUrl = resolveUrl(item.videoCoverUrl);
const active = uiState.isActive;
return (
<div key={item.id} style={{ gridColumn: `span ${spanByCount(items.length, index)}`, minWidth: 0, minHeight: 0, border: '1px solid #edf0f5', borderRadius: 10, overflow: 'hidden', position: 'relative', background: '#f8f9fc' }}>
{resultUrl && uiState.isSuccess ? (
<button type="button" onClick={() => onPreview(resultUrl, isVideo ? 'video' : 'image', `生成结果 ${item.generationIndex || index + 1}`)} style={{ width: '100%', height: '100%', padding: 0, border: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}>
{isVideo ? (coverUrl ? <img src={coverUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : <video src={resultUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />) : <img src={resultUrl} alt="生成图片" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)', color: '#fff', fontSize: 38, filter: 'drop-shadow(0 3px 8px rgba(0,0,0,.35))' }} /> : null}
</button>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 9, padding: 12, textAlign: 'center' }}>
{active ? <Spin size="small" /> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={null} />}
<Tag color={uiState.color}>{uiState.label}</Tag>
{item.errorMessage && !active ? <Typography.Text type="danger" style={{ fontSize: 11 }}>{item.errorMessage}</Typography.Text> : null}
</div>
)}
{items.length > 1 ? <span style={{ position: 'absolute', top: 6, left: 6, padding: '1px 7px', borderRadius: 10, color: '#fff', background: 'rgba(17,24,39,.58)', fontSize: 11 }}>#{item.generationIndex || index + 1}</span> : null}
</div>
);
})}
</div>
);
};
export default GenerationTaskResourceGrid;
@@ -1,486 +0,0 @@
import React, { useEffect } from 'react';
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Select, Space } from 'antd';
import dayjs from 'dayjs';
import type {
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingRule,
ModelPricingRulePayload,
} from '../../types';
interface Props {
initial?: ModelPricingRule | null;
loading?: boolean;
onSubmit: (payload: ModelPricingRulePayload) => Promise<void> | void;
onCancel: () => void;
}
const modeOptions = [
{ value: 'text_token_tiered', label: '文本分档 Token 计价' },
{ value: 'image_per_output', label: '按输出图片数量计价' },
{ value: 'image_input_output_tiered', label: '输入图片 + 输出像素分档计价' },
{ value: 'video_token_rate', label: '视频像素 Token 计价' },
];
const calculatorByMode: Record<ModelPricingBillingMode, ModelPricingCalculatorVersion> = {
text_token_tiered: 'text_token_tiered_v1',
image_per_output: 'image_per_output_v1',
image_input_output_tiered: 'image_input_output_tiered_v1',
video_token_rate: 'video_pixel_token_v1',
};
const billByOptions = [
{ value: 'successful_output_count', label: '实际成功输出数' },
{ value: 'requested_output_count', label: '请求输出数(估算)' },
{ value: 'provider_billed_count', label: '供应商明确计费数' },
];
function splitList(value?: string): string[] {
return String(value || '').split(',').map(v => v.trim()).filter(Boolean);
}
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
return source[camelKey];
}
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
const result = { ...source };
keys.forEach(key => delete result[key]);
return result;
}
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'max_context_tokens', 'maxContextTokens',
'input_rate', 'inputRate',
'audio_input_rate', 'audioInputRate',
'output_rate', 'outputRate',
'cached_input_rate', 'cachedInputRate',
'cached_audio_input_rate', 'cachedAudioInputRate',
]);
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
return {
...result,
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
};
}
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
return {
...result,
max_pixels: maxPixels === undefined ? null : maxPixels,
rate: value?.rate,
};
}
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'has_input_video', 'hasInputVideo',
'generate_audio', 'generateAudio',
'inference_modes', 'inferenceModes',
]);
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
return {
...result,
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
};
}
/**
* 后端规则 JSON 统一使用 snake_case;同时兼容接口层已转换成 camelCase 的历史/当前数据。
* 返回值会移除已知 camelCase 别名,避免保存时同时存在两套字段。
*/
function normalizeRuleJson(
mode: ModelPricingBillingMode,
value: Record<string, any>,
): Record<string, any> {
const source = value || {};
const base = removeKeys(source, [
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
'output_rate', 'outputRate',
'bill_by', 'billBy',
'free_input_images', 'freeInputImages',
'input_image_rate', 'inputImageRate',
'output_tiers', 'outputTiers',
'token_formula', 'tokenFormula',
'default_fps', 'defaultFps',
'supported_resolutions', 'supportedResolutions',
'dimension_map', 'dimensionMap',
]);
if (mode === 'text_token_tiered') {
return {
...base,
cache_storage_rate_per_million_token_hour: readAlias(
source,
'cache_storage_rate_per_million_token_hour',
'cacheStorageRatePerMillionTokenHour',
),
tiers: (Array.isArray(source.tiers) ? source.tiers : []).map(item => normalizeTextTier(item || {})),
};
}
if (mode === 'image_per_output') {
return {
...base,
output_rate: readAlias(source, 'output_rate', 'outputRate'),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
if (mode === 'image_input_output_tiered') {
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
return {
...base,
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
return {
...base,
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
};
}
function dimensionRows(rule: Record<string, any>): Array<Record<string, any>> {
const rows: Array<Record<string, any>> = [];
Object.entries(rule.dimension_map || {}).forEach(([resolution, ratios]) => {
Object.entries((ratios || {}) as Record<string, any>).forEach(([aspectRatio, value]) => {
const item = value as Record<string, any>;
rows.push({ resolution, aspectRatio, width: item.width, height: item.height });
});
});
return rows;
}
function toFormPricing(mode: ModelPricingBillingMode, rule: Record<string, any>): Record<string, any> {
const normalized = normalizeRuleJson(mode, rule || {});
if (mode === 'text_token_tiered') {
return {
tiers: normalized.tiers || [],
cacheStorageRate: normalized.cache_storage_rate_per_million_token_hour ?? 0,
};
}
if (mode === 'image_per_output') {
return {
outputRate: normalized.output_rate ?? 0,
billBy: normalized.bill_by || 'successful_output_count',
};
}
if (mode === 'image_input_output_tiered') {
return {
freeInputImages: normalized.free_input_images ?? 1,
inputImageRate: normalized.input_image_rate ?? 0,
outputTiers: normalized.output_tiers || [],
billBy: normalized.bill_by || 'successful_output_count',
};
}
return {
tokenFormula: normalized.token_formula
|| '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
supportedResolutions: (normalized.supported_resolutions || []).join(','),
defaultFps: normalized.default_fps ?? 30,
dimensionRows: dimensionRows(normalized),
rates: (normalized.rates || []).map((r: any) => ({
...r,
resolutions: (r.resolutions || []).join(','),
inferenceModes: (r.inference_modes || []).join(','),
hasInputVideo: r.has_input_video === undefined ? 'any' : r.has_input_video ? 'true' : 'false',
generateAudio: r.generate_audio === undefined ? 'any' : r.generate_audio ? 'true' : 'false',
})),
};
}
function buildDimensionMap(rows: Array<Record<string, any>>): Record<string, any> {
const result: Record<string, any> = {};
(rows || []).forEach(row => {
const resolution = String(row.resolution || '').trim().toLowerCase();
const aspectRatio = String(row.aspectRatio || '').trim();
const width = Number(row.width || 0);
const height = Number(row.height || 0);
if (!resolution || !aspectRatio || width <= 0 || height <= 0) return;
result[resolution] ||= {};
result[resolution][aspectRatio] = { width, height };
});
return result;
}
function buildRuleJson(
mode: ModelPricingBillingMode,
pricing: Record<string, any>,
baseRule: Record<string, any>,
): Record<string, any> {
const base = normalizeRuleJson(mode, baseRule || {});
if (mode === 'text_token_tiered') {
return {
...base,
unit: 'CNY_per_million_tokens',
cache_storage_rate_per_million_token_hour: String(pricing.cacheStorageRate ?? 0),
tiers: (pricing.tiers || []).map((v: any) => ({
max_context_tokens: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === '' || readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === undefined
? null
: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens'),
input_rate: String(readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
audio_input_rate: String(readAlias(v || {}, 'audio_input_rate', 'audioInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
output_rate: String(readAlias(v || {}, 'output_rate', 'outputRate') ?? 0),
cached_input_rate: String(readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
cached_audio_input_rate: String(
readAlias(v || {}, 'cached_audio_input_rate', 'cachedAudioInputRate')
?? readAlias(v || {}, 'cached_input_rate', 'cachedInputRate')
?? readAlias(v || {}, 'audio_input_rate', 'audioInputRate')
?? readAlias(v || {}, 'input_rate', 'inputRate')
?? 0,
),
})),
};
}
if (mode === 'image_per_output') {
return {
...base,
unit: 'CNY_per_image',
output_rate: String(pricing.outputRate ?? 0),
bill_by: pricing.billBy || 'successful_output_count',
};
}
if (mode === 'image_input_output_tiered') {
return {
...base,
unit: 'CNY_per_image',
free_input_images: Number(pricing.freeInputImages || 0),
input_image_rate: String(pricing.inputImageRate ?? 0),
output_tiers: (pricing.outputTiers || []).map((v: any) => ({
max_pixels: readAlias(v || {}, 'max_pixels', 'maxPixels') === undefined
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === null
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === ''
? null
: Number(readAlias(v || {}, 'max_pixels', 'maxPixels')),
rate: String(v.rate ?? 0),
})),
bill_by: pricing.billBy || 'successful_output_count',
};
}
return {
...base,
unit: 'CNY_per_million_tokens',
token_formula: '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
default_fps: Number(pricing.defaultFps || 30),
supported_resolutions: splitList(pricing.supportedResolutions).map(v => v.toLowerCase()),
dimension_map: buildDimensionMap(pricing.dimensionRows || []),
rates: (pricing.rates || []).map((v: any) => ({
...(splitList(v.resolutions).length
? { resolutions: splitList(v.resolutions).map(item => item.toLowerCase()) }
: {}),
...(v.hasInputVideo !== 'any' && v.hasInputVideo !== undefined
? { has_input_video: v.hasInputVideo === 'true' }
: {}),
...(v.generateAudio !== 'any' && v.generateAudio !== undefined
? { generate_audio: v.generateAudio === 'true' }
: {}),
...(splitList(v.inferenceModes).length
? { inference_modes: splitList(v.inferenceModes).map(item => item.toLowerCase()) }
: {}),
rate: String(v.rate ?? 0),
})),
};
}
const PricingRuleForm: React.FC<Props> = ({ initial, loading, onSubmit, onCancel }) => {
const [form] = Form.useForm();
const mode = Form.useWatch('billingMode', form) as ModelPricingBillingMode | undefined;
useEffect(() => {
const billingMode = initial?.billingMode || 'video_token_rate';
form.setFieldsValue({
provider: initial?.provider || 'volcengine',
modelName: initial?.modelName || '',
modelCategory: initial?.modelCategory || 'video',
billingMode,
calculatorVersion: initial?.calculatorVersion || calculatorByMode[billingMode],
versionCode: initial?.versionCode || `manual_${dayjs().format('YYYYMMDD_HHmmss')}`,
effectiveRange: [
dayjs(initial?.effectiveFrom || undefined),
initial?.effectiveTo ? dayjs(initial.effectiveTo) : null,
],
currency: initial?.currency || 'CNY',
ruleSchemaVersion: initial?.ruleSchemaVersion || 1,
sourceUrl: initial?.sourceUrl || 'https://www.volcengine.com/docs/82379/1544106',
sourceUpdatedAt: initial?.sourceUpdatedAt ? dayjs(initial.sourceUpdatedAt) : null,
remark: initial?.remark || '',
pricing: toFormPricing(billingMode, initial?.ruleJson || {}),
});
}, [initial, form]);
useEffect(() => {
if (!mode) return;
form.setFieldValue('calculatorVersion', calculatorByMode[mode]);
}, [mode, form]);
const submit = async (values: any) => {
const billingMode = values.billingMode as ModelPricingBillingMode;
const [from, to] = values.effectiveRange || [];
const preserveBase = initial?.billingMode === billingMode ? initial.ruleJson : {};
await onSubmit({
provider: values.provider,
model_name: values.modelName,
model_category: values.modelCategory,
billing_mode: billingMode,
calculator_version: calculatorByMode[billingMode],
version_code: values.versionCode,
effective_from: from.toISOString(),
effective_to: to ? to.toISOString() : null,
currency: values.currency || 'CNY',
rule_schema_version: Number(values.ruleSchemaVersion || initial?.ruleSchemaVersion || 1),
rule_json: buildRuleJson(billingMode, values.pricing || {}, preserveBase || {}),
source_url: values.sourceUrl || null,
source_updated_at: values.sourceUpdatedAt ? values.sourceUpdatedAt.toISOString() : null,
remark: values.remark || null,
});
};
return (
<Form form={form} layout="vertical" onFinish={submit} preserve={false}>
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="种子规则只会创建草稿。请核对真实生效时间、单价和模型输出尺寸后再发布。"
/>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="provider" label="供应商" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name="modelName" label="完整模型名称" rules={[{ required: true }]} style={{ flex: 2 }}><Input /></Form.Item>
</Space>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="modelCategory" label="模型类型" rules={[{ required: true }]} style={{ flex: 1 }}>
<Select options={[{ value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
</Form.Item>
<Form.Item name="billingMode" label="计价模式" rules={[{ required: true }]} style={{ flex: 2 }}>
<Select options={modeOptions} />
</Form.Item>
<Form.Item name="calculatorVersion" label="计算器版本" style={{ flex: 2 }}><Input disabled /></Form.Item>
</Space>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="versionCode" label="价格版本号" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name="effectiveRange" label="真实生效区间 [开始, 结束)" rules={[{ required: true }]} style={{ flex: 2 }}>
<DatePicker.RangePicker showTime allowEmpty={[false, true]} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="currency" label="币种" style={{ width: 100 }}><Input /></Form.Item>
<Form.Item name="ruleSchemaVersion" label="结构版本" style={{ width: 100 }}><InputNumber min={1} /></Form.Item>
</Space>
<Divider titlePlacement="start"></Divider>
{mode === 'text_token_tiered' && <>
<Form.Item name={['pricing', 'cacheStorageRate']} label="缓存存储单价(元/M Token·小时)">
<InputNumber min={0} stringMode style={{ width: 260 }} />
</Form.Item>
<Form.List name={['pricing', 'tiers']}>
{(fields, { add, remove }) => <>
{fields.map(field => (
<Space key={field.key} align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'max_context_tokens']} label="最大上下文 Token"><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'input_rate']} label="输入单价/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'audio_input_rate']} label="音频输入/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'cached_input_rate']} label="缓存文本/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'cached_audio_input_rate']} label="缓存音频/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'output_rate']} label="输出单价/M"><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>
))}
<Button onClick={() => add({})}> Token </Button>
</>}
</Form.List>
</>}
{mode === 'image_per_output' && <Space align="baseline">
<Form.Item name={['pricing', 'outputRate']} label="输出图片单价(元/张)" rules={[{ required: true }]}>
<InputNumber min={0} stringMode style={{ width: 220 }} />
</Form.Item>
<Form.Item name={['pricing', 'billBy']} label="计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
</Space>}
{mode === 'image_input_output_tiered' && <>
<Space align="baseline">
<Form.Item name={['pricing', 'freeInputImages']} label="免费输入图片数"><InputNumber min={0} /></Form.Item>
<Form.Item name={['pricing', 'inputImageRate']} label="超出后输入图片单价"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item name={['pricing', 'billBy']} label="输出计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
</Space>
<Form.List name={['pricing', 'outputTiers']}>
{(fields, { add, remove }) => <>
{fields.map(field => <Space key={field.key} align="baseline">
<Form.Item {...field} name={[field.name, 'max_pixels']} label="最大像素(末档留空)"><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'rate']} label="输出单价(元/张)"><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>)}
<Button onClick={() => add({ max_pixels: null, rate: 0 })}></Button>
</>}
</Form.List>
</>}
{mode === 'video_token_rate' && <>
<Form.Item name={['pricing', 'tokenFormula']} label="Token 公式说明(由计算器版本实现,不执行文本公式)"><Input disabled /></Form.Item>
<Space align="baseline" wrap>
<Form.Item name={['pricing', 'supportedResolutions']} label="支持分辨率(逗号分隔)"><Input placeholder="480p,720p" /></Form.Item>
<Form.Item name={['pricing', 'defaultFps']} label="默认 FPS"><InputNumber min={1} max={120} /></Form.Item>
</Space>
<Divider titlePlacement="start" plain> + </Divider>
<Form.List name={['pricing', 'dimensionRows']}>
{(fields, { add, remove }) => <>
{fields.map(field => <Space key={field.key} align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'resolution']} label="分辨率" rules={[{ required: true }]}><Input placeholder="720p" /></Form.Item>
<Form.Item {...field} name={[field.name, 'aspectRatio']} label="比例" rules={[{ required: true }]}><Input placeholder="9:16" /></Form.Item>
<Form.Item {...field} name={[field.name, 'width']} label="宽" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'height']} label="高" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>)}
<Button onClick={() => add({})}></Button>
</>}
</Form.List>
<Divider titlePlacement="start" plain></Divider>
<Form.List name={['pricing', 'rates']}>
{(fields, { add, remove }) => <>
{fields.map(field => <div key={field.key} style={{ border: '1px solid #eee', padding: 12, marginBottom: 12, borderRadius: 8 }}>
<Space align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'resolutions']} label="分辨率"><Input placeholder="480p,720p;可空" /></Form.Item>
<Form.Item {...field} name={[field.name, 'hasInputVideo']} label="含输入视频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
<Form.Item {...field} name={[field.name, 'generateAudio']} label="生成音频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
<Form.Item {...field} name={[field.name, 'inferenceModes']} label="推理模式"><Input placeholder="online,flex;可空" /></Form.Item>
<Form.Item {...field} name={[field.name, 'rate']} label="单价/M Token" rules={[{ required: true }]}><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>
</div>)}
<Button onClick={() => add({ hasInputVideo: 'any', generateAudio: 'any' })}></Button>
</>}
</Form.List>
</>}
<Divider titlePlacement="start"></Divider>
<Form.Item name="sourceUrl" label="官方来源 URL"><Input /></Form.Item>
<Form.Item name="sourceUpdatedAt" label="官方文档更新时间(不是价格生效时间)"><DatePicker showTime /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit" loading={loading}>稿</Button>
</Space>
</Form>
);
};
export default PricingRuleForm;
@@ -1,229 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Input, message, Space, Typography } from 'antd';
import { previewModelPricing } from '../../api';
import type {
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingPreviewResponse,
} from '../../types';
interface Props {
billingMode: ModelPricingBillingMode;
calculatorVersion: ModelPricingCalculatorVersion;
ruleJson: Record<string, any>;
}
const examples: Record<ModelPricingBillingMode, Record<string, any>> = {
text_token_tiered: {
input_tokens: 1000,
output_tokens: 300,
cached_input_tokens: 0,
audio_input_tokens: 0,
usage_source: 'provider',
},
image_per_output: {
requested_output_count: 1,
successful_output_count: 1,
provider_billed_count: 1,
usage_source: 'provider_response',
},
image_input_output_tiered: {
provider_input_image_count: 2,
requested_output_count: 1,
successful_output_count: 1,
output_items: [{ width: 2048, height: 2048, pixels: 4194304 }],
usage_source: 'provider_response',
},
video_token_rate: {
total_tokens: 1000000,
resolution: '720p',
aspect_ratio: '9:16',
has_input_video: false,
generate_audio: false,
inference_mode: 'online',
usage_source: 'provider',
},
};
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
return source[camelKey];
}
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
const result = { ...source };
keys.forEach(key => delete result[key]);
return result;
}
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'max_context_tokens', 'maxContextTokens',
'input_rate', 'inputRate',
'audio_input_rate', 'audioInputRate',
'output_rate', 'outputRate',
'cached_input_rate', 'cachedInputRate',
'cached_audio_input_rate', 'cachedAudioInputRate',
]);
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
return {
...result,
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
};
}
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
return {
...result,
max_pixels: maxPixels === undefined ? null : maxPixels,
rate: value?.rate,
};
}
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'has_input_video', 'hasInputVideo',
'generate_audio', 'generateAudio',
'inference_modes', 'inferenceModes',
]);
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
return {
...result,
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
};
}
/**
* API 返回层可能会把嵌套 rule_json 一并转成 camelCase。
* 后端计价器只接受 snake_case,因此试算前必须按计价模式恢复标准结构。
*/
function normalizeRuleJson(
billingMode: ModelPricingBillingMode,
value: Record<string, any>,
): Record<string, any> {
const source = value || {};
const base = removeKeys(source, [
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
'output_rate', 'outputRate',
'bill_by', 'billBy',
'free_input_images', 'freeInputImages',
'input_image_rate', 'inputImageRate',
'output_tiers', 'outputTiers',
'token_formula', 'tokenFormula',
'default_fps', 'defaultFps',
'supported_resolutions', 'supportedResolutions',
'dimension_map', 'dimensionMap',
]);
if (billingMode === 'text_token_tiered') {
const tiers = Array.isArray(source.tiers) ? source.tiers : [];
return {
...base,
cache_storage_rate_per_million_token_hour: readAlias(
source,
'cache_storage_rate_per_million_token_hour',
'cacheStorageRatePerMillionTokenHour',
),
tiers: tiers.map(item => normalizeTextTier(item || {})),
};
}
if (billingMode === 'image_per_output') {
return {
...base,
output_rate: readAlias(source, 'output_rate', 'outputRate'),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
if (billingMode === 'image_input_output_tiered') {
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
return {
...base,
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
return {
...base,
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
};
}
const PricingRulePreview: React.FC<Props> = ({ billingMode, calculatorVersion, ruleJson }) => {
const initial = useMemo(() => JSON.stringify(examples[billingMode], null, 2), [billingMode]);
const [usageText, setUsageText] = useState(initial);
const [result, setResult] = useState<ModelPricingPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setUsageText(initial);
setResult(null);
}, [initial, ruleJson, calculatorVersion]);
const run = async () => {
setLoading(true);
try {
const usage = JSON.parse(usageText);
setResult(await previewModelPricing({
billing_mode: billingMode,
calculator_version: calculatorVersion,
rule_json: normalizeRuleJson(billingMode, ruleJson),
usage,
currency: 'CNY',
}));
} catch (e: any) {
message.error(e?.message || '计价试算失败');
} finally {
setLoading(false);
}
};
return <Card size="small" title="规则试算" style={{ marginTop: 16 }}>
<Typography.Text type="secondary">
{calculatorVersion}使使 Token
</Typography.Text>
<Input.TextArea
value={usageText}
onChange={e => setUsageText(e.target.value)}
rows={10}
style={{ marginTop: 10, fontFamily: 'monospace' }}
/>
<Space style={{ marginTop: 10 }}>
<Button type="primary" loading={loading} onClick={run}></Button>
</Space>
{result && <Alert
style={{ marginTop: 12 }}
type={result.isEstimated ? 'warning' : 'success'}
showIcon
message={`${result.currency} ${result.amount}${result.isEstimated ? '(估算)' : ''}`}
description={<>
<div style={{ marginBottom: 8 }}>{result.usageSource || '-'}</div>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{JSON.stringify(result.breakdown, null, 2)}</pre>
</>}
/>}
</Card>;
};
export default PricingRulePreview;
+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;
@@ -30,7 +30,7 @@ const AdminContactRequests: React.FC = () => {
const { user } = useAdminStore();
const fetchData = async () => {
if (!user?.isAdmin) return;
// if (!user?.isAdmin) return;
setLoading(true);
try {
const query = new URLSearchParams();
@@ -39,7 +39,7 @@ const AdminContactRequests: React.FC = () => {
if (isHandledFilter !== null) {
query.set('is_handled', String(isHandledFilter));
}
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/contact/requests?${query.toString()}`);
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/admin/contact-requests?${query.toString()}`);
setData(res.items);
setTotal(res.total);
} catch (err: any) {
@@ -55,7 +55,7 @@ const AdminContactRequests: React.FC = () => {
const handleMarkHandled = async (id: string) => {
try {
await api.put(`/contact/requests/${id}/handle`);
await api.put(`/admin/contact-requests/${id}/handle`);
message.success('已标记为处理');
fetchData();
} catch (err: any) {
@@ -65,7 +65,7 @@ const AdminContactRequests: React.FC = () => {
const handleDelete = async (id: string) => {
try {
await api.delete(`/contact/requests/${id}`);
await api.delete(`/admin/contact-requests/${id}`);
message.success('已删除');
fetchData();
} catch (err: any) {
+332 -225
View File
@@ -1,12 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Descriptions, Drawer, Input, message, Select, Space, Table, Tag, Typography,
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, EyeOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
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 { formatDate } from '../utils/formatDate';
@@ -14,15 +14,26 @@ import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
totalRecharge: 0, totalConsume: 0, totalRefund: 0, transactionCount: 0,
generationCount: 0, generationAttemptCount: 0, imageGenerationCount: 0, videoGenerationCount: 0,
imageConsume: 0, videoConsume: 0, textConsume: 0, analysisConsume: 0,
totalTokens: 0, inputTokens: 0, outputTokens: 0,
attachmentImageCount: 0, attachmentVideoCount: 0, attachmentAudioCount: 0, attachmentTotalCount: 0,
generatedImageCount: 0, generatedVideoCount: 0, generatedTotalCount: 0,
providerCostCalculatedTotal: '0.00000000', providerCostEstimatedTotal: '0.00000000',
providerCostCombinedTotal: '0.00000000', providerCostTotal: '0.00000000',
providerCostPendingCount: 0, providerCostEstimatedCount: 0, providerCostAbnormalCount: 0,
totalRecharge: 0,
totalConsume: 0,
totalRefund: 0,
totalCharge: 0,
totalHold: 0,
totalRefundReal: 0,
totalHoldRelease: 0,
netConsume: 0,
transactionCount: 0,
generationCount: 0,
generationAttemptCount: 0,
imageGenerationCount: 0,
videoGenerationCount: 0,
imageConsume: 0,
videoConsume: 0,
textConsume: 0,
analysisConsume: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
};
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
@@ -32,39 +43,91 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
};
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
charge: { text: '真实扣费', color: 'red' },
refund: { text: '真实退款', color: 'blue' },
hold: { text: '预扣占用', color: 'gold' },
hold_release: { text: '预扣释放', color: 'green' },
};
const userScopeOptions = [
{ value: '', label: '全部用户' }, { value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' }, { value: 'frontend_external', label: '前台外部用户' },
{ value: '', label: '全部用户' },
{ value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' },
{ value: 'frontend_external', label: '前台外部用户' },
];
const recordTypeOptions = [
{ value: '', label: '全部流水' }, { value: 'recharge', label: '充值' }, { value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' }, { value: 'team_internal', label: '团队内部' },
{ value: '', label: '全部流水' },
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'team_internal', label: '团队内部' },
];
const creditSubjectOptions = [
{ value: '', label: '全部积分类型' }, { value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' }, { value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' }, { value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' }, { value: 'unknown', label: '历史未知' },
{ value: '', label: '全部积分类型' },
{ value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' },
{ value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' },
{ value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' },
{ value: 'unknown', label: '历史未知' },
];
const mediaTypeOptions = [{ value: '', label: '全部媒体' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }];
const mediaTypeOptions = [
{ value: '', label: '全部媒体' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
];
const chargeKindOptions = [
{ value: '', label: '全部扣费子类' }, { value: 'media', label: '媒体生成' }, { value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' }, { value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' }, { value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' }, { value: 'admin_adjust', label: '管理员调整' },
{ value: '', label: '全部扣费子类' },
{ value: 'media', label: '媒体生成' },
{ value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' },
{ value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' },
{ value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
];
const chargeActionOptions = [
{ value: '', label: '全部交易动作' },
{ value: 'charge', label: '真实扣费' },
{ value: 'refund', label: '真实退款' },
{ value: 'hold', label: '预扣占用' },
{ value: 'hold_release', label: '预扣释放' },
];
const sourceModuleOptions = [
{ value: '', label: '全部模块' }, { value: 'ai_creation', label: 'AI创作' }, { value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' }, { value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' }, { value: 'payment', label: '支付充值' }, { value: 'team', label: '团队管理' },
{ value: '', label: '全部模块' },
{ value: 'ai_creation', label: 'AI创作' },
{ value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' },
{ value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' },
{ value: 'payment', label: '支付充值' },
{ value: 'team', label: '团队管理' },
{ value: 'unknown', label: '历史未知' },
];
const sourceStepOptions = [
{ value: '', label: '全部步骤' }, { value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' }, { value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' }, { value: 'video_analysis', label: '视频分析' },
{ value: '', label: '全部步骤' },
{ value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' },
{ value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' },
{ value: 'video_analysis', label: '视频分析' },
];
const billingSceneOptions = [
{ value: '', label: '全部计费场景' },
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
@@ -94,45 +157,24 @@ const billingSceneOptions = [
{ value: 'team_internal_transfer', label: '团队内部转账' },
{ value: 'unknown', label: '历史未知' },
];
const costStatusOptions = [
{ value: '', label: '全部成本状态' }, { value: 'pending', label: '待回填' }, { value: 'calculated', label: '已核算' },
{ value: 'estimated', label: '估算' }, { value: 'unmatched_rule', label: '未匹配价格' },
{ value: 'usage_missing', label: '用量缺失' }, { value: 'historical_price_unavailable', label: '历史价格缺失' },
{ value: 'historical_engine_unavailable', label: '历史引擎缺失' },
{ value: 'provider_result_uncertain', label: '供应商结果不确定' },
{ value: 'not_incurred', label: '供应商费用未发生' },
{ value: 'error', label: '核算异常' }, { value: 'not_applicable', label: '不涉及成本' },
];
function n(value: number | string | undefined | null, digits = 0): string {
const parsed = Number(value || 0);
return parsed.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
function n(value: number | undefined | null): string {
return Number(value || 0).toLocaleString();
}
function engineTypeLabel(type?: string): string {
if (type === 'model') return '提词/分析模型';
if (type === 'image') return '图片引擎';
if (type === 'video') return '视频引擎';
return '执行配置';
}
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
if (scope === 'admin') return { userType: 'admin' };
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
return {};
}
function costStatusColor(status?: string): string {
if (status === 'calculated') return 'green';
if (status === 'estimated') return 'orange';
if (status === 'pending') return 'blue';
if (status === 'not_applicable') return 'default';
return 'red';
}
const JsonBlock: React.FC<{ value?: Record<string, any> | null }> = ({ value }) => (
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
{value ? JSON.stringify(value, null, 2) : '-'}
</pre>
);
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
@@ -143,7 +185,6 @@ const AdminCreditRecords: React.FC = () => {
const [exportProgress, setExportProgress] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [detail, setDetail] = useState<AdminCreditRecord | null>(null);
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
@@ -152,28 +193,30 @@ const AdminCreditRecords: React.FC = () => {
const [creditSubject, setCreditSubject] = useState('');
const [mediaType, setMediaType] = useState('');
const [chargeKind, setChargeKind] = useState('');
const [chargeAction, setChargeAction] = useState('');
const [sourceModule, setSourceModule] = useState('');
const [sourceStepCode, setSourceStepCode] = useState('');
const [billingScene, setBillingScene] = useState('');
const [userNameFilter, setUserNameFilter] = useState('');
const [engineProvider, setEngineProvider] = useState('');
const [engineModelName, setEngineModelName] = useState('');
const [pricingVersionCode, setPricingVersionCode] = useState('');
const [providerCostStatus, setProviderCostStatus] = useState('');
const [hasAttachment, setHasAttachment] = useState('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
page, pageSize, userName: userNameFilter || undefined, teamId: teamFilter || undefined,
recordType: recordType || undefined, creditSubject: creditSubject || undefined, mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined, sourceModule: sourceModule || undefined, sourceStepCode: sourceStepCode || undefined,
page,
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined,
chargeAction: chargeAction || undefined,
sourceModule: sourceModule || undefined,
sourceStepCode: sourceStepCode || undefined,
billingScene: billingScene || undefined,
engineProvider: engineProvider || undefined, engineModelName: engineModelName || undefined,
pricingVersionCode: pricingVersionCode || undefined, providerCostStatus: providerCostStatus || undefined,
hasAttachment: hasAttachment === '' ? undefined : hasAttachment === 'true',
startDate: dateRange[0]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'),
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, engineProvider, engineModelName, pricingVersionCode, providerCostStatus, hasAttachment, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
const load = async () => {
setLoading(true);
@@ -181,20 +224,34 @@ const AdminCreditRecords: React.FC = () => {
const res = await getCreditRecords(query);
setRecords(res.items || []);
setTotal(res.total || 0);
setSummary({ ...DEFAULT_SUMMARY, ...(res.summary || {}) });
setSummary(res.summary || DEFAULT_SUMMARY);
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
useEffect(() => { getTeamOptions(true).then(setTeamOptions).catch(() => {}); }, []);
useEffect(() => {
getTeamOptions(true).then(setTeamOptions).catch(() => {});
}, []);
const handleReset = () => {
setUserScope(''); setTeamFilter(''); setRecordType(''); setCreditSubject(''); setMediaType(''); setChargeKind('');
setSourceModule(''); setSourceStepCode(''); setBillingScene(''); setUserNameFilter(''); setEngineProvider(''); setEngineModelName('');
setPricingVersionCode(''); setProviderCostStatus(''); setHasAttachment(''); setDateRange([null, null]); setPage(1);
setUserScope('');
setTeamFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
setChargeKind('');
setChargeAction('');
setSourceModule('');
setSourceStepCode('');
setBillingScene('');
setUserNameFilter('');
setDateRange([null, null]);
setPage(1);
};
const exportExcel = async () => {
@@ -205,179 +262,229 @@ const AdminCreditRecords: React.FC = () => {
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
const first = await getCreditRecords(baseQuery);
const all: AdminCreditRecord[] = [...(first.items || [])];
const exportSummary = { ...DEFAULT_SUMMARY, ...(first.summary || {}) };
const exportSummary = first.summary || DEFAULT_SUMMARY;
const totalRows = first.total || 0;
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
for (let p = 2; p <= totalPages; p += 1) {
const res = await getCreditRecords({ ...baseQuery, page: p });
all.push(...(res.items || []));
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
}
const columns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: r => formatDate(r.createdAt || '') },
{ title: '用户', maxWidth: 20, render: r => r.username || '-' }, { title: '手机号', maxWidth: 18, render: r => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: r => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: r => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: r => r.teamNameSnapshot || '未分配团队' },
{ title: '流水类型', maxWidth: 14, render: r => r.recordTypeLabel || r.type }, { title: '积分类型', maxWidth: 20, render: r => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 20, 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: 12, align: 'center', render: r => r.mediaTypeLabel || '-' },
{ title: '变动积分', numFmt: '#,##0.00', align: 'right', render: r => r.amount }, { title: '变动后余额', numFmt: '#,##0.00', align: 'right', render: r => r.balanceAfter },
{ title: '输入Token', numFmt: '#,##0', align: 'right', render: r => r.inputTokens || 0 }, { title: '输出Token', numFmt: '#,##0', align: 'right', render: r => r.outputTokens || 0 },
{ title: '实际Token', numFmt: '#,##0', align: 'right', render: r => r.totalTokens || 0 },
{ title: '图片附件数', numFmt: '#,##0', render: r => r.attachmentImageCount || 0 }, { title: '视频附件数', numFmt: '#,##0', render: r => r.attachmentVideoCount || 0 },
{ title: '音频附件数', numFmt: '#,##0', render: r => r.attachmentAudioCount || 0 }, { title: '附件总数', numFmt: '#,##0', render: r => r.attachmentTotalCount || 0 },
{ title: '输入视频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentVideoDurationSeconds || 0) },
{ title: '输入音频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentAudioDurationSeconds || 0) },
{ title: '请求生成数', numFmt: '#,##0', render: r => r.requestedOutputCount || 0 }, { title: '实际生成图片数', numFmt: '#,##0', render: r => r.generatedImageCount || 0 },
{ title: '实际生成视频数', numFmt: '#,##0', render: r => r.generatedVideoCount || 0 }, { title: '实际生成总数', numFmt: '#,##0', render: r => r.generatedTotalCount || 0 },
{ title: '供应商', maxWidth: 18, render: r => r.engineProvider || '-' }, { title: '模型', maxWidth: 30, render: r => r.engineModelName || '-' },
{ title: '计价模式', maxWidth: 24, render: r => r.pricingBillingModeLabel || '-' }, { title: '计算器版本', maxWidth: 28, render: r => r.pricingCalculatorVersion || '-' },
{ title: '计价版本', maxWidth: 22, render: r => r.pricingVersionCode || '-' }, { title: '用量来源', maxWidth: 20, render: r => r.pricingUsageSource || '-' },
{ title: '计价时间', maxWidth: 22, render: r => formatDate(r.pricingReferenceAt || '') },
{ title: '供应商成本', numFmt: '#,##0.00000000', align: 'right', render: r => Number(r.providerCostAmount || 0) },
{ title: '成本币种', render: r => r.providerCostCurrency || 'CNY' }, { title: '成本状态', maxWidth: 18, render: r => r.providerCostStatusLabel || '-' },
{ title: '是否估算', render: r => r.providerCostIsEstimated ? '' : '' },
{ title: '最终核算时间', maxWidth: 22, render: r => formatDate(r.providerCostFinalizedAt || '') },
{ title: '主供应商用量', render: r => r.providerUsagePrimary ? '是' : '否' },
{ title: '执行类型', maxWidth: 18, render: r => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: r => r.engineName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: r => r.ownerDeleted ? '关联已删除' : '正常' },
{ title: '说明', maxWidth: 42, render: r => r.description || '' }, { title: '业务归属类型', maxWidth: 22, render: r => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 30, render: r => r.ownerId || '' }, { title: 'BizKey', maxWidth: 36, render: r => r.bizKey || '' },
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ 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: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ 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 },
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
{ 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: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
exportStyledExcel({
filename: `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, sheetName: '积分流水', title: '积分流水与供应商成本核查',
metadataRows: [['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], ['导出条数', totalRows]],
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
exportStyledExcel<AdminCreditRecord>({
filename,
sheetName: '积分流水',
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
summaryRows: [
['总充值', exportSummary.totalRecharge], ['总消费', exportSummary.totalConsume], ['总回退', exportSummary.totalRefund], ['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount], ['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume], ['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume], ['视频分析积分', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens], ['输入 Token', exportSummary.inputTokens], ['输出 Token', exportSummary.outputTokens],
['输入图片附件数', exportSummary.attachmentImageCount], ['输入视频附件数', exportSummary.attachmentVideoCount], ['输入音频附件数', exportSummary.attachmentAudioCount],
['实际生成图片数', exportSummary.generatedImageCount], ['实际生成视频数', exportSummary.generatedVideoCount],
['已核算供应商成本', Number(exportSummary.providerCostCalculatedTotal || 0)],
['估算供应商成本', Number(exportSummary.providerCostEstimatedTotal || 0)],
['成本参考合计', Number(exportSummary.providerCostCombinedTotal || 0)],
['待核算流水数', exportSummary.providerCostPendingCount],
['估算成本流水数', exportSummary.providerCostEstimatedCount], ['异常成本流水数', exportSummary.providerCostAbnormalCount],
], columns, rows: all,
['总充值', exportSummary.totalRecharge],
['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume],
[' · 真实扣费(独立统计:type=消费 & action=charge/NULL', exportSummary.totalCharge],
[' · 预扣占用(独立统计:type=消费 & action=hold', exportSummary.totalHold],
['总回退(真实退款 + 预扣释放)', exportSummary.totalRefund],
[' · 真实退款(独立统计:type=回退 & action=refund/NULL', exportSummary.totalRefundReal],
[' · 预扣释放(独立统计:type=回退 & action=hold_release', exportSummary.totalHoldRelease],
['净消耗(总消费 总回退,≥ 0', exportSummary.netConsume],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分(仅真实扣费)', exportSummary.imageConsume],
['视频消费积分(仅真实扣费)', exportSummary.videoConsume],
['提词消费积分(仅真实扣费)', exportSummary.textConsume],
['视频分析积分(仅真实扣费)', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
],
columns: detailColumns,
rows: all,
});
message.success('Excel 已导出');
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally { setExporting(false); setExportProgress(''); }
} finally {
setExporting(false);
setExportProgress('');
}
};
const columns = [
{ 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) => <div><Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.frontendUserKindLabel || '-'}</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: '流水类型', dataIndex: 'recordType', width: 105, 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: 'creditSubjectLabel', width: 150, render: (v: string, r: AdminCreditRecord) => <div><Tag>{v || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.chargeKindLabel || '-'}</div></div> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 135, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 230, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 85, 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, 2)}</Typography.Text> },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v, 2) },
{ 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: '媒体', 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: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ 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: 240, 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: 105, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
{ 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> },
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 165, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
{ title: '附件', key: 'attachments', width: 155, render: (_: any, r: AdminCreditRecord) => <div> {n(r.attachmentImageCount)} / {n(r.attachmentVideoCount)} / {n(r.attachmentAudioCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.attachmentTotalCount)}</div></div> },
{ title: '生成产出', key: 'outputs', width: 150, render: (_: any, r: AdminCreditRecord) => <div> {n(r.generatedImageCount)} / {n(r.generatedVideoCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.requestedOutputCount)} / {n(r.generatedTotalCount)}</div></div> },
{ title: '供应商实价', key: 'providerCost', width: 175, render: (_: any, r: AdminCreditRecord) => <div><Typography.Text strong>{r.providerCostCurrency || 'CNY'} {n(r.providerCostAmount, 8)}</Typography.Text><div><Tag color={costStatusColor(r.providerCostStatus)}>{r.providerCostStatusLabel || '-'}</Tag>{r.providerCostIsEstimated && <Tag color="orange"></Tag>}</div></div> },
{ title: '计价版本', key: 'pricing', width: 220, render: (_: any, r: AdminCreditRecord) => <div><div>{r.pricingVersionCode || '未锁价'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.pricingBillingModeLabel || '-'} / {r.pricingCalculatorVersion || '-'}</div></div> },
{ title: '操作', key: 'action', width: 90, fixed: 'right' as const, render: (_: any, r: AdminCreditRecord) => <Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}></Button> },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
];
return <div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund, 2)}</div></div></Space></Card>
<Card bordered={false}><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 }}>
<Card size="small">{n(summary.imageGenerationCount)} / {n(summary.imageConsume, 2)} </Card>
<Card size="small">{n(summary.videoGenerationCount)} / {n(summary.videoConsume, 2)} </Card>
<Card size="small">{n(summary.textConsume, 2)} </Card>
<Card size="small">{n(summary.analysisConsume, 2)} </Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small">//{n(summary.attachmentImageCount)} / {n(summary.attachmentVideoCount)} / {n(summary.attachmentAudioCount)}</Card>
<Card size="small">/{n(summary.generatedImageCount)} / {n(summary.generatedVideoCount)}</Card>
<Card size="small">¥{n(summary.providerCostCalculatedTotal, 8)}</Card>
<Card size="small">{n(summary.providerCostEstimatedCount)} / ¥{n(summary.providerCostEstimatedTotal, 8)}</Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small"> Token{n(summary.totalTokens)}</Card>
<Card size="small">{n(summary.providerCostPendingCount)} </Card>
<Card size="small">{n(summary.providerCostAbnormalCount)} </Card>
<Card size="small">¥{n(summary.providerCostCombinedTotal, 8)}</Card>
</div>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={v => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select value={teamFilter} onChange={v => { setPage(1); setTeamFilter(v); }} style={{ width: 170 }} options={[{ value: '', label: '全部团队' }, { value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' }, ...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name }))]} />
<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} />
<Select value={chargeKind} onChange={v => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={sourceModule} onChange={v => { setPage(1); setSourceModule(v); }} style={{ width: 160 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={v => { setPage(1); setSourceStepCode(v); }} style={{ width: 160 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={v => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Select value={providerCostStatus} onChange={v => { setPage(1); setProviderCostStatus(v); }} style={{ width: 160 }} options={costStatusOptions} />
<Select value={hasAttachment} onChange={v => { setPage(1); setHasAttachment(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部附件' }, { value: 'true', label: '有附件' }, { value: 'false', label: '无附件' }]} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={e => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Input placeholder="供应商" value={engineProvider} onChange={e => { setPage(1); setEngineProvider(e.target.value); }} style={{ width: 130 }} allowClear />
<Input placeholder="模型名称" value={engineModelName} onChange={e => { setPage(1); setEngineModelName(e.target.value); }} style={{ width: 230 }} allowClear />
<Input placeholder="计价版本" value={pricingVersionCode} onChange={e => { setPage(1); setPricingVersionCode(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={dates => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} />
</Space>
<Space><Button onClick={handleReset}></Button><Button icon={<ReloadOutlined />} onClick={load}></Button><Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button></Space>
return (
<div>
<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' }}>
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}> + </span>
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
<span style={{ color: '#b91c1c', fontWeight: 600 }}>{n(summary.totalCharge)}</span>
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
<span style={{ color: '#d97706', fontWeight: 600 }}>{n(summary.totalHold)}</span>
</div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}>
退
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>退 + </span>
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
退 <span style={{ color: '#1d4ed8', fontWeight: 600 }}>{n(summary.totalRefundReal)}</span>
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
<span style={{ color: '#047857', fontWeight: 600 }}>{n(summary.totalHoldRelease)}</span>
</div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: 'linear-gradient(135deg, #faf5ff 0%, #eef2ff 100%)' }}>
<Space><DollarOutlined style={{ color: '#6366f1', fontSize: 22 }} />
<div>
<div style={{ color: '#6366f1' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div>
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}> 退 0</div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} />
<div>
<div style={{ color: '#94a3b8' }}> / </div>
<div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div>
</div>
</Space>
</Card>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table columns={columns} dataSource={records} rowKey="id" loading={loading} scroll={{ x: 3650 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); }, showTotal: t => `${t}` }} />
</Card>
<Drawer open={!!detail} width={860} title="积分流水财务核查" onClose={() => setDetail(null)}>
{detail && <>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="流水ID" span={2}>{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.username || '-'} / {detail.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="发生时间">{formatDate(detail.createdAt || '')}</Descriptions.Item>
<Descriptions.Item label="积分变动">{n(detail.amount, 2)}</Descriptions.Item>
<Descriptions.Item label="余额">{n(detail.balanceAfter, 2)}</Descriptions.Item>
<Descriptions.Item label="模型" span={2}>{detail.engineProvider || '-'} / {detail.engineModelName || detail.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="计价版本">{detail.pricingVersionCode || '-'}</Descriptions.Item>
<Descriptions.Item label="计价模式">{detail.pricingBillingModeLabel || '-'}</Descriptions.Item>
<Descriptions.Item label="计算器版本">{detail.pricingCalculatorVersion || '-'}</Descriptions.Item>
<Descriptions.Item label="用量来源">{detail.pricingUsageSource || '-'}</Descriptions.Item>
<Descriptions.Item label="计价参考时间">{formatDate(detail.pricingReferenceAt || '')}</Descriptions.Item>
<Descriptions.Item label="价格生效区间">{formatDate(detail.pricingEffectiveFrom || '')} {detail.pricingEffectiveTo ? formatDate(detail.pricingEffectiveTo) : '长期'}</Descriptions.Item>
<Descriptions.Item label="供应商成本"><b>{detail.providerCostCurrency || 'CNY'} {n(detail.providerCostAmount, 8)}</b></Descriptions.Item>
<Descriptions.Item label="核算状态"><Tag color={costStatusColor(detail.providerCostStatus)}>{detail.providerCostStatusLabel || '-'}</Tag></Descriptions.Item>
<Descriptions.Item label="最终核算时间">{formatDate(detail.providerCostFinalizedAt || '')}</Descriptions.Item>
<Descriptions.Item label="主供应商用量">{detail.providerUsagePrimary ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="附件统计"> {detail.attachmentImageCount} / {detail.attachmentVideoCount} / {detail.attachmentAudioCount}</Descriptions.Item>
<Descriptions.Item label="产出统计"> {detail.requestedOutputCount} / {detail.generatedImageCount} / {detail.generatedVideoCount}</Descriptions.Item>
<Descriptions.Item label="Token"> {detail.inputTokens} / {detail.outputTokens} / {detail.totalTokens}</Descriptions.Item>
<Descriptions.Item label="业务归属">{detail.ownerType || '-'} / {detail.ownerId || '-'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.pricingSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.usageSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.attachmentSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.generationSnapshotJson} />
</>}
</Drawer>
</div>;
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small" bordered={false}>{n(summary.imageGenerationCount)} / {n(summary.imageConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.videoGenerationCount)} / {n(summary.videoConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.textConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.analysisConsume)} </Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select
value={teamFilter}
onChange={(v) => { setPage(1); setTeamFilter(v); }}
style={{ width: 170 }}
options={[
{ value: '', label: '全部团队' },
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<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} />
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={chargeAction} onChange={(v) => { setPage(1); setChargeAction(v); }} style={{ width: 140 }} options={chargeActionOptions} />
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
</Space>
<Space>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button>
</Space>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table
columns={columns}
dataSource={records}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2160 }}
/>
</Card>
</div>
);
};
export default AdminCreditRecords;
+360 -351
View File
@@ -1,14 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Card, Col, Row, Typography, DatePicker, Button, Space } from 'antd';
import { Card, Col, Row, Typography, DatePicker, Button, Space, Spin } from 'antd';
import {
UserOutlined,
ProjectOutlined,
PlayCircleOutlined,
FileTextOutlined,
DollarOutlined,
WalletOutlined,
ArrowUpOutlined,
CalendarOutlined,
UserOutlined, ProjectOutlined, PlayCircleOutlined, FileTextOutlined,
DollarOutlined, WalletOutlined, ArrowUpOutlined, CalendarOutlined,
} from '@ant-design/icons';
import { getAdminStats, getSystemConfigs } from '../api';
import type { AdminStats, SystemConfig } from '../types';
@@ -17,10 +11,25 @@ import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const MODULE_LABELS: Record<string, string> = {
'ai_creation': 'AI创作',
'generation_record': '项目生成',
'hot_opening_replicate': '爆款开头复刻',
'shot_replicate': '拆镜复刻',
'payment': '支付充值',
'admin': '后台管理',
'team': '团队管理',
'unknown': '历史未知',
'other': '其他',
};
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#ec4899', '#14b8a6', '#f97316'];
const AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]);
const [activeRange, setActiveRange] = useState<string>('today');
const [siteName, setSiteName] = useState<string>('数据概览');
const load = async () => {
@@ -39,377 +48,377 @@ const AdminDashboard: React.FC = () => {
const configs = await getSystemConfigs();
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
if (siteConfig) {
const title = `${siteConfig.value} 管理后台`;
setSiteName(title);
document.title = title;
setSiteName(`${siteConfig.value} 管理后台`);
document.title = `${siteConfig.value} 管理后台`;
}
} catch {
setSiteName('数据概览');
document.title = '数据概览';
}
} catch { /* ignore */ }
};
useEffect(() => {
load();
loadSiteName();
}, []);
useEffect(() => { load(); loadSiteName(); }, []);
const handleToday = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('day'), dayjs()];
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs], range?: string) => {
setStartDate(dates);
loadWithDates(dates);
};
const handleYesterday = () => {
const yesterday = dayjs().subtract(1, 'day');
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [yesterday.startOf('day'), yesterday.endOf('day')];
setStartDate(dates);
loadWithDates(dates);
};
const handleWeek = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('week'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const handleMonth = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('month'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs]) => {
if (range) setActiveRange(range);
const start = dates[0]?.format('YYYY-MM-DD') || undefined;
const end = dates[1]?.format('YYYY-MM-DD') || undefined;
setLoading(true);
getAdminStats(start, end).then(data => {
setStats(data);
setLoading(false);
}).catch(() => {
setLoading(false);
});
getAdminStats(start, end).then(data => { setStats(data); setLoading(false); }).catch(() => setLoading(false));
};
const handleToday = () => loadWithDates([dayjs().startOf('day'), dayjs()], 'today');
const handleYesterday = () => { const y = dayjs().subtract(1, 'day'); loadWithDates([y.startOf('day'), y.endOf('day')], 'yesterday'); };
const handleWeek = () => loadWithDates([dayjs().startOf('week'), dayjs()], 'week');
const handleMonth = () => loadWithDates([dayjs().startOf('month'), dayjs()], 'month');
const handleDateChange = (dates: any) => {
if (dates) {
setStartDate([dates[0], dates[1]]);
loadWithDates([dates[0], dates[1]]);
}
};
const baseStats = stats ? [
{
title: '用户数量',
value: stats.totalUsers,
lastPeriodValue: stats.lastPeriodUsers,
icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
prefix: '',
suffix: '位用户',
description: '平台注册用户总数'
},
{
title: '总项目数',
value: stats.totalProjects,
lastPeriodValue: stats.lastPeriodProjects,
icon: <ProjectOutlined />,
gradient: 'linear-gradient(135deg, #00d4ff 0%, #0099cc 100%)',
prefix: '',
suffix: '个项目',
description: '创建的项目总数'
},
{
title: '项目记录',
value: stats.totalRecords,
lastPeriodValue: stats.lastPeriodRecords,
icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
prefix: '',
suffix: '条记录',
description: '项目记录总数'
},
{
title: '创作记录',
value: stats.totalGenerations,
lastPeriodValue: stats.lastPeriodGenerations,
icon: <PlayCircleOutlined />,
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
prefix: '',
suffix: '次创作',
description: 'AI创作记录总数'
},
] : [];
const financeStats = stats ? [
{
title: '支付宝收入',
value: stats.todayAlipayRevenue,
icon: <WalletOutlined />,
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
prefix: '¥',
suffix: '',
description: '支付宝收款',
tag: '支付宝'
},
{
title: '微信收入',
value: stats.todayWechatRevenue,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
prefix: '¥',
suffix: '',
description: '微信收款',
tag: '微信支付'
},
{
title: '总收入',
value: stats.totalRevenue,
lastPeriodValue: stats.lastPeriodRevenue,
icon: <ArrowUpOutlined />,
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
prefix: '¥',
suffix: '',
description: '平台总收入',
tag: '总收入'
},
{
title: '消耗积分',
value: stats.creditsConsumedToday,
lastPeriodValue: stats.lastPeriodCreditsConsumed,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
prefix: '',
suffix: '积分',
description: '用户消耗积分',
tag: '积分消耗'
},
] : [];
const StatCard: React.FC<{
title: string;
value: number;
lastPeriodValue?: number;
icon: React.ReactNode;
gradient: string;
prefix?: string;
suffix?: string;
description?: string;
tag?: string;
}> = ({ title, value, lastPeriodValue, icon, gradient, prefix = '', suffix = '', description, tag }) => {
const change = lastPeriodValue !== undefined && lastPeriodValue > 0
? ((value - lastPeriodValue) / lastPeriodValue * 100).toFixed(1)
: null;
const isPositive = change !== null && parseFloat(change) >= 0;
return (
<Card
bordered={false}
loading={loading}
hoverable
style={{
borderRadius: 16,
border: '1px solid rgba(0,0,0,0.04)',
background: '#ffffff',
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
transition: 'all 0.3s ease',
overflow: 'hidden'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 0'
}}>
<div style={{
width: 52,
height: 52,
borderRadius: 14,
background: gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#fff',
flexShrink: 0,
boxShadow: '0 8px 24px rgba(0,0,0,0.1)',
}}>
{icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 4
}}>
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>{title}</Typography.Text>
{tag && (
<span style={{
fontSize: 10,
padding: '2px 8px',
borderRadius: 10,
background: 'rgba(99,102,241,0.1)',
color: '#6366f1',
fontWeight: 500
}}>
{tag}
</span>
)}
</div>
<div style={{
fontSize: 28,
fontWeight: 800,
color: '#1e293b',
letterSpacing: -0.5,
marginBottom: 2
}}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</div>
{description && (
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
{description}
</Typography.Text>
)}
{change !== null && lastPeriodValue !== undefined && (
<div style={{
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid #f1f5f9',
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
: {prefix}{lastPeriodValue.toLocaleString()}{suffix}
</Typography.Text>
<span style={{
fontSize: 11,
fontWeight: 500,
color: isPositive ? '#10b981' : '#ef4444',
display: 'flex',
alignItems: 'center',
gap: 2
}}>
{isPositive ? '↑' : '↓'} {Math.abs(parseFloat(change))}%
</span>
</div>
)}
</div>
</div>
</Card>
);
if (dates) { setActiveRange(''); loadWithDates([dates[0], dates[1]]); }
};
return (
<div style={{ padding: 0 }}>
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '24px 24px 32px',
borderRadius: 0,
marginBottom: -24,
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
top: -50,
right: -50,
width: 200,
height: 200,
background: 'rgba(255,255,255,0.1)',
borderRadius: '50%'
}} />
<div style={{
position: 'absolute',
bottom: -30,
left: -30,
width: 150,
height: 150,
background: 'rgba(255,255,255,0.08)',
borderRadius: '50%'
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Title level={2} style={{ color: '#fff', marginBottom: 4, fontWeight: 700 }}>
{siteName}
</Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>
</Typography.Text>
<div>
{/* 日期筛选 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 20 }}>
<Space>
<Button type={activeRange === 'today' ? 'primary' : 'default'} size="small" onClick={handleToday}></Button>
<Button type={activeRange === 'yesterday' ? 'primary' : 'default'} size="small" onClick={handleYesterday}></Button>
<Button type={activeRange === 'week' ? 'primary' : 'default'} size="small" onClick={handleWeek}></Button>
<Button type={activeRange === 'month' ? 'primary' : 'default'} size="small" onClick={handleMonth}></Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '4px 12px', borderRadius: 8 }}>
<CalendarOutlined style={{ color: '#64748b' }} />
<DatePicker.RangePicker value={startDate} onChange={handleDateChange} size="small" />
</div>
</div>
<div style={{ marginTop: 40 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
<Space>
<Button
type={!startDate[0] || !startDate[1] || startDate[0]?.isSame(dayjs().startOf('day')) && startDate[1]?.isSame(dayjs(), 'day') ? 'primary' : 'default'}
size="small"
onClick={handleToday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleYesterday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleWeek}
>
</Button>
<Button
type="default"
size="small"
onClick={handleMonth}
>
</Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '6px 12px', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<CalendarOutlined style={{ color: '#64748b', fontSize: 14 }} />
<DatePicker.RangePicker
value={startDate}
onChange={handleDateChange}
placeholder={['开始日期', '结束日期']}
size="small"
/>
</div>
{/* 核心数据 */}
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
</div>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{baseStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
<Row gutter={[12, 12]}>
{[
{ title: '新增用户数量', value: stats?.totalUsers, icon: <UserOutlined />, color: '#6366f1' },
{ title: '总收入', value: stats?.totalRevenue, icon: <WalletOutlined />, color: '#ec4899', prefix: '¥' },
{ title: '消耗积分', value: stats?.creditsConsumedToday, icon: <DollarOutlined />, color: '#ef4444' },
].map(s => (
<Col xs={12} sm={8} md={4} key={s.title}>
<CompactStatCard {...s} loading={loading} />
</Col>
))}
</Row>
</div>
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
{/* 图表区域 */}
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{financeStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
</Col>
))}
<Col xs={24} lg={12}>
<ChartCard title="每日积分消耗趋势" loading={loading}>
<LineChart data={stats?.dailyCreditsByModule || []} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="各模块积分占比" loading={loading}>
<ModulePie data={stats?.periodCreditsByModule || []} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="团队积分消耗排行" loading={loading}>
<HorizontalBarChart data={(stats?.creditsByTeam || []).slice(0, 8)} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="模型使用次数" loading={loading}>
<HorizontalBarChart data={(stats?.modelUsage || []).slice(0, 8)} valueKey="count" />
</ChartCard>
</Col>
</Row>
</div>
{/* 视频参数分布 */}
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} lg={8}>
<ChartCard title="分辨率分布" loading={loading}>
<PieBarChart data={stats?.videoResolutionUsage || []} />
</ChartCard>
</Col>
<Col xs={24} lg={8}>
<ChartCard title="画面比例分布" loading={loading}>
<PieBarChart data={stats?.videoRatioUsage || []} />
</ChartCard>
</Col>
<Col xs={24} lg={8}>
<ChartCard title="时长分布" loading={loading}>
<PieBarChart data={stats?.videoDurationUsage || []} />
</ChartCard>
</Col>
</Row>
</div>
</div>
);
};
// ── 图表卡片 ──
const ChartCard: React.FC<{ title: string; loading: boolean; children: React.ReactNode }> = ({ title, loading, children }) => (
<Card bordered={false} style={{ borderRadius: 16, border: '1px solid #f0f0f5', height: '100%', boxShadow: '0 4px 20px rgba(0,0,0,0.04)', transition: 'box-shadow 0.3s' }}
styles={{ body: { padding: '16px' } }}>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography.Text strong style={{ fontSize: 14 }}>{title}</Typography.Text>
</div>
<Spin spinning={loading}>{children}</Spin>
</Card>
);
// ── 核心数据小卡片 ──
const CompactStatCard: React.FC<{ title: string; value?: number; icon: React.ReactNode; color: string; prefix?: string; loading: boolean }> = ({ title, value, icon, color, prefix = '', loading }) => (
<Card bordered={false} loading={loading} style={{ borderRadius: 10, border: '1px solid #f0f0f5' }}
styles={{ body: { padding: '12px 14px' } }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 34, height: 34, borderRadius: 8, background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 16 }}>
{icon}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{title}</Typography.Text>
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b', lineHeight: 1.3 }}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : '-'}
</div>
</div>
</div>
</Card>
);
// ── 折线图(按日期+模块,固定展示选中日期往前7天)──
const LineChart: React.FC<{ data: { date: string; module: string; credits: number }[] }> = ({ data }) => {
// 以数据中最新日期为基准,往前推 7 天;不足 7 天按实际天数
if (!data.length) return <EmptyChart />;
const sortedDates = Array.from(new Set(data.map(d => d.date))).sort();
const maxDate = sortedDates[sortedDates.length - 1];
// 生成 [maxDate-6, maxDate] 共 7 天
const baseDayjs = dayjs(maxDate);
const sevenDays: string[] = [];
for (let i = 6; i >= 0; i--) sevenDays.push(baseDayjs.subtract(i, 'day').format('YYYY-MM-DD'));
const dateMap = new Map<string, number>();
data.forEach(d => { dateMap.set(d.date, (dateMap.get(d.date) || 0) + d.credits); });
const maxVal = Math.max(...sevenDays.map(d => dateMap.get(d) || 0), 1);
return (
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 6, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
{sevenDays.map(d => {
const val = dateMap.get(d) || 0;
const pct = (val / maxVal) * 100;
return (
<div key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end', position: 'relative' }}
onMouseEnter={e => {
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
if (bar) { bar.style.transform = 'scaleY(1.08)'; bar.style.filter = 'brightness(1.15) drop-shadow(0 4px 12px rgba(99,102,241,0.4))'; }
if (tip) { tip.style.opacity = '1'; }
}}
onMouseLeave={e => {
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
if (bar) { bar.style.transform = 'scaleY(1)'; bar.style.filter = 'none'; }
if (tip) { tip.style.opacity = '0'; }
}}
>
<div className="tip" style={{ position: 'absolute', bottom: '100%', marginBottom: 6, background: '#1e293b', color: '#fff', fontSize: 11, padding: '4px 10px', borderRadius: 6, whiteSpace: 'nowrap', opacity: 0, transition: 'opacity 0.2s', pointerEvents: 'none', zIndex: 10 }}>
{val > 0 ? `${val.toFixed(0)} 积分` : '无数据'}
</div>
<span style={{ fontSize: 9, color: '#6366f1', fontWeight: 600, marginBottom: 2 }}>{val > 0 ? val.toFixed(0) : ''}</span>
<div className="bar" style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #818cf8 0%, #6366f1 40%, #4f46e5 100%)', borderRadius: '4px 4px 0 0', minHeight: 4, transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 2px 8px rgba(99,102,241,0.25)' }} />
</div>
);
})}
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
{sevenDays.map(d => (
<div key={d} style={{ flex: 1, textAlign: 'center' }}>
<span style={{ fontSize: 9, color: '#94a3b8' }}>{d.slice(5)}</span>
</div>
))}
</div>
</div>
);
};
// ── 模块积分占比(饼图)──
const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({ data }) => {
if (!data.length) return <EmptyChart />;
const moduleMap = new Map<string, number>();
data.forEach(d => { moduleMap.set(d.module, (moduleMap.get(d.module) || 0) + d.credits); });
const modules = Array.from(moduleMap.entries()).sort((a, b) => b[1] - a[1]);
const total = modules.reduce((s, [, v]) => s + v, 0) || 1;
// 计算饼图扇形路径
const size = 160;
const cx = size / 2;
const cy = size / 2;
const r = 68;
let cumAngle = -90; // 从顶部开始
const slices = modules.map(([mod, val], i) => {
const pct = val / total;
const angle = pct * 360;
const startAngle = cumAngle;
cumAngle += angle;
const endAngle = cumAngle;
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
const largeArc = angle > 180 ? 1 : 0;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
const d = `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z`;
return { d, color: COLORS[i % COLORS.length], label: MODULE_LABELS[mod] || mod, val, pct };
});
return (
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 16 }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0, filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.08))' }}>
<defs>
{slices.map((s, i) => (
<linearGradient key={i} id={`pie-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={s.color} stopOpacity={1} />
<stop offset="100%" stopColor={s.color} stopOpacity={0.7} />
</linearGradient>
))}
</defs>
{slices.map((s, i) => (
<path key={i} d={s.d} fill={`url(#pie-grad-${i})`} stroke="#fff" strokeWidth={2}
onMouseEnter={e => {
(e.target as SVGPathElement).style.transform = 'scale(1.06)';
(e.target as SVGPathElement).style.filter = 'brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2))';
}}
onMouseLeave={e => {
(e.target as SVGPathElement).style.transform = 'scale(1)';
(e.target as SVGPathElement).style.filter = 'none';
}}
style={{ transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transformOrigin: `${cx}px ${cy}px`, cursor: 'pointer' }}
/>
))}
<circle cx={cx} cy={cy} r={36} fill="#fff" />
<text x={cx} y={cy - 4} textAnchor="middle" fontSize={11} fill="#64748b"></text>
<text x={cx} y={cy + 12} textAnchor="middle" fontSize={13} fontWeight={700} fill="#1e293b">{total.toFixed(0)}</text>
</svg>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{slices.map((s, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b', padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s', cursor: 'default' }}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#f8fafc'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0, boxShadow: `0 2px 4px ${s.color}40` }} />
<span>{s.label}</span>
<span style={{ fontWeight: 600, color: '#1e293b' }}>{s.val.toFixed(0)}</span>
<span style={{ fontSize: 10 }}>{(s.pct * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
);
};
// ── 横向柱状图(团队/模型)──
const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: string; credits?: number; count?: number }[]; valueKey?: string }> = ({ data, valueKey = 'credits' }) => {
if (!data.length) return <EmptyChart />;
const maxVal = Math.max(...data.map(d => (d as any)[valueKey] || 0), 1);
return (
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', paddingRight: 4 }}>
{data.map((d, i) => {
const label = d.teamName || d.modelName || '-';
const val = (d as any)[valueKey] || 0;
const pct = (val / maxVal) * 100;
const c1 = COLORS[i % COLORS.length];
const c2 = COLORS[(i + 1) % COLORS.length];
return (
<div key={i} style={{ padding: '4px 8px', borderRadius: 8, transition: 'background-color 0.2s, box-shadow 0.2s' }}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(99,102,241,0.08)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.boxShadow = 'none'; }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%', fontWeight: 500 }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#1e293b' }}>{val.toLocaleString()}</span>
</div>
<div style={{ height: 18, background: '#f1f5f9', borderRadius: 6, overflow: 'hidden', boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)' }}>
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${c1}, ${c2})`, borderRadius: 6, transition: 'width 0.35s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: `0 1px 3px ${c1}40`, position: 'relative' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)', borderRadius: '6px 6px 0 0' }} />
</div>
</div>
</div>
);
})}
</div>
);
};
// ── 视频参数分布(紧凑饼图+列表)──
const PieBarChart: React.FC<{ data: { model: string; label: string; count: number }[] }> = ({ data }) => {
if (!data.length) return <EmptyChart />;
// 按模型分组
const modelMap = new Map<string, { label: string; count: number }[]>();
data.forEach(d => {
if (!modelMap.has(d.model)) modelMap.set(d.model, []);
modelMap.get(d.model)!.push({ label: d.label, count: d.count });
});
const models = Array.from(modelMap.entries());
const total = data.reduce((s, d) => s + d.count, 0) || 1;
return (
<div style={{ height: 220, overflowY: 'auto' }}>
{models.map(([model, items], mi) => {
const modelTotal = items.reduce((s, it) => s + it.count, 0);
return (
<div key={model} style={{ marginBottom: 12 }}>
{/* 模型名称 + 总数 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: COLORS[mi % COLORS.length] }}>{model}</span>
<span style={{ fontSize: 10, color: '#94a2b3' }}> {modelTotal} ({((modelTotal / total) * 100).toFixed(1)}%)</span>
</div>
{/* 各参数 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{items.map((item, i) => (
<div key={i} style={{
flex: '0 0 calc(50% - 2px)',
padding: '4px 8px',
background: '#f8fafc',
borderRadius: 6,
border: '1px solid #f1f5f9',
transition: 'all 0.2s',
cursor: 'default',
}}
onMouseEnter={e => { Object.assign(e.currentTarget.style, { background: '#fafbff', boxShadow: '0 2px 8px rgba(99,102,241,0.1)', transform: 'translateY(-1px)' }); }}
onMouseLeave={e => { Object.assign(e.currentTarget.style, { background: '#f8fafc', boxShadow: 'none', transform: 'translateY(0)' }); }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#475569', fontWeight: 500 }}>{item.label}</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e293b' }}>{item.count}</span>
</div>
<div style={{ height: 4, background: '#e2e8f0', borderRadius: 2, marginTop: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${(item.count / modelTotal) * 100}%`, background: COLORS[mi % COLORS.length], borderRadius: 2, transition: 'width 0.3s' }} />
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
};
const EmptyChart: React.FC = () => (
<div style={{ height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>
</div>
);
export default AdminDashboard;
@@ -29,9 +29,16 @@ import {
VideoCameraOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminGenerationAiTasks } from '../api';
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
import { getAdminGenerationAiTasks, getImageEngines, getVideoEngines } from '../api';
import type {
GenerationAiImageEngine,
GenerationAIMediaReference,
GenerationAITaskOut,
GenerationAiVideoEngine,
} from '../types';
import { formatDate } from '../utils/formatDate';
import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid';
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
const { RangePicker } = DatePicker;
@@ -66,27 +73,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
references: {},
};
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
pending: { color: 'default', text: '待处理', icon: <ClockCircleOutlined /> },
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
};
const PIPELINE_STAGE_MAP: Record<string, string> = {
timeout: '任务超时',
queued: '已入队',
preparing: '准备中',
creating_provider_task: '创建任务中',
waiting_remote: '等待生成',
result_ready: '结果就绪',
downloading: '下载中',
done: '完成',
download_failed: '下载失败',
polling: '轮询中',
failed: '失败',
};
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
@@ -237,6 +223,9 @@ const AdminGenerationAiRecords: React.FC = () => {
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [reloadKey, setReloadKey] = useState(0);
const [engineListLoading, setEngineListLoading] = useState(false);
const [imageEngines, setImageEngines] = useState<GenerationAiImageEngine[]>([]);
const [videoEngines, setVideoEngines] = useState<GenerationAiVideoEngine[]>([]);
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
@@ -277,6 +266,64 @@ const AdminGenerationAiRecords: React.FC = () => {
load();
}, [load, reloadKey]);
useEffect(() => {
let cancelled = false;
const loadEngineOptions = async () => {
setEngineListLoading(true);
try {
const [images, videos] = await Promise.all([
getImageEngines({ includeDeleted: true }),
getVideoEngines({ includeDeleted: true }),
]);
if (!cancelled) {
setImageEngines(images || []);
setVideoEngines(videos || []);
}
} catch (error: any) {
if (!cancelled) {
message.error(error?.message || '加载模型引擎列表失败');
}
} finally {
if (!cancelled) setEngineListLoading(false);
}
};
void loadEngineOptions();
return () => {
cancelled = true;
};
}, []);
const engineOptions = useMemo(() => {
const toOption = (
engine: GenerationAiImageEngine | GenerationAiVideoEngine,
type: 'image' | 'video',
) => {
const deleted = Boolean(engine.deletedAt);
const typeText = type === 'image' ? '图片' : '视频';
const deletedText = deleted ? '[已删除]' : '';
const detailText = [engine.name, engine.modelName, engine.provider, engine.id]
.filter(Boolean)
.join(' / ');
const label = `[${typeText}]${deletedText} ${detailText}`;
return {
value: engine.id,
label,
searchText: [engine.id, engine.name, engine.modelName, engine.provider, typeText, deleted ? '已删除' : '']
.filter(Boolean)
.join(' ')
.toLowerCase(),
};
};
const merged = [
...imageEngines.map((engine) => toOption(engine, 'image')),
...videoEngines.map((engine) => toOption(engine, 'video')),
];
return Array.from(new Map(merged.map((option) => [option.value, option])).values());
}, [imageEngines, videoEngines]);
useEffect(() => {
if (!preview) {
setResourceState(EMPTY_RESOURCE_STATE);
@@ -425,6 +472,25 @@ const AdminGenerationAiRecords: React.FC = () => {
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
},
},
{
title: '生成数量', key: 'generationCount', width: 150,
render: (_: any, r: GenerationAITaskOut) => {
const count = Math.max(1, Number(r.generationCount || 1));
if (count === 1) return <Tag>1</Tag>;
const children = r.childItems || [];
const completed = children.filter((item) => resolveGenerationUiState(item).isSuccess).length;
const failed = children.filter((item) => resolveGenerationUiState(item).isFailure).length;
const deleted = children.filter((item) => (item.displayStatus || item.status) === 'deleted').length;
return (
<Space size={4} wrap>
<Tag color="purple">{count}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{completed}{failed ? ` / ${failed}失败` : ''}{deleted ? ` / ${deleted}删除` : ''}
</Typography.Text>
</Space>
);
},
},
{
title: '引擎', key: 'engine', width: 160,
render: (_: any, r: GenerationAITaskOut) => {
@@ -475,7 +541,7 @@ const AdminGenerationAiRecords: React.FC = () => {
{
title: '结果', key: 'result', width: 90,
render: (_: any, r: GenerationAITaskOut) => {
if (r.status !== 'completed') {
if (!resolveGenerationUiState(r).isSuccess) {
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
}
if (r.genType === 'video' && r.videoUrl) {
@@ -551,13 +617,14 @@ const AdminGenerationAiRecords: React.FC = () => {
{
title: '状态', dataIndex: 'status', width: 100,
render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
const state = resolveGenerationUiState({ status: v });
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
},
},
{
title: '阶段', dataIndex: 'pipelineStage', width: 120,
render: (v: string) => <Tag color="blue">{PIPELINE_STAGE_MAP[v] || v || '-'}</Tag>,
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
},
{
title: '时间', key: 'time', width: 170,
@@ -579,10 +646,10 @@ const AdminGenerationAiRecords: React.FC = () => {
], [handleOpenPreview]);
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null;
const renderResultImage = () => {
if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null;
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null;
if (!preview.imageUrl) {
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
@@ -722,7 +789,7 @@ const AdminGenerationAiRecords: React.FC = () => {
};
const renderResultVideo = () => {
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
if (!preview || preview.genType !== 'video' || !resolveGenerationUiState(preview).isSuccess) return null;
if (!preview.videoUrl) {
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
@@ -947,21 +1014,17 @@ const AdminGenerationAiRecords: React.FC = () => {
<Select
allowClear
showSearch
loading={engineListLoading}
placeholder="引擎筛选"
value={filterEngineId || undefined}
style={{ width: 180 }}
style={{ width: 260 }}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
optionFilterProp="label"
options={Array.from(
new Map(
records
.filter((r) => r.engineId)
.map((r) => [r.engineId, {
value: r.engineId,
label: getEngineName(r.engineSnapshot as any) || r.engineId,
}]),
).values(),
)}
filterOption={(input, option: any) =>
String(option?.searchText || option?.label || '')
.toLowerCase()
.includes(input.trim().toLowerCase())
}
options={engineOptions}
/>
<RangePicker
value={createdRange}
@@ -1036,8 +1099,8 @@ const AdminGenerationAiRecords: React.FC = () => {
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
{preview.pipelineStage ? <Tag color="blue">{PIPELINE_STAGE_MAP[preview.pipelineStage] || preview.pipelineStage}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
{/*{preview.generationMode ? <Tag>{preview.generationMode}</Tag> : null}*/}
</div>
@@ -1045,6 +1108,7 @@ const AdminGenerationAiRecords: React.FC = () => {
<InfoItem label="用户名称" value={preview.userName || '未知用户'} />
<InfoItem label="用户ID" value={preview.userId || '-'} />
<InfoItem label="任务ID" value={preview.id} />
<InfoItem label="生成数量" value={`${preview.generationCount || 1}`} />
</div>
<div>
@@ -1122,40 +1186,18 @@ const AdminGenerationAiRecords: React.FC = () => {
</div>
) : null}
{preview.status === 'completed' ? (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'}
</Typography.Text>
{preview.genType === 'video' && preview.videoUrl ? (
<Button
size="small"
type="link"
icon={<PlayCircleOutlined />}
onClick={() => handlePreviewResource(preview.videoUrl!, 'video', '生成视频')}
style={{ padding: 0 }}
>
</Button>
) : null}
{preview.genType === 'image' && preview.imageUrl ? (
<Button
size="small"
type="link"
icon={<FileImageOutlined />}
onClick={() => handlePreviewResource(preview.imageUrl!, 'image', '生成图片')}
style={{ padding: 0 }}
>
</Button>
) : null}
</div>
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
</div>
) : null}
<div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
{preview.generationCount || 1}
</Typography.Text>
<GenerationTaskResourceGrid
task={preview}
resolveUrl={apiUrl}
onPreview={handlePreviewResource}
/>
</div>
{preview.status === 'failed' && preview.errorMessage ? (
{previewStatusConfig?.isFailure && preview.errorMessage ? (
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>: {preview.errorMessage}</Typography.Text>
</div>
@@ -22,12 +22,12 @@ import {
CloseCircleOutlined,
SearchOutlined,
VideoCameraOutlined,
ExclamationCircleOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api';
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
import { formatDate } from '../utils/formatDate';
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
// 后端返回的图片/视频一般是 /images、/videos、/uploads 等相对路径。
@@ -50,14 +50,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
references: {},
};
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
optimizing: { color: 'processing', text: '优化中', icon: <LoadingOutlined spin /> },
prompt_optimized: { color: 'processing', text: '待生成', icon: <ClockCircleOutlined /> },
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
};
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
@@ -194,13 +186,14 @@ const AdminGenerationRecords: React.FC = () => {
const [pageSize] = useState(20);
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterUserId, setFilterUserId] = useState<string>('');
const [filterEngineId, setFilterEngineId] = useState<string>('');
const [filterIncludeMedia, setFilterIncludeMedia] = useState<'' | 'true' | 'false'>('');
const [engineOptions, setEngineOptions] = useState<Array<{ value: string; label: string }>>([]);
const [reloadKey, setReloadKey] = useState(0);
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [updating, setUpdating] = useState<string | null>(null);
const [genModal, setGenModal] = useState<{ record: AdminGenerationRecord; ratio: string; resolution: string; image_size: string } | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -208,6 +201,8 @@ const AdminGenerationRecords: React.FC = () => {
const res = await getAdminGenerationRecords({
userId: filterUserId.trim() || undefined,
status: filterStatus || undefined,
engineId: filterEngineId || undefined,
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
page,
pageSize,
});
@@ -240,6 +235,10 @@ const AdminGenerationRecords: React.FC = () => {
imageTokensUsed: item.imageTokensUsed || 0,
imageProportion: item.imageProportion,
imagePx: item.imagePx,
engineId: item.engineId,
engineName: item.engineName,
engineSnapshot: item.engineSnapshot,
includeMediaReferences: item.includeMediaReferences,
})));
setTotal(res.total || 0);
} catch {
@@ -247,12 +246,32 @@ const AdminGenerationRecords: React.FC = () => {
} finally {
setLoading(false);
}
}, [filterStatus, filterUserId, page, pageSize]);
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
useEffect(() => {
load();
}, [load, reloadKey]);
useEffect(() => {
Promise.all([
getImageEngines({ includeDeleted: true }),
getVideoEngines({ includeDeleted: true }),
])
.then(([imageEngines, videoEngines]) => {
const items = [...(imageEngines || []), ...(videoEngines || [])];
const seen = new Set<string>();
setEngineOptions(items.reduce<Array<{ value: string; label: string }>>((acc, item: any) => {
const id = String(item?.id || '');
if (!id || seen.has(id)) return acc;
seen.add(id);
const deletedSuffix = item?.deletedAt ? '(已删除)' : '';
acc.push({ value: id, label: item?.name ? `${item.name}${deletedSuffix} (${id})` : `${id}${deletedSuffix}` });
return acc;
}, []));
})
.catch(() => setEngineOptions([]));
}, []);
useEffect(() => {
if (!preview) {
setResourceState(EMPTY_RESOURCE_STATE);
@@ -349,33 +368,7 @@ const AdminGenerationRecords: React.FC = () => {
}, 0);
};
const handleStatusUpdate = async (recordId: string, newStatus: string, videoUrl?: string) => {
setUpdating(recordId);
try {
await adminUpdateGenerationStatus(recordId, newStatus, videoUrl);
message.success('状态已更新');
load();
} catch (e: any) {
message.error(e?.message || '更新失败');
} finally {
setUpdating(null);
}
};
const handleGenerate = async () => {
if (!genModal) return;
setUpdating(genModal.record.id);
try {
await adminGenerateVideo(genModal.record.id, genModal.ratio, genModal.resolution, genModal.image_size);
message.success(`已提交${genModal.record.genType === 'video' ? '视频' : '图片'}生成`);
setGenModal(null);
load();
} catch (e: any) {
message.error(e?.message || '生成失败');
} finally {
setUpdating(null);
}
};
const columns = useMemo(() => [
{
@@ -416,21 +409,27 @@ const AdminGenerationRecords: React.FC = () => {
title: '参数', key: 'params', width: 160,
render: (_: any, r: AdminGenerationRecord) => (
r.genType === 'video' ? (
r.duration || r.aspectRatio || r.resolution ? (
<Space size={4} wrap>
{r.duration ? <Tag>{r.duration}s</Tag> : null}
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
</Space>
) : <Tag color="default"></Tag>
<Space size={4} wrap>
{r.duration ? <Tag>{r.duration}s</Tag> : null}
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
{!r.duration && !r.aspectRatio && !r.resolution ? <Tag color="default"></Tag> : null}
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
</Tag>
</Space>
) : (
r.imageSize || r.imageProportion || r.imagePx ? (
<Space size={4} wrap>
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
</Space>
) : <Tag color="default"></Tag>
<Space size={4} wrap>
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
{!r.imageSize && !r.imageProportion && !r.imagePx ? <Tag color="default"></Tag> : null}
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
</Tag>
</Space>
)
),
},
@@ -453,10 +452,15 @@ const AdminGenerationRecords: React.FC = () => {
{
title: '状态', dataIndex: 'status', width: 90,
render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
const state = resolveGenerationUiState({ status: v });
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
},
},
{
title: '阶段', dataIndex: 'pipelineStage', width: 150,
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
},
{
title: '时间', key: 'time', width: 150,
render: (_: any, r: AdminGenerationRecord) => (
@@ -467,78 +471,20 @@ const AdminGenerationRecords: React.FC = () => {
),
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
title: '操作', key: 'action', width: 90, fixed: 'right' as const,
render: (_: any, r: AdminGenerationRecord) => (
<Space size={4} wrap>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
</Button>
{r.status === 'generating' ? (
<Button
size="small"
danger
loading={updating === r.id}
onClick={() => {
Modal.confirm({
title: '确认操作',
icon: <ExclamationCircleOutlined />,
content: '确定将此记录标记为失败?',
onOk: () => handleStatusUpdate(r.id, 'failed'),
});
}}
>
</Button>
) : null}
{r.status === 'failed' ? (
<Button
size="small"
type="primary"
danger
loading={updating === r.id}
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
>
</Button>
) : null}
{r.status === 'prompt_optimized' ? (
<>
<Button
size="small"
type="primary"
loading={updating === r.id}
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
style={{ background: '#6366f1', border: 'none' }}
>
{r.genType === 'video' ? '视频' : '图片'}
</Button>
<Button
size="small"
danger
loading={updating === r.id}
onClick={() => {
Modal.confirm({
title: '确认操作',
icon: <ExclamationCircleOutlined />,
content: '确定将此记录标记为失败?',
onOk: () => handleStatusUpdate(r.id, 'failed'),
});
}}
>
</Button>
</>
) : null}
</Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
</Button>
),
},
], [handleOpenPreview, updating]);
], [handleOpenPreview]);
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType || ''] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null;
const renderResultImage = () => {
if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null;
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null;
if (!preview.imageUrl) {
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
@@ -681,7 +627,7 @@ const AdminGenerationRecords: React.FC = () => {
};
const renderResultVideo = () => {
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
if (!preview || preview.genType !== 'video' || !resolveGenerationUiState(preview).isSuccess) return null;
if (!preview.videoUrl) {
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
@@ -894,6 +840,27 @@ const AdminGenerationRecords: React.FC = () => {
{ value: 'failed', label: '失败' },
]}
/>
<Select
placeholder="引擎筛选"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 220 }}
value={filterEngineId || undefined}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); }}
options={engineOptions}
/>
<Select
placeholder="附件状态"
allowClear
style={{ width: 130 }}
value={filterIncludeMedia || undefined}
onChange={(v) => { setFilterIncludeMedia((v || '') as '' | 'true' | 'false'); setPage(1); }}
options={[
{ value: 'true', label: '携带附件' },
{ value: 'false', label: '不携带附件' },
]}
/>
<Input
placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
@@ -956,7 +923,9 @@ const AdminGenerationRecords: React.FC = () => {
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}> / </Typography.Text>
<Space size={4} wrap>
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
{preview.videoUpscaleEnabled ? <Tag color="purple"></Tag> : null}
</Space>
</div>
</div>
@@ -1035,7 +1004,7 @@ const AdminGenerationRecords: React.FC = () => {
{renderReferences()}
{preview.status === 'completed' ? (
{resolveGenerationUiState(preview).isSuccess ? (
<div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'}
@@ -1045,7 +1014,7 @@ const AdminGenerationRecords: React.FC = () => {
) : null}
{/* Error message */}
{preview.status === 'failed' && preview.errorMessage ? (
{resolveGenerationUiState(preview).isFailure && preview.errorMessage ? (
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>: {preview.errorMessage}</Typography.Text>
</div>
@@ -1062,73 +1031,6 @@ const AdminGenerationRecords: React.FC = () => {
)}
</Modal>
{/* Generate modal */}
<Modal
title={(
<Space>
{genModal && (genModal.record.genType === 'video' ? <PlayCircleOutlined /> : <FileImageOutlined />)}
{genModal && (genModal.record.genType === 'video' ? '生成视频' : '生成图片')}
</Space>
)}
open={!!genModal}
onCancel={() => setGenModal(null)}
onOk={handleGenerate}
okText="提交生成"
cancelText="取消"
confirmLoading={genModal ? updating === genModal.record.id : false}
width={420}
>
{genModal ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
{genModal.record.genType === 'video' ? (
<>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}></Typography.Text>
<Typography.Text strong>{genModal.record.duration || 5}s</Typography.Text>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select
value={genModal.ratio}
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, ratio: v } : null))}
style={{ width: '100%' }}
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map((r) => ({ value: r, label: r }))}
/>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select
value={genModal.resolution}
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, resolution: v } : null))}
style={{ width: '100%' }}
options={['480p', '720p', '1080p'].map((r) => ({ value: r, label: r }))}
/>
</div>
</>
) : (
<>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}></Typography.Text>
<Typography.Text strong>{genModal.record.imagePx || '-'}</Typography.Text>
</div>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}></Typography.Text>
<Typography.Text strong>{genModal.record.imageProportion || '-'}</Typography.Text>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select
value={genModal.image_size}
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, image_size: v } : null))}
style={{ width: '100%' }}
options={['2K', '4K'].map((r) => ({ value: r, label: r }))}
/>
</div>
</>
)}
</div>
) : null}
</Modal>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getAdminHotOpeningTasks } from '../api';
import type { HotOpeningTaskListItemOut } from '../types';
@@ -111,16 +111,15 @@ const AdminHotOpeningReplications: React.FC = () => {
};
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</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>
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
</div>
<Space wrap>
<Select
@@ -208,6 +207,7 @@ const AdminHotOpeningReplications: React.FC = () => {
),
},
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> },
{ title: '流程版本', dataIndex: 'flowVersion', width: 100, render: (v: string) => <Tag color={v === 'v2' ? 'blue' : 'default'}>{String(v || 'v1').toUpperCase()}</Tag> },
{ title: '当前步骤', dataIndex: 'currentStepCode', width: 140, render: (v: string) => STEP_MAP[v] || v || '-' },
{ title: '图片结果', dataIndex: 'finalImageUrl', width: 90, render: (v: string) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: '视频结果', dataIndex: 'finalVideoUrl', width: 90, render: (v: string) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
@@ -219,14 +219,12 @@ const AdminHotOpeningReplications: React.FC = () => {
fixed: 'right',
width: 110,
render: (_, record) => (
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}`)}></Button>
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}?flow_version=${record.flowVersion === 'v2' ? 'v2' : 'v1'}`)}></Button>
),
},
]}
/>
</Space>
</Card>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
@@ -21,6 +21,11 @@ interface ImageEngine {
generateUrl: string;
isActive: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
outputFormat: '' | 'png' | 'jpeg';
}
function parseJsonArray(val: unknown): any[] {
@@ -80,6 +85,7 @@ const AdminImageEngines: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => {
setLoading(true);
@@ -126,6 +132,11 @@ const AdminImageEngines: React.FC = () => {
generate_url: values.generateUrl || '',
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
multi_generation_enabled: values.multiGenerationEnabled ?? false,
max_generation_count: values.maxGenerationCount ?? 1,
multi_image_max_images: values.multiImageMaxImages ?? 15,
max_reference_image_count: values.maxReferenceImageCount ?? 14,
output_format: values.outputFormat ?? '',
};
if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload });
@@ -168,6 +179,8 @@ const AdminImageEngines: React.FC = () => {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
multiGenerationEnabled: false, maxGenerationCount: 1, multiImageMaxImages: 15,
maxReferenceImageCount: 14, outputFormat: '',
supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K',
maxImageCount: 0,
@@ -232,6 +245,18 @@ const AdminImageEngines: React.FC = () => {
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
render: (v: number) => <Tag color="purple">{v} </Tag>,
},
{
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
},
{
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
render: (v: number, r: ImageEngine) => (
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
{r.multiGenerationEnabled ? (v || 1) : 1}
</Tag>
),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -350,6 +375,33 @@ const AdminImageEngines: React.FC = () => {
<Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item>
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
2-5 API
</Typography.Paragraph>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" rules={[{ required: true }]}>
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
</Form.Item>
<Form.Item name="multiImageMaxImages" label="组图输入输出总上限" rules={[{ required: true }]}>
<InputNumber min={1} max={15} precision={0} size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxReferenceImageCount" label="最大参考图数量" rules={[{ required: true }]}>
<InputNumber min={0} max={14} precision={0} size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="outputFormat" label="供应商输出格式">
<Select size="large" options={[
{ value: '', label: '不传(兼容不支持 output_format 的模型)' },
{ value: 'png', label: 'PNG' },
{ value: 'jpeg', label: 'JPEG' },
]} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Select size="large" options={[
+3 -3
View File
@@ -233,15 +233,15 @@ const AdminLayout: React.FC = () => {
width: 42,
height: 42,
borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
background: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
overflow: 'hidden',
}}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
<img src={siteLogo} alt="logo" style={{ width: 36, height: 36, objectFit: 'contain' }} />
) : (
<ThunderboltOutlined style={{ fontSize: 20, color: '#ffffff' }} />
)}
@@ -1,146 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Drawer, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { CopyOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createModelPricingRule,
disableModelPricingRule,
getModelPricingRules,
publishModelPricingRule,
updateModelPricingRule,
} from '../api';
import type { ModelPricingRule, ModelPricingRulePayload } from '../types';
import PricingRuleForm from '../components/modelPricing/PricingRuleForm';
import PricingRulePreview from '../components/modelPricing/PricingRulePreview';
import { formatDate } from '../utils/formatDate';
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
published: { color: 'green', text: '已发布' },
disabled: { color: 'red', text: '已停用' },
};
const modeMap: Record<string, string> = {
text_token_tiered: '文本分档 Token',
image_per_output: '按成功输出图片',
image_input_output_tiered: '输入图 + 输出像素',
video_token_rate: '视频 Token',
};
const AdminModelPricingRules: React.FC = () => {
const [rows, setRows] = useState<ModelPricingRule[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [modelName, setModelName] = useState('');
const [status, setStatus] = useState('');
const [category, setCategory] = useState('');
const [editing, setEditing] = useState<ModelPricingRule | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [detail, setDetail] = useState<ModelPricingRule | null>(null);
const load = async () => {
setLoading(true);
try {
const res = await getModelPricingRules({
page,
pageSize,
modelName: modelName || undefined,
publishStatus: status || undefined,
modelCategory: category || undefined,
});
setRows(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载模型计价规则失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [page, pageSize, modelName, status, category]);
const submit = async (payload: ModelPricingRulePayload) => {
setSaving(true);
try {
if (editing?.id && editing.publishStatus === 'draft') {
await updateModelPricingRule(editing.id, payload);
} else {
await createModelPricingRule(payload);
}
message.success('价格草稿已保存');
setFormOpen(false);
setEditing(null);
await load();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const cloneRule = (rule: ModelPricingRule) => {
setEditing({
...rule,
id: '',
publishStatus: 'draft',
versionCode: `${rule.versionCode}_copy_${dayjs().format('YYYYMMDDHHmm')}`,
effectiveFrom: dayjs().add(1, 'minute').toISOString(),
effectiveTo: null,
referencedCount: 0,
});
setFormOpen(true);
};
const columns = [
{ title: '模型', dataIndex: 'modelName', width: 280, fixed: 'left' as const, render: (v: string, r: ModelPricingRule) => <div><Typography.Text strong>{v}</Typography.Text><div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider} / {r.modelCategory}</div></div> },
{ title: '价格版本', dataIndex: 'versionCode', width: 180 },
{ title: '计价模式/计算器', key: 'calculator', width: 230, render: (_: any, r: ModelPricingRule) => <div>{modeMap[r.billingMode] || r.billingMode}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.calculatorVersion}</div></div> },
{ title: '生效时间', key: 'effective', width: 290, render: (_: any, r: ModelPricingRule) => <div>{formatDate(r.effectiveFrom)}<div style={{ color: '#94a3b8', fontSize: 12 }}> {r.effectiveTo ? formatDate(r.effectiveTo) : '长期有效'}</div></div> },
{ title: '状态', dataIndex: 'publishStatus', width: 100, render: (v: string) => <Tag color={(statusMap[v] || {}).color}>{(statusMap[v] || {}).text || v}</Tag> },
{ title: '规则Hash/引用', key: 'hash', width: 190, render: (_: any, r: ModelPricingRule) => <div>{r.ruleContentHash ? `${r.ruleContentHash.slice(0, 12)}` : '-'}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.referencedCount || 0} </div></div> },
{ title: '来源更新时间', dataIndex: 'sourceUpdatedAt', width: 170, render: (v: string) => v ? formatDate(v) : '-' },
{ title: '操作', key: 'action', width: 310, fixed: 'right' as const, render: (_: any, r: ModelPricingRule) => <Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}>/</Button>
{r.publishStatus === 'draft' && <Button size="small" onClick={() => { setEditing(r); setFormOpen(true); }}></Button>}
<Button size="small" icon={<CopyOutlined />} onClick={() => cloneRule(r)}></Button>
{r.publishStatus === 'draft' && <Popconfirm title="发布后价格正文不可修改,确认发布?" onConfirm={async () => { await publishModelPricingRule(r.id); message.success('已发布'); load(); }}><Button size="small" type="primary"></Button></Popconfirm>}
{r.publishStatus === 'published' && <Popconfirm title="停用后不再匹配新消费,历史快照不受影响。确认?" onConfirm={async () => { await disableModelPricingRule(r.id); message.success('已停用'); load(); }}><Button size="small" danger></Button></Popconfirm>}
</Space> },
];
return <div>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap>
<Input allowClear placeholder="模型名称" value={modelName} onChange={e => { setPage(1); setModelName(e.target.value); }} style={{ width: 260 }} />
<Select value={category} onChange={v => { setPage(1); setCategory(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部类型' }, { value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
<Select value={status} onChange={v => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部状态' }, { value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }, { value: 'disabled', label: '已停用' }]} />
</Space>
<Space>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); setFormOpen(true); }}></Button>
</Space>
</div>
<Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1500 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
</Card>
<Modal open={formOpen} title={editing?.id ? '编辑价格草稿' : editing ? '克隆价格版本' : '新增价格版本'} width={1100} footer={null} destroyOnClose onCancel={() => { setFormOpen(false); setEditing(null); }}>
<PricingRuleForm initial={editing} loading={saving} onSubmit={submit} onCancel={() => { setFormOpen(false); setEditing(null); }} />
</Modal>
<Drawer open={!!detail} width={760} title={detail ? `${detail.modelName} / ${detail.versionCode}` : '计价详情'} onClose={() => setDetail(null)}>
{detail && <>
<Space wrap style={{ marginBottom: 12 }}><Tag>{detail.provider}</Tag><Tag>{detail.modelCategory}</Tag><Tag color="blue">{modeMap[detail.billingMode] || detail.billingMode}</Tag><Tag color={(statusMap[detail.publishStatus] || {}).color}>{(statusMap[detail.publishStatus] || {}).text}</Tag></Space>
<Typography.Paragraph>{formatDate(detail.effectiveFrom)} {detail.effectiveTo ? formatDate(detail.effectiveTo) : '长期有效'}<br />{detail.calculatorVersion}<br /> Hash{detail.ruleContentHash || '-'}</Typography.Paragraph>
<Typography.Paragraph>{detail.sourceUrl || '-'}<br />{detail.sourceUpdatedAt ? formatDate(detail.sourceUpdatedAt) : '-'}</Typography.Paragraph>
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto' }}>{JSON.stringify(detail.ruleJson, null, 2)}</pre>
<PricingRulePreview billingMode={detail.billingMode} calculatorVersion={detail.calculatorVersion} ruleJson={detail.ruleJson} />
</>}
</Drawer>
</div>;
};
export default AdminModelPricingRules;
@@ -1,11 +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 { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers, getSystemConfigs, updateSystemConfig, createSystemConfig, resetActivityBanner } from '../api';
import { formatDate } from '../utils/formatDate';
interface NotificationRecord {
@@ -29,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);
@@ -41,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);
@@ -69,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);
@@ -115,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';
@@ -131,6 +205,7 @@ const AdminNotificationManager: React.FC = () => {
},
{
title: '内容', dataIndex: 'content', ellipsis: true,
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
},
{
title: '类型', dataIndex: 'type', width: 80,
@@ -164,37 +239,148 @@ const AdminNotificationManager: React.FC = () => {
},
];
const tabItems = [
{
key: 'notifications',
label: (
<span><BellOutlined style={{ marginRight: 6 }} /></span>
),
children: (
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条消息`,
}}
scroll={{ x: 900 }}
/>
</Card>
),
},
{
key: 'banner',
label: (
<span><NotificationOutlined style={{ marginRight: 6 }} /></span>
),
children: (
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#6366f1',
}}>
<NotificationOutlined />
</div>
<div>
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 13, display: 'block' }}>
</Typography.Text>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}></Typography.Text>
{bannerLoading ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: '#94a3b8' }}>...</div>
) : (
<ReactQuill
theme="snow"
value={bannerContent}
onChange={setBannerContent}
modules={editorModules}
placeholder="请输入横幅内容(支持富文本:加粗、变色、链接等)"
style={{ height: 200, marginBottom: 48 }}
/>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<Button
icon={<NotificationOutlined />}
onClick={handleResetBanner}
size="large"
style={{ borderRadius: 8, minWidth: 160 }}
>
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveBanner}
loading={bannerSaving}
size="large"
style={{ borderRadius: 8, minWidth: 140 }}
>
</Button>
</div>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
💡
</Typography.Text>
{/* 预览区域 */}
{bannerContent && (
<div style={{ marginTop: 24 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}></Typography.Text>
<div style={{
borderRadius: 12,
overflow: 'hidden',
background: 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 50%, #e0e7ff 100%)',
border: '1px solid rgba(139, 92, 246, 0.15)',
padding: '10px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{
width: 28, height: 28, borderRadius: 8,
background: 'rgba(139, 92, 246, 0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<NotificationOutlined style={{ color: '#7c3aed', fontSize: 14 }} />
</div>
<div style={{
color: '#5b21b6',
fontSize: 14,
fontWeight: 500,
lineHeight: 1.5,
flex: 1,
}} dangerouslySetInnerHTML={{ __html: bannerContent }} />
</div>
</div>
)}
</Card>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条消息`,
}}
scroll={{ x: 900 }}
/>
</Card>
<Tabs items={tabItems} defaultActiveKey="notifications" />
{/* Send Notification Modal */}
<Modal
@@ -210,8 +396,8 @@ const AdminNotificationManager: React.FC = () => {
<Input placeholder="请输入消息标题" size="large" />
</Form.Item>
<Form.Item name="content" label="消息内容"
rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
<ReactQuill theme="snow" modules={editorModules} placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
</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 {
+32 -16
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import {
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
Card, Col, Input, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
} from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
} from '@ant-design/icons';
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate';
@@ -22,6 +22,7 @@ const AdminPaymentStats: React.FC = () => {
const [filters, setFilters] = useState<{
paymentMethod?: string;
status?: string;
phone?: string;
startDate: string;
endDate: string;
}>({
@@ -36,6 +37,7 @@ const AdminPaymentStats: React.FC = () => {
getPaymentStats(filters),
getAdminPaymentOrders({
...filters,
phone: filters.phone,
page: orderPage,
pageSize: orderPageSize,
}),
@@ -61,6 +63,7 @@ const AdminPaymentStats: React.FC = () => {
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setOrderPage(1);
};
const handleRefund = async (orderNo: string) => {
@@ -101,6 +104,7 @@ const AdminPaymentStats: React.FC = () => {
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) => {
@@ -237,28 +241,28 @@ const AdminPaymentStats: React.FC = () => {
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}>
{/* Filters */}
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<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: 150 }}
style={{ width: '100%' }}
value={filters.paymentMethod}
onChange={(value) => setFilters(prev => ({ ...prev, paymentMethod: value }))}
onChange={(value) => { setFilters(prev => ({ ...prev, paymentMethod: value })); setOrderPage(1); }}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<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: 150 }}
style={{ width: '100%' }}
value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
onChange={(value) => { setFilters(prev => ({ ...prev, status: value })); setOrderPage(1); }}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
@@ -266,9 +270,21 @@ const AdminPaymentStats: React.FC = () => {
<Option value="refunded">退</Option>
</Select>
</Col>
<Col xs={24} sm={12} md={8}>
<span style={{ marginRight: 8 }}></span>
<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),
@@ -276,9 +292,9 @@ const AdminPaymentStats: React.FC = () => {
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4}>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
<Col xs={24} sm={12} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Button icon={<ReloadOutlined />} onClick={handleReset} style={{ marginBottom: 0 }}>
</Button>
</Col>
</Row>
@@ -21,7 +21,7 @@ import {
PlayCircleOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -36,7 +36,7 @@ interface AdminReplicationProjectDetailProps {
moduleType?: ReplicationModuleType;
}
const STEP_ORDER = [
const V1_STEP_ORDER = [
'material_input',
'image_prompt_optimize',
'image_generate',
@@ -44,6 +44,12 @@ const STEP_ORDER = [
'video_generate',
];
const V2_STEP_ORDER = [
'material_input',
'video_prompt_optimize',
'video_generate',
];
const STEP_DESCRIPTIONS: Record<string, string> = {
material_input: '参考素材、项目名称和核心内容点',
image_prompt_optimize: '图片 AI 提词优化结果',
@@ -104,6 +110,8 @@ const renderPromptText = (value?: string | null, empty = '暂无提词') => {
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
const [loading, setLoading] = useState(false);
@@ -112,15 +120,15 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
setLoading(true);
try {
const res = moduleType === 'hot_opening_replicate'
? await getAdminHotOpeningTaskDetail(projectId)
: await getAdminShotProjectDetail(projectId);
? await getAdminHotOpeningTaskDetail(projectId, flowVersion)
: await getAdminShotProjectDetail(projectId, flowVersion);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载复刻项目详情失败');
} finally {
setLoading(false);
}
}, [moduleType, projectId]);
}, [flowVersion, moduleType, projectId]);
useEffect(() => {
load();
@@ -134,8 +142,10 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
const moduleValue = detail?.module || moduleType;
const moduleName = getModuleLabel(moduleValue);
const isV2 = detail?.flowVersion === 'v2';
const effectiveStepOrder = isV2 ? V2_STEP_ORDER : V1_STEP_ORDER;
const stepItems = useMemo(() => STEP_ORDER.map(code => {
const stepItems = useMemo(() => effectiveStepOrder.map(code => {
const step = stepsByCode[code];
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
if (step?.status === 'completed') status = 'finish';
@@ -146,7 +156,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
description: step ? <StatusTag status={step.status} /> : '未创建',
status,
};
}), [stepsByCode]);
}), [effectiveStepOrder, stepsByCode]);
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
@@ -242,7 +252,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
},
{
key: 'video_prompt_optimize',
label: <StepHeader index={4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
label: <StepHeader index={isV2 ? 2 : 4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
@@ -265,7 +275,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
},
{
key: 'video_generate',
label: <StepHeader index={5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
label: <StepHeader index={isV2 ? 3 : 5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
@@ -300,6 +310,9 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
),
},
];
const visibleCollapseItems = isV2
? collapseItems.filter(item => !['image_prompt_optimize', 'image_generate'].includes(String(item.key)))
: collapseItems;
return (
<div style={{ padding: 24 }}>
@@ -322,6 +335,8 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
<Descriptions.Item label="流程版本"><Tag color={isV2 ? 'blue' : 'default'}>{String(detail.flowVersion || 'v1').toUpperCase()}</Tag></Descriptions.Item>
<Descriptions.Item label="步骤数量">{detail.stepCount || effectiveStepOrder.length}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
@@ -336,9 +351,11 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
<Card title="最终结果预览">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
<Card size="small" title="最终图片">
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
{!isV2 ? (
<Card size="small" title="最终图片">
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
) : null}
<Card size="small" title="最终视频封面">
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
</Card>
@@ -348,7 +365,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
</div>
</Card>
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
<Collapse defaultActiveKey={defaultActiveKeys} items={visibleCollapseItems} />
<Collapse
items={[
+332 -74
View File
@@ -1,17 +1,19 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload,
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
} from 'antd';
import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined,
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
} from '@ant-design/icons';
import {
createSystemConfig,
getGlobalResourceCapacity,
getSystemConfigs,
saveGlobalResourceCapacity,
updateSystemConfig,
uploadLogo,
uploadPdf,
uploadLoginVideo,
} from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types';
@@ -39,9 +41,21 @@ const AdminSettings: React.FC = () => {
getSystemConfigs(),
getGlobalResourceCapacity(),
]);
// 确保 llm_media_as_base64 配置存在
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 编码' });
}
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';
@@ -56,13 +70,83 @@ const AdminSettings: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
// 仅当 llm_billing_enabled 字段在当前标签页渲染时,才校验预扣积分
const llmBillingEnabled = values.llm_billing_enabled !== undefined && values.llm_billing_enabled !== null
? !['0', 'false', 'no', 'off', 'disabled'].includes(
String(values.llm_billing_enabled).trim().toLowerCase(),
)
: false;
if (llmBillingEnabled) {
const holdKeys = [
'optimize_hold_credits',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
];
const invalidKey = holdKeys.find((key) => {
const numericValue = Number(values[key]);
return !Number.isFinite(numericValue) || numericValue <= 0;
});
if (invalidKey) {
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
return;
}
}
setSaving(true);
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'),
@@ -118,12 +202,62 @@ const AdminSettings: React.FC = () => {
return false;
};
const handleLoginVideoUpload = async (file: File) => {
setUploading('login_bg_video');
try {
const res = await uploadLoginVideo(file);
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: res.url } : c));
form.setFieldsValue({ login_bg_video: res.url });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('登录背景视频上传成功并已保存');
} catch (e: any) {
message.error(e?.message || '上传失败');
} finally {
setUploading('');
}
return false;
};
const handleRemoveLoginVideo = async () => {
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: '' } : c));
form.setFieldsValue({ login_bg_video: '' });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, '');
}
message.success('已移除登录背景视频');
};
const handleToggleBase64 = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'llm_media_as_base64');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('llm_media_as_base64', checked ? 'true' : 'false', '文字模型请求时图片/视频使用 base64 编码');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'llm_media_as_base64');
if (exists) return prev.map(c => c.key === 'llm_media_as_base64' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`${checked ? '开启' : '关闭'}文字模型媒体 base64 编码`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
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 => {
@@ -139,6 +273,13 @@ 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 || '';
};
@@ -268,8 +409,8 @@ const AdminSettings: React.FC = () => {
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={0} placeholder={config.description} size="large" />;
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) {
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
@@ -278,6 +419,190 @@ const AdminSettings: React.FC = () => {
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
}
const tabItems = [
{
key: 'basic',
label: '网站基础设置',
children: (
<Form form={form} layout="vertical">
{['站点信息', '协议配置', 'SEO 设置'].map(group => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{group === '协议配置' ? (
groupedConfigs[group]?.map(config => (
<PdfUploadField key={config.id} config={config} />
))
) : (
groupedConfigs[group]?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))
)}
</div>
))}
</Form>
),
},
{
key: 'credits',
label: '用户积分配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
{groupedConfigs['用户积分配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{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: 'other',
label: '其他配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
{groupedConfigs['其他配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</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', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Space>
<VideoCameraOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<Typography.Text strong></Typography.Text>
</Space>
<Space>
{form.getFieldValue('login_bg_video') && (
<Button size="small" danger onClick={handleRemoveLoginVideo}>
</Button>
)}
<Upload
accept="video/*,image/gif,image/webp"
showUploadList={false}
beforeUpload={handleLoginVideoUpload}
>
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'login_bg_video'}>
</Button>
</Upload>
</Space>
</div>
{(() => {
const url = form.getFieldValue('login_bg_video');
if (!url) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
使 backimage.png
</Typography.Text>
);
}
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${url}`;
const isGif = url.toLowerCase().endsWith('.gif');
return isGif ? (
<img src={fullUrl} alt="预览" style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#f0f0f5', objectFit: 'contain' }} />
) : (
<video
src={fullUrl}
controls
muted
loop
playsInline
style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#000' }}
/>
);
})()}
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
MP4WebMMOVGIFWebP 50MB
</Typography.Text>
</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>/ base64 </Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
base64 URL
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'llm_media_as_base64') || {}).value === 'true'}
onChange={handleToggleBase64}
checkedChildren="base64"
unCheckedChildren="链接"
/>
</div>
</div>
</div>
</Form>
),
},
];
return (
<div style={{ maxWidth: 720 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
@@ -292,78 +617,11 @@ const AdminSettings: React.FC = () => {
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">SEO配置</Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
<Form form={form} layout="vertical">
{Object.entries(groupedConfigs).map(([group, items]) => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{group === '协议配置' ? (
items.map(config => (
<PdfUploadField key={config.id} config={config} />
))
) : (
items.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))
)}
</div>
))}
<div style={{ marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, border: '1px solid #f0f0f5', borderRadius: 10, background: '#fafbfc' }}>
<Space align="start" style={{ marginBottom: 16 }}>
<DatabaseOutlined style={{ color: '#6366f1', fontSize: 18, marginTop: 2 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 13, marginTop: 4 }}>
</div>
</div>
</Space>
<Form.Item
name="resource_capacity_enabled"
label="启用全局容量管控"
valuePropName="checked"
extra="关闭时全局不限制;若用户设置了个人配置,则仍按用户个人配置优先判断。"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
<Form.Item
name="resource_capacity_limit_value"
label="容量数值"
extra="最小为1,不能为负数,最多支持3位小数。"
rules={[{ required: true, message: '请输入容量数值' }]}
>
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
</Form.Item>
<Form.Item
name="resource_capacity_limit_unit"
label="容量单位"
extra="MB / GB / TB 固定枚举"
rules={[{ required: true, message: '请选择容量单位' }]}
>
<Select size="large" options={capacityUnitOptions} />
</Form.Item>
</div>
</div>
</div>
</Form>
<Tabs items={tabItems} defaultActiveKey="basic" />
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { CameraOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getAdminShotTaskSets } from '../api';
import type { ShotTaskSetOut } from '../types';
import { formatDate } from '../utils/formatDate';
import { getShotAnalysisStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
const PAGE_SIZE = 20;
@@ -35,28 +36,14 @@ const SPLIT_STATUS_OPTIONS = [
{ value: 'retry_waiting', label: '等待重试' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending_analysis: { color: 'default', text: '等待分析' },
analyzing: { color: 'processing', text: '分析中' },
analysis_completed: { color: 'success', text: '分析完成' },
analysis_failed: { color: 'error', text: '分析失败' },
splitting: { color: 'warning', text: '拆镜中' },
split_completed: { color: 'success', text: '拆镜完成' },
partial_failed: { color: 'orange', text: '部分失败' },
failed: { color: 'error', text: '失败' },
none: { color: 'default', text: '未拆镜' },
pending: { color: 'default', text: '待处理' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '完成' },
retry_waiting: { color: 'orange', text: '等待重试' },
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' }> = ({ status, kind = 'task' }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
const meta = kind === 'analysis'
? getShotAnalysisStatusMeta(status)
: (kind === 'split' ? getShotSplitStatusMeta(status) : getShotTaskStatusMeta(status));
return <Tag color={meta.color}>{meta.text}</Tag>;
};
@@ -109,6 +96,25 @@ const AdminShotReplications: React.FC = () => {
load();
}, [load, reloadKey]);
const hasActiveTasks = items.some((item) => (
getShotTaskStatusMeta(item.status).active
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|| getShotSplitStatusMeta(item.splitStatus).active
));
useEffect(() => {
if (!hasActiveTasks) return undefined;
const refresh = () => {
if (document.visibilityState === 'visible') void load();
};
const timer = window.setInterval(refresh, 30000);
document.addEventListener('visibilitychange', refresh);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refresh);
};
}, [hasActiveTasks, load]);
const doSearch = () => {
setQueryKeyword(inputKeyword.trim());
setQueryUserId(inputUserId.trim());
@@ -134,16 +140,15 @@ const AdminShotReplications: React.FC = () => {
};
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary">AI </Typography.Text>
</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>
<CameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
</div>
<Space wrap>
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
@@ -186,9 +191,9 @@ const AdminShotReplications: React.FC = () => {
</Space>
),
},
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} kind="task" /> },
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="split" /> },
{
title: '切片进度',
width: 180,
@@ -211,9 +216,7 @@ const AdminShotReplications: React.FC = () => {
},
]}
/>
</Space>
</Card>
</div>
);
};
@@ -7,7 +7,6 @@ import {
Descriptions,
Drawer,
Empty,
Input,
Select,
Space,
Spin,
@@ -23,6 +22,7 @@ import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDet
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
import { formatDate } from '../utils/formatDate';
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
import { getShotAnalysisStatusMeta, getShotReplicateStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
@@ -55,25 +55,6 @@ const REPLICATE_STATUS_OPTIONS = [
{ value: 'failed', label: '复刻失败' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending_analysis: { color: 'default', text: '等待分析' },
analyzing: { color: 'processing', text: '分析中' },
analysis_completed: { color: 'success', text: '分析完成' },
analysis_failed: { color: 'error', text: '分析失败' },
splitting: { color: 'warning', text: '拆镜中' },
split_completed: { color: 'success', text: '拆镜完成' },
partial_failed: { color: 'orange', text: '部分失败' },
failed: { color: 'error', text: '失败' },
none: { color: 'default', text: '未拆镜' },
pending: { color: 'default', text: '待处理' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '完成' },
retry_waiting: { color: 'orange', text: '等待重试' },
not_required: { color: 'default', text: '无需分析' },
not_started: { color: 'default', text: '未复刻' },
project_created: { color: 'processing', text: '已创建项目' },
};
const apiUrl = (url?: string | null): string => {
if (!url) return '';
const value = String(url).trim();
@@ -85,9 +66,13 @@ const apiUrl = (url?: string | null): string => {
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' | 'replicate' }> = ({ status, kind = 'task' }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
const meta = kind === 'analysis'
? getShotAnalysisStatusMeta(status)
: (kind === 'split'
? getShotSplitStatusMeta(status)
: (kind === 'replicate' ? getShotReplicateStatusMeta(status) : getShotTaskStatusMeta(status)));
return <Tag color={meta.color}>{meta.text}</Tag>;
};
@@ -177,6 +162,32 @@ const AdminShotTaskSetDetail: React.FC = () => {
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
const hasActiveTasks = Boolean(detail && (
getShotTaskStatusMeta(detail.status).active
|| getShotAnalysisStatusMeta(detail.analysisStatus).active
|| getShotSplitStatusMeta(detail.splitStatus).active
|| segments.some((item) => (
getShotSplitStatusMeta(item.splitStatus).active
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|| getShotReplicateStatusMeta(item.replicateStatus).active
))
));
useEffect(() => {
if (!hasActiveTasks) return undefined;
const refresh = () => {
if (document.visibilityState !== 'visible') return;
void loadDetail();
void loadSegments();
};
const timer = window.setInterval(refresh, 20000);
document.addEventListener('visibilitychange', refresh);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refresh);
};
}, [hasActiveTasks, loadDetail, loadSegments]);
const openSegmentDetail = async (segmentId: string) => {
setDrawerOpen(true);
setSegmentDetail(null);
@@ -224,9 +235,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="总状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item>
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></Descriptions.Item>
<Descriptions.Item label="总状态"><StatusTag status={detail.status} kind="task" /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} kind="analysis" /></Descriptions.Item>
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} kind="split" /></Descriptions.Item>
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount} {detail.failedSegmentCount}</Descriptions.Item>
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
@@ -274,9 +285,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan"></Tag> },
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="split" /> },
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="replicate" /> },
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
{
@@ -284,9 +295,14 @@ const AdminShotTaskSetDetail: React.FC = () => {
width: 220,
render: (_, record) => record.moduleProjectId ? (
<Space direction="vertical" size={0}>
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}`)}>{shortId(record.moduleProjectId)}</Button>
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}?flow_version=${record.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{shortId(record.moduleProjectId)}</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
<StatusTag status={record.moduleProjectStatus} />
<Space size={4}>
<StatusTag status={record.moduleProjectStatus} kind="replicate" />
<Tag color={record.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>
{String(record.moduleProjectFlowVersion || 'v1').toUpperCase()}
</Tag>
</Space>
</Space>
) : <Tag></Tag>,
},
@@ -310,10 +326,11 @@ const AdminShotTaskSetDetail: React.FC = () => {
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} kind="split" /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} kind="analysis" /></Descriptions.Item>
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} kind="replicate" /></Descriptions.Item>
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}?flow_version=${segmentDetail.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
<Descriptions.Item label="项目流程版本">{segmentDetail.moduleProjectId ? <Tag color={segmentDetail.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>{String(segmentDetail.moduleProjectFlowVersion || 'v1').toUpperCase()}</Tag> : '-'}</Descriptions.Item>
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
+22 -3
View File
@@ -3,7 +3,7 @@ import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
} from '@ant-design/icons';
import {
adjustCredits,
@@ -191,7 +191,7 @@ const AdminUsers: React.FC = () => {
user_type: userType,
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
frontend_user_kind: values.frontend_user_kind || 'external',
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0,
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 50) : 0,
});
message.success('用户创建成功');
setCreateModal(false);
@@ -411,6 +411,7 @@ const AdminUsers: React.FC = () => {
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
<div style={{ color: '#c0c4cc', fontSize: 11, fontFamily: 'monospace' }}>ID: {r.id}</div>
</div>
</Space>
),
@@ -693,6 +694,24 @@ const AdminUsers: React.FC = () => {
{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: '请输入积分数量' }]}>
@@ -882,7 +901,7 @@ const AdminUsers: React.FC = () => {
<Form.Item
name="private_portrait_asset_limit"
label="私域人像素材总量上限"
initialValue={5}
initialValue={50}
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
>
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
@@ -25,6 +25,8 @@ interface VideoEngine {
supportsUniversalReference: boolean;
isActive: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
}
function parseJsonArray(val: unknown): any[] {
@@ -40,6 +42,7 @@ const AdminVideoEngines: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => {
setLoading(true);
@@ -80,6 +83,8 @@ const AdminVideoEngines: React.FC = () => {
supports_universal_reference: values.supportsUniversalReference ?? true,
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
multi_generation_enabled: values.multiGenerationEnabled ?? false,
max_generation_count: values.maxGenerationCount ?? 1,
};
if (modal.engine) {
await saveVideoEngine({ id: modal.engine.id, ...payload });
@@ -115,7 +120,8 @@ const AdminVideoEngines: React.FC = () => {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
maxDuration: 30,
multiGenerationEnabled: false, maxGenerationCount: 1,
maxDuration: 15,
maxImageCount: 2,
maxVideoCount: 0,
maxAudioCount: 0,
@@ -180,6 +186,18 @@ const AdminVideoEngines: React.FC = () => {
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
},
{
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
},
{
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
render: (v: number, r: VideoEngine) => (
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
{r.multiGenerationEnabled ? (v || 1) : 1}
</Tag>
),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -268,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>
@@ -288,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();
},
},
@@ -315,7 +333,19 @@ const AdminVideoEngines: React.FC = () => {
<Switch />
</Form.Item>
</div>
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
1
</Typography.Paragraph>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked" style={{ flex: 1 }}>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
</Form.Item>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 0, label: '0 (默认)' },
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
App,
Button,
Card,
Col,
Empty,
Row,
Select,
Space,
Spin,
Switch,
Tag,
Typography,
} from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { getVideoUpscaleConfig, saveVideoUpscaleConfig } from '../api';
import type {
VideoUpscaleConfigData,
VideoUpscaleProcessorKey,
VideoUpscaleResolutionRule,
} from '../types';
const { Title, Text, Paragraph } = Typography;
const PROCESSORS: Array<{ key: VideoUpscaleProcessorKey; label: string }> = [
{ key: 'local_ffmpeg_crop_v1', label: '本地 FFmpegcrop' },
{ key: 'volc_standard_v1', label: '火山画质增强(标准版)' },
{ key: 'volc_professional_v1', label: '火山画质增强(专业版)' },
{ key: 'volc_large_model_v1', label: '火山画质增强(大模型)' },
];
const RESOLUTION_OPTIONS = ['480p', '720p', '1080p', '2K', '4K'].map((value) => ({
label: value,
value,
}));
const defaultRule = (): VideoUpscaleResolutionRule => ({
targetResolution: '1080p',
providerGenerationResolution: '720p',
processorKey: 'local_ffmpeg_crop_v1',
enabled: true,
});
function normalizeConfig(data: VideoUpscaleConfigData): VideoUpscaleConfigData {
return {
enabled: !!data.enabled,
version: Number(data.version || 1),
deleteSourceAfterSuccess: data.deleteSourceAfterSuccess !== false,
rules: Array.isArray(data.rules) ? data.rules.map((rule) => ({ ...rule })) : [],
};
}
function toSavePayload(data: VideoUpscaleConfigData) {
return {
data: {
enabled: data.enabled,
version: data.version,
delete_source_after_success: data.deleteSourceAfterSuccess,
rules: data.rules.map((rule) => ({
target_resolution: rule.targetResolution,
provider_generation_resolution: rule.providerGenerationResolution,
processor_key: rule.processorKey,
enabled: rule.enabled,
})),
},
};
}
const AdminVideoUpscale: React.FC = () => {
const { message, modal } = App.useApp();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [config, setConfig] = useState<VideoUpscaleConfigData | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await getVideoUpscaleConfig();
setConfig(normalizeConfig(result.data));
} catch (error) {
message.error(error instanceof Error ? error.message : '读取视频超分配置失败');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
void load();
}, [load]);
const updateRule = (index: number, patch: Partial<VideoUpscaleResolutionRule>) => {
setConfig((current) => {
if (!current) return current;
return {
...current,
rules: current.rules.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item),
};
});
};
const removeRule = (index: number) => {
setConfig((current) => current ? {
...current,
rules: current.rules.filter((_, itemIndex) => itemIndex !== index),
} : current);
};
const addRule = () => {
setConfig((current) => current ? { ...current, rules: [...current.rules, defaultRule()] } : current);
};
const save = async () => {
if (!config) return;
const targets = config.rules
.filter((item) => item.enabled)
.map((item) => item.targetResolution.trim().toLowerCase());
if (new Set(targets).size !== targets.length) {
message.error('同一个客户目标分辨率只能存在一条启用规则');
return;
}
setSaving(true);
try {
const result = await saveVideoUpscaleConfig(toSavePayload(config));
setConfig(normalizeConfig(result.data));
message.success(`视频超分配置已保存,版本 ${result.data.version}`);
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
};
if (loading || !config) {
return (
<div style={{ minHeight: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
);
}
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card>
<Row justify="space-between" align="middle" gutter={[16, 16]}>
<Col>
<Title level={3} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
</Paragraph>
</Col>
<Col>
<Space>
<Tag> {config.version}</Tag>
<Button icon={<ReloadOutlined />} onClick={() => void load()}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
</Button>
</Space>
</Col>
</Row>
<Space direction="vertical" size={14} style={{ marginTop: 20 }}>
<Space>
<Text strong></Text>
<Switch
checked={config.enabled}
onChange={(enabled) => setConfig({ ...config, enabled })}
/>
<Text type="secondary"></Text>
</Space>
<Space>
<Text strong></Text>
<Switch
checked={config.deleteSourceAfterSuccess}
onChange={(deleteSourceAfterSuccess) => setConfig({ ...config, deleteSourceAfterSuccess })}
/>
<Text type="secondary">
</Text>
</Space>
</Space>
</Card>
<Card
title="目标分辨率规则"
extra={<Button icon={<PlusOutlined />} onClick={addRule}></Button>}
>
{config.rules.length === 0 ? (
<Empty description="暂无规则;未匹配规则的视频任务继续走原流程" />
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{config.rules.map((rule, index) => {
const duplicate = rule.enabled && config.rules.filter(
(item) => item.enabled && item.targetResolution.toLowerCase() === rule.targetResolution.toLowerCase(),
).length > 1;
return (
<Card key={`${index}-${rule.targetResolution}`} size="small">
<Row gutter={[12, 12]} align="bottom">
<Col xs={24} md={5}>
<Text type="secondary"></Text>
<Select
value={rule.targetResolution}
options={RESOLUTION_OPTIONS}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { targetResolution: value })}
status={duplicate ? 'error' : undefined}
/>
</Col>
<Col xs={24} md={5}>
<Text type="secondary"></Text>
<Select
value={rule.providerGenerationResolution}
options={RESOLUTION_OPTIONS}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { providerGenerationResolution: value })}
/>
</Col>
<Col xs={24} md={7}>
<Text type="secondary"></Text>
<Select
value={rule.processorKey}
options={PROCESSORS.map((item) => ({ value: item.key, label: item.label }))}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { processorKey: value })}
/>
</Col>
<Col xs={12} md={2}>
<Text type="secondary"></Text>
<div style={{ marginTop: 8 }}>
<Switch checked={rule.enabled} onChange={(enabled) => updateRule(index, { enabled })} />
</div>
</Col>
<Col xs={12} md={2}>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => modal.confirm({
title: '删除这条超分规则?',
onOk: () => removeRule(index),
})}
/>
</Col>
<Col span={24}>
<Text type="secondary">
</Text>
</Col>
</Row>
</Card>
);
})}
</Space>
)}
</Card>
</Space>
);
};
export default AdminVideoUpscale;
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
import { copyToClipboard } from '../../../utils/clipboard';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
@@ -37,12 +38,8 @@ const MediaPreview: React.FC<MediaPreviewProps> = ({
const copyUrl = async () => {
if (!resolvedUrl) return;
try {
await navigator.clipboard.writeText(resolvedUrl);
message.success('资源地址已复制');
} catch {
message.error('复制失败,请手动复制');
}
const ok = await copyToClipboard(resolvedUrl);
message.success(ok ? '资源地址已复制' : '复制失败,请手动复制');
};
const tools = resolvedUrl ? (
@@ -21,13 +21,22 @@ const STATUS_LABELS: Record<string, LabelMeta> = {
// 生成任务 pipeline / download stage
creating_provider_task: { text: '创建远端任务', color: 'processing' },
provider_result_staged: { text: '供应商结果已暂存', color: 'processing' },
waiting_remote: { text: '等待远端结果', color: 'processing' },
polling: { text: '轮询远端结果', color: 'processing' },
result_ready: { text: '结果已就绪', color: 'success' },
download_queued: { text: '下载已入队', color: 'processing' },
downloading: { text: '下载中', color: 'processing' },
done: { text: '已完成', color: 'success' },
download_failed: { text: '下载失败', color: 'error' },
retry_waiting: { text: '等待重试', color: 'orange' },
upscale_queued: { text: '超分已入队', color: 'purple' },
upscale_processing: { text: '本地超分处理中', color: 'purple' },
upscale_polling: { text: '轮询远程超分', color: 'purple' },
upscale_downloading: { text: '下载超分结果', color: 'purple' },
upscale_finalizing: { text: '超分结果最终化', color: 'purple' },
upscale_retry_waiting: { text: '超分等待重试', color: 'orange' },
upscale_failed: { text: '超分失败', color: 'error' },
// 拆镜总任务状态
pending_analysis: { text: '等待分析', color: 'default' },
@@ -35,7 +35,7 @@ const statusMap: Record<string, { text: string; color: string }> = {
};
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
text: '民众普康 AI',
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
@@ -27,7 +27,7 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
marginX: 24,
marginY: 24,
textWatermark: {
text: '民众普康 AI',
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
@@ -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';
@@ -25,7 +25,7 @@ const positionOptions = [
];
const defaultTextWatermark = {
text: '民众普康 AI',
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
@@ -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}>
+150 -120
View File
@@ -199,6 +199,30 @@ export interface AdminUser {
privatePortraitAssetLimit: number;
}
export interface DailyCredit {
date: string;
module: string;
credits: number;
}
export interface TeamCredit {
teamName: string;
teamId: string | null;
credits: number;
}
export interface ModelUsageOut {
modelName: string;
provider: string;
count: number;
}
export interface VideoParamOut {
model: string;
label: string;
count: number;
}
export interface AdminStats {
totalUsers: number;
totalProjects: number;
@@ -214,6 +238,13 @@ export interface AdminStats {
lastPeriodRecords: number;
lastPeriodRevenue: number;
lastPeriodCreditsConsumed: number;
dailyCreditsByModule: DailyCredit[];
periodCreditsByModule: DailyCredit[];
creditsByTeam: TeamCredit[];
modelUsage: ModelUsageOut[];
videoResolutionUsage: VideoParamOut[];
videoRatioUsage: VideoParamOut[];
videoDurationUsage: VideoParamOut[];
}
export interface PaymentStats {
@@ -281,6 +312,12 @@ export interface GenerationAiImageEngine {
supportedSizes: Record<string, Record<string, string>>;
defaultSize: string;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
isActive?: boolean;
deletedAt?: string | null;
}
export interface GenerationAiVideoEngine {
@@ -299,6 +336,10 @@ export interface GenerationAiVideoEngine {
supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
isActive?: boolean;
deletedAt?: string | null;
}
export interface GenerationAiEnginesResponse {
@@ -326,9 +367,37 @@ export interface GenerationAiEngineOption {
supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean;
priority: number;
multiGenerationEnabled?: boolean;
maxGenerationCount?: number;
multiImageMaxImages?: number;
maxReferenceImageCount?: number;
genType: GenerationAiGenType;
}
export type GenerationPipelineStage =
| 'queued'
| 'preparing'
| 'creating_provider_task'
| 'provider_result_staged'
| 'waiting_remote'
| 'polling'
| 'result_ready'
| 'download_queued'
| 'downloading'
| 'retry_waiting'
| 'upscale_queued'
| 'upscale_processing'
| 'upscale_polling'
| 'upscale_downloading'
| 'upscale_finalizing'
| 'upscale_retry_waiting'
| 'upscale_failed'
| 'done'
| 'failed'
| 'timeout'
| 'download_failed'
| (string & {});
export interface AdminGenerationRecord {
id: string;
userId: string;
@@ -342,8 +411,10 @@ export interface AdminGenerationRecord {
aspectRatio?: string;
resolution?: string;
status: 'optimizing' | 'prompt_optimized' | 'generating' | 'completed' | 'failed' | string;
pipelineStage?: GenerationPipelineStage | null;
videoUrl?: string;
videoCoverUrl?: string;
videoUpscaleEnabled?: boolean;
references?: GenerationAIMediaReference[] | null;
creditsCost: number;
textCreditsCost: number;
@@ -358,6 +429,10 @@ export interface AdminGenerationRecord {
imageTokensUsed?: number;
imageProportion?: string;
imagePx?: string;
engineId?: string;
engineName?: string;
engineSnapshot?: Record<string, unknown> | null;
includeMediaReferences?: boolean;
}
export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string;
@@ -399,7 +474,11 @@ export interface GenerationAITaskOut {
projectId?: string | null;
genType: GenerationAiGenType | string;
generationMode?: string | null;
pipelineStage?: string | null;
parentTaskId?: string | null;
generationCount: number;
generationIndex?: number | null;
displayStatus?: string | null;
pipelineStage?: GenerationPipelineStage | null;
status: GenerationAITaskStatus;
originalPrompt: string;
optimizedPrompt?: string | null;
@@ -428,6 +507,7 @@ export interface GenerationAITaskOut {
errorMessage?: string | null;
createdAt?: string | null;
generatedAt?: string | null;
childItems: GenerationAITaskOut[];
}
export interface GenerationAITaskListOut {
@@ -531,6 +611,9 @@ export interface ReplicationProjectDetailOut {
title?: string | null;
status: ModuleReplicationStatus;
currentStepCode?: string | null;
flowVersion?: 'v1' | 'v2' | string | null;
stepCount?: number;
stepIoSchemaVersion?: string | null;
finalImageUrl?: string | null;
finalVideoUrl?: string | null;
finalVideoCoverUrl?: string | null;
@@ -553,6 +636,8 @@ export interface HotOpeningTaskListItemOut {
title?: string | null;
status: ModuleReplicationStatus;
currentStepCode?: string | null;
flowVersion?: 'v1' | 'v2' | string | null;
stepCount?: number;
sourceProjectName?: string | null;
targetProjectName?: string | null;
coreContentPoint?: string | null;
@@ -666,6 +751,7 @@ export interface ShotSegmentOut {
moduleProjectTitle?: string | null;
moduleProjectStatus?: string | null;
moduleProjectCurrentStepCode?: string | null;
moduleProjectFlowVersion?: 'v1' | 'v2' | string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
@@ -782,34 +868,36 @@ export interface VideoPromptSchemaPreviewOut {
export interface AdminCreditRecordSummary {
totalRecharge: number;
/** 总消费(仅 type=consume,不含团队内部转账)= 真实扣费 + 预扣占用 */
totalConsume: number;
/** 总回退(仅 type=refund= 真实退款 + 预扣释放 */
totalRefund: number;
/** 独立统计列:真实扣费 charge(含历史 NULL),对应"筛选类型=消费 & action=charge/NULL"求和 */
totalCharge: number;
/** 独立统计列:预扣占用 hold */
totalHold: number;
/** 独立统计列:真实退款 refund(含历史 NULL) */
totalRefundReal: number;
/** 独立统计列:预扣释放 hold_releasetype=refund, action=hold_release */
totalHoldRelease: number;
/** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */
netConsume: number;
transactionCount: number;
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;
attachmentImageCount: number;
attachmentVideoCount: number;
attachmentAudioCount: number;
attachmentTotalCount: number;
generatedImageCount: number;
generatedVideoCount: number;
generatedTotalCount: number;
providerCostCalculatedTotal: string;
providerCostEstimatedTotal: string;
providerCostCombinedTotal: string;
providerCostTotal: string;
providerCostPendingCount: number;
providerCostEstimatedCount: number;
providerCostAbnormalCount: number;
}
export interface AdminCreditRecord {
@@ -841,6 +929,7 @@ export interface AdminCreditRecord {
chargeKind?: string;
chargeKindLabel?: string;
chargeAction?: string;
chargeActionLabel?: string;
creditSubject?: string;
creditSubjectLabel?: string;
mediaType?: string;
@@ -862,38 +951,6 @@ export interface AdminCreditRecord {
engineName?: string;
engineProvider?: string;
engineModelName?: string;
pricingRuleId?: string;
pricingVersionCode?: string;
pricingBillingMode?: string;
pricingBillingModeLabel?: string;
pricingCalculatorVersion?: ModelPricingCalculatorVersion;
pricingUsageSource?: string;
pricingReferenceAt?: string;
pricingEffectiveFrom?: string;
pricingEffectiveTo?: string;
pricingSnapshotHash?: string;
providerCostCurrency?: string;
providerCostAmount?: string;
providerCostStatus?: string;
providerCostStatusLabel?: string;
providerCostCalculatedAt?: string;
providerCostFinalizedAt?: string;
providerCostIsEstimated?: boolean;
providerUsagePrimary?: boolean;
attachmentImageCount: number;
attachmentVideoCount: number;
attachmentAudioCount: number;
attachmentTotalCount: number;
attachmentVideoDurationSeconds?: string;
attachmentAudioDurationSeconds?: string;
requestedOutputCount: number;
generatedImageCount: number;
generatedVideoCount: number;
generatedTotalCount: number;
pricingSnapshotJson?: Record<string, any> | null;
usageSnapshotJson?: Record<string, any> | null;
attachmentSnapshotJson?: Record<string, any> | null;
generationSnapshotJson?: Record<string, any> | null;
createdAt?: string;
}
@@ -916,86 +973,14 @@ export interface AdminCreditRecordQueryParams {
creditSubject?: string;
mediaType?: string;
chargeKind?: string;
chargeAction?: string;
sourceModule?: string;
sourceStepCode?: string;
billingScene?: string;
engineProvider?: string;
engineModelName?: string;
pricingVersionCode?: string;
providerCostStatus?: string;
providerCostIsEstimated?: boolean;
hasAttachment?: boolean;
startDate?: string;
endDate?: string;
}
export type ModelPricingCategory = 'text' | 'image' | 'video';
export type ModelPricingBillingMode =
| 'text_token_tiered'
| 'image_per_output'
| 'image_input_output_tiered'
| 'video_token_rate';
export type ModelPricingRuleStatus = 'draft' | 'published' | 'disabled';
export type ModelPricingCalculatorVersion =
| 'text_token_tiered_v1'
| 'image_per_output_v1'
| 'image_input_output_tiered_v1'
| 'video_pixel_token_v1';
export interface ModelPricingRule {
id: string;
provider: string;
modelName: string;
modelCategory: ModelPricingCategory;
billingMode: ModelPricingBillingMode;
calculatorVersion: ModelPricingCalculatorVersion;
versionCode: string;
effectiveFrom: string;
effectiveTo?: string | null;
publishStatus: ModelPricingRuleStatus;
currency: string;
ruleSchemaVersion: number;
ruleContentHash: string;
ruleJson: Record<string, any>;
sourceUrl?: string | null;
sourceUpdatedAt?: string | null;
remark?: string | null;
referencedCount: number;
createdAt?: string;
updatedAt?: string;
}
export interface ModelPricingRulePayload {
provider: string;
model_name: string;
model_category: ModelPricingCategory;
billing_mode: ModelPricingBillingMode;
calculator_version: ModelPricingCalculatorVersion;
version_code: string;
effective_from: string;
effective_to?: string | null;
currency: string;
rule_schema_version: number;
rule_json: Record<string, any>;
source_url?: string | null;
source_updated_at?: string | null;
remark?: string | null;
}
export interface ModelPricingRuleListResponse {
items: ModelPricingRule[];
total: number;
}
export interface ModelPricingPreviewResponse {
amount: string;
currency: string;
isEstimated: boolean;
selectedRate?: string | null;
usageSource: string;
breakdown: Record<string, any>;
}
// ── 首页素材行业装修 ──────────────────────────────────────
@@ -1412,3 +1397,48 @@ export interface PrivatePortraitSelectableAssetListOut {
page: number;
pageSize: number;
}
// ── Video Upscale ────────────────────────────────────────
export type VideoUpscaleProcessorKey =
| 'local_ffmpeg_crop_v1'
| 'volc_large_model_v1'
| 'volc_standard_v1'
| 'volc_professional_v1';
export interface VideoUpscaleResolutionRule {
targetResolution: '480p' | '720p' | '1080p' | '2K' | '4K';
providerGenerationResolution: '480p' | '720p' | '1080p' | '2K' | '4K';
processorKey: VideoUpscaleProcessorKey;
enabled: boolean;
}
export interface VideoUpscaleConfigData {
enabled: boolean;
version: number;
deleteSourceAfterSuccess: boolean;
rules: VideoUpscaleResolutionRule[];
}
export interface VideoUpscaleConfigOut {
id?: string | null;
key: string;
description?: string | null;
data: VideoUpscaleConfigData;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface VideoUpscaleConfigSavePayload {
data: {
enabled: boolean;
version: number;
delete_source_after_success: boolean;
rules: Array<{
target_resolution: string;
provider_generation_resolution: string;
processor_key: VideoUpscaleProcessorKey;
enabled: boolean;
}>;
};
}
+23
View File
@@ -0,0 +1,23 @@
/** 安全复制文本到剪贴板,兼容非 HTTPS 环境 */
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text);
return true;
}
// 降级方案:使用 textarea + execCommand
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const succeeded = document.execCommand('copy');
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
+35 -8
View File
@@ -1,11 +1,38 @@
const CST_OFFSET = 8 * 60; // CST = UTC+8, in minutes
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '-';
let s = iso.trim();
if (!s.includes('T')) s = s.replace(' ', 'T');
// Truncate microseconds: 2026-05-13T15:04:04.313751 → 2026-05-13T15:04:04
const dotIdx = s.indexOf('.');
if (dotIdx > 0) s = s.slice(0, dotIdx);
// Remove any trailing timezone info (backend now sends naive datetimes)
s = s.replace(/[+-]\d{2}:?\d{0,2}$/, '').replace(/Z$/, '');
return s.replace('T', ' ').slice(0, 16);
const s = iso.trim();
if (!s) return '-';
// Parse the ISO string, handling timezone offset
// Match: 2026-05-13T15:04:04.313751+00:00 or 2026-05-13T15:04:04Z or 2026-05-13T15:04:04
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.slice(0, 16).replace('T', ' ');
const [, year, month, day, hour, min, sec, tz] = m;
// Build a Date in UTC
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
if (tz && tz !== 'Z') {
// Has explicit offset like +00:00 or +08:00 — already accounted for in the matched components
// We parsed HH:MM:SS as-is, which are in the given offset.
// Convert to UTC first by subtracting the offset
const sign = tz[0] === '+' ? 1 : -1;
const [oh, om] = tz.slice(1).split(':');
const offsetMin = sign * (+oh * 60 + +om);
const localMs = utcMs - offsetMin * 60000 + CST_OFFSET * 60000;
const d = new Date(localMs);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
// No tz or Z: if Z it's UTC, if no tz it's naive (assume CST from backend)
const isUTC = tz === 'Z';
const localMs = isUTC ? utcMs + CST_OFFSET * 60000 : utcMs;
const d = new Date(localMs);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
@@ -0,0 +1,97 @@
export interface GenerationStatusLike {
status?: string | null;
displayStatus?: string | null;
pipelineStage?: string | null;
shouldPoll?: boolean | null;
}
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
export interface GenerationUiState {
status: string;
displayStatus: string;
pipelineStage: string;
effectiveKey: string;
label: string;
color: GenerationUiColor;
isActive: boolean;
isSuccess: boolean;
isFailure: boolean;
isTerminal: boolean;
}
const ACTIVE_STATUS_KEYS = new Set(['pending', 'optimizing', 'generating']);
const ACTIVE_PIPELINE_STAGES = new Set([
'queued', 'preparing', 'creating_provider_task', 'provider_result_staged',
'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading',
'retry_waiting', 'recovery_inconsistent', 'upscale_queued', 'upscale_processing',
'upscale_polling', 'upscale_downloading', 'upscale_finalizing', 'upscale_retry_waiting',
]);
const SUCCESS_KEYS = new Set(['completed', 'done']);
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
const LABELS: Record<string, string> = {
pending: '待处理', optimizing: '提词处理中', prompt_optimized: '待生成', generating: '生成中',
queued: '已入队', preparing: '准备中', creating_provider_task: '创建供应商任务',
provider_result_staged: '供应商结果已暂存', waiting_remote: '等待供应商结果', polling: '轮询供应商结果',
result_ready: '远程结果已就绪', download_queued: '下载已入队', downloading: '下载中',
retry_waiting: '下载等待重试', recovery_inconsistent: '恢复证据异常', upscale_queued: '超分已入队',
upscale_processing: '本地超分处理中', upscale_polling: '轮询远程超分',
upscale_downloading: '下载超分结果', upscale_finalizing: '超分结果最终化',
upscale_retry_waiting: '超分等待重试', completed: '已完成', done: '已完成', timeout: '任务超时',
download_failed: '下载失败', upscale_failed: '超分失败', failed: '失败', deleted: '已删除',
};
const COLOR_MAP: Record<string, GenerationUiColor> = {
pending: 'default', optimizing: 'processing', prompt_optimized: 'blue', generating: 'warning',
queued: 'processing', preparing: 'processing', creating_provider_task: 'processing',
provider_result_staged: 'processing', waiting_remote: 'processing', polling: 'processing',
result_ready: 'processing', download_queued: 'processing', downloading: 'processing', retry_waiting: 'orange',
recovery_inconsistent: 'orange', upscale_queued: 'purple', upscale_processing: 'purple',
upscale_polling: 'purple', upscale_downloading: 'purple', upscale_finalizing: 'purple',
upscale_retry_waiting: 'orange', completed: 'success', done: 'success', failed: 'error',
timeout: 'error', download_failed: 'error', upscale_failed: 'error', deleted: 'default',
};
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
const firstMatching = (values: string[], keys: Set<string>): string => values.find((item) => keys.has(item)) || '';
export const getGenerationStageLabel = (key?: string | null): string => {
const normalized = normalize(key);
return LABELS[normalized] || normalized || '未知状态';
};
export const getGenerationStatusColor = (key?: string | null): GenerationUiColor => {
const normalized = normalize(key);
return COLOR_MAP[normalized] || 'default';
};
export const resolveGenerationUiState = (value: GenerationStatusLike): GenerationUiState => {
const status = normalize(value.status);
const displayStatus = normalize(value.displayStatus);
const pipelineStage = normalize(value.pipelineStage);
const values = [pipelineStage, displayStatus, status].filter(Boolean);
const failureKey = firstMatching(values, FAILURE_KEYS);
const successKey = firstMatching(values, SUCCESS_KEYS);
const deletedKey = firstMatching(values, new Set(['deleted']));
const effectiveKey = failureKey || deletedKey || successKey || pipelineStage || displayStatus || status || 'pending';
const isFailure = FAILURE_KEYS.has(effectiveKey);
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
const isActive = typeof value.shouldPoll === 'boolean'
? value.shouldPoll
: (!isFailure && !isSuccess && effectiveKey !== 'deleted' && (ACTIVE_PIPELINE_STAGES.has(pipelineStage) || ACTIVE_STATUS_KEYS.has(status) || ACTIVE_STATUS_KEYS.has(displayStatus)));
return {
status, displayStatus, pipelineStage, effectiveKey,
label: getGenerationStageLabel(effectiveKey),
color: getGenerationStatusColor(effectiveKey),
isActive, isSuccess, isFailure,
isTerminal: TERMINAL_KEYS.has(effectiveKey),
};
};
export const isGenerationActive = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isActive;
export const isGenerationSuccess = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isSuccess;
export const isGenerationFailure = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isFailure;
export const isGenerationTerminal = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isTerminal;
@@ -0,0 +1,76 @@
export interface ShotStatusMeta {
key: string;
text: string;
color: string;
active: boolean;
terminal: boolean;
}
type ShotStatusMap = Record<string, Omit<ShotStatusMeta, 'key'>>;
const TASK_STATUS_MAP: ShotStatusMap = {
pending_analysis: { text: '等待分析', color: 'default', active: true, terminal: false },
analyzing: { text: '分析中', color: 'processing', active: true, terminal: false },
analysis_completed: { text: '分析完成', color: 'blue', active: false, terminal: false },
analysis_failed: { text: '分析失败', color: 'error', active: false, terminal: true },
splitting: { text: '拆镜中', color: 'processing', active: true, terminal: false },
split_completed: { text: '拆镜完成', color: 'success', active: false, terminal: true },
partial_failed: { text: '部分失败', color: 'warning', active: false, terminal: true },
failed: { text: '失败', color: 'error', active: false, terminal: true },
deleted: { text: '已删除', color: 'default', active: false, terminal: true },
};
const ANALYSIS_STATUS_MAP: ShotStatusMap = {
not_required: { text: '无需分析', color: 'default', active: false, terminal: true },
pending: { text: '等待分析', color: 'default', active: true, terminal: false },
processing: { text: '分析中', color: 'processing', active: true, terminal: false },
completed: { text: '分析完成', color: 'success', active: false, terminal: true },
failed: { text: '分析失败', color: 'error', active: false, terminal: true },
};
const SPLIT_STATUS_MAP: ShotStatusMap = {
none: { text: '未拆镜', color: 'default', active: false, terminal: true },
pending: { text: '等待拆镜', color: 'default', active: true, terminal: false },
processing: { text: '拆镜中', color: 'processing', active: true, terminal: false },
retry_waiting: { text: '等待拆镜重试', color: 'orange', active: true, terminal: false },
completed: { text: '拆镜完成', color: 'success', active: false, terminal: true },
failed: { text: '拆镜失败', color: 'error', active: false, terminal: true },
};
const REPLICATE_STATUS_MAP: ShotStatusMap = {
not_started: { text: '未复刻', color: 'default', active: false, terminal: true },
project_created: { text: '已创建项目', color: 'processing', active: true, terminal: false },
pending: { text: '等待复刻', color: 'default', active: true, terminal: false },
waiting_user: { text: '等待用户操作', color: 'processing', active: true, terminal: false },
processing: { text: '复刻中', color: 'processing', active: true, terminal: false },
completed: { text: '复刻完成', color: 'success', active: false, terminal: true },
failed: { text: '复刻失败', color: 'error', active: false, terminal: true },
cancelled: { text: '已取消', color: 'default', active: false, terminal: true },
canceled: { text: '已取消', color: 'default', active: false, terminal: true },
};
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
const resolveMeta = (status: string | null | undefined, map: ShotStatusMap, fallback = '未知状态'): ShotStatusMeta => {
const key = normalize(status);
return {
key,
...(map[key] || { text: key || fallback, color: 'default', active: false, terminal: false }),
};
};
export const getShotTaskStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, TASK_STATUS_MAP);
export const getShotAnalysisStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, ANALYSIS_STATUS_MAP);
export const getShotSplitStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, SPLIT_STATUS_MAP);
export const getShotReplicateStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, REPLICATE_STATUS_MAP);
export const getShotStatusMeta = (status?: string | null): ShotStatusMeta => {
const key = normalize(status);
return getShotTaskStatusMeta(key).text !== key
? getShotTaskStatusMeta(key)
: (ANALYSIS_STATUS_MAP[key]
? getShotAnalysisStatusMeta(key)
: (SPLIT_STATUS_MAP[key]
? getShotSplitStatusMeta(key)
: getShotReplicateStatusMeta(key)));
};
+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/modelpricing/pricingruleform.tsx","./src/components/modelpricing/pricingrulepreview.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/adminmodelpricingrules.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/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/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/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"}
+6 -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
@@ -43,6 +43,10 @@ CAPTCHA_ENABLED=true
# CORS
CORS_ORIGINS=["*"]
# Base URL (用于 favicon、回调地址等)
#BASE_URL=http://localhost:8000
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
# RESOURCE
RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3
RESOURCE_SIGN_EXPIRE_SECONDS=86400
@@ -58,6 +62,7 @@ VIDEO_COVER_TIMEOUT_SECONDS=15
VIDEO_COVER_FORMAT=png
# VOLC
VOLC_API_KEY=AKLTOWMwMjVhNzg0OGE2NDMwZWJkYWIyNzM3ZmMxMjc5NTQ
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
+3
View File
@@ -39,3 +39,6 @@ CAPTCHA_ENABLED=true
# CORS
CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"]
# Base URL (用于 favicon、回调地址等)
BASE_URL=https://your-domain.com
@@ -1,582 +0,0 @@
"""add model pricing rules and credit pricing snapshots
Revision ID: 1c93b40133f0
Revises: 2026070902
Create Date: 2026-07-10 15:10:44.983521
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "1c93b40133f0"
down_revision: Union[str, None] = "2026070902"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
FK_CREDIT_RECORD_PRICING_RULE = (
"fk_credit_records_pricing_rule_id_model_pricing_rules"
)
def _json_type() -> sa.types.TypeEngine:
"""Use JSONB on PostgreSQL and JSON on other supported development DBs."""
return sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
# 1. Versioned model pricing rules.
op.create_table(
"model_pricing_rules",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("model_name", sa.String(length=128), nullable=False),
sa.Column("model_category", sa.String(length=16), nullable=False),
sa.Column("billing_mode", sa.String(length=48), nullable=False),
sa.Column("calculator_version", sa.String(length=64), nullable=False),
sa.Column("version_code", sa.String(length=64), nullable=False),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("publish_status", sa.String(length=16), nullable=False),
sa.Column("currency", sa.String(length=8), nullable=False),
sa.Column("rule_schema_version", sa.Integer(), nullable=False),
sa.Column("rule_json", _json_type(), nullable=False),
sa.Column("rule_content_hash", sa.String(length=64), nullable=False),
sa.Column("source_url", sa.Text(), nullable=True),
sa.Column("source_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=32), nullable=True),
sa.Column("updated_by", sa.String(length=32), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
# A disabled future rule is represented by an empty interval where
# effective_to == effective_from, so equality must be allowed.
sa.CheckConstraint(
"effective_to IS NULL OR effective_to >= effective_from",
name="ck_model_pricing_rules_effective_range",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"uq_model_pricing_rules_provider_model_version",
"model_pricing_rules",
["provider", "model_name", "version_code"],
unique=True,
)
op.create_index(
"ix_model_pricing_rules_resolve",
"model_pricing_rules",
[
"provider",
"model_name",
"publish_status",
"effective_from",
"effective_to",
],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_category_status",
"model_pricing_rules",
["model_category", "publish_status"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_provider",
"model_pricing_rules",
["provider"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_model_name",
"model_pricing_rules",
["model_name"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_model_category",
"model_pricing_rules",
["model_category"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_billing_mode",
"model_pricing_rules",
["billing_mode"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_calculator_version",
"model_pricing_rules",
["calculator_version"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_effective_from",
"model_pricing_rules",
["effective_from"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_effective_to",
"model_pricing_rules",
["effective_to"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_publish_status",
"model_pricing_rules",
["publish_status"],
unique=False,
)
op.create_index(
"ix_model_pricing_rules_rule_content_hash",
"model_pricing_rules",
["rule_content_hash"],
unique=False,
)
# 2. Bind provider callbacks to the exact billing attempt.
op.add_column(
"chat_generation_tasks",
sa.Column("current_billing_attempt_no", sa.Integer(), nullable=True),
)
op.create_index(
"ix_chat_generation_tasks_current_billing_attempt_no",
"chat_generation_tasks",
["current_billing_attempt_no"],
unique=False,
)
# 3. Immutable pricing, usage, attachment, and output snapshots.
op.add_column(
"credit_records",
sa.Column("pricing_rule_id", sa.String(length=32), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_version_code", sa.String(length=64), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_billing_mode", sa.String(length=48), nullable=True),
)
op.add_column(
"credit_records",
sa.Column(
"pricing_calculator_version",
sa.String(length=64),
nullable=True,
),
)
op.add_column(
"credit_records",
sa.Column("pricing_usage_source", sa.String(length=32), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_reference_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_effective_from", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_effective_to", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_snapshot_schema_version", sa.Integer(), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("pricing_snapshot_hash", sa.String(length=64), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("provider_cost_currency", sa.String(length=8), nullable=True),
)
op.add_column(
"credit_records",
sa.Column(
"provider_cost_amount",
sa.Numeric(precision=20, scale=8),
nullable=True,
),
)
op.add_column(
"credit_records",
sa.Column("provider_cost_status", sa.String(length=32), nullable=True),
)
op.add_column(
"credit_records",
sa.Column(
"provider_cost_calculated_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
op.add_column(
"credit_records",
sa.Column(
"provider_cost_finalized_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
op.add_column(
"credit_records",
sa.Column(
"provider_usage_primary",
sa.Boolean(),
server_default=sa.text("true"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"provider_cost_is_estimated",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_image_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_video_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_audio_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_total_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_video_duration_seconds",
sa.Numeric(precision=20, scale=6),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"attachment_audio_duration_seconds",
sa.Numeric(precision=20, scale=6),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"requested_output_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"generated_image_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"generated_video_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column(
"generated_total_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
)
op.add_column(
"credit_records",
sa.Column("pricing_snapshot_json", _json_type(), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("usage_snapshot_json", _json_type(), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("attachment_snapshot_json", _json_type(), nullable=True),
)
op.add_column(
"credit_records",
sa.Column("generation_snapshot_json", _json_type(), nullable=True),
)
op.create_index(
"ix_credit_records_pricing_rule_id",
"credit_records",
["pricing_rule_id"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_version_code",
"credit_records",
["pricing_version_code"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_usage_source",
"credit_records",
["pricing_usage_source"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_reference_at",
"credit_records",
["pricing_reference_at"],
unique=False,
)
op.create_index(
"ix_credit_records_provider_cost_status",
"credit_records",
["provider_cost_status"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_status_time",
"credit_records",
["provider_cost_status", "created_at"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_model_time",
"credit_records",
["engine_provider", "engine_model_name", "pricing_reference_at"],
unique=False,
)
op.create_index(
"ix_credit_records_pricing_version",
"credit_records",
["pricing_version_code", "pricing_rule_id"],
unique=False,
)
op.create_foreign_key(
FK_CREDIT_RECORD_PRICING_RULE,
"credit_records",
"model_pricing_rules",
["pricing_rule_id"],
["id"],
ondelete="RESTRICT",
)
# 4. Lock the engine and billing attempt for legacy project generations.
op.add_column(
"generation_records",
sa.Column("engine_id", sa.String(length=32), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("engine_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("provider_response_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("current_billing_attempt_no", sa.Integer(), nullable=True),
)
op.create_index(
"ix_generation_records_engine_id",
"generation_records",
["engine_id"],
unique=False,
)
op.create_index(
"ix_generation_records_current_billing_attempt_no",
"generation_records",
["current_billing_attempt_no"],
unique=False,
)
def downgrade() -> None:
# Reverse legacy generation record extensions.
op.drop_index(
"ix_generation_records_current_billing_attempt_no",
table_name="generation_records",
)
op.drop_index(
"ix_generation_records_engine_id",
table_name="generation_records",
)
op.drop_column("generation_records", "current_billing_attempt_no")
op.drop_column("generation_records", "provider_response_json")
op.drop_column("generation_records", "engine_snapshot_json")
op.drop_column("generation_records", "engine_id")
# Reverse credit pricing snapshots before dropping the referenced rule table.
op.drop_constraint(
FK_CREDIT_RECORD_PRICING_RULE,
"credit_records",
type_="foreignkey",
)
op.drop_index("ix_credit_records_pricing_version", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_model_time", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_status_time", table_name="credit_records")
op.drop_index("ix_credit_records_provider_cost_status", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_reference_at", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_usage_source", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_version_code", table_name="credit_records")
op.drop_index("ix_credit_records_pricing_rule_id", table_name="credit_records")
op.drop_column("credit_records", "generation_snapshot_json")
op.drop_column("credit_records", "attachment_snapshot_json")
op.drop_column("credit_records", "usage_snapshot_json")
op.drop_column("credit_records", "pricing_snapshot_json")
op.drop_column("credit_records", "generated_total_count")
op.drop_column("credit_records", "generated_video_count")
op.drop_column("credit_records", "generated_image_count")
op.drop_column("credit_records", "requested_output_count")
op.drop_column("credit_records", "attachment_audio_duration_seconds")
op.drop_column("credit_records", "attachment_video_duration_seconds")
op.drop_column("credit_records", "attachment_total_count")
op.drop_column("credit_records", "attachment_audio_count")
op.drop_column("credit_records", "attachment_video_count")
op.drop_column("credit_records", "attachment_image_count")
op.drop_column("credit_records", "provider_cost_is_estimated")
op.drop_column("credit_records", "provider_usage_primary")
op.drop_column("credit_records", "provider_cost_finalized_at")
op.drop_column("credit_records", "provider_cost_calculated_at")
op.drop_column("credit_records", "provider_cost_status")
op.drop_column("credit_records", "provider_cost_amount")
op.drop_column("credit_records", "provider_cost_currency")
op.drop_column("credit_records", "pricing_snapshot_hash")
op.drop_column("credit_records", "pricing_snapshot_schema_version")
op.drop_column("credit_records", "pricing_effective_to")
op.drop_column("credit_records", "pricing_effective_from")
op.drop_column("credit_records", "pricing_reference_at")
op.drop_column("credit_records", "pricing_usage_source")
op.drop_column("credit_records", "pricing_calculator_version")
op.drop_column("credit_records", "pricing_billing_mode")
op.drop_column("credit_records", "pricing_version_code")
op.drop_column("credit_records", "pricing_rule_id")
# Reverse exact billing-attempt binding.
op.drop_index(
"ix_chat_generation_tasks_current_billing_attempt_no",
table_name="chat_generation_tasks",
)
op.drop_column("chat_generation_tasks", "current_billing_attempt_no")
# Reverse pricing-rule storage.
op.drop_index(
"ix_model_pricing_rules_rule_content_hash",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_publish_status",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_effective_to",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_effective_from",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_calculator_version",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_billing_mode",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_model_category",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_model_name",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_provider",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_category_status",
table_name="model_pricing_rules",
)
op.drop_index(
"ix_model_pricing_rules_resolve",
table_name="model_pricing_rules",
)
op.drop_index(
"uq_model_pricing_rules_provider_model_version",
table_name="model_pricing_rules",
)
op.drop_table("model_pricing_rules")
@@ -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,364 @@
"""repair llm billing optimize idempotency
Revision ID: 20da1d353914
Revises: 6a3ea8d0b4c8
Create Date: 2026-07-24 13:35:48.513710
"""
from __future__ import annotations
import re
from typing import Any, Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine import Connection
# revision identifiers, used by Alembic.
revision: str = "20da1d353914"
down_revision: Union[str, None] = "6a3ea8d0b4c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_PROMPT_USAGE_COLUMN = "prompt_usage_snapshot_json"
_INDEX_SPECS: tuple[dict[str, Any], ...] = (
{
"name": "uq_credit_records_user_refund_target",
"table": "credit_records",
"columns": ("user_id", "refund_for_biz_key"),
"where_sql": "type = 'refund' AND refund_for_biz_key IS NOT NULL",
"definition_fragments": (
"(user_id, refund_for_biz_key)",
"type",
"refund",
"refund_for_biz_key is not null",
),
"duplicate_sql": """
SELECT
user_id,
refund_for_biz_key,
COUNT(*) AS duplicate_count
FROM credit_records
WHERE type = 'refund'
AND refund_for_biz_key IS NOT NULL
GROUP BY user_id, refund_for_biz_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, user_id, refund_for_biz_key
LIMIT 20
""",
},
{
"name": "uq_genrec_user_idempotency_active",
"table": "generation_records",
"columns": ("user_id", "idempotency_key"),
"where_sql": "idempotency_key IS NOT NULL AND deleted_at IS NULL",
"definition_fragments": (
"(user_id, idempotency_key)",
"idempotency_key is not null",
"deleted_at is null",
),
"duplicate_sql": """
SELECT
user_id,
idempotency_key,
COUNT(*) AS duplicate_count
FROM generation_records
WHERE idempotency_key IS NOT NULL
AND deleted_at IS NULL
GROUP BY user_id, idempotency_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, user_id, idempotency_key
LIMIT 20
""",
},
{
"name": "uq_token_usage_user_biz_key",
"table": "token_usage",
"columns": ("user_id", "biz_key"),
"where_sql": "biz_key IS NOT NULL",
"definition_fragments": (
"(user_id, biz_key)",
"biz_key is not null",
),
"duplicate_sql": """
SELECT
user_id,
biz_key,
COUNT(*) AS duplicate_count
FROM token_usage
WHERE user_id IS NOT NULL
AND biz_key IS NOT NULL
GROUP BY user_id, biz_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, user_id, biz_key
LIMIT 20
""",
},
)
def _bind() -> Connection:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
raise RuntimeError(
"Migration 20da1d353914 requires PostgreSQL; "
f"current dialect is {bind.dialect.name!r}."
)
return bind
def _current_schema(bind: Connection) -> str:
schema = bind.execute(sa.text("SELECT current_schema()")).scalar_one_or_none()
if not schema:
raise RuntimeError("Unable to resolve PostgreSQL current_schema().")
return str(schema)
def _require_table(bind: Connection, schema: str, table_name: str) -> None:
exists = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = :schema
AND table_name = :table_name
)
"""
),
{"schema": schema, "table_name": table_name},
).scalar_one()
if not bool(exists):
raise RuntimeError(
f"Required table {schema}.{table_name} does not exist; "
"refusing to apply migration on an unexpected schema baseline."
)
def _column_exists(
bind: Connection,
schema: str,
table_name: str,
column_name: str,
) -> bool:
return bool(
bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table_name
AND column_name = :column_name
)
"""
),
{
"schema": schema,
"table_name": table_name,
"column_name": column_name,
},
).scalar_one()
)
def _index_info(
bind: Connection,
schema: str,
index_name: str,
) -> dict[str, Any] | None:
row = bind.execute(
sa.text(
"""
SELECT
table_rel.relname AS table_name,
index_meta.indisvalid AS is_valid,
index_meta.indisunique AS is_unique,
pg_get_indexdef(index_rel.oid) AS index_definition
FROM pg_class AS index_rel
JOIN pg_namespace AS namespace_rel
ON namespace_rel.oid = index_rel.relnamespace
JOIN pg_index AS index_meta
ON index_meta.indexrelid = index_rel.oid
JOIN pg_class AS table_rel
ON table_rel.oid = index_meta.indrelid
WHERE namespace_rel.nspname = :schema
AND index_rel.relname = :index_name
"""
),
{"schema": schema, "index_name": index_name},
).mappings().one_or_none()
return dict(row) if row is not None else None
def _normalize_index_definition(value: str) -> str:
normalized = value.lower().replace('"', "")
normalized = re.sub(r"::[a-z_ ]+(?:\[\])?", "", normalized)
normalized = re.sub(r"[()]", lambda match: match.group(0), normalized)
normalized = re.sub(r"\s+", " ", normalized)
return normalized.strip()
def _assert_existing_index_matches(spec: dict[str, Any], info: dict[str, Any]) -> None:
if str(info["table_name"]) != str(spec["table"]):
raise RuntimeError(
f"Index {spec['name']} already exists on table {info['table_name']}, "
f"expected table {spec['table']}."
)
if not bool(info["is_unique"]):
raise RuntimeError(
f"Index {spec['name']} already exists but is not UNIQUE."
)
definition = _normalize_index_definition(str(info["index_definition"] or ""))
missing = [
fragment
for fragment in spec["definition_fragments"]
if fragment not in definition
]
if missing:
raise RuntimeError(
f"Index {spec['name']} already exists with an unexpected definition; "
f"missing expected fragments: {missing}. Actual definition: "
f"{info['index_definition']}"
)
def _assert_no_duplicates(bind: Connection, spec: dict[str, Any]) -> None:
rows = bind.execute(sa.text(spec["duplicate_sql"])).mappings().all()
if not rows:
return
samples = "; ".join(
", ".join(f"{key}={value!r}" for key, value in row.items())
for row in rows
)
raise RuntimeError(
f"Cannot create unique index {spec['name']}: duplicate historical data "
f"exists. Resolve the conflicts first. Samples: {samples}"
)
def _quote_identifier(bind: Connection, value: str) -> str:
return bind.dialect.identifier_preparer.quote(value)
def _create_missing_indexes_concurrently(
bind: Connection,
schema: str,
) -> None:
missing_specs: list[dict[str, Any]] = []
for spec in _INDEX_SPECS:
_require_table(bind, schema, str(spec["table"]))
info = _index_info(bind, schema, str(spec["name"]))
if info is None:
_assert_no_duplicates(bind, spec)
missing_specs.append(spec)
continue
if bool(info["is_valid"]):
_assert_existing_index_matches(spec, info)
continue
# A failed CREATE INDEX CONCURRENTLY may leave an invalid index behind.
# Remove only the invalid index with this migration-owned name, then retry.
missing_specs.append(spec)
if not missing_specs:
return
context = op.get_context()
with context.autocommit_block():
for spec in missing_specs:
current = _index_info(bind, schema, str(spec["name"]))
quoted_index = _quote_identifier(bind, str(spec["name"]))
quoted_schema = _quote_identifier(bind, schema)
if current is not None:
if bool(current["is_valid"]):
_assert_existing_index_matches(spec, current)
continue
op.execute(
sa.text(
f"DROP INDEX CONCURRENTLY IF EXISTS "
f"{quoted_schema}.{quoted_index}"
)
)
quoted_table = _quote_identifier(bind, str(spec["table"]))
quoted_columns = ", ".join(
_quote_identifier(bind, str(column))
for column in spec["columns"]
)
op.execute(
sa.text(
f"CREATE UNIQUE INDEX CONCURRENTLY {quoted_index} "
f"ON {quoted_schema}.{quoted_table} ({quoted_columns}) "
f"WHERE {spec['where_sql']}"
)
)
def _drop_indexes_concurrently(bind: Connection, schema: str) -> None:
existing_names = [
str(spec["name"])
for spec in _INDEX_SPECS
if _index_info(bind, schema, str(spec["name"])) is not None
]
if not existing_names:
return
quoted_schema = _quote_identifier(bind, schema)
context = op.get_context()
with context.autocommit_block():
for index_name in existing_names:
quoted_index = _quote_identifier(bind, index_name)
op.execute(
sa.text(
f"DROP INDEX CONCURRENTLY IF EXISTS "
f"{quoted_schema}.{quoted_index}"
)
)
def upgrade() -> None:
bind = _bind()
schema = _current_schema(bind)
_require_table(bind, schema, "generation_records")
if not _column_exists(
bind,
schema,
"generation_records",
_PROMPT_USAGE_COLUMN,
):
op.add_column(
"generation_records",
sa.Column(_PROMPT_USAGE_COLUMN, sa.Text(), nullable=True),
schema=schema,
)
_create_missing_indexes_concurrently(bind, schema)
def downgrade() -> None:
bind = _bind()
schema = _current_schema(bind)
_drop_indexes_concurrently(bind, schema)
_require_table(bind, schema, "generation_records")
if _column_exists(
bind,
schema,
"generation_records",
_PROMPT_USAGE_COLUMN,
):
op.drop_column(
"generation_records",
_PROMPT_USAGE_COLUMN,
schema=schema,
)
@@ -0,0 +1,180 @@
"""add video upscale pipeline
Revision ID: 3f47680a71d0
Revises: abae3e1c70f7
Create Date: 2026-07-16 11:08:16.449960
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3f47680a71d0"
down_revision: Union[str, None] = "abae3e1c70f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"chat_generation_tasks",
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column(
"video_upscale_enabled_snapshot",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"chat_generation_tasks",
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
)
op.add_column(
"generation_records",
sa.Column(
"video_upscale_enabled_snapshot",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"generation_records",
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("pipeline_stage", sa.String(length=48), nullable=True),
)
op.create_index(
"ix_generation_records_pipeline_stage",
"generation_records",
["pipeline_stage"],
unique=False,
)
op.create_table(
"video_upscale_tasks",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("chat_generation_task_id", sa.String(length=32), nullable=True),
sa.Column("generation_record_id", sa.String(length=32), nullable=True),
sa.Column("status", sa.String(length=32), server_default="pending", nullable=False),
sa.Column("stage", sa.String(length=48), server_default="upscale_queued", nullable=False),
sa.Column("processor_key", sa.String(length=64), nullable=False),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("failure_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("manual_retry_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("source_local_path", sa.Text(), nullable=True),
sa.Column("source_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("source_width", sa.Integer(), nullable=True),
sa.Column("source_height", sa.Integer(), nullable=True),
sa.Column("source_duration_seconds", sa.Float(), nullable=True),
sa.Column("source_deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_delete_error", sa.Text(), nullable=True),
sa.Column("source_remote_url", sa.Text(), nullable=True),
sa.Column("source_remote_url_signed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_last_probe_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_probe_status", sa.String(length=32), nullable=True),
sa.Column("input_source_type", sa.String(length=32), nullable=True),
sa.Column("input_source_fallback_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("target_width", sa.Integer(), nullable=False),
sa.Column("target_height", sa.Integer(), nullable=False),
sa.Column("effective_target_width", sa.Integer(), nullable=True),
sa.Column("effective_target_height", sa.Integer(), nullable=True),
sa.Column("provider_task_id", sa.String(length=160), nullable=True),
sa.Column("provider_request_json", sa.Text(), nullable=True),
sa.Column("provider_response_json", sa.Text(), nullable=True),
sa.Column("provider_output_url", sa.Text(), nullable=True),
sa.Column("provider_output_url_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provider_submitted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("final_local_path", sa.Text(), nullable=True),
sa.Column("final_resource_url", sa.Text(), nullable=True),
sa.Column("final_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("celery_task_id", sa.String(length=160), nullable=True),
sa.Column("lease_token", sa.String(length=64), nullable=True),
sa.Column("lease_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("failed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint(
"(chat_generation_task_id IS NOT NULL AND generation_record_id IS NULL) OR "
"(chat_generation_task_id IS NULL AND generation_record_id IS NOT NULL)",
name="ck_video_upscale_tasks_exactly_one_owner",
),
sa.ForeignKeyConstraint(
["chat_generation_task_id"],
["chat_generation_tasks.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["generation_record_id"],
["generation_records.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_video_upscale_tasks_provider_task_id",
"video_upscale_tasks",
["provider_task_id"],
unique=False,
)
op.create_index(
"idx_video_upscale_tasks_status_lease",
"video_upscale_tasks",
["status", "lease_until"],
unique=False,
)
op.create_index(
"idx_video_upscale_tasks_status_next_retry",
"video_upscale_tasks",
["status", "next_retry_at"],
unique=False,
)
op.create_index(
"uq_video_upscale_tasks_chat_task",
"video_upscale_tasks",
["chat_generation_task_id"],
unique=True,
)
op.create_index(
"uq_video_upscale_tasks_generation_record",
"video_upscale_tasks",
["generation_record_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_video_upscale_tasks_generation_record", table_name="video_upscale_tasks")
op.drop_index("uq_video_upscale_tasks_chat_task", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_status_next_retry", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_status_lease", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_provider_task_id", table_name="video_upscale_tasks")
op.drop_table("video_upscale_tasks")
op.drop_index("ix_generation_records_pipeline_stage", table_name="generation_records")
op.drop_column("generation_records", "pipeline_stage")
op.drop_column("generation_records", "video_upscale_snapshot_json")
op.drop_column("generation_records", "video_upscale_enabled_snapshot")
op.drop_column("generation_records", "provider_generation_resolution")
op.drop_column("chat_generation_tasks", "video_upscale_snapshot_json")
op.drop_column("chat_generation_tasks", "video_upscale_enabled_snapshot")
op.drop_column("chat_generation_tasks", "provider_generation_resolution")
@@ -0,0 +1,773 @@
"""repair generation record frozen config
Revision ID: 6a3ea8d0b4c8
Revises: 7cf645f7c418
Create Date: 2026-07-23 14:01:54.109369
修复GenerationRecord相关数据,并备份修改数据
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "6a3ea8d0b4c8"
down_revision: Union[str, None] = "7cf645f7c418"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_KEY = "repair_generation_record_frozen_config_20260723"
BACKUP_TABLE = "generation_records_repair_backup_20260723"
_TARGET_CONDITION = """
gr.deleted_at IS NULL
AND p.deleted_at IS NULL
AND gr.status = 'prompt_optimized'
AND gr.gen_type IN ('video', 'image')
AND (
gr.engine_id IS NULL
OR gr.engine_id = ''
OR gr.engine_snapshot_json IS NULL
OR gr.engine_snapshot_json = ''
OR gr.engine_snapshot_json IS NOT JSON
OR (
gr.gen_type = 'video'
AND (
gr.duration IS NULL
OR gr.aspect_ratio IS NULL
OR gr.aspect_ratio = ''
OR gr.resolution IS NULL
OR gr.resolution = ''
)
)
OR (
gr.gen_type = 'image'
AND (
gr.image_size IS NULL
OR gr.image_size = ''
OR gr.image_proportion IS NULL
OR gr.image_proportion = ''
OR gr.image_px IS NULL
OR gr.image_px = ''
)
)
)
"""
_CREATE_BACKUP_TABLE_SQL = f"""
CREATE TABLE IF NOT EXISTS {BACKUP_TABLE} (
repair_key VARCHAR(96) NOT NULL,
record_id VARCHAR(32) NOT NULL,
gen_type VARCHAR(16),
old_engine_id VARCHAR(32),
old_engine_snapshot_json TEXT,
old_duration INTEGER,
old_aspect_ratio VARCHAR(8),
old_resolution VARCHAR(8),
old_provider_generation_resolution VARCHAR(16),
old_video_upscale_enabled_snapshot BOOLEAN,
old_video_upscale_snapshot_json TEXT,
old_image_size VARCHAR(8),
old_image_proportion VARCHAR(8),
old_image_px VARCHAR(10),
old_include_media_references BOOLEAN,
old_updated_at TIMESTAMP WITH TIME ZONE,
backed_up_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (repair_key, record_id)
)
"""
_INSERT_BACKUP_SQL = f"""
INSERT INTO {BACKUP_TABLE} (
repair_key,
record_id,
gen_type,
old_engine_id,
old_engine_snapshot_json,
old_duration,
old_aspect_ratio,
old_resolution,
old_provider_generation_resolution,
old_video_upscale_enabled_snapshot,
old_video_upscale_snapshot_json,
old_image_size,
old_image_proportion,
old_image_px,
old_include_media_references,
old_updated_at
)
SELECT
:repair_key,
gr.id,
gr.gen_type,
gr.engine_id,
gr.engine_snapshot_json,
gr.duration,
gr.aspect_ratio,
gr.resolution,
gr.provider_generation_resolution,
gr.video_upscale_enabled_snapshot,
gr.video_upscale_snapshot_json,
gr.image_size,
gr.image_proportion,
gr.image_px,
gr.include_media_references,
gr.updated_at
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
ON CONFLICT (repair_key, record_id) DO NOTHING
"""
# PostgreSQL JSON helper snippets used inside SQL expressions. These avoid calling
# jsonb_array_elements_text/jsonb_object_keys on malformed JSON or the wrong JSON type.
_VIDEO_RATIOS_JSON = """
CASE
WHEN ve.supported_ratios IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_ratios::jsonb) = 'array'
THEN ve.supported_ratios::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_VIDEO_RESOLUTIONS_JSON = """
CASE
WHEN ve.supported_resolutions IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_resolutions::jsonb) = 'array'
THEN ve.supported_resolutions::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_VIDEO_DURATIONS_JSON = """
CASE
WHEN ve.supported_durations IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_durations::jsonb) = 'array'
THEN ve.supported_durations::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_IMAGE_SUPPORTED_MODELS_JSON = """
CASE
WHEN px_resolved.supported_models IS JSON THEN
CASE
WHEN jsonb_typeof(px_resolved.supported_models::jsonb) = 'array'
THEN px_resolved.supported_models::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_IMAGE_SUPPORTED_SIZES_JSON_IE = """
CASE
WHEN ie.supported_sizes IS JSON THEN
CASE
WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{}'::jsonb
END
ELSE '{}'::jsonb
END
"""
_IMAGE_SUPPORTED_SIZES_JSON_ES = """
CASE
WHEN ie.supported_sizes IS JSON THEN
CASE
WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{}'::jsonb
END
ELSE '{}'::jsonb
END
"""
_REPAIR_VIDEO_SQL = f"""
WITH target_records AS (
SELECT gr.*
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'video'
),
resolved AS (
SELECT
tr.id AS record_id,
ve.id AS selected_engine_id,
ve.name AS engine_name,
ve.provider,
ve.api_base,
ve.api_key,
ve.model_name,
ve.generate_url,
ve.query_url,
ve.supported_ratios,
ve.supported_resolutions,
ve.supported_durations,
ve.max_duration,
ve.max_image_count,
ve.max_video_count,
ve.max_audio_count,
ve.supports_universal_reference,
ve.supports_first_last_frame,
ve.multi_generation_enabled,
ve.max_generation_count,
COALESCE(
CASE
WHEN tr.duration IS NOT NULL
AND tr.duration > 0
AND (
jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int = tr.duration
)
)
AND (
ve.max_duration IS NULL
OR ve.max_duration <= 0
OR tr.duration <= ve.max_duration
)
THEN tr.duration
END,
(
SELECT d.value::int
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) WITH ORDINALITY d(value, ord)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int > 0
AND (
ve.max_duration IS NULL
OR ve.max_duration <= 0
OR d.value::int <= ve.max_duration
)
ORDER BY d.ord
LIMIT 1
),
CASE
WHEN ve.max_duration IS NOT NULL AND ve.max_duration > 0 THEN LEAST(4, ve.max_duration)
ELSE 4
END
) AS final_duration,
COALESCE(
CASE
WHEN tr.aspect_ratio IS NOT NULL
AND tr.aspect_ratio <> ''
AND (
jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = tr.aspect_ratio
)
)
THEN tr.aspect_ratio
END,
CASE
WHEN EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = '16:9'
)
THEN '16:9'
END,
(
SELECT r.value
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) WITH ORDINALITY r(value, ord)
WHERE r.value <> ''
ORDER BY r.ord
LIMIT 1
),
'16:9'
) AS final_aspect_ratio,
COALESCE(
CASE
WHEN tr.resolution IS NOT NULL
AND tr.resolution <> ''
AND (
jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = tr.resolution
)
)
THEN tr.resolution
END,
CASE
WHEN EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = '480p'
)
THEN '480p'
END,
(
SELECT r.value
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) WITH ORDINALITY r(value, ord)
WHERE r.value <> ''
ORDER BY r.ord
LIMIT 1
),
'480p'
) AS final_resolution
FROM target_records tr
JOIN LATERAL (
SELECT ve.*
FROM video_engines ve
WHERE ve.is_active IS TRUE
AND ve.deleted_at IS NULL
ORDER BY
CASE
WHEN (
(
tr.duration IS NULL
OR tr.duration <= 0
OR jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int = tr.duration
)
)
AND (
tr.aspect_ratio IS NULL
OR tr.aspect_ratio = ''
OR jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = tr.aspect_ratio
)
)
AND (
tr.resolution IS NULL
OR tr.resolution = ''
OR jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = tr.resolution
)
)
)
THEN 0
ELSE 1
END,
ve.priority DESC,
ve.created_at ASC,
ve.id ASC
LIMIT 1
) ve ON TRUE
)
UPDATE generation_records gr
SET
engine_id = resolved.selected_engine_id,
duration = resolved.final_duration,
aspect_ratio = resolved.final_aspect_ratio,
resolution = resolved.final_resolution,
provider_generation_resolution = resolved.final_resolution,
video_upscale_enabled_snapshot = FALSE,
video_upscale_snapshot_json = NULL,
engine_snapshot_json = jsonb_build_object(
'engine_type', 'video',
'id', resolved.selected_engine_id,
'name', resolved.engine_name,
'provider', resolved.provider,
'api_base', resolved.api_base,
'api_key_masked', CASE WHEN COALESCE(resolved.api_key, '') <> '' THEN '****' ELSE '' END,
'model_name', resolved.model_name,
'generate_url', resolved.generate_url,
'query_url', resolved.query_url,
'supported_ratios', CASE
WHEN resolved.supported_ratios IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_ratios::jsonb) = 'array'
THEN resolved.supported_ratios::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'supported_resolutions', CASE
WHEN resolved.supported_resolutions IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_resolutions::jsonb) = 'array'
THEN resolved.supported_resolutions::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'supported_durations', CASE
WHEN resolved.supported_durations IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_durations::jsonb) = 'array'
THEN resolved.supported_durations::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'max_duration', resolved.max_duration,
'max_image_count', COALESCE(resolved.max_image_count, 0),
'max_video_count', COALESCE(resolved.max_video_count, 0),
'max_audio_count', COALESCE(resolved.max_audio_count, 0),
'supports_universal_reference', COALESCE(resolved.supports_universal_reference, FALSE),
'supports_first_last_frame', COALESCE(resolved.supports_first_last_frame, FALSE),
'multi_generation_enabled', COALESCE(resolved.multi_generation_enabled, FALSE),
'max_generation_count', LEAST(5, GREATEST(1, COALESCE(resolved.max_generation_count, 1))),
'selected_ratio', resolved.final_aspect_ratio,
'selected_resolution', resolved.final_resolution,
'selected_duration', resolved.final_duration
)::text,
include_media_references = COALESCE(gr.include_media_references, FALSE),
updated_at = NOW()
FROM resolved
WHERE gr.id = resolved.record_id
"""
_REPAIR_IMAGE_SQL = f"""
WITH target_records AS (
SELECT gr.*
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'image'
),
engine_selected AS (
SELECT
tr.*,
ie.id AS selected_engine_id,
ie.name AS engine_name,
ie.provider,
ie.api_base,
ie.api_key,
ie.model_name,
ie.generate_url,
ie.supported_models,
ie.supported_sizes,
ie.default_size,
ie.multi_generation_enabled,
ie.max_generation_count,
ie.multi_image_max_images,
ie.max_reference_image_count,
ie.output_format,
CASE
WHEN ie.supported_sizes IS JSON AND jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{{}}'::jsonb
END AS sizes_json
FROM target_records tr
JOIN LATERAL (
SELECT ie.*
FROM image_engines ie
WHERE ie.is_active IS TRUE
AND ie.deleted_at IS NULL
ORDER BY
CASE
WHEN (
ie.supported_sizes IS NULL
OR ie.supported_sizes = ''
OR ie.supported_sizes IS NOT JSON
OR jsonb_typeof(ie.supported_sizes::jsonb) <> 'object'
OR ie.supported_sizes::jsonb = '{{}}'::jsonb
OR (
(
tr.image_size IS NULL
OR tr.image_size = ''
OR ie.supported_sizes::jsonb ? tr.image_size
)
AND (
tr.image_size IS NULL
OR tr.image_size = ''
OR tr.image_proportion IS NULL
OR tr.image_proportion = ''
OR (
jsonb_typeof(ie.supported_sizes::jsonb -> tr.image_size) = 'object'
AND (ie.supported_sizes::jsonb -> tr.image_size) ? tr.image_proportion
)
)
)
)
THEN 0
ELSE 1
END,
ie.priority DESC,
ie.created_at ASC,
ie.id ASC
LIMIT 1
) ie ON TRUE
),
size_selected AS (
SELECT
es.*,
COALESCE(
CASE
WHEN es.image_size IS NOT NULL
AND es.image_size <> ''
AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.image_size)
THEN es.image_size
END,
CASE
WHEN es.default_size IS NOT NULL
AND es.default_size <> ''
AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.default_size)
THEN es.default_size
END,
CASE WHEN es.sizes_json ? '2K' THEN '2K' END,
(
SELECT key
FROM jsonb_object_keys(es.sizes_json) AS key
ORDER BY key
LIMIT 1
),
'2K'
) AS final_image_size
FROM engine_selected es
),
ratio_selected AS (
SELECT
ss.*,
CASE
WHEN jsonb_typeof(ss.sizes_json -> ss.final_image_size) = 'object'
THEN ss.sizes_json -> ss.final_image_size
ELSE '{{}}'::jsonb
END AS ratio_json
FROM size_selected ss
),
final_resolved AS (
SELECT
rs.*,
COALESCE(
CASE
WHEN rs.image_proportion IS NOT NULL
AND rs.image_proportion <> ''
AND (rs.ratio_json = '{{}}'::jsonb OR rs.ratio_json ? rs.image_proportion)
THEN rs.image_proportion
END,
CASE WHEN rs.ratio_json ? '1:1' THEN '1:1' END,
(
SELECT key
FROM jsonb_object_keys(rs.ratio_json) AS key
ORDER BY key
LIMIT 1
),
'1:1'
) AS final_image_proportion
FROM ratio_selected rs
),
px_resolved AS (
SELECT
fr.*,
regexp_replace(
lower(
replace(
COALESCE(
NULLIF(fr.ratio_json ->> fr.final_image_proportion, ''),
NULLIF(fr.image_px, ''),
'2048x2048'
),
'×',
'x'
)
),
'x+',
'x',
'g'
) AS final_image_px
FROM final_resolved fr
)
UPDATE generation_records gr
SET
engine_id = px_resolved.selected_engine_id,
image_size = px_resolved.final_image_size,
image_proportion = px_resolved.final_image_proportion,
image_px = LEFT(px_resolved.final_image_px, 10),
provider_generation_resolution = NULL,
video_upscale_enabled_snapshot = FALSE,
video_upscale_snapshot_json = NULL,
engine_snapshot_json = jsonb_build_object(
'engine_type', 'image',
'id', px_resolved.selected_engine_id,
'name', px_resolved.engine_name,
'provider', px_resolved.provider,
'api_base', px_resolved.api_base,
'api_key_masked', CASE WHEN COALESCE(px_resolved.api_key, '') <> '' THEN '****' ELSE '' END,
'model_name', px_resolved.model_name,
'generate_url', px_resolved.generate_url,
'supported_models', {_IMAGE_SUPPORTED_MODELS_JSON},
'default_size', px_resolved.default_size,
'multi_generation_enabled', COALESCE(px_resolved.multi_generation_enabled, FALSE),
'max_generation_count', LEAST(5, GREATEST(1, COALESCE(px_resolved.max_generation_count, 1))),
'multi_image_max_images', COALESCE(px_resolved.multi_image_max_images, 15),
'max_reference_image_count', COALESCE(px_resolved.max_reference_image_count, 0),
'output_format', lower(trim(COALESCE(px_resolved.output_format, ''))),
'selected_size', px_resolved.final_image_size,
'selected_proportion', px_resolved.final_image_proportion,
'selected_px', LEFT(px_resolved.final_image_px, 10)
)::text,
include_media_references = COALESCE(gr.include_media_references, FALSE),
updated_at = NOW()
FROM px_resolved
WHERE gr.id = px_resolved.id
"""
_DOWNGRADE_SQL = f"""
UPDATE generation_records gr
SET
engine_id = b.old_engine_id,
engine_snapshot_json = b.old_engine_snapshot_json,
duration = b.old_duration,
aspect_ratio = b.old_aspect_ratio,
resolution = b.old_resolution,
provider_generation_resolution = b.old_provider_generation_resolution,
video_upscale_enabled_snapshot = COALESCE(b.old_video_upscale_enabled_snapshot, FALSE),
video_upscale_snapshot_json = b.old_video_upscale_snapshot_json,
image_size = b.old_image_size,
image_proportion = b.old_image_proportion,
image_px = b.old_image_px,
include_media_references = COALESCE(b.old_include_media_references, FALSE),
updated_at = b.old_updated_at
FROM {BACKUP_TABLE} b
WHERE b.repair_key = :repair_key
AND b.record_id = gr.id
AND gr.status = 'prompt_optimized'
AND gr.deleted_at IS NULL
"""
_DROP_BACKUP_TABLE_SQL = f"DROP TABLE IF EXISTS {BACKUP_TABLE}"
def _scalar_int(sql: str, **params: object) -> int:
bind = op.get_bind()
value = bind.execute(sa.text(sql), params).scalar()
return int(value or 0)
def _ensure_required_engines() -> None:
video_targets = _scalar_int(
f"""
SELECT COUNT(*)
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'video'
"""
)
image_targets = _scalar_int(
f"""
SELECT COUNT(*)
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'image'
"""
)
video_engines = _scalar_int(
"""
SELECT COUNT(*)
FROM video_engines
WHERE is_active IS TRUE
AND deleted_at IS NULL
"""
)
image_engines = _scalar_int(
"""
SELECT COUNT(*)
FROM image_engines
WHERE is_active IS TRUE
AND deleted_at IS NULL
"""
)
if video_targets > 0 and video_engines <= 0:
raise RuntimeError("存在待修复的视频生成记录,但没有可用的视频引擎")
if image_targets > 0 and image_engines <= 0:
raise RuntimeError("存在待修复的图片生成记录,但没有可用的图片引擎")
def _ensure_postgresql() -> None:
bind = op.get_bind()
dialect_name = getattr(bind.dialect, "name", "")
if dialect_name != "postgresql":
raise RuntimeError("本迁移只支持 PostgreSQL,当前数据库类型不支持此数据修复")
def _execute(sql: str, **params: object) -> None:
bind = op.get_bind()
bind.execute(sa.text(sql), params)
def _backup_table_exists() -> bool:
return (
_scalar_int(
"""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = :table_name
""",
table_name=BACKUP_TABLE,
)
> 0
)
def upgrade() -> None:
"""Repair historical prompt_optimized GenerationRecord frozen config.
This migration only fills missing frozen generation configuration for old
records. It does not change status, charge credits, create tasks, enqueue
Celery jobs, or call application services.
"""
_ensure_postgresql()
_execute(_CREATE_BACKUP_TABLE_SQL)
_ensure_required_engines()
# Keep the original values for a guarded downgrade. The ON CONFLICT clause
# makes this migration safe to re-run inside a partially repaired database.
_execute(_INSERT_BACKUP_SQL, repair_key=REPAIR_KEY)
# Repair by generation type. The update SQL only targets records that are
# still prompt_optimized and still incomplete, so already repaired records
# are skipped.
_execute(_REPAIR_VIDEO_SQL)
_execute(_REPAIR_IMAGE_SQL)
def downgrade() -> None:
"""Restore backed-up values for records that are still not generated.
Records that moved past prompt_optimized are intentionally not restored;
reverting those after users have generated media would corrupt production
state.
"""
_ensure_postgresql()
if not _backup_table_exists():
return
_execute(_DOWNGRADE_SQL, repair_key=REPAIR_KEY)
_execute(_DROP_BACKUP_TABLE_SQL)
@@ -0,0 +1,266 @@
"""add celery runtime fencing fields
Revision ID: 7cf645f7c418
Revises: d8ebe79ab575
Create Date: 2026-07-22 13:45:57.949417
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# revision identifiers, used by Alembic.
revision: str = "7cf645f7c418"
down_revision: str | None = "d8ebe79ab575"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_SHOT_SEGMENT_TABLE = "shot_replicate_segments"
_SHOT_TASK_SET_TABLE = "shot_replicate_task_sets"
_ANALYSIS_ATTEMPT_COLUMN = "analysis_attempt_no"
# SQLAlchemy Inspector caches reflected metadata. Always create a fresh
# inspector after DDL so partially applied migrations are detected correctly.
def _inspector() -> Inspector:
return sa.inspect(op.get_bind())
def _require_table(table_name: str) -> None:
if not _inspector().has_table(table_name):
raise RuntimeError(
f"Required table {table_name!r} does not exist; "
"refusing to mark migration 7cf645f7c418 as applied incompletely."
)
def _column_names(table_name: str) -> set[str]:
return {
str(column["name"])
for column in _inspector().get_columns(table_name)
}
def _add_column_if_missing(table_name: str, column: sa.Column[object]) -> bool:
"""Add one column only when its name is absent.
Returns True when DDL was executed and False when the column already exists.
"""
if column.name in _column_names(table_name):
return False
op.add_column(table_name, column)
return True
def _drop_column_if_exists(table_name: str, column_name: str) -> bool:
"""Drop one column only when both the table and column still exist."""
inspector = _inspector()
if not inspector.has_table(table_name):
return False
if column_name not in {
str(column["name"])
for column in inspector.get_columns(table_name)
}:
return False
op.drop_column(table_name, column_name)
return True
def _index_definitions(table_name: str) -> dict[str, dict[str, object]]:
return {
str(index["name"]): index
for index in _inspector().get_indexes(table_name)
if index.get("name")
}
def _ensure_index(
index_name: str,
table_name: str,
columns: Sequence[str],
*,
unique: bool = False,
) -> None:
"""Create an index if absent and reject a conflicting same-name index."""
required_columns = tuple(columns)
existing_columns = _column_names(table_name)
missing_columns = [name for name in required_columns if name not in existing_columns]
if missing_columns:
raise RuntimeError(
f"Cannot create index {index_name!r}: table {table_name!r} "
f"is missing columns {missing_columns!r}."
)
existing = _index_definitions(table_name).get(index_name)
if existing is not None:
reflected_columns = tuple(
str(name)
for name in (existing.get("column_names") or [])
)
reflected_unique = bool(existing.get("unique", False))
if reflected_columns != required_columns or reflected_unique != unique:
raise RuntimeError(
f"Index {index_name!r} already exists with an unexpected definition: "
f"columns={reflected_columns!r}, unique={reflected_unique!r}; "
f"expected columns={required_columns!r}, unique={unique!r}."
)
return
op.create_index(
index_name,
table_name,
list(required_columns),
unique=unique,
)
def _drop_index_if_exists(index_name: str, table_name: str) -> bool:
"""Drop one index only when the table and named index still exist."""
inspector = _inspector()
if not inspector.has_table(table_name):
return False
existing_names = {
str(index["name"])
for index in inspector.get_indexes(table_name)
if index.get("name")
}
if index_name not in existing_names:
return False
op.drop_index(index_name, table_name=table_name)
return True
def _ensure_analysis_attempt_column(table_name: str) -> None:
"""Create/backfill the non-null attempt counter safely for existing rows."""
_add_column_if_missing(
table_name,
sa.Column(
_ANALYSIS_ATTEMPT_COLUMN,
sa.Integer(),
nullable=False,
server_default=sa.text("1"),
),
)
# Also repairs a partially applied/manual migration where the column exists
# but contains NULL values or still carries the temporary database default.
quoted_table = op.get_bind().dialect.identifier_preparer.quote(table_name)
quoted_column = op.get_bind().dialect.identifier_preparer.quote(
_ANALYSIS_ATTEMPT_COLUMN
)
op.execute(
sa.text(
f"UPDATE {quoted_table} "
f"SET {quoted_column} = 1 "
f"WHERE {quoted_column} IS NULL"
)
)
op.alter_column(
table_name,
_ANALYSIS_ATTEMPT_COLUMN,
existing_type=sa.Integer(),
nullable=False,
server_default=None,
)
def upgrade() -> None:
"""Add only the Celery fencing fields required by shot replication."""
_require_table(_SHOT_SEGMENT_TABLE)
_require_table(_SHOT_TASK_SET_TABLE)
# Segment split fencing.
_add_column_if_missing(
_SHOT_SEGMENT_TABLE,
sa.Column("split_claim_token", sa.String(length=64), nullable=True),
)
# Segment analysis fencing.
_ensure_analysis_attempt_column(_SHOT_SEGMENT_TABLE)
_add_column_if_missing(
_SHOT_SEGMENT_TABLE,
sa.Column("analysis_claim_token", sa.String(length=64), nullable=True),
)
_add_column_if_missing(
_SHOT_SEGMENT_TABLE,
sa.Column("analysis_started_at", sa.DateTime(timezone=True), nullable=True),
)
_add_column_if_missing(
_SHOT_SEGMENT_TABLE,
sa.Column("analysis_lease_until", sa.DateTime(timezone=True), nullable=True),
)
_ensure_index(
"idx_shot_segments_analysis_lease",
_SHOT_SEGMENT_TABLE,
("analysis_status", "analysis_lease_until"),
)
_ensure_index(
"idx_shot_segments_split_lease",
_SHOT_SEGMENT_TABLE,
("split_status", "split_lease_until"),
)
# Task-set analysis fencing.
_ensure_analysis_attempt_column(_SHOT_TASK_SET_TABLE)
_add_column_if_missing(
_SHOT_TASK_SET_TABLE,
sa.Column("analysis_claim_token", sa.String(length=64), nullable=True),
)
_add_column_if_missing(
_SHOT_TASK_SET_TABLE,
sa.Column("analysis_started_at", sa.DateTime(timezone=True), nullable=True),
)
_add_column_if_missing(
_SHOT_TASK_SET_TABLE,
sa.Column("analysis_lease_until", sa.DateTime(timezone=True), nullable=True),
)
_ensure_index(
"idx_shot_task_sets_analysis_lease",
_SHOT_TASK_SET_TABLE,
("analysis_status", "analysis_lease_until"),
)
def downgrade() -> None:
"""Remove only fields and indexes introduced by this revision.
Every operation is guarded so a partially reverted database does not fail
merely because an index or column is already absent.
"""
_drop_index_if_exists(
"idx_shot_task_sets_analysis_lease",
_SHOT_TASK_SET_TABLE,
)
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_lease_until")
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_started_at")
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_claim_token")
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, _ANALYSIS_ATTEMPT_COLUMN)
_drop_index_if_exists(
"idx_shot_segments_split_lease",
_SHOT_SEGMENT_TABLE,
)
_drop_index_if_exists(
"idx_shot_segments_analysis_lease",
_SHOT_SEGMENT_TABLE,
)
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_lease_until")
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_started_at")
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_claim_token")
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, _ANALYSIS_ATTEMPT_COLUMN)
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "split_claim_token")
@@ -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,230 @@
"""add client-selectable multi generation and image batch claim
Revision ID: abae3e1c70f7
Revises: 2026070902
Create Date: 2026-07-15 10:49:31.803342
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "abae3e1c70f7"
down_revision: Union[str, None] = "2026070902"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
FK_CHAT_TASK_PARENT = "fk_chat_generation_tasks_parent_task_id"
CK_CHAT_TASK_GENERATION_COUNT = "ck_chat_generation_tasks_generation_count"
CK_CHAT_TASK_GENERATION_INDEX = "ck_chat_generation_tasks_generation_index"
CK_IMAGE_ENGINE_MAX_GENERATION_COUNT = "ck_image_engines_max_generation_count"
CK_IMAGE_ENGINE_MULTI_IMAGE_MAX = "ck_image_engines_multi_image_max_images"
CK_IMAGE_ENGINE_MAX_REFERENCE = "ck_image_engines_max_reference_image_count"
CK_VIDEO_ENGINE_MAX_GENERATION_COUNT = "ck_video_engines_max_generation_count"
def upgrade() -> None:
# ChatGenerationTask:任务级实际生成数量、主子关联和图片批次执行租约。
op.add_column(
"chat_generation_tasks",
sa.Column("parent_task_id", sa.String(length=32), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.add_column(
"chat_generation_tasks",
sa.Column("generation_index", sa.Integer(), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_claim_token", sa.String(length=64), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_lease_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_started_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_check_constraint(
CK_CHAT_TASK_GENERATION_COUNT,
"chat_generation_tasks",
"generation_count BETWEEN 1 AND 5",
)
op.create_check_constraint(
CK_CHAT_TASK_GENERATION_INDEX,
"chat_generation_tasks",
"generation_index IS NULL OR generation_index > 0",
)
op.create_foreign_key(
FK_CHAT_TASK_PARENT,
"chat_generation_tasks",
"chat_generation_tasks",
["parent_task_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"idx_chat_generation_tasks_parent",
"chat_generation_tasks",
["parent_task_id"],
unique=False,
)
op.create_index(
"idx_chat_generation_tasks_user_mode_created",
"chat_generation_tasks",
["user_id", "generation_mode", "created_at"],
unique=False,
)
op.create_index(
"ix_chat_generation_tasks_provider_create_claim_token",
"chat_generation_tasks",
["provider_create_claim_token"],
unique=False,
)
op.create_index(
"ix_chat_generation_tasks_provider_create_lease_until",
"chat_generation_tasks",
["provider_create_lease_until"],
unique=False,
)
op.create_index(
"uq_chat_generation_tasks_parent_index",
"chat_generation_tasks",
["parent_task_id", "generation_index"],
unique=True,
postgresql_where=sa.text(
"parent_task_id IS NOT NULL AND generation_index IS NOT NULL"
),
)
op.create_index(
"uq_chat_generation_tasks_user_chat_idempotency",
"chat_generation_tasks",
["user_id", "idempotency_key"],
unique=True,
postgresql_where=sa.text(
"deleted_at IS NULL "
"AND idempotency_key IS NOT NULL "
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
),
)
# ImageEngine:管理后台只配置是否允许客户端多份生成和数量上限。
op.add_column(
"image_engines",
sa.Column(
"multi_generation_enabled",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"image_engines",
sa.Column("max_generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("multi_image_max_images", sa.Integer(), server_default=sa.text("15"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("max_reference_image_count", sa.Integer(), server_default=sa.text("14"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("output_format", sa.String(length=16), server_default=sa.text("''"), nullable=False),
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MAX_GENERATION_COUNT,
"image_engines",
"max_generation_count BETWEEN 1 AND 5",
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MULTI_IMAGE_MAX,
"image_engines",
"multi_image_max_images BETWEEN 1 AND 15",
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MAX_REFERENCE,
"image_engines",
"max_reference_image_count BETWEEN 0 AND 14",
)
# VideoEngine:管理后台只配置是否允许客户端多份生成和数量上限。
op.add_column(
"video_engines",
sa.Column(
"multi_generation_enabled",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"video_engines",
sa.Column("max_generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.create_check_constraint(
CK_VIDEO_ENGINE_MAX_GENERATION_COUNT,
"video_engines",
"max_generation_count BETWEEN 1 AND 5",
)
def downgrade() -> None:
op.drop_constraint(CK_VIDEO_ENGINE_MAX_GENERATION_COUNT, "video_engines", type_="check")
op.drop_column("video_engines", "max_generation_count")
op.drop_column("video_engines", "multi_generation_enabled")
op.drop_constraint(CK_IMAGE_ENGINE_MAX_REFERENCE, "image_engines", type_="check")
op.drop_constraint(CK_IMAGE_ENGINE_MULTI_IMAGE_MAX, "image_engines", type_="check")
op.drop_constraint(CK_IMAGE_ENGINE_MAX_GENERATION_COUNT, "image_engines", type_="check")
op.drop_column("image_engines", "output_format")
op.drop_column("image_engines", "max_reference_image_count")
op.drop_column("image_engines", "multi_image_max_images")
op.drop_column("image_engines", "max_generation_count")
op.drop_column("image_engines", "multi_generation_enabled")
op.drop_index(
"uq_chat_generation_tasks_user_chat_idempotency",
table_name="chat_generation_tasks",
postgresql_where=sa.text(
"deleted_at IS NULL "
"AND idempotency_key IS NOT NULL "
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
),
)
op.drop_index(
"uq_chat_generation_tasks_parent_index",
table_name="chat_generation_tasks",
postgresql_where=sa.text(
"parent_task_id IS NOT NULL AND generation_index IS NOT NULL"
),
)
op.drop_index(
"ix_chat_generation_tasks_provider_create_lease_until",
table_name="chat_generation_tasks",
)
op.drop_index(
"ix_chat_generation_tasks_provider_create_claim_token",
table_name="chat_generation_tasks",
)
op.drop_index("idx_chat_generation_tasks_user_mode_created", table_name="chat_generation_tasks")
op.drop_index("idx_chat_generation_tasks_parent", table_name="chat_generation_tasks")
op.drop_constraint(FK_CHAT_TASK_PARENT, "chat_generation_tasks", type_="foreignkey")
op.drop_constraint(CK_CHAT_TASK_GENERATION_INDEX, "chat_generation_tasks", type_="check")
op.drop_constraint(CK_CHAT_TASK_GENERATION_COUNT, "chat_generation_tasks", type_="check")
op.drop_column("chat_generation_tasks", "provider_create_started_at")
op.drop_column("chat_generation_tasks", "provider_create_lease_until")
op.drop_column("chat_generation_tasks", "provider_create_claim_token")
op.drop_column("chat_generation_tasks", "generation_index")
op.drop_column("chat_generation_tasks", "generation_count")
op.drop_column("chat_generation_tasks", "parent_task_id")
@@ -0,0 +1,56 @@
"""add api_model_pricings table
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2026-07-28 14:00:00.000000
API 模型价格表 - 使用直接金额(元)计费,镜像 credit_ratios 结构。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6g7h'
down_revision: Union[str, None] = 'a1b2c3d4e5f6g'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'api_model_pricings',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('model_config_id', sa.String(32), nullable=False, index=True),
sa.Column('gen_type', sa.String(16), nullable=False, default='video', index=True),
sa.Column('resolution', sa.String(16), nullable=False, index=True),
sa.Column('price_ratio', sa.Float, nullable=False, default=1.0),
sa.Column('base_price', sa.Float, nullable=False, default=0.0),
sa.Column('per_second_price', sa.Float, nullable=False, default=0.0),
sa.Column('input_video_ratio', sa.Float, nullable=False, default=1.0),
sa.Column('input_video_base_price', sa.Float, nullable=False, default=0.0),
sa.Column('input_video_per_second_price', sa.Float, nullable=False, default=0.0),
sa.Column('input_image_ratio', sa.Float, nullable=False, default=1.0),
sa.Column('input_image_base_price', sa.Float, nullable=False, default=0.0),
sa.Column('input_image_per_image_price', sa.Float, nullable=False, default=0.0),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index(
'ix_api_model_pricings_gen_type_engine_resolution',
'api_model_pricings',
['gen_type', 'model_config_id', 'resolution'],
)
op.create_index(
'ix_api_model_pricings_gen_type_resolution',
'api_model_pricings',
['gen_type', 'resolution'],
)
def downgrade() -> None:
op.drop_index('ix_api_model_pricings_gen_type_resolution', table_name='api_model_pricings')
op.drop_index('ix_api_model_pricings_gen_type_engine_resolution', table_name='api_model_pricings')
op.drop_table('api_model_pricings')
@@ -0,0 +1,33 @@
"""add api_key_encrypted column to api_keys
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7h
Create Date: 2026-07-28 16:00:00.000000
添加 api_key_encrypted 字段用于存储加密的完整 API Key,支持随时揭秘复制。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6g7h8'
down_revision: Union[str, None] = 'b2c3d4e5f6g7h'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'api_keys',
sa.Column('api_key_encrypted', sa.Text, nullable=True, comment='AES-256-GCM 加密的完整 API Key'),
)
# 为现有记录设置空值(新创建的 Key 会自动加密)
op.execute("UPDATE api_keys SET api_key_encrypted = '' WHERE api_key_encrypted IS NULL")
op.alter_column('api_keys', 'api_key_encrypted', nullable=False)
def downgrade() -> None:
op.drop_column('api_keys', 'api_key_encrypted')
@@ -0,0 +1,29 @@
"""add model_name to api_generation_tasks
Revision ID: c4d5e6f7g8h9
Revises: c3d4e5f6g7h8
Create Date: 2026-07-29 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c4d5e6f7g8h9'
down_revision: Union[str, None] = 'c3d4e5f6g7h8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'api_generation_tasks',
sa.Column('model_name', sa.String(128), nullable=False, server_default='', comment="模型名称,如 doubao-seedance-2-0-260128"),
)
def downgrade() -> None:
op.drop_column('api_generation_tasks', 'model_name')
@@ -0,0 +1,68 @@
"""add api_generation_task_id to video_upscale_tasks
Revision ID: d5e6f7g8h9i0
Revises: c4d5e6f7g8h9
Create Date: 2026-07-29 16:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd5e6f7g8h9i0'
down_revision: Union[str, None] = 'c4d5e6f7g8h9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 添加 api_generation_task_id 字段
op.add_column(
'video_upscale_tasks',
sa.Column('api_generation_task_id', sa.String(32), nullable=True, comment="API v3 任务ID,关联 api_generation_tasks.id"),
)
op.create_index(
'idx_video_upscale_tasks_api_generation_task_id',
'video_upscale_tasks',
['api_generation_task_id'],
)
op.create_foreign_key(
'fk_video_upscale_tasks_api_generation_task_id',
'video_upscale_tasks',
'api_generation_tasks',
['api_generation_task_id'],
['id'],
ondelete='CASCADE',
)
# 删除旧的检查约束,创建新的(允许 api_generation_task_id
op.execute("ALTER TABLE video_upscale_tasks DROP CONSTRAINT IF EXISTS ck_video_upscale_tasks_exactly_one_owner")
op.execute("""
ALTER TABLE video_upscale_tasks
ADD CONSTRAINT ck_video_upscale_tasks_exactly_one_owner
CHECK (
(chat_generation_task_id IS NOT NULL)::int +
(generation_record_id IS NOT NULL)::int +
(api_generation_task_id IS NOT NULL)::int = 1
)
""")
def downgrade() -> None:
# 恢复旧约束
op.execute("ALTER TABLE video_upscale_tasks DROP CONSTRAINT IF EXISTS ck_video_upscale_tasks_exactly_one_owner")
op.execute("""
ALTER TABLE video_upscale_tasks
ADD CONSTRAINT ck_video_upscale_tasks_exactly_one_owner
CHECK (
(chat_generation_task_id IS NOT NULL)::int +
(generation_record_id IS NOT NULL)::int = 1
)
""")
op.drop_constraint('fk_video_upscale_tasks_api_generation_task_id', 'video_upscale_tasks', type_='foreignkey')
op.drop_index('idx_video_upscale_tasks_api_generation_task_id', table_name='video_upscale_tasks')
op.drop_column('video_upscale_tasks', 'api_generation_task_id')
@@ -0,0 +1,87 @@
"""add module flow version and generation reference option
Revision ID: d8ebe79ab575
Revises: e6eac828ff61
Create Date: 2026-07-21 09:28:04.318458
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "d8ebe79ab575"
down_revision: Union[str, None] = "e6eac828ff61"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _column_names(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
return {str(column["name"]) for column in inspector.get_columns(table_name)}
def upgrade() -> None:
generation_record_columns = _column_names("generation_records")
if "include_media_references" not in generation_record_columns:
op.add_column(
"generation_records",
sa.Column(
"include_media_references",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
else:
op.execute(
sa.text(
"UPDATE generation_records "
"SET include_media_references = false "
"WHERE include_media_references IS NULL"
)
)
op.alter_column(
"generation_records",
"include_media_references",
existing_type=sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
)
project_columns = _column_names("module_generation_projects")
if "flow_version" not in project_columns:
op.add_column(
"module_generation_projects",
sa.Column(
"flow_version",
sa.String(length=16),
server_default=sa.text("'v1'"),
nullable=False,
),
)
else:
op.execute(
sa.text(
"UPDATE module_generation_projects "
"SET flow_version = 'v1' "
"WHERE flow_version IS NULL OR btrim(flow_version) = ''"
)
)
op.alter_column(
"module_generation_projects",
"flow_version",
existing_type=sa.String(length=16),
nullable=False,
server_default=sa.text("'v1'"),
)
def downgrade() -> None:
project_columns = _column_names("module_generation_projects")
if "flow_version" in project_columns:
op.drop_column("module_generation_projects", "flow_version")
generation_record_columns = _column_names("generation_records")
if "include_media_references" in generation_record_columns:
op.drop_column("generation_records", "include_media_references")
@@ -0,0 +1,43 @@
"""enhance api_usage_logs with detailed consumption fields
Revision ID: e6f7g8h9i0j1
Revises: d5e6f7g8h9i0
Create Date: 2026-07-29 17:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e6f7g8h9i0j1'
down_revision: Union[str, None] = 'd5e6f7g8h9i0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 添加新字段
op.add_column('api_usage_logs', sa.Column('price_action', sa.String(16), nullable=False, server_default='deduct', comment='deduct=扣除, refund=退回'))
op.add_column('api_usage_logs', sa.Column('resolution', sa.String(16), nullable=True, comment="分辨率: 480p/720p/1080p/2K/4K"))
op.add_column('api_usage_logs', sa.Column('duration', sa.Integer(), nullable=True, comment="视频时长(秒)"))
op.add_column('api_usage_logs', sa.Column('refund_amount', sa.Float(), nullable=False, server_default='0.0', comment='退回金额'))
op.add_column('api_usage_logs', sa.Column('quota_before', sa.Float(), nullable=True, comment='操作前配额余额'))
op.add_column('api_usage_logs', sa.Column('quota_after', sa.Float(), nullable=True, comment='操作后配额余额'))
op.add_column('api_usage_logs', sa.Column('price_detail_json', sa.Text(), nullable=True, comment='价格计算明细JSON'))
# 添加索引
op.create_index('idx_api_usage_logs_action', 'api_usage_logs', ['price_action'])
def downgrade() -> None:
op.drop_index('idx_api_usage_logs_action', table_name='api_usage_logs')
op.drop_column('api_usage_logs', 'price_detail_json')
op.drop_column('api_usage_logs', 'quota_after')
op.drop_column('api_usage_logs', 'quota_before')
op.drop_column('api_usage_logs', 'refund_amount')
op.drop_column('api_usage_logs', 'duration')
op.drop_column('api_usage_logs', 'resolution')
op.drop_column('api_usage_logs', 'price_action')
@@ -0,0 +1,29 @@
"""add local_media_json to api_generation_tasks
Revision ID: f7g8h9i0j1k2
Revises: e6f7g8h9i0j1
Create Date: 2026-07-29 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f7g8h9i0j1k2'
down_revision: Union[str, None] = 'e6f7g8h9i0j1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'api_generation_tasks',
sa.Column('local_media_json', sa.Text, nullable=True, comment='下载到本地的媒体文件路径JSON'),
)
def downgrade() -> None:
op.drop_column('api_generation_tasks', 'local_media_json')
@@ -0,0 +1,3 @@
from app.admin_api.api_keys.routes import router
__all__ = ["router"]
@@ -0,0 +1,435 @@
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.api.api_key import ApiKey
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
from app.models.api.api_usage_log import ApiUsageLog
from app.models.user import User
from app.schemas.admin_api.api_key import (
ApiKeyCallableModel,
ApiKeyCreateRequest,
ApiKeyCreateResponse,
ApiKeyListItem,
ApiKeyListOut,
ApiKeyQuotaAdjustRequest,
ApiKeyRevealResponse,
ApiKeyResponse,
ApiKeyUpdateRequest,
)
from app.schemas.admin_api.api_upscale import (
ApiUpscaleConfigData,
ApiUpscaleConfigResponse,
ApiUpscaleConfigSaveRequest,
)
from app.schemas.admin_api.api_usage import ApiUsageLogResponse, ApiUsageSummaryResponse
from app.services.api_v3 import (
key_service,
upscale_service,
usage_log_service,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/admin/api-keys", tags=["admin-api-keys"])
def _key_to_list_item(key: ApiKey) -> ApiKeyListItem:
"""将 ApiKey 模型转为列表项 Schema。"""
try:
callable_models = json.loads(key.callable_models) if key.callable_models else []
except (json.JSONDecodeError, TypeError):
callable_models = []
return ApiKeyListItem(
id=key.id,
company_name=key.company_name,
api_key_prefix=f"{key.api_key_prefix}****",
description=key.description,
callable_models=[ApiKeyCallableModel(**m) for m in callable_models],
quota_limit=key.quota_limit,
quota_cycle=key.quota_cycle,
quota_used=key.quota_used,
is_active=key.is_active,
valid_from=key.valid_from,
valid_until=key.valid_until,
max_concurrent_video_tasks=key.max_concurrent_video_tasks,
last_used_at=key.last_used_at,
created_at=key.created_at,
)
# === API Key CRUD ===
@router.get("", response_model=ApiKeyListOut, summary="列出 API Key")
async def list_keys(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
company_name: str | None = None,
is_active: bool | None = None,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListOut:
"""列出所有 API Key(分页+筛选)。"""
total, keys = await key_service.list_api_keys(
db, skip=skip, limit=limit,
company_name=company_name, is_active=is_active,
)
return ApiKeyListOut(
total=total,
items=[_key_to_list_item(k) for k in keys],
)
@router.post(
"",
response_model=ApiKeyCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="创建 API Key",
)
async def create_key(
req: ApiKeyCreateRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyCreateResponse:
"""创建新的 API Key。
返回包含完整明文 api_key,仅此一次。
"""
callable_models = [m.model_dump() for m in req.callable_models] if req.callable_models else []
key, raw_key = await key_service.create_api_key(
db=db,
company_name=req.company_name,
callable_models=callable_models,
quota_limit=req.quota_limit,
quota_cycle=req.quota_cycle,
valid_from=req.valid_from,
valid_until=req.valid_until,
max_concurrent_video_tasks=req.max_concurrent_video_tasks,
description=req.description,
)
await db.commit()
return ApiKeyCreateResponse(
id=key.id,
company_name=key.company_name,
api_key=raw_key,
api_key_prefix=key.api_key_prefix,
valid_until=key.valid_until,
created_at=key.created_at,
)
@router.get("/{key_id}/reveal", response_model=ApiKeyRevealResponse, summary="揭秘 API Key")
async def reveal_key(
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyRevealResponse:
"""揭秘 API Key(随时可获取完整明文 Key)。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
plaintext = key.decrypt_api_key()
if not plaintext:
raise HTTPException(
status_code=400,
detail="该 API Key 创建时未启用加密存储,无法揭秘。请重新创建 Key。",
)
return ApiKeyRevealResponse(
id=key.id,
company_name=key.company_name,
api_key=plaintext,
api_key_prefix=key.api_key_prefix,
)
@router.get("/{key_id}", response_model=ApiKeyListItem, summary="获取 API Key 详情")
async def get_key(
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListItem:
"""获取单个 API Key 详情。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
return _key_to_list_item(key)
@router.put("/{key_id}", response_model=ApiKeyListItem, summary="更新 API Key")
async def update_key(
req: ApiKeyUpdateRequest,
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyResponse:
"""更新 API Key 配置。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
# model_dump 默认输出 snake_case 字段名,直接传给 service
update_data = req.model_dump(exclude_none=True)
if "callable_models" in update_data and update_data["callable_models"] is not None:
update_data["callable_models"] = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["callable_models"]
]
key = await key_service.update_api_key(db, key, **update_data)
await db.commit()
return _key_to_list_item(key)
@router.delete("/{key_id}", summary="删除 API Key")
async def delete_key(
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""软删除 API Key。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
await key_service.delete_api_key(db, key)
await db.commit()
return {"status": "deleted", "id": key_id}
# === 超分配置 ===
@router.get("/{key_id}/upscale", response_model=ApiUpscaleConfigResponse, summary="获取 API Key 超分配置")
async def get_upscale_config(
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUpscaleConfigResponse:
"""获取 API Key 的超分配置。"""
config = await upscale_service.get_or_create_upscale_config(db, key_id)
try:
rules = json.loads(config.rules_json) if config.rules_json else []
except (json.JSONDecodeError, TypeError):
rules = []
return ApiUpscaleConfigResponse(
data=ApiUpscaleConfigData(
enabled=config.enabled,
delete_source_after_success=config.delete_source_after_success,
rules=rules,
),
)
@router.put("/{key_id}/upscale", response_model=ApiUpscaleConfigResponse, summary="保存 API Key 超分配置")
async def save_upscale_config(
req: ApiUpscaleConfigSaveRequest,
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUpscaleConfigResponse:
"""保存 API Key 的超分配置。"""
config = await upscale_service.save_upscale_config(
db=db,
api_key_id=key_id,
enabled=req.data.enabled,
delete_source_after_success=req.data.delete_source_after_success,
rules=[r.model_dump() for r in req.data.rules],
)
await db.commit()
return ApiUpscaleConfigResponse(
data=ApiUpscaleConfigData(
enabled=config.enabled,
delete_source_after_success=config.delete_source_after_success,
rules=json.loads(config.rules_json) if config.rules_json else [],
),
)
# === 使用日志 ===
@router.get("/{key_id}/usage", response_model=ApiUsageSummaryResponse, summary="获取 API Key 使用统计")
async def get_usage(
key_id: str = Path(...),
days: int = Query(30, ge=1, le=365),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUsageSummaryResponse:
"""获取 API Key 的使用统计和明细。"""
# 验证 key 存在
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
summary = await usage_log_service.get_usage_summary(db, api_key_id=key_id, days=days)
total, logs = await usage_log_service.list_usage_logs(db, api_key_id=key_id, limit=page_size, skip=(page - 1) * page_size)
return ApiUsageSummaryResponse(
total_requests=summary["total_requests"],
total_credits_cost=summary["total_credits_cost"],
total_tokens_used=summary["total_tokens_used"],
success_count=summary["success_count"],
failed_count=summary["failed_count"],
avg_duration_ms=summary["avg_duration_ms"],
total=total,
page=page,
page_size=page_size,
items=[
ApiUsageLogResponse(
id=log.id,
api_key_id=log.api_key_id,
api_generation_task_id=log.api_generation_task_id,
request_type=log.request_type,
model_name=log.model_name,
gen_type=log.gen_type,
credits_cost=log.credits_cost,
tokens_used=log.tokens_used,
request_duration_ms=log.request_duration_ms,
status=log.status,
error_message=log.error_message,
error_code=log.error_code,
created_at=log.created_at,
)
for log in logs
],
)
# === 整体消耗列表 ===
@router.get("/usage/all", response_model=dict, summary="获取整体 API 消耗列表")
async def list_all_usage(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
api_key_id: str | None = None,
gen_type: str | None = None,
status_filter: str | None = Query(None, alias="status"),
start_date: datetime | None = None,
end_date: datetime | None = None,
search: str | None = None,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""获取整体 API 消耗列表(跨所有 Key,支持筛选和分页)。"""
# 构建查询
query = select(ApiUsageLog, ApiKey.company_name, ApiKey.api_key_prefix).join(
ApiKey, ApiUsageLog.api_key_id == ApiKey.id
)
count_query = select(func.count(ApiUsageLog.id)).join(
ApiKey, ApiUsageLog.api_key_id == ApiKey.id
)
# 筛选条件
filters = []
if api_key_id:
filters.append(ApiUsageLog.api_key_id == api_key_id)
if gen_type:
filters.append(ApiUsageLog.gen_type == gen_type)
if status_filter:
filters.append(ApiUsageLog.status == status_filter)
if start_date:
filters.append(ApiUsageLog.created_at >= start_date)
if end_date:
filters.append(ApiUsageLog.created_at <= end_date)
if search:
search_pattern = f"%{search}%"
filters.append(
(ApiKey.company_name.ilike(search_pattern))
| (ApiKey.api_key_prefix.ilike(search_pattern))
)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
# 总数
total_result = await db.execute(count_query)
total = total_result.scalar_one()
# 分页查询
query = query.order_by(ApiUsageLog.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
rows = result.all()
items = []
for log, company_name, key_prefix in rows:
items.append({
"id": log.id,
"apiKeyId": log.api_key_id,
"companyName": company_name,
"apiKeyPrefix": f"{key_prefix}****" if key_prefix else None,
"taskId": log.api_generation_task_id,
"requestType": log.request_type,
"modelName": log.model_name,
"genType": log.gen_type,
"creditsCost": log.credits_cost,
"tokensUsed": log.tokens_used,
"requestDurationMs": log.request_duration_ms,
"duration": log.duration,
"resolution": log.resolution,
"status": log.status,
"errorMessage": log.error_message,
"errorCode": log.error_code,
"createdAt": log.created_at.isoformat() if log.created_at else None,
})
return {
"total": total,
"items": items,
}
@router.post("/{key_id}/quota-adjust", response_model=ApiKeyListItem, summary="调整 API Key 配额")
async def quota_adjust(
req: ApiKeyQuotaAdjustRequest,
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListItem:
"""调整 API Key 配额(增加总额/重置已用/设置限额/修改周期)。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
key, changes = await key_service.adjust_quota(
db,
key,
action=req.action,
quota_limit_delta=req.quota_limit_delta,
quota_limit=req.quota_limit,
quota_cycle=req.quota_cycle,
)
# 审计日志
try:
from app.services.operation_log import log_operation
await log_operation(
db=db,
user_id=str(admin.id),
username=str(admin.username),
action=f"quota_adjust:{req.action}",
method="POST",
path=f"/admin/api-keys/{key_id}/quota-adjust",
detail=json.dumps(
{**changes, "reason": req.reason},
ensure_ascii=False,
default=str,
),
)
except Exception as log_exc:
logger.warning("配额调整审计日志记录失败: %s", log_exc)
await db.commit()
return _key_to_list_item(key)
@@ -0,0 +1,3 @@
from app.admin_api.api_model_pricings.routes import router
__all__ = ["router"]
@@ -0,0 +1,131 @@
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.api.api_model_pricing import ApiModelPricing
from app.models.image_engine import ImageEngine
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.schemas.admin_api.api_model_pricing import ApiModelPricingCreate, ApiModelPricingOut
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/admin/api-model-pricings", tags=["admin-api-model-pricings"])
async def _validate_pricing_engine(db: AsyncSession, req: ApiModelPricingCreate) -> None:
"""校验定价规则绑定的引擎是否存在。"""
gen_type = (req.gen_type or "").lower().strip()
engine_id = (req.model_config_id or "").strip()
if gen_type not in ("image", "video"):
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
if not engine_id:
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
model = ImageEngine if gen_type == "image" else VideoEngine
result = await db.execute(
select(model).where(model.id == engine_id, model.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
detail = "图片定价规则绑定的图片引擎不存在" if gen_type == "image" else "视频定价规则绑定的视频引擎不存在"
raise HTTPException(status_code=400, detail=detail)
@router.get("", response_model=list[ApiModelPricingOut])
async def list_pricings(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).order_by(
ApiModelPricing.gen_type.desc(),
ApiModelPricing.model_config_id.desc(),
)
)
return result.scalars().all()
@router.post("", response_model=ApiModelPricingOut)
async def create_pricing(
req: ApiModelPricingCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""创建 API 模型价格。"""
await _validate_pricing_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
pricing = ApiModelPricing(id=generate_id(), **data)
db.add(pricing)
await db.commit()
await db.refresh(pricing)
return pricing
@router.put("/{pricing_id}", response_model=ApiModelPricingOut)
async def update_pricing(
pricing_id: str,
req: ApiModelPricingCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).where(ApiModelPricing.id == pricing_id).limit(1)
)
pricing = result.scalar_one_or_none()
if not pricing:
raise HTTPException(status_code=404, detail="定价规则不存在")
await _validate_pricing_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
for k, v in data.items():
setattr(pricing, k, v)
await db.commit()
await db.refresh(pricing)
return pricing
@router.delete("/{pricing_id}")
async def delete_pricing(
pricing_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).where(ApiModelPricing.id == pricing_id).limit(1)
)
pricing = result.scalar_one_or_none()
if not pricing:
raise HTTPException(status_code=404, detail="定价规则不存在")
await db.delete(pricing)
await db.commit()
return {"message": "ok"}
@router.get("/grouped", response_model=dict)
async def list_pricings_grouped(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""按 gen_type 分组列出价格。"""
result = await db.execute(select(ApiModelPricing))
pricings = result.scalars().all()
grouped = {}
for pricing in pricings:
if pricing.gen_type not in grouped:
grouped[pricing.gen_type] = []
grouped[pricing.gen_type].append(ApiModelPricingOut.model_validate(pricing))
return grouped
@@ -0,0 +1,3 @@
from app.admin_api.vp_v3_quota.routes import router
__all__ = ["router"]
@@ -0,0 +1,80 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.models.virtual_portrait_v3.api_key_quota import VpV3ApiKeyQuota
from app.schemas.admin_api.vp_v3_quota import (
VpV3QuotaConfigData,
VpV3QuotaConfigResponse,
)
from app.services.api_v3 import key_service
from app.services.virtual_portrait_v3.quota_service import get_quota
router = APIRouter(prefix="/admin/api-keys", tags=["admin-vp-v3-quota"])
def _to_response(quota: VpV3ApiKeyQuota) -> VpV3QuotaConfigResponse:
enabled = any([
(quota.project_limit or 0) > 0,
(quota.asset_limit or 0) > 0,
(quota.storage_mb_limit or 0) > 0,
])
return VpV3QuotaConfigResponse(
api_key_id=quota.api_key_id,
project_limit=int(quota.project_limit or 0),
asset_limit=int(quota.asset_limit or 0),
storage_mb_limit=int(quota.storage_mb_limit or 0),
remark=quota.remark,
project_used=int(quota.project_used or 0),
asset_used=int(quota.asset_used or 0),
storage_mb_used=float(quota.storage_mb_used or 0),
enabled=enabled,
)
@router.get(
"/{key_id}/vp-v3-quota",
response_model=VpV3QuotaConfigResponse,
summary="获取 API Key 的虚拟素材库配额配置",
description="返回指定 API Key 的虚拟素材库配额上限及当前使用量。不存在配额记录时自动创建默认 0 值。",
)
async def get_vp_v3_quota(
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> VpV3QuotaConfigResponse:
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
quota = await get_quota(db, api_key_id=key_id, refresh=True)
await db.commit()
return _to_response(quota)
@router.post(
"/{key_id}/vp-v3-quota",
response_model=VpV3QuotaConfigResponse,
summary="保存 API Key 的虚拟素材库配额配置",
description="保存虚拟素材库配额(项目数/素材数/存储 MB),默认 0=不可使用该功能。保存后自动刷新已使用量。",
)
async def save_vp_v3_quota(
payload: VpV3QuotaConfigData,
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> VpV3QuotaConfigResponse:
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
quota = await get_quota(db, api_key_id=key_id, refresh=True)
quota.project_limit = int(payload.project_limit or 0)
quota.asset_limit = int(payload.asset_limit or 0)
quota.storage_mb_limit = int(payload.storage_mb_limit or 0)
quota.remark = payload.remark if payload.remark is not None else quota.remark
await db.flush()
await db.refresh(quota)
await db.commit()
return _to_response(quota)
+10 -2
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
from app.api.admin.video_upscale import router as video_upscale_router
from app.api.admin.resource_capacity import router as resource_capacity_router
from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router
@@ -8,10 +9,14 @@ 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.menu_config import router as menu_config_router
from app.api.admin.upload import router as admin_upload_router
from app.api.admin.model_pricing import router as model_pricing_router
from app.api.admin.contact import router as admin_contact_router
from app.admin_api.api_keys import router as api_keys_admin_router
from app.admin_api.api_model_pricings import router as api_model_pricings_admin_router
from app.admin_api.vp_v3_quota import router as vp_v3_quota_admin_router
router = APIRouter()
router.include_router(video_prompt_schema_config_router)
router.include_router(video_upscale_router)
router.include_router(resource_capacity_router)
router.include_router(team_router)
router.include_router(home_material_router)
@@ -19,4 +24,7 @@ router.include_router(private_portrait_router)
router.include_router(recharge_package_router)
router.include_router(menu_config_router)
router.include_router(admin_upload_router)
router.include_router(model_pricing_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)
+75
View File
@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.contact_request import ContactRequest
from app.models.user import User
from app.schemas.contact import ContactRequestListOut, ContactRequestOut
router = APIRouter(prefix="/admin/contact-requests", tags=["admin-contact-requests"])
@router.get("", response_model=ContactRequestListOut)
async def list_contact_requests(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
is_handled: bool | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(ContactRequest)
count_query = select(func.count(ContactRequest.id))
if is_handled is not None:
query = query.where(ContactRequest.is_handled == is_handled)
count_query = count_query.where(ContactRequest.is_handled == is_handled)
query = query.order_by(ContactRequest.created_at.desc())
offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size))
items = result.scalars().all()
total = (await db.execute(count_query)).scalar_one()
return {"items": items, "total": total}
@router.get("/{request_id}", response_model=ContactRequestOut)
async def get_contact_request(
request_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="记录不存在")
return item
@router.put("/{request_id}/handle")
async def mark_contact_handled(
request_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="记录不存在")
item.is_handled = True
await db.commit()
await db.refresh(item)
return {"message": "已标记为处理"}
@router.delete("/{request_id}")
async def delete_contact_request(
request_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="记录不存在")
await db.delete(item)
await db.commit()
return {"message": "删除成功"}
@@ -1,238 +0,0 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.schemas.model_pricing import (
ModelPricingPreviewOut,
ModelPricingPreviewRequest,
ModelPricingRuleCreate,
ModelPricingRuleListOut,
ModelPricingRuleOut,
ModelPricingRuleUpdate,
)
from app.services.model_pricing.calculator import PricingCalculationError, calculate_pricing
from app.services.model_pricing.rule_service import (
PricingRuleError,
create_rule,
disable_rule,
get_rule_snapshot,
list_rules,
publish_rule,
update_draft_rule,
)
from app.services.operation_log import log_operation
from app.services.operation_log_service import log_model_pricing_event
router = APIRouter(prefix="/admin/model-pricing", tags=["admin-model-pricing"])
def _http_error(exc: Exception) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/rules", response_model=ModelPricingRuleListOut)
async def admin_list_model_pricing_rules(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=500),
provider: str | None = Query(None),
model_name: str | None = Query(None),
model_category: str | None = Query(None),
publish_status: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
return await list_rules(
db,
page=page,
page_size=page_size,
provider=provider,
model_name=model_name,
model_category=model_category,
publish_status=publish_status,
)
@router.get("/rules/{rule_id}", response_model=ModelPricingRuleOut)
async def admin_get_model_pricing_rule(
rule_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
snapshot = await get_rule_snapshot(db, rule_id)
return {**snapshot, "referenced_count": 0}
except PricingRuleError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/rules", response_model=ModelPricingRuleOut)
async def admin_create_model_pricing_rule(
req: ModelPricingRuleCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
admin_id = str(admin.id)
admin_username = str(admin.username or "")
try:
snapshot = await create_rule(db, payload=req.model_dump(), operator_id=admin_id)
await log_operation(
db,
admin_id,
admin_username,
f"创建模型计价草稿 {snapshot['model_name']}/{snapshot['version_code']}",
"POST",
"/admin/model-pricing/rules",
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
await db.commit()
log_model_pricing_event(
event_type="pricing_rule_validate",
user_id=admin_id,
pricing_rule_id=snapshot["id"],
pricing_version=snapshot["version_code"],
provider=snapshot["provider"],
model_name=snapshot["model_name"],
billing_mode=snapshot["billing_mode"],
message="模型计价草稿创建成功",
)
return {**snapshot, "referenced_count": 0}
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
except PricingRuleError as exc:
await db.rollback()
raise _http_error(exc) from exc
@router.put("/rules/{rule_id}", response_model=ModelPricingRuleOut)
async def admin_update_model_pricing_rule(
rule_id: str,
req: ModelPricingRuleUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
admin_id = str(admin.id)
admin_username = str(admin.username or "")
try:
snapshot = await update_draft_rule(
db,
rule_id=rule_id,
payload=req.model_dump(exclude_unset=True),
operator_id=admin_id,
)
await log_operation(
db,
admin_id,
admin_username,
f"更新模型计价草稿 {snapshot['model_name']}/{snapshot['version_code']}",
"PUT",
f"/admin/model-pricing/rules/{rule_id}",
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
await db.commit()
return {**snapshot, "referenced_count": 0}
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
except PricingRuleError as exc:
await db.rollback()
raise _http_error(exc) from exc
@router.post("/rules/{rule_id}/publish", response_model=ModelPricingRuleOut)
async def admin_publish_model_pricing_rule(
rule_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
admin_id = str(admin.id)
admin_username = str(admin.username or "")
try:
snapshot = await publish_rule(db, rule_id=rule_id, operator_id=admin_id)
await log_operation(
db,
admin_id,
admin_username,
f"发布模型计价版本 {snapshot['model_name']}/{snapshot['version_code']}",
"POST",
f"/admin/model-pricing/rules/{rule_id}/publish",
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
await db.commit()
log_model_pricing_event(
event_type="pricing_rule_publish",
user_id=admin_id,
pricing_rule_id=snapshot["id"],
pricing_version=snapshot["version_code"],
provider=snapshot["provider"],
model_name=snapshot["model_name"],
billing_mode=snapshot["billing_mode"],
)
return {**snapshot, "referenced_count": 0}
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
except PricingRuleError as exc:
await db.rollback()
raise _http_error(exc) from exc
@router.post("/rules/{rule_id}/disable", response_model=ModelPricingRuleOut)
async def admin_disable_model_pricing_rule(
rule_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
admin_id = str(admin.id)
admin_username = str(admin.username or "")
try:
snapshot = await disable_rule(db, rule_id=rule_id, operator_id=admin_id)
await log_operation(
db,
admin_id,
admin_username,
f"停用模型计价版本 {snapshot['model_name']}/{snapshot['version_code']}",
"POST",
f"/admin/model-pricing/rules/{rule_id}/disable",
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
await db.commit()
return {**snapshot, "referenced_count": 0}
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
except PricingRuleError as exc:
await db.rollback()
raise _http_error(exc) from exc
@router.post("/preview", response_model=ModelPricingPreviewOut)
async def admin_preview_model_pricing(
req: ModelPricingPreviewRequest,
admin: User = Depends(get_admin_user),
):
try:
result = calculate_pricing(
billing_mode=req.billing_mode,
calculator_version=req.calculator_version,
rule_json=req.rule_json,
usage=req.usage,
currency=req.currency,
)
return {
"amount": str(result.amount),
"currency": result.currency,
"is_estimated": result.is_estimated,
"selected_rate": str(result.selected_rate) if result.selected_rate is not None else None,
"usage_source": result.usage_source,
"breakdown": result.breakdown,
}
except PricingCalculationError as exc:
raise _http_error(exc) from exc
@@ -0,0 +1,53 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.schemas.video_upscale import VideoUpscaleConfigOut, VideoUpscaleConfigSaveRequest
from app.services.operation_log import log_operation
from app.services.video_upscale.config_service import get_video_upscale_config, save_video_upscale_config
router = APIRouter(prefix="/admin/video-upscale", tags=["admin-video-upscale"])
@router.get("/config", response_model=VideoUpscaleConfigOut, summary="获取视频超分配置")
async def get_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await get_video_upscale_config(db)
@router.put("/config", response_model=VideoUpscaleConfigOut, summary="保存视频超分配置")
async def save_config(
req: VideoUpscaleConfigSaveRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
before = await get_video_upscale_config(db)
result = await save_video_upscale_config(db, req.data)
await log_operation(
db,
admin.id,
admin.username,
"保存视频超分配置",
"PUT",
"/admin/video-upscale/config",
detail=json.dumps(
{
"before_version": before["data"].get("version"),
"after_version": result["data"].get("version"),
"enabled": result["data"].get("enabled"),
"delete_source_after_success": result["data"].get("delete_source_after_success"),
"rule_count": len(result["data"].get("rules") or []),
},
ensure_ascii=False,
),
)
await db.commit()
return result
File diff suppressed because it is too large Load Diff
+29 -11
View File
@@ -1,4 +1,6 @@
from datetime import datetime
from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
@@ -15,6 +17,7 @@ from app.models.system_config import SystemConfig
from app.models.user import User
from app.schemas.auth import (
ChangePasswordRequest,
ChangeUsernameRequest,
LoginRequest,
RegisterRequest,
SetPasswordRequest,
@@ -117,7 +120,7 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
if credits <= 0:
return
today = datetime.now().date()
today = datetime.now(CST).date()
if user.last_login_at:
last_login_date = user.last_login_at.date()
if last_login_date >= today:
@@ -159,7 +162,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
)
await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now()
user.last_login_at = datetime.now(CST)
await db.flush()
return _token_response(user, req.remember_me)
@@ -190,7 +193,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
)
await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now()
user.last_login_at = datetime.now(CST)
await db.flush()
return _token_response(user, req.remember_me)
@@ -222,7 +225,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.phone,
phone=req.phone,
hashed_password=hash_password(req.password),
password_set_at=datetime.now(),
password_set_at=datetime.now(CST),
credits=register_credits,
is_admin=False,
user_type="frontend",
@@ -294,7 +297,7 @@ async def set_password(
)
current_user.hashed_password = hash_password(req.new_password)
current_user.password_set_at = datetime.now()
current_user.password_set_at = datetime.now(CST)
await db.flush()
return {"message": "密码设置成功", "must_set_password": False}
@@ -318,17 +321,28 @@ async def change_password(
)
current_user.hashed_password = hash_password(req.new_password)
current_user.password_set_at = datetime.now()
current_user.password_set_at = datetime.now(CST)
await db.flush()
return {"message": "密码修改成功"}
@router.post("/change-username")
async def change_username(
req: ChangeUsernameRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_user.username = req.new_username.strip()
await db.flush()
return {"message": "用户名修改成功"}
@router.get("/site-info")
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"
"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()
@@ -346,11 +360,15 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
return f"{base_url}{path}"
return {
"site_name": info.get("site_name", "VideoGen.AI"),
"site_name": info.get("site_name", "智创"),
"site_logo": to_full_url(info.get("site_logo")),
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
"site_copyright": info.get("site_copyright", "© 2026 智创 版权所有"),
"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),
}
@@ -370,7 +388,7 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
detail="该账号不是管理员账号",
)
user.last_login_at = datetime.now()
user.last_login_at = datetime.now(CST)
await db.flush()
token = create_access_token(user.id, req.remember_me)
+2 -92
View File
@@ -1,14 +1,14 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.contact_request import ContactRequest
from app.models.user import User
from app.schemas.contact import ContactRequestCreate, ContactRequestListOut, ContactRequestOut
from app.schemas.contact import ContactRequestCreate
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/contact", tags=["contact"])
@@ -63,93 +63,3 @@ async def create_contact_request(
)
return {"message": "提交成功,我们会尽快与您联系"}
@router.get("/requests", summary="获取联系请求列表", response_model=ContactRequestListOut)
async def get_contact_requests(
page: int = 1,
page_size: int = 20,
is_handled: bool | None = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
query = select(ContactRequest)
count_query = select(func.count(ContactRequest.id))
if is_handled is not None:
query = query.where(ContactRequest.is_handled == is_handled)
count_query = count_query.where(ContactRequest.is_handled == is_handled)
query = query.order_by(ContactRequest.created_at.desc())
offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size))
items = result.scalars().all()
total = (await db.execute(count_query)).scalar_one()
return {"items": items, "total": total}
@router.get("/requests/{request_id}", summary="获取联系请求详情", response_model=ContactRequestOut)
async def get_contact_request(
request_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
contact_request = result.scalar_one_or_none()
if not contact_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
return contact_request
@router.put("/requests/{request_id}/handle", summary="标记为已处理")
async def mark_as_handled(
request_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
contact_request = result.scalar_one_or_none()
if not contact_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
contact_request.is_handled = True
await db.commit()
await db.refresh(contact_request)
return {"message": "已标记为处理"}
@router.delete("/requests/{request_id}", summary="删除联系请求")
async def delete_contact_request(
request_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
contact_request = result.scalar_one_or_none()
if not contact_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
await db.delete(contact_request)
await db.commit()
return {"message": "删除成功"}
+2 -2
View File
@@ -62,14 +62,14 @@ async def get_credit_ratios(
video_engines_result = await db.execute(
select(VideoEngine.id)
.where(VideoEngine.is_active == True)
.where(VideoEngine.is_active == 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(
select(ImageEngine.id)
.where(ImageEngine.is_active == True)
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc())
)
image_engine_ids = image_engines_result.scalars().all()
File diff suppressed because it is too large Load Diff
+332 -149
View File
@@ -1,9 +1,11 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import and_, select
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_current_user, get_db
from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User
@@ -19,25 +21,40 @@ from app.schemas.generation_ai import (
GenerationAITaskListOut,
GenerationAITaskOut,
)
from app.services.generation_ai_service import (
create_async_generation_task,
from app.services.generation.pipeline.db_lock_service import (
DatabaseRowLockBusy,
execute_with_lock_timeout,
)
from app.services.generation.ai.service import (
build_task_out_list,
list_generation_ai_engine_options,
list_async_generation_tasks,
list_generation_history_day_items,
list_generation_history_grouped_days,
record_to_out,
soft_delete_chat_generation_task,
)
from app.services.generation_billing_service import (
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus, GenerationMode
from app.services.generation.ai.task_create_service import (
GenerationTaskCreateResult,
create_generation_task_group,
enqueue_created_generation_tasks,
find_existing_top_level_task,
)
from app.services.generation.ai.task_group_service import (
aggregate_main_task_status,
load_children_map,
soft_delete_child_task,
soft_delete_top_level_task_group,
)
from app.services.generation.billing_service import (
OWNER_CHAT_GENERATION_TASK,
charge_generation_media_by_params,
get_next_credit_attempt_no,
)
from app.services.generation_history_delete_service import batch_delete_generation_history_items
from app.services.generation_log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.generation.history_delete_service import batch_delete_generation_history_items
from app.services.generation.log_service import log_task_event
from app.services.generation.media_reference_service import calculate_media_reference_usage
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.operation_log_service import log_operation_event
from app.tasks.celery_app import celery_app
router = APIRouter(
@@ -146,6 +163,7 @@ async def create_task(
...,
description=(
"AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。"
"generation_count 为客户端本次选择的生成数量,默认1,后端会按引擎开关和数量上限校验。"
"枚举:gen_type=image/videomedia_references[].type=image/video/audio"
"media_references[].source=upload_resource/private_portrait_asset/空;"
"media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。"
@@ -157,34 +175,88 @@ async def create_task(
if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
task = await create_async_generation_task(db, current_user, req)
await db.commit()
try:
create_result = await create_generation_task_group(db, current_user, req)
top_level_task_id = str(create_result.top_level_task_id)
enqueue_task_ids = list(create_result.enqueue_task_ids)
await db.commit()
except IntegrityError:
# 并发重复请求可能同时通过预查询;唯一索引负责兜底。
# 回滚本次任务和计费后,按幂等键返回已经成功提交的顶层任务。
await db.rollback()
existing = await find_existing_top_level_task(
db,
user_id=current_user.id,
idempotency_key=req.idempotency_key,
)
if not existing:
raise
create_result = GenerationTaskCreateResult(
top_level_task_id=str(existing.id),
generation_count=int(existing.generation_count or 1),
gen_type=str(existing.gen_type),
created=False,
)
top_level_task_id = str(existing.id)
enqueue_task_ids = []
if create_result.created:
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_COMMIT_SUCCESS",
event_status="success",
source="api",
user_id=current_user.id,
group_id=top_level_task_id,
task_id=top_level_task_id,
detail={
"gen_type": create_result.gen_type,
"generation_count": create_result.generation_count,
"child_task_ids": create_result.child_task_ids,
"physical_files_deleted": False,
},
)
await log_task_event(
task,
event_type="TASK_CREATED",
to_status="generating",
to_stage="queued",
detail={"gen_type": task.gen_type},
task_id=top_level_task_id,
event_type=(
"TASK_CREATED" if create_result.created else "IDEMPOTENCY_HIT"
),
to_status="generating" if create_result.created else None,
to_stage="queued" if create_result.created else None,
detail={
"gen_type": create_result.gen_type,
"generation_count": create_result.generation_count,
"child_task_ids": create_result.child_task_ids,
"created": create_result.created,
},
)
from app.tasks.generation_create_tasks import chatapi_create_generation_task
try:
chatapi_create_generation_task.delay(task.id)
except Exception as exc:
await mark_chat_generation_task_failed_and_refund_once(
failed_enqueue_ids: list[str] = []
if create_result.created and enqueue_task_ids:
failed_enqueue_ids = await enqueue_created_generation_tasks(
db,
task_id=task.id,
error_message=f"任务队列投递失败: {exc}",
pipeline_stage="failed",
task_ids=enqueue_task_ids,
)
await db.commit()
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
return record_to_out(task, media_references=refs)
result = await db.execute(
select(ChatGenerationTask).where(
ChatGenerationTask.id == top_level_task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.deleted_at.is_(None),
).limit(1)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务创建后未找到")
output = await build_task_out_list(
db,
[task],
viewer_user_id=current_user.id,
)
if failed_enqueue_ids and len(failed_enqueue_ids) == len(enqueue_task_ids):
raise HTTPException(status_code=503, detail="任务已创建,但任务队列投递失败,请稍后重试")
return output[0]
@router.get(
"/tasks",
@@ -261,10 +333,8 @@ async def list_tasks(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
is_admin = False
if current_user.user_type == 'admin':
is_admin = True
else:
is_admin = current_user.user_type == "admin"
if not is_admin:
user_id = current_user.id
total, items = await list_async_generation_tasks(
@@ -280,24 +350,17 @@ async def list_tasks(
created_start=created_start,
created_end=created_end,
)
# ====================== 在这里加排序(最新在前)======================
if not is_admin:
# 按 created_at 降序(没有则用 id 降序)
items_sorted = sorted(
items,
key=lambda x: x.created_at if x.created_at is not None else x.id,
reverse=False # 升序
)
else:
items_sorted = items
refs_map = await batch_resolve_private_portrait_reference_display_urls(
# 同一个 API 同时服务管理后台和客户端:
# - 管理员保持数据库倒序,最新记录在列表上方;
# - 普通用户先查询最新一页,再仅反转当前页,聊天消息从旧到新排列。
items_for_output = items if is_admin else list(reversed(items))
out_items = await build_task_out_list(
db,
{item.id: record_to_out(task=item, is_admin=is_admin).media_references for item in items_sorted},
user_id=None if is_admin else current_user.id,
items_for_output,
is_admin=is_admin,
viewer_user_id=None if is_admin else current_user.id,
)
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin, media_references=refs_map.get(i.id)) for i in items_sorted])
return GenerationAITaskListOut(total=total, items=out_items)
@router.get(
"/history",
@@ -513,8 +576,9 @@ async def list_history_day_items(
summary="获取AI生成任务详情",
description=(
"根据任务ID获取当前登录用户的AI生成任务详情。"
"只能查询当前用户自己的任务,且只查询 generation_mode=chatapi_async 的任务"
"如果任务不存在或不属于当前用户,返回404"
"支持 chatapi_async、chatapi_main 和未删除的 chatapi_child"
"查询 chatapi_main 时返回按 generation_index 升序排列的 child_items"
"已软删除 child 只在父任务 child_items 中保留槽位,不能通过 child ID 单独查询。"
),
responses={
200: {
@@ -541,17 +605,19 @@ async def get_task(
select(ChatGenerationTask).where(
ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.deleted_at.is_(None),
)
.limit(1)
).limit(1)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
return record_to_out(task, media_references=refs)
if task.deleted_at is not None:
raise HTTPException(status_code=404, detail="任务不存在")
output = await build_task_out_list(
db,
[task],
viewer_user_id=current_user.id,
)
return output[0]
@router.delete(
"/tasks/{task_id}",
@@ -587,38 +653,33 @@ async def delete_task(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ChatGenerationTask).where(
mode_result = await db.execute(
select(ChatGenerationTask.generation_mode).where(
ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.deleted_at.is_(None),
).limit(1)
)
generation_mode = mode_result.scalar_one_or_none()
if generation_mode == GenerationMode.CHATAPI_CHILD.value:
freed_size_bytes = await soft_delete_child_task(
db,
child_task_id=task_id,
user_id=current_user.id,
)
.limit(1)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status == "generating":
raise HTTPException(status_code=400, detail="当前任务正在生成中,暂不能删除")
deleted_at = datetime.now(timezone.utc)
freed_size_bytes = await soft_delete_chat_generation_task(
db,
task=task,
deleted_at=deleted_at,
)
await db.flush()
else:
freed_size_bytes = await soft_delete_top_level_task_group(
db,
task_id=task_id,
user_id=current_user.id,
)
await db.commit()
return GenerationAITaskDeleteOut(
message="任务已删除",
task_id=task.id,
task_id=task_id,
deleted=True,
freed_size_bytes=freed_size_bytes,
)
@router.post(
"/tasks/{task_id}/retry",
response_model=GenerationAIRetryOut,
@@ -660,83 +721,205 @@ async def retry_task(
if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
result = await db.execute(
select(ChatGenerationTask).where(
ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.deleted_at.is_(None),
try:
result = await execute_with_lock_timeout(
db,
select(ChatGenerationTask).where(
ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.deleted_at.is_(None),
).with_for_update().limit(1),
)
.with_for_update()
.limit(1)
)
except DatabaseRowLockBusy as exc:
raise HTTPException(status_code=409, detail=exc.detail) from exc
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status != "failed":
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
retry_targets: list[ChatGenerationTask]
retrying_group_children = False
if task.generation_mode == GenerationMode.CHATAPI_MAIN.value:
children_map = await load_children_map(db, [task.id], include_deleted=False)
children = children_map.get(task.id, [])
if task.gen_type == "video":
retry_targets = [
child for child in children
if child.status == ChatGenerationTaskStatus.FAILED.value
]
retrying_group_children = True
if not retry_targets:
raise HTTPException(status_code=400, detail="当前视频任务组没有可重试的失败子任务")
elif children:
# 图片供应商全部成功后才会拆子任务;已有子任务时只允许重试下载,
# 不能再次扣费并覆盖原有生成序号。
retry_targets = [
child for child in children
if child.status == ChatGenerationTaskStatus.FAILED.value
and child.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
and bool(child.remote_result_url)
]
retrying_group_children = True
if not retry_targets:
raise HTTPException(status_code=400, detail="当前图片任务组没有可重试的下载失败子任务")
else:
# 图片批次在供应商阶段整批失败时尚未创建子任务,可整批重新生成并重新计费。
if task.status != ChatGenerationTaskStatus.FAILED.value:
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
retry_targets = [task]
else:
if task.status != ChatGenerationTaskStatus.FAILED.value:
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
retry_targets = [task]
upscale_failed_ids = [
str(target.id)
for target in retry_targets
if target.pipeline_stage == ChatGenerationPipelineStage.UPSCALE_FAILED.value
]
if upscale_failed_ids:
raise HTTPException(
status_code=409,
detail={
"message": "画质增强失败任务不能通过普通生成重试,请由管理员使用视频超分恢复命令处理",
"task_ids": upscale_failed_ids,
},
)
await assert_user_resource_capacity_available(db, current_user.id)
enqueue_ids: list[str] = []
download_retry_ids: list[str] = []
for target in retry_targets:
if int(target.manual_retry_count or 0) >= 3:
raise HTTPException(status_code=400, detail=f"任务 {target.id} 已超过最大重试次数")
attempt_no = await get_next_credit_attempt_no(
db,
owner_type=OWNER_CHAT_GENERATION_TASK,
owner_id=task.id,
)
media_billing = await charge_generation_media_by_params(
db,
user_id=task.user_id,
record_id=task.id,
gen_type=task.gen_type,
image_size=task.image_size,
image_px=task.image_px,
aspect_ratio=task.aspect_ratio or task.image_proportion,
duration=task.duration,
resolution=task.resolution,
fps=24 if task.gen_type == "video" else None,
engine_id=task.engine_id,
project_name="AI生成任务",
description_prefix="Chat任务重试",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=attempt_no,
media_references=task.media_references,
)
is_download_retry = bool(
target.remote_result_url
and target.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
)
if not is_download_retry:
attempt_no = await get_next_credit_attempt_no(
db,
owner_type=OWNER_CHAT_GENERATION_TASK,
owner_id=target.id,
)
quantity = int(target.generation_count or 1) if (
target.generation_mode == GenerationMode.CHATAPI_MAIN.value and target.gen_type == "image"
) else 1
refs = target.media_references or "[]"
if isinstance(refs, str):
import json
try:
refs = json.loads(refs)
except Exception:
refs = []
reference_usage = calculate_media_reference_usage(refs, include=True)
media_billing = await charge_generation_media_by_params(
db,
user_id=target.user_id,
record_id=target.id,
gen_type=target.gen_type,
image_size=target.image_size,
duration=target.duration,
resolution=target.resolution,
engine_id=target.engine_id,
input_video_duration=reference_usage.input_video_duration or None,
input_image_count=reference_usage.image_count or None,
project_name="AI生成任务",
description_prefix="Chat任务重试",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=attempt_no,
quantity=quantity,
)
target.credits_cost = round(float(target.credits_cost or 0) + media_billing.total_charged, 2)
resource_started_at = datetime.now(timezone.utc)
target.generation_attempt_no = int(attempt_no)
target.resource_generation_started_at = resource_started_at
if target.gen_type == "image":
target.deadline_at = resource_started_at + timedelta(
minutes=int(settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES or 30)
)
else:
target.deadline_at = resource_started_at + timedelta(
hours=int(settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS or 24)
)
target.provider_task_id = None
target.seedance_task_id = None
target.remote_result_url = None
target.provider_response_json = None
target.provider_create_claim_token = None
target.provider_create_lease_until = None
target.provider_create_started_at = None
target.poll_started_at = None
target.poll_claim_token = None
target.poll_lease_until = None
target.poll_error_count = 0
target.next_poll_at = None
target.poll_interval_seconds = 0
target.download_celery_task_id = None
target.download_enqueued_at = None
target.download_started_at = None
target.download_claim_token = None
target.download_lease_until = None
target.download_next_retry_at = None
target.download_attempt_count = 0
target.download_last_error = None
target.download_storage_date_dir = None
target.image_url = None
target.video_url = None
target.video_cover_url = None
target.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
enqueue_ids.append(str(target.id))
else:
target.pipeline_stage = ChatGenerationPipelineStage.RESULT_READY.value
download_retry_ids.append(str(target.id))
task.current_billing_attempt_no = attempt_no
task.status = "generating"
task.pipeline_stage = "queued"
task.error_message = None
task.poll_count = 0
task.last_poll_at = None
task.provider_task_id = None
task.seedance_task_id = None
task.remote_result_url = None
task.provider_response_json = None
task.image_url = None
task.video_url = None
task.video_cover_url = None
task.generated_at = None
task.credits_cost = round(float(task.credits_cost or 0) + media_billing.total_charged, 2)
target.status = ChatGenerationTaskStatus.GENERATING.value
target.error_message = None
target.poll_count = 0
target.last_poll_at = None
target.generated_at = None
target.manual_retry_count = int(target.manual_retry_count or 0) + 1
target.retry_count = int(target.manual_retry_count or 0)
if retrying_group_children:
await db.flush()
await aggregate_main_task_status(db, parent_task_id=str(task.id))
refreshed_task_id = str(task.id)
await db.commit()
from app.tasks.generation_create_tasks import chatapi_create_generation_task
failed_enqueue_ids = await enqueue_created_generation_tasks(db, task_ids=enqueue_ids) if enqueue_ids else []
failed_download_enqueue_ids: list[str] = []
if download_retry_ids:
from app.tasks.generation_download_tasks import enqueue_download_task
for target_id in download_retry_ids:
target_result = await db.execute(
select(ChatGenerationTask).where(
ChatGenerationTask.id == target_id,
ChatGenerationTask.deleted_at.is_(None),
).limit(1)
)
target = target_result.scalar_one_or_none()
if not target or not await enqueue_download_task(db, target, recover=True, reason="manual_retry"):
failed_download_enqueue_ids.append(target_id)
try:
chatapi_create_generation_task.delay(task.id)
except Exception as exc:
await mark_chat_generation_task_failed_and_refund_once(
db,
task_id=task.id,
error_message=f"任务队列投递失败: {exc}",
pipeline_stage="failed",
)
await db.commit()
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
requested_enqueue_count = len(enqueue_ids) + len(download_retry_ids)
failed_total_count = len(failed_enqueue_ids) + len(failed_download_enqueue_ids)
if requested_enqueue_count and failed_total_count == requested_enqueue_count:
raise HTTPException(status_code=503, detail="任务状态已重置,但任务队列投递全部失败,将由恢复任务继续处理")
refreshed = await db.execute(
select(ChatGenerationTask).where(ChatGenerationTask.id == refreshed_task_id).limit(1)
)
refreshed_task = refreshed.scalar_one_or_none()
if not refreshed_task:
raise HTTPException(status_code=404, detail="任务不存在")
return GenerationAIRetryOut(
id=task.id,
status=task.status,
pipeline_stage=task.pipeline_stage,
message="任务已重新扣费并重新投递",
id=refreshed_task.id,
status=refreshed_task.status,
pipeline_stage=refreshed_task.pipeline_stage,
message=(
f"请求重试 {len(retry_targets)} 个任务,成功投递 {max(0, requested_enqueue_count - failed_total_count)} 个,"
f"投递失败 {failed_total_count}"
),
)
+244 -59
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect
@@ -9,7 +10,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.enums.common import ModuleProjectStatusEnum
from app.enums.common import ModuleProjectStatusEnum, ModuleEventTypeEnum
from app.enums.generation_task import GenerationOwnerType
from app.enums.credit_record import (
CreditRecordBillingScene,
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,
@@ -41,11 +49,21 @@ from app.services.hot_opening_replicate_service import (
update_hot_opening_material_input,
update_hot_opening_video_prompt_schema,
)
from app.services.module_generation_log_service import log_module_error
from app.services.module_generation_log_service import log_module_error, log_module_event_file
from app.services.llm_billing import (
LlmBillingContext,
log_celery_dispatch_compensated,
log_celery_dispatch_failure,
log_celery_dispatch_start,
log_celery_dispatch_success,
)
from app.services.module_async_recovery_service import (
OBJECT_MODULE_STEP,
TASK_HOT_IMAGE_PROMPT,
TASK_HOT_VIDEO_PROMPT,
has_live_object_lock,
register_module_step_task,
remove_active_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
@@ -137,6 +155,47 @@ def _log_api_exception_from_locals(exc: BaseException, local_values: dict, messa
exc=exc,
)
def _prompt_dispatch_billing_context(
*,
user_id: str,
project_id: str,
step_id: str,
step_code: str,
attempt_no: int,
celery_task_id: str,
) -> LlmBillingContext:
is_image = step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
return LlmBillingContext(
user_id=user_id,
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
owner_id=step_id,
attempt_no=attempt_no,
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=(
CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value
if is_image
else CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value
),
source_module=MODULE,
source_project_id=project_id,
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 "爆款开头复刻视频提词优化"
),
trace_id=f"hot-opening-prompt:{step_id}:attempt:{attempt_no}",
celery_task_id=celery_task_id,
)
async def _reload_project_detail(
db: AsyncSession,
current_user: User,
@@ -160,10 +219,26 @@ async def _mark_dispatch_failed_and_raise(
project_id: str,
step_id: str | None,
message: str,
billing_context: LlmBillingContext | None = None,
) -> None:
"""Celery 投递失败后,数据库事务已提交,单独标记步骤失败,避免一直 processing。"""
"""Celery 投递失败后补偿步骤和冻结积分,避免一直 processing。"""
if billing_context is not None:
log_celery_dispatch_failure(billing_context, error=message)
compensated = False
if step_id:
try:
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
log_module_error(
module=MODULE,
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿",
detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message},
error=message,
)
raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交")
await mark_hot_opening_step_dispatch_failed(
db,
current_user=_user_context(current_user),
@@ -172,6 +247,23 @@ async def _mark_dispatch_failed_and_raise(
error_message=message,
)
await db.commit()
compensated = True
if billing_context is not None:
log_celery_dispatch_compensated(billing_context, error=message)
try:
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id)
except Exception as cleanup_exc:
_log_api_error(
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery 投递补偿完成,但清理 active registry 失败",
detail={"dispatch_error": message},
exc=cleanup_exc,
)
except HTTPException:
raise
except Exception as exc:
await db.rollback()
_log_api_error(
@@ -190,12 +282,89 @@ async def _mark_dispatch_failed_and_raise(
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail={"reason": "celery_dispatch_failed"},
detail={"reason": "celery_dispatch_failed", "compensated": compensated},
error=message,
)
raise HTTPException(status_code=503, detail=message)
async def _dispatch_prompt_task(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str,
step_code: str,
task_name: str,
celery_task: Any,
celery_task_id: str,
billing_context: LlmBillingContext,
error_prefix: str,
) -> None:
"""Redis 注册与 Celery 直投任一成功即视为可恢复投递。"""
registry_error: Exception | None = None
try:
await register_module_step_task(
module=MODULE,
project_id=project_id,
step_id=step_id,
step_code=step_code,
task_name=task_name,
)
except Exception as exc:
registry_error = exc
log_module_error(
module=MODULE,
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投",
detail={"channel": "active_registry"},
exc=exc,
)
celery_error: Exception | None = None
try:
celery_task.apply_async(
args=[project_id, step_id],
queue="gen_chatapi_create",
countdown=0,
task_id=celery_task_id,
)
except Exception as exc:
celery_error = exc
if celery_error is None:
log_celery_dispatch_success(billing_context)
return
if registry_error is None:
log_celery_dispatch_failure(
billing_context,
error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}",
)
log_module_event_file(
module=MODULE,
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="Celery 直投失败,任务将由 active registry 恢复投递",
detail={"recoverable": True, "celery_task_id": celery_task_id},
error=str(celery_error),
)
return
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error}",
billing_context=billing_context,
)
@router.get(
"/spec",
response_model=HotOpeningSpecOut,
@@ -282,29 +451,17 @@ async def create_task(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project = await create_hot_opening_project(db, current_user, req)
project_id_value = str(project.id)
await bind_upload_resources(
db,
user_id=current_user.id,
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_id=project_id_value,
resource_ids=[req.material_video_resource_id, req.material_image_resource_id],
urls=[req.material_video_url, req.material_image_url],
allow_common_migrate=True,
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"创建爆款开头复刻项目失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}")
return await _reload_project_detail(db, current_user, project_id_value)
log_module_event_file(
module=MODULE,
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
user_id=current_user.id,
message="拦截爆款开头复刻 V1 创建请求",
detail={"api_version": "v1", "flow_version": "v1"},
)
raise HTTPException(
status_code=410,
detail="V1 创建流程已停止,请使用 V2 API",
)
@router.get(
@@ -525,6 +682,17 @@ async def generate_image_prompt(
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id)
project_id_value = str(project.id)
step_id_value = str(step.id)
user_id_value = str(project.user_id)
attempt_no_value = int(step.version or 1)
celery_task_id = f"hot-opening:image-prompt:{step_id_value}"
billing_context = _prompt_dispatch_billing_context(
user_id=user_id_value,
project_id=project_id_value,
step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
attempt_no=attempt_no_value,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -536,23 +704,19 @@ async def generate_image_prompt(
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
await register_module_step_task(
module=MODULE,
log_celery_dispatch_start(billing_context)
await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
task_name=TASK_HOT_IMAGE_PROMPT,
celery_task=start_image_prompt_optimize,
celery_task_id=celery_task_id,
billing_context=billing_context,
error_prefix="图片提词任务投递失败",
)
try:
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"图片提词任务投递失败: {exc}",
)
return HotOpeningActionOut(
message="图片 AI 提词任务已提交",
@@ -610,14 +774,21 @@ async def generate_image(
from app.tasks.generation_create_tasks import chatapi_create_generation_task
try:
chatapi_create_generation_task.delay(chat_task_id_value)
chatapi_create_generation_task.apply_async(
args=[chat_task_id_value],
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
queue="gen_chatapi_create",
countdown=0,
)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
_log_api_error(
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"图片生成任务投递失败: {exc}",
message=f"图片生成任务投递失败,等待生成恢复任务补投: {exc}",
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
exc=exc,
)
return HotOpeningActionOut(
@@ -661,6 +832,17 @@ async def generate_video_prompt(
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
project_id_value = str(project.id)
step_id_value = str(step.id)
user_id_value = str(project.user_id)
attempt_no_value = int(step.version or 1)
celery_task_id = f"hot-opening:video-prompt:{step_id_value}"
billing_context = _prompt_dispatch_billing_context(
user_id=user_id_value,
project_id=project_id_value,
step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
attempt_no=attempt_no_value,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -672,23 +854,19 @@ async def generate_video_prompt(
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
await register_module_step_task(
module=MODULE,
log_celery_dispatch_start(billing_context)
await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
task_name=TASK_HOT_VIDEO_PROMPT,
celery_task=start_video_prompt_optimize,
celery_task_id=celery_task_id,
billing_context=billing_context,
error_prefix="视频提词任务投递失败",
)
try:
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"视频提词任务投递失败: {exc}",
)
return HotOpeningActionOut(
message="视频 AI 提词任务已提交",
@@ -747,14 +925,21 @@ async def generate_video(
from app.tasks.generation_create_tasks import chatapi_create_generation_task
try:
chatapi_create_generation_task.delay(chat_task_id_value)
chatapi_create_generation_task.apply_async(
args=[chat_task_id_value],
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
queue="gen_chatapi_create",
countdown=0,
)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
_log_api_error(
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"视频生成任务投递失败: {exc}",
message=f"视频生成任务投递失败,等待生成恢复任务补投: {exc}",
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
exc=exc,
)
return HotOpeningActionOut(
+5 -1
View File
@@ -21,7 +21,7 @@ async def list_active_engines(
"""Public endpoint returning active image engine capabilities."""
result = await db.execute(
select(ImageEngine)
.where(ImageEngine.is_active == True)
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc())
)
engines = result.scalars().all()
@@ -45,5 +45,9 @@ async def list_active_engines(
"supported_sizes": sizes,
"default_size": e.default_size,
"max_image_count": e.max_image_count,
"multi_generation_enabled": bool(getattr(e, "multi_generation_enabled", False)),
"max_generation_count": int(getattr(e, "max_generation_count", 1) or 1),
"multi_image_max_images": int(getattr(e, "multi_image_max_images", 15) or 15),
"max_reference_image_count": int(getattr(e, "max_reference_image_count", 14) or 0),
})
return {"items": items}
+1 -1
View File
@@ -38,10 +38,10 @@ from app.schemas.private_portrait import (
build_private_portrait_enum_meta,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.quota_service import get_user_private_portrait_config
from app.services.private_portrait.asset_service import (
DOMAIN,
asset_to_out,
get_user_private_portrait_config,
get_validate_session,
handle_validate_callback,
list_assets,
@@ -32,10 +32,10 @@ from app.schemas.private_portrait import (
build_private_portrait_enum_meta,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.quota_service import get_user_private_portrait_config
from app.services.private_portrait.asset_service import (
DOMAIN,
asset_to_out,
get_user_private_portrait_config,
list_assets,
list_selectable_assets,
soft_delete_asset,
+5
View File
@@ -10,6 +10,7 @@ from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.schemas.project import ProjectCreate, ProjectOut
from app.services.resource_accounting_service import soft_delete_generation_record_resources
from app.services.video_upscale.guard_service import assert_no_recoverable_failed_upscale_tasks
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"])
@@ -88,6 +89,10 @@ async def delete_project(
)
records = list(records_result.scalars().all())
record_ids = [record.id for record in records]
await assert_no_recoverable_failed_upscale_tasks(
db,
generation_record_ids=record_ids,
)
now = datetime.now(timezone.utc)
project.deleted_at = now
+465 -75
View File
@@ -2,14 +2,25 @@ from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.enums.common import ModuleEventTypeEnum
from app.enums.generation_task import GenerationOwnerType
from app.enums.credit_record import (
CreditRecordBillingScene,
CreditRecordChargeKind,
CreditRecordOwnerType,
CreditRecordSourceStepCode,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
@@ -23,7 +34,6 @@ from app.enums.shot_replicate import (
)
from app.schemas.shot_replicate import (
ShotReplicateActionOut,
ShotReplicateDeleteOut,
ShotReplicateGenerateImagePromptRequest,
ShotReplicateGenerateImageRequest,
ShotReplicateGenerateVideoPromptRequest,
@@ -52,7 +62,6 @@ from app.schemas.shot_replicate import (
)
from app.services.shot_replicate_flow_service import (
_get_project_for_user,
create_shot_replicate_project_from_segment,
generate_image_from_prompt,
generate_video_from_prompt,
mark_shot_replicate_step_dispatch_failed,
@@ -64,14 +73,17 @@ from app.services.shot_replicate_flow_service import (
update_shot_replicate_video_prompt_schema,
)
from app.services.shot_replicate_taskset_service import (
build_task_set_analysis_billing_context,
create_custom_segment,
create_segments_by_ai,
create_task_set,
delete_segment,
delete_task_set,
get_segment_for_user,
list_segments,
list_task_sets,
mark_custom_segment_split_dispatch_failed,
mark_segment_analysis_dispatch_failed,
mark_task_set_analysis_dispatch_failed,
prepare_reanalyze_segment,
prepare_reanalyze_task_set,
prepare_retry_split_segment,
@@ -79,13 +91,20 @@ from app.services.shot_replicate_taskset_service import (
task_set_detail,
)
from app.services.module_generation_log_service import log_module_error, log_module_event_file
from app.services.llm_billing import (
LlmBillingContext,
log_celery_dispatch_compensated,
log_celery_dispatch_failure,
log_celery_dispatch_start,
log_celery_dispatch_success,
)
from app.services.module_async_recovery_service import (
OBJECT_MODULE_STEP,
TASK_SHOT_IMAGE_PROMPT,
TASK_SHOT_VIDEO_PROMPT,
has_live_object_lock,
register_module_step_task,
register_shot_segment_analysis_task,
register_shot_split_task,
register_shot_task_set_analysis_task,
remove_active_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
@@ -190,6 +209,83 @@ def _ensure_celery_enabled(*, current_user: User | None = None, project_id: str
)
raise HTTPException(status_code=503, detail=message)
def _prompt_dispatch_billing_context(
*,
user_id: str,
project_id: str,
step_id: str,
step_code: str,
attempt_no: int,
celery_task_id: str,
) -> LlmBillingContext:
is_image = step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
return LlmBillingContext(
user_id=user_id,
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
owner_id=step_id,
attempt_no=attempt_no,
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=(
CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value
if is_image
else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value
),
source_module=MODULE,
source_project_id=project_id,
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 "拆镜复刻视频提词优化"
),
trace_id=f"shot-replicate-prompt:{step_id}:attempt:{attempt_no}",
celery_task_id=celery_task_id,
)
def _analysis_dispatch_billing_context(
*,
user_id: str,
owner_id: str,
attempt_no: int,
task_set_id: str,
is_segment: bool,
celery_task_id: str,
) -> LlmBillingContext:
return LlmBillingContext(
user_id=user_id,
owner_type=(
CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value
if is_segment
else CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value
),
owner_id=owner_id,
attempt_no=attempt_no,
charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value,
billing_scene=(
CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value
if is_segment
else CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value
),
source_module=MODULE,
source_project_id=task_set_id,
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分析"
),
trace_id=f"shot-analysis:{owner_id}:attempt:{attempt_no}",
celery_task_id=celery_task_id,
)
async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
project = await _get_project_for_user(
db,
@@ -208,9 +304,25 @@ async def _mark_dispatch_failed_and_raise(
project_id: str,
step_id: str | None,
message: str,
billing_context: LlmBillingContext | None = None,
) -> None:
if billing_context is not None:
log_celery_dispatch_failure(billing_context, error=message)
compensated = False
if step_id:
try:
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿",
detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message},
error=message,
)
raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交")
await mark_shot_replicate_step_dispatch_failed(
db,
current_user=_user_context(current_user),
@@ -219,6 +331,23 @@ async def _mark_dispatch_failed_and_raise(
error_message=message,
)
await db.commit()
compensated = True
if billing_context is not None:
log_celery_dispatch_compensated(billing_context, error=message)
try:
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id)
except Exception as cleanup_exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery 投递补偿完成,但清理 active registry 失败",
detail={"dispatch_error": message},
exc=cleanup_exc,
)
except HTTPException:
raise
except Exception as exc:
await db.rollback()
_log_api_error(
@@ -237,12 +366,89 @@ async def _mark_dispatch_failed_and_raise(
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail={"reason": "celery_dispatch_failed"},
detail={"reason": "celery_dispatch_failed", "compensated": compensated},
error=message,
)
raise HTTPException(status_code=503, detail=message)
async def _dispatch_prompt_task(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str,
step_code: str,
task_name: str,
celery_task: Any,
celery_task_id: str,
billing_context: LlmBillingContext,
error_prefix: str,
) -> None:
"""Redis 注册与 Celery 直投任一成功即视为可恢复投递。"""
registry_error: Exception | None = None
try:
await register_module_step_task(
module=MODULE,
project_id=project_id,
step_id=step_id,
step_code=step_code,
task_name=task_name,
)
except Exception as exc:
registry_error = exc
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投",
detail={"channel": "active_registry"},
exc=exc,
)
celery_error: Exception | None = None
try:
celery_task.apply_async(
args=[project_id, step_id],
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
task_id=celery_task_id,
)
except Exception as exc:
celery_error = exc
if celery_error is None:
log_celery_dispatch_success(billing_context)
return
if registry_error is None:
log_celery_dispatch_failure(
billing_context,
error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}",
)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="Celery 直投失败,任务将由 active registry 恢复投递",
detail={"recoverable": True, "celery_task_id": celery_task_id},
error=str(celery_error),
)
return
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error}",
billing_context=billing_context,
)
@router.get(
"/spec",
response_model=ShotReplicateSpecOut,
@@ -330,8 +536,16 @@ async def create_shot_task_set(
):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
try:
task_set = await create_task_set(db, current_user=current_user, req=req)
task_set_id = task_set.id
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 任务由原投递或恢复任务继续处理。
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))
celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}"
billing_context = build_task_set_analysis_billing_context(task_set)
billing_context.celery_task_id = celery_task_id
await bind_upload_resources(
db,
user_id=current_user.id,
@@ -351,12 +565,19 @@ async def create_shot_task_set(
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
log_celery_dispatch_start(billing_context)
try:
from app.tasks.shot_replicate_tasks import analyze_original_video
await register_shot_task_set_analysis_task(task_set_id)
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0)
analyze_original_video.apply_async(
args=[task_set_id, analysis_attempt_no],
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
countdown=0,
task_id=celery_task_id,
)
log_celery_dispatch_success(billing_context)
except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
@@ -365,6 +586,27 @@ async def create_shot_task_set(
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc,
)
try:
compensated = await mark_task_set_analysis_dispatch_failed(
db,
current_user=_user_context(current_user),
task_set_id=task_set_id,
expected_attempt_no=analysis_attempt_no,
error_message=f"拆镜分析任务投递失败: {exc}",
)
await db.commit()
if compensated:
log_celery_dispatch_compensated(billing_context, error=str(exc))
except Exception as mark_exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=task_set_id,
message="拆镜分析任务投递失败后补偿失败",
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=mark_exc,
)
raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}")
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
@@ -432,7 +674,7 @@ async def get_shot_task_set(
"/task-sets/{task_set_id}/reanalyze",
response_model=ShotReanalyzeOut,
summary="重新投递原视频 AI 分析任务",
description="用于处理原视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_original_video",
description="用于重新处理原视频分析失败的数据;处理中、待处理或已完成状态均拒绝重复投递",
)
async def reanalyze_task_set(
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
@@ -446,9 +688,18 @@ async def reanalyze_task_set(
db,
current_user=current_user,
task_set_id=task_set_id,
force=req.force,
reason=req.reason,
)
analysis_attempt_no = int(out.analysis_attempt_no)
celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}"
billing_context = _analysis_dispatch_billing_context(
user_id=str(current_user.id),
owner_id=task_set_id,
attempt_no=analysis_attempt_no,
task_set_id=task_set_id,
is_segment=False,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException as exc:
await db.rollback()
@@ -474,11 +725,17 @@ async def reanalyze_task_set(
)
raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}")
log_celery_dispatch_start(billing_context)
try:
from app.tasks.shot_replicate_tasks import analyze_original_video
await register_shot_task_set_analysis_task(task_set_id)
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0)
analyze_original_video.apply_async(
args=[task_set_id, analysis_attempt_no],
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
countdown=0,
task_id=celery_task_id,
)
log_celery_dispatch_success(billing_context)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value,
@@ -488,6 +745,7 @@ async def reanalyze_task_set(
detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()},
)
except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
@@ -496,6 +754,27 @@ async def reanalyze_task_set(
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc,
)
try:
compensated = await mark_task_set_analysis_dispatch_failed(
db,
current_user=_user_context(current_user),
task_set_id=task_set_id,
expected_attempt_no=analysis_attempt_no,
error_message=f"原视频再次分析任务投递失败: {exc}",
)
await db.commit()
if compensated:
log_celery_dispatch_compensated(billing_context, error=str(exc))
except Exception as mark_exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=task_set_id,
message="原视频再次分析任务投递失败后补偿失败",
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=mark_exc,
)
raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}")
out.message = "原视频再次分析任务已提交"
return out
@@ -532,8 +811,7 @@ async def split_by_ai(
from app.tasks.shot_replicate_tasks import split_one_segment
for segment_id in segment_ids:
await register_shot_split_task(segment_id, task_set_id=task_set_id)
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
return out
@@ -564,8 +842,38 @@ async def split_custom(
from app.tasks.shot_replicate_tasks import split_one_segment
await register_shot_split_task(segment_id, task_set_id=task_set_id)
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
try:
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
except Exception as exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=task_set_id,
step_id=segment_id,
message=f"自定义拆镜切片任务投递失败: {exc}",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment"},
exc=exc,
)
try:
await mark_custom_segment_split_dispatch_failed(
db,
current_user=_user_context(current_user),
segment_id=segment_id,
error_message=f"自定义拆镜切片任务投递失败: {exc}",
)
await db.commit()
except Exception as mark_exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=task_set_id,
step_id=segment_id,
message="自定义拆镜切片投递失败后补偿失败",
detail={"segment_id": segment_id, "task_set_id": task_set_id},
exc=mark_exc,
)
raise HTTPException(status_code=503, detail=f"自定义拆镜切片任务投递失败: {exc}")
return out
@@ -617,7 +925,7 @@ async def get_segment(
"/segments/{segment_id}/reanalyze",
response_model=ShotReanalyzeOut,
summary="重新投递切片视频 AI 分析任务",
description="用于处理自定义切片视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_custom_segment_video",
description="用于重新处理自定义切片视频分析失败的数据;处理中、待处理或已完成状态均拒绝重复投递",
)
async def reanalyze_segment(
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
@@ -631,10 +939,19 @@ async def reanalyze_segment(
db,
current_user=current_user,
segment_id=segment_id,
force=req.force,
reason=req.reason,
)
task_set_id = out.task_set_id
task_set_id = str(out.task_set_id)
analysis_attempt_no = int(out.analysis_attempt_no)
celery_task_id = f"shot-analysis:segment:{segment_id}:attempt:{analysis_attempt_no}"
billing_context = _analysis_dispatch_billing_context(
user_id=str(current_user.id),
owner_id=segment_id,
attempt_no=analysis_attempt_no,
task_set_id=task_set_id,
is_segment=True,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException as exc:
await db.rollback()
@@ -660,11 +977,17 @@ async def reanalyze_segment(
)
raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}")
log_celery_dispatch_start(billing_context)
try:
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
await register_shot_segment_analysis_task(segment_id, task_set_id=task_set_id)
analyze_custom_segment_video.apply_async(args=[segment_id], queue="gen_chatapi_create", countdown=0)
analyze_custom_segment_video.apply_async(
args=[segment_id, analysis_attempt_no],
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
countdown=0,
task_id=celery_task_id,
)
log_celery_dispatch_success(billing_context)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value,
@@ -675,6 +998,7 @@ async def reanalyze_segment(
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()},
)
except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
@@ -684,6 +1008,28 @@ async def reanalyze_segment(
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
exc=exc,
)
try:
compensated = await mark_segment_analysis_dispatch_failed(
db,
current_user=_user_context(current_user),
segment_id=segment_id,
expected_attempt_no=analysis_attempt_no,
error_message=f"切片视频再次分析任务投递失败: {exc}",
)
await db.commit()
if compensated:
log_celery_dispatch_compensated(billing_context, error=str(exc))
except Exception as mark_exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=task_set_id,
step_id=segment_id,
message="切片视频再次分析任务投递失败后补偿失败",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
exc=mark_exc,
)
raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}")
out.message = "切片视频再次分析任务已提交"
return out
@@ -730,10 +1076,9 @@ async def retry_split_segment(
try:
from app.tasks.shot_replicate_tasks import split_one_segment
await register_shot_split_task(segment_id, task_set_id=task_set_id)
split_one_segment.apply_async(
args=[segment_id],
queue="gen_result_download",
queue=CeleryQueue.GEN_SHOT_SPLIT.value,
countdown=0,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
)
@@ -748,7 +1093,7 @@ async def retry_split_segment(
"segment_id": segment_id,
"task_set_id": task_set_id,
"task": "split_one_segment",
"queue": "gen_result_download",
"queue": CeleryQueue.GEN_SHOT_SPLIT.value,
"request": req.model_dump(),
},
)
@@ -861,24 +1206,17 @@ async def create_replication_project_from_segment(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req)
project_id = project.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"创建拆镜复刻项目失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建拆镜复刻项目失败: {exc}")
return ShotReplicateActionOut(
message="已从拆镜片段创建复刻项目,素材视频已锁定",
project_id=project_id,
step_id=None,
detail=await _reload_project_detail(db, current_user, project_id),
log_module_event_file(
module=MODULE,
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
user_id=current_user.id,
step_id=segment_id,
message="拦截拆镜复刻 V1 创建请求",
detail={"api_version": "v1", "flow_version": "v1", "segment_id": segment_id},
)
raise HTTPException(
status_code=410,
detail="V1 创建流程已停止,请使用 V2 API",
)
@@ -1001,7 +1339,18 @@ async def generate_image_prompt(
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try:
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
project_id_value, step_id_value = str(project.id), str(step.id)
user_id_value = str(project.user_id)
attempt_no_value = int(step.version or 1)
celery_task_id = f"shot-replicate:image-prompt:{step_id_value}"
billing_context = _prompt_dispatch_billing_context(
user_id=user_id_value,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
attempt_no=attempt_no_value,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -1011,19 +1360,21 @@ async def generate_image_prompt(
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}")
try:
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
await register_module_step_task(
module=MODULE,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_IMAGE_PROMPT,
)
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}")
log_celery_dispatch_start(billing_context)
await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_IMAGE_PROMPT,
celery_task=start_image_prompt_optimize,
celery_task_id=celery_task_id,
billing_context=billing_context,
error_prefix="图片 AI 提词任务投递失败",
)
return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@@ -1061,9 +1412,22 @@ async def generate_image(
try:
from app.tasks.generation_create_tasks import chatapi_create_generation_task
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
chatapi_create_generation_task.apply_async(
args=[chat_task_id_value],
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片生成任务投递失败: {exc}")
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"图片生成任务投递失败,等待生成恢复任务补投: {exc}",
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
exc=exc,
)
return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@@ -1088,7 +1452,18 @@ async def generate_video_prompt(
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try:
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
project_id_value, step_id_value = str(project.id), str(step.id)
user_id_value = str(project.user_id)
attempt_no_value = int(step.version or 1)
celery_task_id = f"shot-replicate:video-prompt:{step_id_value}"
billing_context = _prompt_dispatch_billing_context(
user_id=user_id_value,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
attempt_no=attempt_no_value,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -1098,19 +1473,21 @@ async def generate_video_prompt(
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}")
try:
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
await register_module_step_task(
module=MODULE,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_VIDEO_PROMPT,
)
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}")
log_celery_dispatch_start(billing_context)
await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_VIDEO_PROMPT,
celery_task=start_video_prompt_optimize,
celery_task_id=celery_task_id,
billing_context=billing_context,
error_prefix="视频 AI 提词任务投递失败",
)
return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@@ -1148,9 +1525,22 @@ async def generate_video(
try:
from app.tasks.generation_create_tasks import chatapi_create_generation_task
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
chatapi_create_generation_task.apply_async(
args=[chat_task_id_value],
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频生成任务投递失败: {exc}")
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"视频生成任务投递失败,等待生成恢复任务补投: {exc}",
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
exc=exc,
)
return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
+33 -5
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
@@ -377,15 +379,41 @@ async def export_team_credit_records(
end_date=end_date,
)
# 生成 CSV(兼容 Excel 打开)
# 生成 CSV(兼容 Excel 打开UTF-8 BOM
import csv
import io
from datetime import datetime as _dt
def _format_dt(val):
if val is None:
return "-"
return str(datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S"))
try:
# 情况 1:已经是 datetime
if isinstance(val, _dt):
dt = val
elif isinstance(val, (int, float)):
# 情况 2:Unix 时间戳(极少,兼容旧代码)
dt = _dt.fromtimestamp(val)
elif isinstance(val, str):
# 情况 3ISO 字符串(admin_credit_record_service._iso 返回的格式)
s = val.strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = _dt.fromisoformat(s)
except ValueError:
# 兼容旧格式 YYYY-MM-DD HH:MM:SS
dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S")
else:
return str(val)
# 统一转东八区展示
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=CST)
else:
dt = dt.astimezone(CST)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception: # noqa: BLE001
return str(val) if val else "-"
output = io.StringIO()
writer = csv.writer(output)
@@ -405,7 +433,7 @@ async def export_team_credit_records(
from urllib.parse import quote
output.seek(0)
safe_team_name = team.name or "team"
filename = f"团队积分_{safe_team_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
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()]),
+3 -1
View File
@@ -21,7 +21,7 @@ async def list_active_engines(
"""Public endpoint returning active video engine capabilities."""
result = await db.execute(
select(VideoEngine)
.where(VideoEngine.is_active == True)
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc())
)
engines = result.scalars().all()
@@ -52,6 +52,8 @@ async def list_active_engines(
"max_image_count": e.max_image_count,
"max_video_count": e.max_video_count,
"max_audio_count": e.max_audio_count,
"multi_generation_enabled": bool(getattr(e, "multi_generation_enabled", False)),
"max_generation_count": int(getattr(e, "max_generation_count", 1) or 1),
"supports_first_last_frame": e.supports_first_last_frame,
"supports_universal_reference": e.supports_universal_reference,
})
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from app.api.v2.hot_opening_replicate import router as hot_opening_router
from app.api.v2.shot_replicate import router as shot_replicate_router
api_router_v2 = APIRouter()
api_router_v2.include_router(hot_opening_router)
api_router_v2.include_router(shot_replicate_router)
@@ -0,0 +1,302 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, Path
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User
from app.schemas.hot_opening_replicate import HotOpeningActionOut, HotOpeningDeleteOut, HotOpeningTaskDetailOut
from app.schemas.module_generation_v2 import (
HotOpeningTaskCreateV2,
ModuleVideoPromptRetryV2,
ModuleVideoPromptSchemaUpdateV2,
)
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
from app.services.hot_opening_replicate_service import project_to_detail_out
from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated
from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock
from app.services.module_generation_v2.config import HOT_OPENING_V2
from app.services.module_generation_v2.dispatch_service import (
dispatch_video_prompt_v2,
ensure_v2_celery_enabled,
)
from app.services.module_generation_v2.flow_service import (
build_v2_video_prompt_billing_context,
create_hot_opening_project_v2,
delete_project_v2,
generate_video_from_prompt_v2,
get_v2_project_for_user,
is_project_idempotency_conflict,
mark_video_prompt_dispatch_failed_v2,
rebuild_video_prompt_step_v2,
update_video_prompt_schema_v2,
)
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
router = APIRouter(prefix="/hot-opening-replications", tags=["hot-opening-replications-v2"])
def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext:
context = build_v2_video_prompt_billing_context(
user_id=user_id,
project_id=project_id,
step_id=step_id,
step_version=step_version,
module=HOT_OPENING_V2.module,
display_name=HOT_OPENING_V2.display_name,
)
context.celery_task_id = f"module-v2-video-prompt:{step_id}"
return context
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> HotOpeningTaskDetailOut:
project = await get_v2_project_for_user(
db,
config=HOT_OPENING_V2,
project_id=project_id,
current_user=current_user,
)
return await project_to_detail_out(db, project)
async def _dispatch_or_mark_failed(
db: AsyncSession,
*,
project_id: str,
step_id: str,
billing_context: LlmBillingContext,
) -> None:
dispatch = await dispatch_video_prompt_v2(
config=HOT_OPENING_V2,
project_id=project_id,
step_id=step_id,
billing_context=billing_context,
)
if dispatch.recoverable:
return
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
# apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。
return
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
await mark_video_prompt_dispatch_failed_v2(
db,
config=HOT_OPENING_V2,
project_id=project_id,
step_id=step_id,
error_message=error_message,
)
log_celery_dispatch_compensated(billing_context, error=error_message)
raise HTTPException(status_code=503, detail=error_message)
@router.post("/tasks", response_model=HotOpeningTaskDetailOut)
async def create_task_v2(
req: HotOpeningTaskCreateV2 = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_v2_celery_enabled()
try:
result = await create_hot_opening_project_v2(db, current_user=current_user, req=req)
project_id = str(result.project.id)
step_id = str(result.prompt_step.id)
created_new = bool(result.created_new)
billing_context = _dispatch_context(
user_id=str(result.project.user_id),
project_id=project_id,
step_id=step_id,
step_version=int(result.prompt_step.version or 1),
)
await db.commit()
except IntegrityError as exc:
await db.rollback()
if not req.idempotency_key or not is_project_idempotency_conflict(exc):
raise HTTPException(status_code=500, detail="项目创建失败") from exc
# 同幂等键并发请求由唯一索引收敛;回查已提交项目并按幂等成功返回。
result = await create_hot_opening_project_v2(db, current_user=current_user, req=req)
project_id = str(result.project.id)
step_id = str(result.prompt_step.id)
created_new = bool(result.created_new)
billing_context = _dispatch_context(
user_id=str(result.project.user_id),
project_id=project_id,
step_id=step_id,
step_version=int(result.prompt_step.version or 1),
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail="创建爆款复刻 V2 项目失败") from exc
if created_new:
await _dispatch_or_mark_failed(
db, project_id=project_id, step_id=step_id, billing_context=billing_context
)
return await _detail(db, current_user, project_id)
@router.get("/tasks/{project_id}", response_model=HotOpeningTaskDetailOut)
async def get_task_v2(
project_id: str = Path(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await _detail(db, current_user, project_id)
@router.post(
"/tasks/{project_id}/steps/{step_id}/retry-video-prompt",
response_model=HotOpeningActionOut,
)
async def retry_video_prompt_v2(
project_id: str,
step_id: str,
req: ModuleVideoPromptRetryV2,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_v2_celery_enabled()
try:
project, new_step = await rebuild_video_prompt_step_v2(
db,
config=HOT_OPENING_V2,
current_user=current_user,
project_id=project_id,
source_prompt_step_id=step_id,
video_config=req.video_config,
)
project_id_value = str(project.id)
step_id_value = str(new_step.id)
billing_context = _dispatch_context(
user_id=str(project.user_id),
project_id=project_id_value,
step_id=step_id_value,
step_version=int(new_step.version or 1),
)
await db.commit()
except HTTPException:
await db.rollback()
raise
await _dispatch_or_mark_failed(
db,
project_id=project_id_value,
step_id=step_id_value,
billing_context=billing_context,
)
return HotOpeningActionOut(
message="视频提词已重新提交",
project_id=project_id_value,
step_id=step_id_value,
detail=await _detail(db, current_user, project_id_value),
)
@router.put(
"/tasks/{project_id}/steps/{step_id}/video-prompt-schema",
response_model=HotOpeningActionOut,
)
async def update_video_prompt_schema_route_v2(
project_id: str,
step_id: str,
req: ModuleVideoPromptSchemaUpdateV2,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await update_video_prompt_schema_v2(
db,
config=HOT_OPENING_V2,
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
)
project_id_value = str(project.id)
step_id_value = str(step.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
return HotOpeningActionOut(
message="视频提词已保存",
project_id=project_id_value,
step_id=step_id_value,
detail=await _detail(db, current_user, project_id_value),
)
@router.post(
"/tasks/{project_id}/steps/{step_id}/generate-video",
response_model=HotOpeningActionOut,
)
async def generate_video_v2(
project_id: str,
step_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_v2_celery_enabled()
try:
project, step, task = await generate_video_from_prompt_v2(
db,
config=HOT_OPENING_V2,
current_user=current_user,
project_id=project_id,
prompt_step_id=step_id,
)
project_id_value = str(project.id)
step_id_value = str(step.id)
task_id = str(task.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
# commit 后重新读取,避免 ORM expire/lazy-load 风险。
queued_task = await db.get(ChatGenerationTask, task_id)
if queued_task is None:
raise HTTPException(status_code=500, detail="视频生成任务提交后无法重新读取")
try:
await enqueue_generation_create(queued_task, reason="hot_opening_v2_generate_video")
except Exception as exc:
# queued 状态已持久化,周期生成恢复任务会使用确定性 task_id 补投。
raise HTTPException(status_code=503, detail="视频生成任务暂未投递,将由恢复任务自动补投") from exc
return HotOpeningActionOut(
message="视频生成任务已提交",
project_id=project_id_value,
step_id=step_id_value,
detail=await _detail(db, current_user, project_id_value),
)
@router.delete("/tasks/{project_id}", response_model=HotOpeningDeleteOut)
async def delete_project_route_v2(
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
payload = await delete_project_v2(
db,
config=HOT_OPENING_V2,
current_user=current_user,
project_id=project_id,
)
pending_ids = list(payload.get("pending_delete_resource_ids") or [])
await db.commit()
except HTTPException:
await db.rollback()
raise
if pending_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
await db.commit()
except Exception:
await db.rollback()
return HotOpeningDeleteOut(**payload)
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, Path
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User
from app.schemas.module_generation_v2 import (
ModuleVideoPromptRetryV2,
ModuleVideoPromptSchemaUpdateV2,
ShotReplicateProjectCreateV2,
)
from app.schemas.shot_replicate import ShotReplicateActionOut, ShotReplicateDeleteOut, ShotReplicateTaskDetailOut
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated
from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock
from app.services.module_generation_v2.config import SHOT_REPLICATE_V2
from app.services.module_generation_v2.dispatch_service import (
dispatch_video_prompt_v2,
ensure_v2_celery_enabled,
)
from app.services.module_generation_v2.flow_service import (
build_v2_video_prompt_billing_context,
create_shot_replicate_project_v2,
delete_project_v2,
generate_video_from_prompt_v2,
get_v2_project_for_user,
is_project_idempotency_conflict,
mark_video_prompt_dispatch_failed_v2,
rebuild_video_prompt_step_v2,
update_video_prompt_schema_v2,
)
from app.services.shot_replicate_flow_service import project_to_detail_out
from app.services.shot_replicate_taskset_service import get_segment_for_user
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
router = APIRouter(prefix="/shot-replications", tags=["shot-replications-v2"])
def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext:
context = build_v2_video_prompt_billing_context(
user_id=user_id,
project_id=project_id,
step_id=step_id,
step_version=step_version,
module=SHOT_REPLICATE_V2.module,
display_name=SHOT_REPLICATE_V2.display_name,
)
context.celery_task_id = f"module-v2-video-prompt:{step_id}"
return context
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
project = await get_v2_project_for_user(
db,
config=SHOT_REPLICATE_V2,
project_id=project_id,
current_user=current_user,
)
return await project_to_detail_out(db, project)
async def _dispatch_or_mark_failed(
db: AsyncSession,
*,
project_id: str,
step_id: str,
billing_context: LlmBillingContext,
) -> None:
dispatch = await dispatch_video_prompt_v2(
config=SHOT_REPLICATE_V2,
project_id=project_id,
step_id=step_id,
billing_context=billing_context,
)
if dispatch.recoverable:
return
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
# apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。
return
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
await mark_video_prompt_dispatch_failed_v2(
db,
config=SHOT_REPLICATE_V2,
project_id=project_id,
step_id=step_id,
error_message=error_message,
)
log_celery_dispatch_compensated(billing_context, error=error_message)
raise HTTPException(status_code=503, detail=error_message)
@router.post(
"/segments/{segment_id}/replication-projects",
response_model=ShotReplicateActionOut,
)
async def create_project_v2(
segment_id: str = Path(...),
req: ShotReplicateProjectCreateV2 = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_v2_celery_enabled()
try:
segment = await get_segment_for_user(
db, segment_id=segment_id, user=current_user, for_update=True
)
result = await create_shot_replicate_project_v2(
db, current_user=current_user, segment=segment, req=req
)
project_id = str(result.project.id)
step_id = str(result.prompt_step.id)
created_new = bool(result.created_new)
billing_context = _dispatch_context(
user_id=str(result.project.user_id),
project_id=project_id,
step_id=step_id,
step_version=int(result.prompt_step.version or 1),
)
await db.commit()
except IntegrityError as exc:
await db.rollback()
if not req.idempotency_key or not is_project_idempotency_conflict(exc):
raise HTTPException(status_code=500, detail="项目创建失败") from exc
segment = await get_segment_for_user(
db, segment_id=segment_id, user=current_user, for_update=True
)
result = await create_shot_replicate_project_v2(
db, current_user=current_user, segment=segment, req=req
)
project_id = str(result.project.id)
step_id = str(result.prompt_step.id)
created_new = bool(result.created_new)
billing_context = _dispatch_context(
user_id=str(result.project.user_id),
project_id=project_id,
step_id=step_id,
step_version=int(result.prompt_step.version or 1),
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail="创建拆镜复刻 V2 项目失败") from exc
if created_new:
await _dispatch_or_mark_failed(
db, project_id=project_id, step_id=step_id, billing_context=billing_context
)
return ShotReplicateActionOut(
message="V2 项目已创建,视频提词已自动提交" if created_new else "已返回现有幂等项目",
project_id=project_id,
step_id=step_id,
detail=await _detail(db, current_user, project_id),
)
@router.get("/projects/{project_id}", response_model=ShotReplicateTaskDetailOut)
async def get_project_v2(
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await _detail(db, current_user, project_id)
@router.post(
"/projects/{project_id}/steps/{step_id}/retry-video-prompt",
response_model=ShotReplicateActionOut,
)
async def retry_video_prompt_v2(
project_id: str,
step_id: str,
req: ModuleVideoPromptRetryV2,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_v2_celery_enabled()
try:
project, new_step = await rebuild_video_prompt_step_v2(
db,
config=SHOT_REPLICATE_V2,
current_user=current_user,
project_id=project_id,
source_prompt_step_id=step_id,
video_config=req.video_config,
)
project_id_value = str(project.id)
step_id_value = str(new_step.id)
billing_context = _dispatch_context(
user_id=str(project.user_id),
project_id=project_id_value,
step_id=step_id_value,
step_version=int(new_step.version or 1),
)
await db.commit()
except HTTPException:
await db.rollback()
raise
await _dispatch_or_mark_failed(
db,
project_id=project_id_value,
step_id=step_id_value,
billing_context=billing_context,
)
return ShotReplicateActionOut(
message="视频提词已重新提交",
project_id=project_id_value,
step_id=step_id_value,
detail=await _detail(db, current_user, project_id_value),
)
@router.put(
"/projects/{project_id}/steps/{step_id}/video-prompt-schema",
response_model=ShotReplicateActionOut,
)
async def update_video_prompt_schema_route_v2(
project_id: str,
step_id: str,
req: ModuleVideoPromptSchemaUpdateV2,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await update_video_prompt_schema_v2(
db,
config=SHOT_REPLICATE_V2,
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
)
project_id_value = str(project.id)
step_id_value = str(step.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
return ShotReplicateActionOut(
message="视频提词已保存",
project_id=project_id_value,
step_id=step_id_value,
detail=await _detail(db, current_user, project_id_value),
)
@router.post(
"/projects/{project_id}/steps/{step_id}/generate-video",
response_model=ShotReplicateActionOut,
)
async def generate_video_v2(
project_id: str,
step_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_v2_celery_enabled()
try:
project, step, task = await generate_video_from_prompt_v2(
db,
config=SHOT_REPLICATE_V2,
current_user=current_user,
project_id=project_id,
prompt_step_id=step_id,
)
project_id_value = str(project.id)
step_id_value = str(step.id)
task_id = str(task.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
queued_task = await db.get(ChatGenerationTask, task_id)
if queued_task is None:
raise HTTPException(status_code=500, detail="视频生成任务提交后无法重新读取")
try:
await enqueue_generation_create(queued_task, reason="shot_replicate_v2_generate_video")
except Exception as exc:
raise HTTPException(status_code=503, detail="视频生成任务暂未投递,将由恢复任务自动补投") from exc
return ShotReplicateActionOut(
message="视频生成任务已提交",
project_id=project_id_value,
step_id=step_id_value,
detail=await _detail(db, current_user, project_id_value),
)
@router.delete("/projects/{project_id}", response_model=ShotReplicateDeleteOut)
async def delete_project_route_v2(
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
payload = await delete_project_v2(
db,
config=SHOT_REPLICATE_V2,
current_user=current_user,
project_id=project_id,
)
pending_ids = list(payload.get("pending_delete_resource_ids") or [])
await db.commit()
except HTTPException:
await db.rollback()
raise
if pending_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
await db.commit()
except Exception:
await db.rollback()
return ShotReplicateDeleteOut(**payload)
+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]}",
)

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