165 Commits
Author SHA1 Message Date
sjy 1ee3722a6e 1 2026-08-14 13:31:23 +08:00
sjy 5d2c63ced1 ai创建替换缩略图 2026-08-03 13:47:39 +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 508732bc76 1 2026-07-10 19:23:52 +08:00
root 614ae1b4d6 1 2026-07-10 19:20:53 +08:00
267 changed files with 43604 additions and 11504 deletions
+2 -1
View File
@@ -27,4 +27,5 @@ bak/
# *.pyc # *.pyc
# !dir/*.pycnode_modules/ # !dir/*.pycnode_modules/
*.tmp.* *.tmp.*
*_上线.py *_上线.py
.env*
+2 -1
View File
@@ -1,5 +1,6 @@
# VITE_API_BASE=http://192.168.120.17:8000 # 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_USE_MOCK=false VITE_USE_MOCK=false
# Encryption disabled for dev — enable in production # Encryption disabled for dev — enable in production
VITE_ENCRYPTION_KEY= VITE_ENCRYPTION_KEY=
File diff suppressed because one or more lines are too long
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}
+37 -37
View File
@@ -1,37 +1,37 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title> <title>后台管理</title>
<script> <script>
(function() { (function() {
var cached = localStorage.getItem('siteInfo'); var cached = localStorage.getItem('siteInfo');
if (cached) { if (cached) {
try { try {
var info = JSON.parse(cached); var info = JSON.parse(cached);
if (info.siteName) { if (info.siteName) {
document.title = info.siteName + ' - 管理后台'; document.title = info.siteName + ' - 管理后台';
} }
if (info.siteLogo) { if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]'); var link = document.querySelector('link[rel="icon"]');
if (link) { if (link) {
link.href = info.siteLogo; link.href = info.siteLogo;
link.type = 'image/png'; link.type = 'image/png';
} }
} }
} catch (e) {} } catch (e) {}
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-B5S0hm2T.js"></script> <script type="module" crossorigin src="/assets/index-BZDhy9nW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+82
View File
@@ -13,6 +13,7 @@
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-quill-new": "^3.8.3",
"react-router-dom": "^7.15.0", "react-router-dom": "^7.15.0",
"xlsx-js-style": "^1.2.0", "xlsx-js-style": "^1.2.0",
"zustand": "^5.0.13" "zustand": "^5.0.13"
@@ -1428,6 +1429,12 @@
"node": ">=8" "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": { "node_modules/exit-on-epipe": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
@@ -1437,6 +1444,12 @@
"node": ">=0.8" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1761,6 +1774,25 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "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": "^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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1841,6 +1879,35 @@
"node": ">=0.8" "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": { "node_modules/react": {
"version": "19.2.6", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -1868,6 +1935,21 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "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": { "node_modules/react-router": {
"version": "7.15.0", "version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-quill-new": "^3.8.3",
"react-router-dom": "^7.15.0", "react-router-dom": "^7.15.0",
"xlsx-js-style": "^1.2.0", "xlsx-js-style": "^1.2.0",
"zustand": "^5.0.13" "zustand": "^5.0.13"
+2
View File
@@ -32,6 +32,7 @@ import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail'; import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail'; import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig'; import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import AdminVideoUpscale from './pages/AdminVideoUpscale';
import AdminContactRequests from './pages/AdminContactRequests'; import AdminContactRequests from './pages/AdminContactRequests';
import AdminHomeMaterials from './pages/AdminHomeMaterials'; import AdminHomeMaterials from './pages/AdminHomeMaterials';
import AdminPreTestTemplates from './pages/AdminPreTestTemplates'; import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
@@ -98,6 +99,7 @@ const App = () => {
<Route path="payment-stats" element={<AdminPaymentStats />} /> <Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} /> <Route path="settings" element={<AdminSettings />} />
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} /> <Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
<Route path="video-upscale" element={<AdminVideoUpscale />} />
<Route path="notifications" element={<AdminNotificationManager />} /> <Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} /> <Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} /> <Route path="operation-logs" element={<AdminOperationLogs />} />
+66 -21
View File
@@ -17,6 +17,7 @@ import type {
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene, AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
} from '../types'; } from '../types';
import type { import type {
@@ -41,6 +42,18 @@ import type {
HomeMaterialWatermarkQueryParams, HomeMaterialWatermarkQueryParams,
} from '../types'; } 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 ────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> { export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
@@ -224,6 +237,10 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
await api.put(`/admin/system-configs/${id}`, { value }); 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 getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> { export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
return api.get('/admin/resource-capacity/global'); return api.get('/admin/resource-capacity/global');
} }
@@ -276,6 +293,22 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
return { url: res.url }; 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 { function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)); if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
} }
@@ -293,6 +326,7 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'credit_subject', filters?.creditSubject); setMaybe(params, 'credit_subject', filters?.creditSubject);
setMaybe(params, 'media_type', filters?.mediaType); setMaybe(params, 'media_type', filters?.mediaType);
setMaybe(params, 'charge_kind', filters?.chargeKind); setMaybe(params, 'charge_kind', filters?.chargeKind);
setMaybe(params, 'charge_action', filters?.chargeAction);
setMaybe(params, 'source_module', filters?.sourceModule); setMaybe(params, 'source_module', filters?.sourceModule);
setMaybe(params, 'source_step_code', filters?.sourceStepCode); setMaybe(params, 'source_step_code', filters?.sourceStepCode);
setMaybe(params, 'billing_scene', filters?.billingScene); setMaybe(params, 'billing_scene', filters?.billingScene);
@@ -315,8 +349,9 @@ export async function deleteIndustryConfig(id: string): Promise<void> {
await api.delete(`/admin/industry-configs/${id}`); await api.delete(`/admin/industry-configs/${id}`);
} }
export async function getVideoEngines(): Promise<any[]> { export async function getVideoEngines(options?: { includeDeleted?: boolean }): Promise<any[]> {
return api.get('/admin/video-engines'); const query = options?.includeDeleted ? '?include_deleted=true' : '';
return api.get(`/admin/video-engines${query}`);
} }
export async function saveVideoEngine(engine: any): Promise<any> { export async function saveVideoEngine(engine: any): Promise<any> {
@@ -328,8 +363,9 @@ export async function deleteVideoEngine(id: string): Promise<void> {
await api.delete(`/admin/video-engines/${id}`); await api.delete(`/admin/video-engines/${id}`);
} }
export async function getImageEngines(): Promise<any[]> { export async function getImageEngines(options?: { includeDeleted?: boolean }): Promise<any[]> {
return api.get('/admin/image-engines'); const query = options?.includeDeleted ? '?include_deleted=true' : '';
return api.get(`/admin/image-engines${query}`);
} }
export async function saveImageEngine(engine: any): Promise<any> { export async function saveImageEngine(engine: any): Promise<any> {
@@ -387,10 +423,11 @@ export async function getPaymentStats(params?: {
return api.get(url); 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(); const qs = new URLSearchParams();
if (params?.method) qs.set('payment_method', params.method); if (params?.method) qs.set('payment_method', params.method);
if (params?.status) qs.set('status', params.status); 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?.startDate) qs.set('start_date', params.startDate);
if (params?.endDate) qs.set('end_date', params.endDate); if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page)); if (params?.page) qs.set('page', String(params.page));
@@ -582,28 +619,26 @@ export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
// ── Generation Records (Admin) ───────────────────────────── // ── Generation Records (Admin) ─────────────────────────────
export async function getAdminGenerationRecords(params?: { 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[] }> { }): Promise<{ total: number; items: any[] }> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params?.userId) q.set('user_id', params.userId); if (params?.userId) q.set('user_id', params.userId);
if (params?.status) q.set('status', params.status); 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?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('page_size', String(params.pageSize)); if (params?.pageSize) q.set('page_size', String(params.pageSize));
const qs = q.toString(); const qs = q.toString();
return api.get(`/admin/generation-records${qs ? `?${qs}` : ''}`); 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) ───────────────────────────── // ── Generation AI Engines (Admin) ─────────────────────────────
@@ -649,8 +684,13 @@ export async function getAdminHotOpeningTasks(params?: AdminHotOpeningTaskQueryP
return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`); return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`);
} }
export async function getAdminHotOpeningTaskDetail(projectId: string): Promise<ReplicationProjectDetailOut> { export async function getAdminHotOpeningTaskDetail(
return api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`); 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> { export async function getAdminShotTaskSets(params?: AdminShotTaskSetQueryParams): Promise<ShotTaskSetListOut> {
@@ -689,8 +729,13 @@ export async function getAdminShotSegmentDetail(segmentId: string): Promise<Shot
return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`); return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`);
} }
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> { export async function getAdminShotProjectDetail(
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`); 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,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;
@@ -30,7 +30,7 @@ const AdminContactRequests: React.FC = () => {
const { user } = useAdminStore(); const { user } = useAdminStore();
const fetchData = async () => { const fetchData = async () => {
if (!user?.isAdmin) return; // if (!user?.isAdmin) return;
setLoading(true); setLoading(true);
try { try {
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -39,7 +39,7 @@ const AdminContactRequests: React.FC = () => {
if (isHandledFilter !== null) { if (isHandledFilter !== null) {
query.set('is_handled', String(isHandledFilter)); 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); setData(res.items);
setTotal(res.total); setTotal(res.total);
} catch (err: any) { } catch (err: any) {
@@ -55,7 +55,7 @@ const AdminContactRequests: React.FC = () => {
const handleMarkHandled = async (id: string) => { const handleMarkHandled = async (id: string) => {
try { try {
await api.put(`/contact/requests/${id}/handle`); await api.put(`/admin/contact-requests/${id}/handle`);
message.success('已标记为处理'); message.success('已标记为处理');
fetchData(); fetchData();
} catch (err: any) { } catch (err: any) {
@@ -65,7 +65,7 @@ const AdminContactRequests: React.FC = () => {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await api.delete(`/contact/requests/${id}`); await api.delete(`/admin/contact-requests/${id}`);
message.success('已删除'); message.success('已删除');
fetchData(); fetchData();
} catch (err: any) { } catch (err: any) {
@@ -38,6 +38,14 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> }, 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 = [ const userScopeOptions = [
{ value: '', label: '全部用户' }, { value: '', label: '全部用户' },
{ value: 'admin', label: '后台用户' }, { value: 'admin', label: '后台用户' },
@@ -85,6 +93,15 @@ const chargeKindOptions = [
{ value: 'team_internal', label: '团队内部转移' }, { value: 'team_internal', label: '团队内部转移' },
]; ];
const chargeActionOptions = [
{ value: '', label: '全部交易动作' },
{ value: 'charge', label: '真实扣费' },
{ value: 'refund', label: '真实退款' },
{ value: 'hold', label: '预扣占用' },
{ value: 'hold_release', label: '预扣释放' },
];
const sourceModuleOptions = [ const sourceModuleOptions = [
{ value: '', label: '全部模块' }, { value: '', label: '全部模块' },
{ value: 'ai_creation', label: 'AI创作' }, { value: 'ai_creation', label: 'AI创作' },
@@ -171,6 +188,7 @@ const AdminCreditRecords: React.FC = () => {
const [creditSubject, setCreditSubject] = useState(''); const [creditSubject, setCreditSubject] = useState('');
const [mediaType, setMediaType] = useState(''); const [mediaType, setMediaType] = useState('');
const [chargeKind, setChargeKind] = useState(''); const [chargeKind, setChargeKind] = useState('');
const [chargeAction, setChargeAction] = useState('');
const [sourceModule, setSourceModule] = useState(''); const [sourceModule, setSourceModule] = useState('');
const [sourceStepCode, setSourceStepCode] = useState(''); const [sourceStepCode, setSourceStepCode] = useState('');
const [billingScene, setBillingScene] = useState(''); const [billingScene, setBillingScene] = useState('');
@@ -186,13 +204,14 @@ const AdminCreditRecords: React.FC = () => {
creditSubject: creditSubject || undefined, creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined, mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined, chargeKind: chargeKind || undefined,
chargeAction: chargeAction || undefined,
sourceModule: sourceModule || undefined, sourceModule: sourceModule || undefined,
sourceStepCode: sourceStepCode || undefined, sourceStepCode: sourceStepCode || undefined,
billingScene: billingScene || undefined, billingScene: billingScene || undefined,
startDate: dateRange[0]?.format('YYYY-MM-DD'), startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope), ...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]); }), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
@@ -221,6 +240,7 @@ const AdminCreditRecords: React.FC = () => {
setCreditSubject(''); setCreditSubject('');
setMediaType(''); setMediaType('');
setChargeKind(''); setChargeKind('');
setChargeAction('');
setSourceModule(''); setSourceModule('');
setSourceStepCode(''); setSourceStepCode('');
setBillingScene(''); setBillingScene('');
@@ -255,6 +275,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' }, { title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' }, { title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' }, { 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: 20, render: (r) => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' }, { title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' }, { title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
@@ -321,6 +342,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> }, { 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: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{ 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: '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: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' }, { title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> }, { title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
@@ -368,6 +390,7 @@ const AdminCreditRecords: React.FC = () => {
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} /> <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={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={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={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={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} /> <Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
@@ -394,7 +417,7 @@ const AdminCreditRecords: React.FC = () => {
showSizeChanger: true, showSizeChanger: true,
showTotal: (t) => `${t} 条记录`, showTotal: (t) => `${t} 条记录`,
}} }}
scroll={{ x: 2050 }} scroll={{ x: 2160 }}
/> />
</Card> </Card>
</div> </div>
+360 -351
View File
@@ -1,14 +1,8 @@
import React, { useEffect, useState } from 'react'; 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 { import {
UserOutlined, UserOutlined, ProjectOutlined, PlayCircleOutlined, FileTextOutlined,
ProjectOutlined, DollarOutlined, WalletOutlined, ArrowUpOutlined, CalendarOutlined,
PlayCircleOutlined,
FileTextOutlined,
DollarOutlined,
WalletOutlined,
ArrowUpOutlined,
CalendarOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getAdminStats, getSystemConfigs } from '../api'; import { getAdminStats, getSystemConfigs } from '../api';
import type { AdminStats, SystemConfig } from '../types'; import type { AdminStats, SystemConfig } from '../types';
@@ -17,10 +11,25 @@ import 'dayjs/locale/zh-cn';
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 AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null); const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]); 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 [siteName, setSiteName] = useState<string>('数据概览');
const load = async () => { const load = async () => {
@@ -39,377 +48,377 @@ const AdminDashboard: React.FC = () => {
const configs = await getSystemConfigs(); const configs = await getSystemConfigs();
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name'); const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
if (siteConfig) { if (siteConfig) {
const title = `${siteConfig.value} 管理后台`; setSiteName(`${siteConfig.value} 管理后台`);
setSiteName(title); document.title = `${siteConfig.value} 管理后台`;
document.title = title;
} }
} catch { } catch { /* ignore */ }
setSiteName('数据概览');
document.title = '数据概览';
}
}; };
useEffect(() => { useEffect(() => { load(); loadSiteName(); }, []);
load();
loadSiteName();
}, []);
const handleToday = () => { const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs], range?: string) => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('day'), dayjs()];
setStartDate(dates); setStartDate(dates);
loadWithDates(dates); if (range) setActiveRange(range);
};
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]) => {
const start = dates[0]?.format('YYYY-MM-DD') || undefined; const start = dates[0]?.format('YYYY-MM-DD') || undefined;
const end = dates[1]?.format('YYYY-MM-DD') || undefined; const end = dates[1]?.format('YYYY-MM-DD') || undefined;
setLoading(true); setLoading(true);
getAdminStats(start, end).then(data => { getAdminStats(start, end).then(data => { setStats(data); setLoading(false); }).catch(() => setLoading(false));
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) => { const handleDateChange = (dates: any) => {
if (dates) { if (dates) { setActiveRange(''); loadWithDates([dates[0], dates[1]]); }
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>
);
}; };
return ( return (
<div style={{ padding: 0 }}> <div>
<div style={{ {/* 日期筛选 */}
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 20 }}>
padding: '24px 24px 32px', <Space>
borderRadius: 0, <Button type={activeRange === 'today' ? 'primary' : 'default'} size="small" onClick={handleToday}></Button>
marginBottom: -24, <Button type={activeRange === 'yesterday' ? 'primary' : 'default'} size="small" onClick={handleYesterday}></Button>
position: 'relative', <Button type={activeRange === 'week' ? 'primary' : 'default'} size="small" onClick={handleWeek}></Button>
overflow: 'hidden' <Button type={activeRange === 'month' ? 'primary' : 'default'} size="small" onClick={handleMonth}></Button>
}}> </Space>
<div style={{ <div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '4px 12px', borderRadius: 8 }}>
position: 'absolute', <CalendarOutlined style={{ color: '#64748b' }} />
top: -50, <DatePicker.RangePicker value={startDate} onChange={handleDateChange} size="small" />
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>
</div> </div>
<div style={{ marginTop: 40 }}> {/* 核心数据 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}> <div style={{ marginBottom: 20 }}>
<Space> <div style={{ marginBottom: 12 }}>
<Button <Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
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> </div>
<div style={{ marginBottom: 16, paddingLeft: 4 }}> <Row gutter={[12, 12]}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text> {[
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text> { title: '新增用户数量', value: stats?.totalUsers, icon: <UserOutlined />, color: '#6366f1' },
</div> { title: '总收入', value: stats?.totalRevenue, icon: <WalletOutlined />, color: '#ec4899', prefix: '¥' },
<Row gutter={[16, 16]}> { title: '消耗积分', value: stats?.creditsConsumedToday, icon: <DollarOutlined />, color: '#ef4444' },
{baseStats.map((s) => ( ].map(s => (
<Col xs={12} sm={8} lg={6} key={s.title}> <Col xs={12} sm={8} md={4} key={s.title}>
<StatCard {...s} /> <CompactStatCard {...s} loading={loading} />
</Col> </Col>
))} ))}
</Row> </Row>
</div> </div>
<div style={{ marginTop: 24 }}> {/* 图表区域 */}
<div style={{ marginBottom: 16, paddingLeft: 4 }}> <div style={{ marginBottom: 20 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text> <div style={{ marginBottom: 12 }}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text> <Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
</div> </div>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{financeStats.map((s) => ( <Col xs={24} lg={12}>
<Col xs={12} sm={8} lg={6} key={s.title}> <ChartCard title="每日积分消耗趋势" loading={loading}>
<StatCard {...s} /> <LineChart data={stats?.dailyCreditsByModule || []} />
</Col> </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> </Row>
</div> </div>
</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; export default AdminDashboard;
@@ -29,9 +29,16 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getAdminGenerationAiTasks } from '../api'; import { getAdminGenerationAiTasks, getImageEngines, getVideoEngines } from '../api';
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types'; import type {
GenerationAiImageEngine,
GenerationAIMediaReference,
GenerationAITaskOut,
GenerationAiVideoEngine,
} from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid';
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -66,27 +73,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
references: {}, 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 }> = { const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> }, image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> }, video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
@@ -237,6 +223,9 @@ const AdminGenerationAiRecords: React.FC = () => {
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]); const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]); const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [reloadKey, setReloadKey] = useState(0); 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 [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE); const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
@@ -277,6 +266,64 @@ const AdminGenerationAiRecords: React.FC = () => {
load(); load();
}, [load, reloadKey]); }, [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(() => { useEffect(() => {
if (!preview) { if (!preview) {
setResourceState(EMPTY_RESOURCE_STATE); setResourceState(EMPTY_RESOURCE_STATE);
@@ -425,6 +472,25 @@ const AdminGenerationAiRecords: React.FC = () => {
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; 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, title: '引擎', key: 'engine', width: 160,
render: (_: any, r: GenerationAITaskOut) => { render: (_: any, r: GenerationAITaskOut) => {
@@ -475,7 +541,7 @@ const AdminGenerationAiRecords: React.FC = () => {
{ {
title: '结果', key: 'result', width: 90, title: '结果', key: 'result', width: 90,
render: (_: any, r: GenerationAITaskOut) => { render: (_: any, r: GenerationAITaskOut) => {
if (r.status !== 'completed') { if (!resolveGenerationUiState(r).isSuccess) {
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>; return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
} }
if (r.genType === 'video' && r.videoUrl) { if (r.genType === 'video' && r.videoUrl) {
@@ -551,13 +617,14 @@ const AdminGenerationAiRecords: React.FC = () => {
{ {
title: '状态', dataIndex: 'status', width: 100, title: '状态', dataIndex: 'status', width: 100,
render: (v: string) => { render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null }; const state = resolveGenerationUiState({ status: v });
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; 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, 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, title: '时间', key: 'time', width: 170,
@@ -579,10 +646,10 @@ const AdminGenerationAiRecords: React.FC = () => {
], [handleOpenPreview]); ], [handleOpenPreview]);
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType] || { text: preview.genType || '-', color: 'default', icon: null }) : null; 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 = () => { 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) { if (!preview.imageUrl) {
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />; return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
@@ -722,7 +789,7 @@ const AdminGenerationAiRecords: React.FC = () => {
}; };
const renderResultVideo = () => { 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) { if (!preview.videoUrl) {
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />; return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
@@ -947,21 +1014,17 @@ const AdminGenerationAiRecords: React.FC = () => {
<Select <Select
allowClear allowClear
showSearch showSearch
loading={engineListLoading}
placeholder="引擎筛选" placeholder="引擎筛选"
value={filterEngineId || undefined} value={filterEngineId || undefined}
style={{ width: 180 }} style={{ width: 260 }}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }} onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
optionFilterProp="label" filterOption={(input, option: any) =>
options={Array.from( String(option?.searchText || option?.label || '')
new Map( .toLowerCase()
records .includes(input.trim().toLowerCase())
.filter((r) => r.engineId) }
.map((r) => [r.engineId, { options={engineOptions}
value: r.engineId,
label: getEngineName(r.engineSnapshot as any) || r.engineId,
}]),
).values(),
)}
/> />
<RangePicker <RangePicker
value={createdRange} value={createdRange}
@@ -1036,8 +1099,8 @@ const AdminGenerationAiRecords: React.FC = () => {
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null} {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="blue">{PIPELINE_STAGE_MAP[preview.pipelineStage] || preview.pipelineStage}</Tag> : null} {preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
{/*{preview.generationMode ? <Tag>{preview.generationMode}</Tag> : null}*/} {/*{preview.generationMode ? <Tag>{preview.generationMode}</Tag> : null}*/}
</div> </div>
@@ -1045,6 +1108,7 @@ const AdminGenerationAiRecords: React.FC = () => {
<InfoItem label="用户名称" value={preview.userName || '未知用户'} /> <InfoItem label="用户名称" value={preview.userName || '未知用户'} />
<InfoItem label="用户ID" value={preview.userId || '-'} /> <InfoItem label="用户ID" value={preview.userId || '-'} />
<InfoItem label="任务ID" value={preview.id} /> <InfoItem label="任务ID" value={preview.id} />
<InfoItem label="生成数量" value={`${preview.generationCount || 1}`} />
</div> </div>
<div> <div>
@@ -1122,40 +1186,18 @@ const AdminGenerationAiRecords: React.FC = () => {
</div> </div>
) : null} ) : null}
{preview.status === 'completed' ? ( <div>
<div> <Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> {preview.generationCount || 1}
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}> </Typography.Text>
{preview.genType === 'video' ? '生成视频' : '生成图片'} <GenerationTaskResourceGrid
</Typography.Text> task={preview}
{preview.genType === 'video' && preview.videoUrl ? ( resolveUrl={apiUrl}
<Button onPreview={handlePreviewResource}
size="small" />
type="link" </div>
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}
{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)' }}> <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> <Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>: {preview.errorMessage}</Typography.Text>
</div> </div>
@@ -22,12 +22,12 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
SearchOutlined, SearchOutlined,
VideoCameraOutlined, VideoCameraOutlined,
ExclamationCircleOutlined,
FileImageOutlined, FileImageOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api'; import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types'; import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
// 后端返回的图片/视频一般是 /images、/videos、/uploads 等相对路径。 // 后端返回的图片/视频一般是 /images、/videos、/uploads 等相对路径。
@@ -50,14 +50,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
references: {}, 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 }> = { const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> }, image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> }, video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
@@ -194,13 +186,14 @@ const AdminGenerationRecords: React.FC = () => {
const [pageSize] = useState(20); const [pageSize] = useState(20);
const [filterStatus, setFilterStatus] = useState<string>(''); const [filterStatus, setFilterStatus] = useState<string>('');
const [filterUserId, setFilterUserId] = 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 [reloadKey, setReloadKey] = useState(0);
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null); const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE); const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
const [videoPlaying, setVideoPlaying] = useState(false); const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null); 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 () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -208,6 +201,8 @@ const AdminGenerationRecords: React.FC = () => {
const res = await getAdminGenerationRecords({ const res = await getAdminGenerationRecords({
userId: filterUserId.trim() || undefined, userId: filterUserId.trim() || undefined,
status: filterStatus || undefined, status: filterStatus || undefined,
engineId: filterEngineId || undefined,
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
page, page,
pageSize, pageSize,
}); });
@@ -240,6 +235,10 @@ const AdminGenerationRecords: React.FC = () => {
imageTokensUsed: item.imageTokensUsed || 0, imageTokensUsed: item.imageTokensUsed || 0,
imageProportion: item.imageProportion, imageProportion: item.imageProportion,
imagePx: item.imagePx, imagePx: item.imagePx,
engineId: item.engineId,
engineName: item.engineName,
engineSnapshot: item.engineSnapshot,
includeMediaReferences: item.includeMediaReferences,
}))); })));
setTotal(res.total || 0); setTotal(res.total || 0);
} catch { } catch {
@@ -247,12 +246,32 @@ const AdminGenerationRecords: React.FC = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [filterStatus, filterUserId, page, pageSize]); }, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
useEffect(() => { useEffect(() => {
load(); load();
}, [load, reloadKey]); }, [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(() => { useEffect(() => {
if (!preview) { if (!preview) {
setResourceState(EMPTY_RESOURCE_STATE); setResourceState(EMPTY_RESOURCE_STATE);
@@ -349,33 +368,7 @@ const AdminGenerationRecords: React.FC = () => {
}, 0); }, 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(() => [ const columns = useMemo(() => [
{ {
@@ -416,21 +409,27 @@ const AdminGenerationRecords: React.FC = () => {
title: '参数', key: 'params', width: 160, title: '参数', key: 'params', width: 160,
render: (_: any, r: AdminGenerationRecord) => ( render: (_: any, r: AdminGenerationRecord) => (
r.genType === 'video' ? ( r.genType === 'video' ? (
r.duration || r.aspectRatio || r.resolution ? ( <Space size={4} wrap>
<Space size={4} wrap> {r.duration ? <Tag>{r.duration}s</Tag> : null}
{r.duration ? <Tag>{r.duration}s</Tag> : null} {r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null} {r.resolution ? <Tag>{r.resolution}</Tag> : null}
{r.resolution ? <Tag>{r.resolution}</Tag> : null} {!r.duration && !r.aspectRatio && !r.resolution ? <Tag color="default"></Tag> : null}
</Space> {r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
) : <Tag color="default"></Tag> <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>
<Space size={4} wrap> {r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null} {r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null} {r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null} {!r.imageSize && !r.imageProportion && !r.imagePx ? <Tag color="default"></Tag> : null}
</Space> {r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
) : <Tag color="default"></Tag> <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, title: '状态', dataIndex: 'status', width: 90,
render: (v: string) => { render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null }; const state = resolveGenerationUiState({ status: v });
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; 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, title: '时间', key: 'time', width: 150,
render: (_: any, r: AdminGenerationRecord) => ( 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) => ( render: (_: any, r: AdminGenerationRecord) => (
<Space size={4} wrap> <Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
</Button>
</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>
), ),
}, },
], [handleOpenPreview, updating]); ], [handleOpenPreview]);
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType || ''] || { text: preview.genType || '-', color: 'default', icon: null }) : null; 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 = () => { 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) { if (!preview.imageUrl) {
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />; return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
@@ -681,7 +627,7 @@ const AdminGenerationRecords: React.FC = () => {
}; };
const renderResultVideo = () => { 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) { if (!preview.videoUrl) {
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />; return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
@@ -894,6 +840,27 @@ const AdminGenerationRecords: React.FC = () => {
{ value: 'failed', label: '失败' }, { 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 <Input
placeholder="用户ID搜索" placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />} prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
@@ -956,7 +923,9 @@ const AdminGenerationRecords: React.FC = () => {
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}> / </Typography.Text> <Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}> / </Typography.Text>
<Space size={4} wrap> <Space size={4} wrap>
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null} {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> </Space>
</div> </div>
</div> </div>
@@ -1035,7 +1004,7 @@ const AdminGenerationRecords: React.FC = () => {
{renderReferences()} {renderReferences()}
{preview.status === 'completed' ? ( {resolveGenerationUiState(preview).isSuccess ? (
<div> <div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}> <Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'} {preview.genType === 'video' ? '生成视频' : '生成图片'}
@@ -1045,7 +1014,7 @@ const AdminGenerationRecords: React.FC = () => {
) : null} ) : null}
{/* Error message */} {/* 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)' }}> <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> <Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>: {preview.errorMessage}</Typography.Text>
</div> </div>
@@ -1062,73 +1031,6 @@ const AdminGenerationRecords: React.FC = () => {
)} )}
</Modal> </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> </div>
); );
}; };
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd'; 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 { useNavigate } from 'react-router-dom';
import { getAdminHotOpeningTasks } from '../api'; import { getAdminHotOpeningTasks } from '../api';
import type { HotOpeningTaskListItemOut } from '../types'; import type { HotOpeningTaskListItemOut } from '../types';
@@ -111,16 +111,15 @@ const AdminHotOpeningReplications: React.FC = () => {
}; };
return ( return (
<div style={{ padding: 24 }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}> <PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<div> <Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title> <Tag color="purple">{total} </Tag>
<Typography.Text type="secondary"></Typography.Text> </Space>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button> <Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space> </div>
<Space wrap> <Space wrap>
<Select <Select
@@ -208,6 +207,7 @@ const AdminHotOpeningReplications: React.FC = () => {
), ),
}, },
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> }, { 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: '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: '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> }, { 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', fixed: 'right',
width: 110, width: 110,
render: (_, record) => ( 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> </Card>
</div>
); );
}; };
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { 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'; } from 'antd';
import { import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
@@ -21,6 +21,11 @@ interface ImageEngine {
generateUrl: string; generateUrl: string;
isActive: boolean; isActive: boolean;
priority: number; priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
outputFormat: '' | 'png' | 'jpeg';
} }
function parseJsonArray(val: unknown): any[] { function parseJsonArray(val: unknown): any[] {
@@ -80,6 +85,7 @@ const AdminImageEngines: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null }); const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm(); const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
@@ -126,6 +132,11 @@ const AdminImageEngines: React.FC = () => {
generate_url: values.generateUrl || '', generate_url: values.generateUrl || '',
is_active: values.isActive ?? true, is_active: values.isActive ?? true,
priority: values.priority ?? 0, 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) { if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload }); await saveImageEngine({ id: modal.engine.id, ...payload });
@@ -168,6 +179,8 @@ const AdminImageEngines: React.FC = () => {
form.resetFields(); form.resetFields();
form.setFieldsValue({ form.setFieldsValue({
isActive: true, priority: 0, isActive: true, priority: 0,
multiGenerationEnabled: false, maxGenerationCount: 1, multiImageMaxImages: 15,
maxReferenceImageCount: 14, outputFormat: '',
supportedModels: ['doubao-seedream-5-0-260128'], supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K', defaultSize: '2K',
maxImageCount: 0, maxImageCount: 0,
@@ -232,6 +245,18 @@ const AdminImageEngines: React.FC = () => {
title: '最大图片', dataIndex: 'maxImageCount', width: 100, title: '最大图片', dataIndex: 'maxImageCount', width: 100,
render: (v: number) => <Tag color="purple">{v} </Tag>, 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, title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>, render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -350,6 +375,33 @@ const AdminImageEngines: React.FC = () => {
<Form.Item name="generateUrl" label="生成接口地址"> <Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" /> <Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item> </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 }}> <div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级"> <Form.Item name="priority" label="优先级">
<Select size="large" options={[ <Select size="large" options={[
+7 -7
View File
@@ -230,18 +230,18 @@ const AdminLayout: React.FC = () => {
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)', background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
}}> }}>
<div style={{ <div style={{
width: 42, width: 42,
height: 42, height: 42,
borderRadius: 14, borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)', background: '#ffffff',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: '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', overflow: 'hidden',
}}> }}>
{siteLogo ? ( {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' }} /> <ThunderboltOutlined style={{ fontSize: 20, color: '#ffffff' }} />
)} )}
@@ -5,6 +5,8 @@ import {
import { import {
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined, BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api'; import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
@@ -131,6 +133,7 @@ const AdminNotificationManager: React.FC = () => {
}, },
{ {
title: '内容', dataIndex: 'content', ellipsis: true, title: '内容', dataIndex: 'content', ellipsis: true,
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
}, },
{ {
title: '类型', dataIndex: 'type', width: 80, title: '类型', dataIndex: 'type', width: 80,
@@ -210,8 +213,8 @@ const AdminNotificationManager: React.FC = () => {
<Input placeholder="请输入消息标题" size="large" /> <Input placeholder="请输入消息标题" size="large" />
</Form.Item> </Form.Item>
<Form.Item name="content" label="消息内容" <Form.Item name="content" label="消息内容"
rules={[{ required: true, message: '请输入内容' }]}> rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" /> <ReactQuill theme="snow" placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
</Form.Item> </Form.Item>
<div style={{ display: 'flex', gap: 16 }}> <div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="type" label="消息类型" style={{ flex: 1 }} <Form.Item name="type" label="消息类型" style={{ flex: 1 }}
+32 -16
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { 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'; } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import { import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api'; import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
@@ -22,6 +22,7 @@ const AdminPaymentStats: React.FC = () => {
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
paymentMethod?: string; paymentMethod?: string;
status?: string; status?: string;
phone?: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
}>({ }>({
@@ -36,6 +37,7 @@ const AdminPaymentStats: React.FC = () => {
getPaymentStats(filters), getPaymentStats(filters),
getAdminPaymentOrders({ getAdminPaymentOrders({
...filters, ...filters,
phone: filters.phone,
page: orderPage, page: orderPage,
pageSize: orderPageSize, pageSize: orderPageSize,
}), }),
@@ -61,6 +63,7 @@ const AdminPaymentStats: React.FC = () => {
startDate: dayjs().format('YYYY-MM-DD'), startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD'),
}); });
setOrderPage(1);
}; };
const handleRefund = async (orderNo: string) => { const handleRefund = async (orderNo: string) => {
@@ -101,6 +104,7 @@ const AdminPaymentStats: React.FC = () => {
const columns = [ const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 }, { title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 }, { 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, title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
render: (m: string) => { render: (m: string) => {
@@ -237,28 +241,28 @@ const AdminPaymentStats: React.FC = () => {
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }} <Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}> title={<Space><DollarOutlined /></Space>}>
{/* Filters */} {/* Filters */}
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={8} md={4}>
<span style={{ marginRight: 8 }}></span> <Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select <Select
placeholder="全部" placeholder="全部"
allowClear allowClear
style={{ width: 150 }} style={{ width: '100%' }}
value={filters.paymentMethod} 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="alipay"></Option>
<Option value="wechat"></Option> <Option value="wechat"></Option>
</Select> </Select>
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={8} md={4}>
<span style={{ marginRight: 8 }}></span> <Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select <Select
placeholder="全部" placeholder="全部"
allowClear allowClear
style={{ width: 150 }} style={{ width: '100%' }}
value={filters.status} 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="paid"></Option>
<Option value="pending"></Option> <Option value="pending"></Option>
@@ -266,9 +270,21 @@ const AdminPaymentStats: React.FC = () => {
<Option value="refunded">退</Option> <Option value="refunded">退</Option>
</Select> </Select>
</Col> </Col>
<Col xs={24} sm={12} md={8}> <Col xs={24} sm={8} md={5}>
<span style={{ marginRight: 8 }}></span> <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 <RangePicker
style={{ width: '100%' }}
value={[ value={[
dayjs(filters.startDate), dayjs(filters.startDate),
dayjs(filters.endDate), dayjs(filters.endDate),
@@ -276,9 +292,9 @@ const AdminPaymentStats: React.FC = () => {
onChange={handleDateChange} onChange={handleDateChange}
/> />
</Col> </Col>
<Col xs={24} sm={12} md={4}> <Col xs={24} sm={12} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Button icon={<ReloadOutlined />} onClick={handleReset}> <Button icon={<ReloadOutlined />} onClick={handleReset} style={{ marginBottom: 0 }}>
</Button> </Button>
</Col> </Col>
</Row> </Row>
@@ -21,7 +21,7 @@ import {
PlayCircleOutlined, PlayCircleOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from '@ant-design/icons'; } 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 { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types'; import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
@@ -36,7 +36,7 @@ interface AdminReplicationProjectDetailProps {
moduleType?: ReplicationModuleType; moduleType?: ReplicationModuleType;
} }
const STEP_ORDER = [ const V1_STEP_ORDER = [
'material_input', 'material_input',
'image_prompt_optimize', 'image_prompt_optimize',
'image_generate', 'image_generate',
@@ -44,6 +44,12 @@ const STEP_ORDER = [
'video_generate', 'video_generate',
]; ];
const V2_STEP_ORDER = [
'material_input',
'video_prompt_optimize',
'video_generate',
];
const STEP_DESCRIPTIONS: Record<string, string> = { const STEP_DESCRIPTIONS: Record<string, string> = {
material_input: '参考素材、项目名称和核心内容点', material_input: '参考素材、项目名称和核心内容点',
image_prompt_optimize: '图片 AI 提词优化结果', image_prompt_optimize: '图片 AI 提词优化结果',
@@ -104,6 +110,8 @@ const renderPromptText = (value?: string | null, empty = '暂无提词') => {
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => { const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate(); 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 [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -112,15 +120,15 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
setLoading(true); setLoading(true);
try { try {
const res = moduleType === 'hot_opening_replicate' const res = moduleType === 'hot_opening_replicate'
? await getAdminHotOpeningTaskDetail(projectId) ? await getAdminHotOpeningTaskDetail(projectId, flowVersion)
: await getAdminShotProjectDetail(projectId); : await getAdminShotProjectDetail(projectId, flowVersion);
setDetail(res); setDetail(res);
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '加载复刻项目详情失败'); message.error(e?.message || '加载复刻项目详情失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [moduleType, projectId]); }, [flowVersion, moduleType, projectId]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -134,8 +142,10 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
const moduleValue = detail?.module || moduleType; const moduleValue = detail?.module || moduleType;
const moduleName = getModuleLabel(moduleValue); 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]; const step = stepsByCode[code];
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait'; let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
if (step?.status === 'completed') status = 'finish'; if (step?.status === 'completed') status = 'finish';
@@ -146,7 +156,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
description: step ? <StatusTag status={step.status} /> : '未创建', description: step ? <StatusTag status={step.status} /> : '未创建',
status, status,
}; };
}), [stepsByCode]); }), [effectiveStepOrder, stepsByCode]);
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]); const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
@@ -242,7 +252,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
}, },
{ {
key: 'video_prompt_optimize', 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: ( children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small"> <Descriptions column={3} bordered size="small">
@@ -265,7 +275,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
}, },
{ {
key: 'video_generate', 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: ( children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small"> <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 ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
@@ -322,6 +335,8 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item> <Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</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="当前步骤"><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="状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item> <Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item> <Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
@@ -336,9 +351,11 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
<Card title="最终结果预览"> <Card title="最终结果预览">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
<Card size="small" title="最终图片"> {!isV2 ? (
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" /> <Card size="small" title="最终图片">
</Card> <MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
) : null}
<Card size="small" title="最终视频封面"> <Card size="small" title="最终视频封面">
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" /> <MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
</Card> </Card>
@@ -348,7 +365,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
</div> </div>
</Card> </Card>
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} /> <Collapse defaultActiveKey={defaultActiveKeys} items={visibleCollapseItems} />
<Collapse <Collapse
items={[ items={[
+328 -73
View File
@@ -1,17 +1,19 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { 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'; } from 'antd';
import { import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
createSystemConfig,
getGlobalResourceCapacity, getGlobalResourceCapacity,
getSystemConfigs, getSystemConfigs,
saveGlobalResourceCapacity, saveGlobalResourceCapacity,
updateSystemConfig, updateSystemConfig,
uploadLogo, uploadLogo,
uploadPdf, uploadPdf,
uploadLoginVideo,
} from '../api'; } from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types'; import type { ResourceCapacityUnit, SystemConfig } from '../types';
@@ -39,9 +41,21 @@ const AdminSettings: React.FC = () => {
getSystemConfigs(), getSystemConfigs(),
getGlobalResourceCapacity(), 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); setConfigs(data);
const formValues: Record<string, any> = {}; const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; }); 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_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000'; formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB'; formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
@@ -56,13 +70,80 @@ const AdminSettings: React.FC = () => {
const handleSave = async () => { const handleSave = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes(
String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(),
);
if (llmBillingEnabled) {
const holdKeys = [
'optimize_hold_credits',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
];
const invalidKey = holdKeys.find((key) => {
const numericValue = Number(values[key]);
return !Number.isFinite(numericValue) || numericValue <= 0;
});
if (invalidKey) {
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
return;
}
}
setSaving(true); 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) { for (const config of configs) {
if (llmManagedKeys.has(config.key)) continue;
const newVal = values[config.key]; const newVal = values[config.key];
if (newVal !== undefined && String(newVal) !== config.value) { if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? '')); 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({ await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled, enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'), limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
@@ -118,12 +199,62 @@ const AdminSettings: React.FC = () => {
return false; 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[]> = { const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')), '站点信息': configs.filter(c => c.key.startsWith('site_')),
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'), '协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')), 'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')), '用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'其他配置': configs.filter(c => c.key === 'operation_manual'), '其他配置': 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 => { const getFieldDescription = (config: SystemConfig): string => {
@@ -139,6 +270,13 @@ const AdminSettings: React.FC = () => {
user_login_credits: '用户每日登录赠送的积分数量', user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能', user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接', 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 || ''; return descMap[config.key] || config.description || '';
}; };
@@ -268,8 +406,8 @@ const AdminSettings: React.FC = () => {
</div> </div>
); );
} }
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') { 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={0} placeholder={config.description} size="large" />; return <Input type="number" min={1} placeholder={config.description} size="large" />;
} }
return <Input placeholder={config.description} size="large" />; return <Input placeholder={config.description} size="large" />;
}; };
@@ -278,6 +416,190 @@ const AdminSettings: React.FC = () => {
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />; 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 ( return (
<div style={{ maxWidth: 720 }}> <div style={{ maxWidth: 720 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
@@ -292,78 +614,11 @@ const AdminSettings: React.FC = () => {
</div> </div>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">SEO配置</Typography.Text> <Typography.Text type="secondary"></Typography.Text>
</div> </div>
</div> </div>
<Form form={form} layout="vertical"> <Tabs items={tabItems} defaultActiveKey="basic" />
{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>
</Card> </Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd'; 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 { useNavigate } from 'react-router-dom';
import { getAdminShotTaskSets } from '../api'; import { getAdminShotTaskSets } from '../api';
import type { ShotTaskSetOut } from '../types'; import type { ShotTaskSetOut } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
import { getShotAnalysisStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -35,28 +36,14 @@ const SPLIT_STATUS_OPTIONS = [
{ value: 'retry_waiting', label: '等待重试' }, { 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 safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : 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>; 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>; return <Tag color={meta.color}>{meta.text}</Tag>;
}; };
@@ -109,6 +96,25 @@ const AdminShotReplications: React.FC = () => {
load(); load();
}, [load, reloadKey]); }, [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 = () => { const doSearch = () => {
setQueryKeyword(inputKeyword.trim()); setQueryKeyword(inputKeyword.trim());
setQueryUserId(inputUserId.trim()); setQueryUserId(inputUserId.trim());
@@ -134,16 +140,15 @@ const AdminShotReplications: React.FC = () => {
}; };
return ( return (
<div style={{ padding: 24 }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}> <CameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<div> <Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title> <Tag color="purple">{total} </Tag>
<Typography.Text type="secondary">AI </Typography.Text> </Space>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button> <Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space> </div>
<Space wrap> <Space wrap>
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} /> <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> </Space>
), ),
}, },
{ title: '总状态', dataIndex: 'status', width: 120, 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} /> }, { title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} /> }, { title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="split" /> },
{ {
title: '切片进度', title: '切片进度',
width: 180, width: 180,
@@ -211,9 +216,7 @@ const AdminShotReplications: React.FC = () => {
}, },
]} ]}
/> />
</Space>
</Card> </Card>
</div>
); );
}; };
@@ -7,7 +7,6 @@ import {
Descriptions, Descriptions,
Drawer, Drawer,
Empty, Empty,
Input,
Select, Select,
Space, Space,
Spin, Spin,
@@ -23,6 +22,7 @@ import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDet
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types'; import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
import { getStepCodeLabel } from './adminReplication/components/StatusTag'; 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 RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, ''); const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
@@ -55,25 +55,6 @@ const REPLICATE_STATUS_OPTIONS = [
{ value: 'failed', label: '复刻失败' }, { 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 => { const apiUrl = (url?: string | null): string => {
if (!url) return ''; if (!url) return '';
const value = String(url).trim(); 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 safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : 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>; 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>; return <Tag color={meta.color}>{meta.text}</Tag>;
}; };
@@ -177,6 +162,32 @@ const AdminShotTaskSetDetail: React.FC = () => {
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]); useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
useEffect(() => { loadSegments(); }, [loadSegments, 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) => { const openSegmentDetail = async (segmentId: string) => {
setDrawerOpen(true); setDrawerOpen(true);
setSegmentDetail(null); setSegmentDetail(null);
@@ -224,9 +235,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item> <Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item> <Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</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.status} kind="task" /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item> <Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} kind="analysis" /></Descriptions.Item>
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></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.completedSegmentCount}/{detail.segmentCount} {detail.failedSegmentCount}</Descriptions.Item>
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item> <Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</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: '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: 'timeNode', width: 130 },
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` }, { 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: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="split" /> },
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> }, { title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> }, { 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: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' }, { title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
{ {
@@ -284,9 +295,14 @@ const AdminShotTaskSetDetail: React.FC = () => {
width: 220, width: 220,
render: (_, record) => record.moduleProjectId ? ( render: (_, record) => record.moduleProjectId ? (
<Space direction="vertical" size={0}> <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> <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> </Space>
) : <Tag></Tag>, ) : <Tag></Tag>,
}, },
@@ -310,10 +326,11 @@ const AdminShotTaskSetDetail: React.FC = () => {
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item> <Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item> <Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</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.splitStatus} kind="split" /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item> <Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} kind="analysis" /></Descriptions.Item>
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></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}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</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="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item> <Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item> <Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
+20 -1
View File
@@ -3,7 +3,7 @@ import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography, Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd'; } from 'antd';
import { 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'; } from '@ant-design/icons';
import { import {
adjustCredits, adjustCredits,
@@ -411,6 +411,7 @@ const AdminUsers: React.FC = () => {
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>} {r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
</div> </div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div> <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
<div style={{ color: '#c0c4cc', fontSize: 11, fontFamily: 'monospace' }}>ID: {r.id}</div>
</div> </div>
</Space> </Space>
), ),
@@ -693,6 +694,24 @@ const AdminUsers: React.FC = () => {
{creditModal.user?.credits.toLocaleString()} {creditModal.user?.credits.toLocaleString()}
</span> </span>
</div> </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 form={form} layout="vertical">
<Form.Item name="amount" label="积分变动" <Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}> rules={[{ required: true, message: '请输入积分数量' }]}>
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { 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'; } from 'antd';
import { import {
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
@@ -25,6 +25,8 @@ interface VideoEngine {
supportsUniversalReference: boolean; supportsUniversalReference: boolean;
isActive: boolean; isActive: boolean;
priority: number; priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
} }
function parseJsonArray(val: unknown): any[] { function parseJsonArray(val: unknown): any[] {
@@ -40,6 +42,7 @@ const AdminVideoEngines: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null }); const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm(); const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
@@ -80,6 +83,8 @@ const AdminVideoEngines: React.FC = () => {
supports_universal_reference: values.supportsUniversalReference ?? true, supports_universal_reference: values.supportsUniversalReference ?? true,
is_active: values.isActive ?? true, is_active: values.isActive ?? true,
priority: values.priority ?? 0, priority: values.priority ?? 0,
multi_generation_enabled: values.multiGenerationEnabled ?? false,
max_generation_count: values.maxGenerationCount ?? 1,
}; };
if (modal.engine) { if (modal.engine) {
await saveVideoEngine({ id: modal.engine.id, ...payload }); await saveVideoEngine({ id: modal.engine.id, ...payload });
@@ -115,6 +120,7 @@ const AdminVideoEngines: React.FC = () => {
form.resetFields(); form.resetFields();
form.setFieldsValue({ form.setFieldsValue({
isActive: true, priority: 0, isActive: true, priority: 0,
multiGenerationEnabled: false, maxGenerationCount: 1,
maxDuration: 30, maxDuration: 30,
maxImageCount: 2, maxImageCount: 2,
maxVideoCount: 0, maxVideoCount: 0,
@@ -180,6 +186,18 @@ const AdminVideoEngines: React.FC = () => {
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100, title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>, 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, title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>, render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -315,7 +333,19 @@ const AdminVideoEngines: React.FC = () => {
<Switch /> <Switch />
</Form.Item> </Form.Item>
</div> </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 }}> <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 }}> <Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<Select size="large" options={[ <Select size="large" options={[
{ value: 0, label: '0 (默认)' }, { 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 React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd'; import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
import { CopyOutlined, LinkOutlined } from '@ant-design/icons'; 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 RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, ''); const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
@@ -37,12 +38,8 @@ const MediaPreview: React.FC<MediaPreviewProps> = ({
const copyUrl = async () => { const copyUrl = async () => {
if (!resolvedUrl) return; if (!resolvedUrl) return;
try { const ok = await copyToClipboard(resolvedUrl);
await navigator.clipboard.writeText(resolvedUrl); message.success(ok ? '资源地址已复制' : '复制失败,请手动复制');
message.success('资源地址已复制');
} catch {
message.error('复制失败,请手动复制');
}
}; };
const tools = resolvedUrl ? ( const tools = resolvedUrl ? (
@@ -21,13 +21,22 @@ const STATUS_LABELS: Record<string, LabelMeta> = {
// 生成任务 pipeline / download stage // 生成任务 pipeline / download stage
creating_provider_task: { text: '创建远端任务', color: 'processing' }, creating_provider_task: { text: '创建远端任务', color: 'processing' },
provider_result_staged: { text: '供应商结果已暂存', color: 'processing' },
waiting_remote: { text: '等待远端结果', color: 'processing' }, waiting_remote: { text: '等待远端结果', color: 'processing' },
polling: { text: '轮询远端结果', color: 'processing' }, polling: { text: '轮询远端结果', color: 'processing' },
result_ready: { text: '结果已就绪', color: 'success' }, result_ready: { text: '结果已就绪', color: 'success' },
download_queued: { text: '下载已入队', color: 'processing' },
downloading: { text: '下载中', color: 'processing' }, downloading: { text: '下载中', color: 'processing' },
done: { text: '已完成', color: 'success' }, done: { text: '已完成', color: 'success' },
download_failed: { text: '下载失败', color: 'error' }, download_failed: { text: '下载失败', color: 'error' },
retry_waiting: { text: '等待重试', color: 'orange' }, 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' }, pending_analysis: { text: '等待分析', color: 'default' },
@@ -35,7 +35,7 @@ const statusMap: Record<string, { text: string; color: string }> = {
}; };
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = { const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
text: '民众普康 AI', text: '民众智创',
opacityLevel: 2, opacityLevel: 2,
fontSizePx: 28, fontSizePx: 28,
color: '#ffffff', color: '#ffffff',
@@ -27,7 +27,7 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
marginX: 24, marginX: 24,
marginY: 24, marginY: 24,
textWatermark: { textWatermark: {
text: '民众普康 AI', text: '民众智创',
opacityLevel: 2, opacityLevel: 2,
fontSizePx: 28, fontSizePx: 28,
color: '#ffffff', color: '#ffffff',
@@ -25,7 +25,7 @@ const positionOptions = [
]; ];
const defaultTextWatermark = { const defaultTextWatermark = {
text: '民众普康 AI', text: '民众智创',
opacityLevel: 2, opacityLevel: 2,
fontSizePx: 28, fontSizePx: 28,
color: '#ffffff', color: '#ffffff',
+134 -1
View File
@@ -199,6 +199,30 @@ export interface AdminUser {
privatePortraitAssetLimit: number; 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 { export interface AdminStats {
totalUsers: number; totalUsers: number;
totalProjects: number; totalProjects: number;
@@ -214,6 +238,13 @@ export interface AdminStats {
lastPeriodRecords: number; lastPeriodRecords: number;
lastPeriodRevenue: number; lastPeriodRevenue: number;
lastPeriodCreditsConsumed: number; lastPeriodCreditsConsumed: number;
dailyCreditsByModule: DailyCredit[];
periodCreditsByModule: DailyCredit[];
creditsByTeam: TeamCredit[];
modelUsage: ModelUsageOut[];
videoResolutionUsage: VideoParamOut[];
videoRatioUsage: VideoParamOut[];
videoDurationUsage: VideoParamOut[];
} }
export interface PaymentStats { export interface PaymentStats {
@@ -281,6 +312,12 @@ export interface GenerationAiImageEngine {
supportedSizes: Record<string, Record<string, string>>; supportedSizes: Record<string, Record<string, string>>;
defaultSize: string; defaultSize: string;
priority: number; priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
isActive?: boolean;
deletedAt?: string | null;
} }
export interface GenerationAiVideoEngine { export interface GenerationAiVideoEngine {
@@ -299,6 +336,10 @@ export interface GenerationAiVideoEngine {
supportsFirstLastFrame?: boolean; supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean; supportsUniversalReference?: boolean;
priority: number; priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
isActive?: boolean;
deletedAt?: string | null;
} }
export interface GenerationAiEnginesResponse { export interface GenerationAiEnginesResponse {
@@ -326,9 +367,37 @@ export interface GenerationAiEngineOption {
supportsFirstLastFrame?: boolean; supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean; supportsUniversalReference?: boolean;
priority: number; priority: number;
multiGenerationEnabled?: boolean;
maxGenerationCount?: number;
multiImageMaxImages?: number;
maxReferenceImageCount?: number;
genType: GenerationAiGenType; 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 { export interface AdminGenerationRecord {
id: string; id: string;
userId: string; userId: string;
@@ -342,8 +411,10 @@ export interface AdminGenerationRecord {
aspectRatio?: string; aspectRatio?: string;
resolution?: string; resolution?: string;
status: 'optimizing' | 'prompt_optimized' | 'generating' | 'completed' | 'failed' | string; status: 'optimizing' | 'prompt_optimized' | 'generating' | 'completed' | 'failed' | string;
pipelineStage?: GenerationPipelineStage | null;
videoUrl?: string; videoUrl?: string;
videoCoverUrl?: string; videoCoverUrl?: string;
videoUpscaleEnabled?: boolean;
references?: GenerationAIMediaReference[] | null; references?: GenerationAIMediaReference[] | null;
creditsCost: number; creditsCost: number;
textCreditsCost: number; textCreditsCost: number;
@@ -358,6 +429,10 @@ export interface AdminGenerationRecord {
imageTokensUsed?: number; imageTokensUsed?: number;
imageProportion?: string; imageProportion?: string;
imagePx?: string; imagePx?: string;
engineId?: string;
engineName?: string;
engineSnapshot?: Record<string, unknown> | null;
includeMediaReferences?: boolean;
} }
export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string; export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string;
@@ -399,7 +474,11 @@ export interface GenerationAITaskOut {
projectId?: string | null; projectId?: string | null;
genType: GenerationAiGenType | string; genType: GenerationAiGenType | string;
generationMode?: string | null; generationMode?: string | null;
pipelineStage?: string | null; parentTaskId?: string | null;
generationCount: number;
generationIndex?: number | null;
displayStatus?: string | null;
pipelineStage?: GenerationPipelineStage | null;
status: GenerationAITaskStatus; status: GenerationAITaskStatus;
originalPrompt: string; originalPrompt: string;
optimizedPrompt?: string | null; optimizedPrompt?: string | null;
@@ -428,6 +507,7 @@ export interface GenerationAITaskOut {
errorMessage?: string | null; errorMessage?: string | null;
createdAt?: string | null; createdAt?: string | null;
generatedAt?: string | null; generatedAt?: string | null;
childItems: GenerationAITaskOut[];
} }
export interface GenerationAITaskListOut { export interface GenerationAITaskListOut {
@@ -531,6 +611,9 @@ export interface ReplicationProjectDetailOut {
title?: string | null; title?: string | null;
status: ModuleReplicationStatus; status: ModuleReplicationStatus;
currentStepCode?: string | null; currentStepCode?: string | null;
flowVersion?: 'v1' | 'v2' | string | null;
stepCount?: number;
stepIoSchemaVersion?: string | null;
finalImageUrl?: string | null; finalImageUrl?: string | null;
finalVideoUrl?: string | null; finalVideoUrl?: string | null;
finalVideoCoverUrl?: string | null; finalVideoCoverUrl?: string | null;
@@ -553,6 +636,8 @@ export interface HotOpeningTaskListItemOut {
title?: string | null; title?: string | null;
status: ModuleReplicationStatus; status: ModuleReplicationStatus;
currentStepCode?: string | null; currentStepCode?: string | null;
flowVersion?: 'v1' | 'v2' | string | null;
stepCount?: number;
sourceProjectName?: string | null; sourceProjectName?: string | null;
targetProjectName?: string | null; targetProjectName?: string | null;
coreContentPoint?: string | null; coreContentPoint?: string | null;
@@ -666,6 +751,7 @@ export interface ShotSegmentOut {
moduleProjectTitle?: string | null; moduleProjectTitle?: string | null;
moduleProjectStatus?: string | null; moduleProjectStatus?: string | null;
moduleProjectCurrentStepCode?: string | null; moduleProjectCurrentStepCode?: string | null;
moduleProjectFlowVersion?: 'v1' | 'v2' | string | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
} }
@@ -827,6 +913,7 @@ export interface AdminCreditRecord {
chargeKind?: string; chargeKind?: string;
chargeKindLabel?: string; chargeKindLabel?: string;
chargeAction?: string; chargeAction?: string;
chargeActionLabel?: string;
creditSubject?: string; creditSubject?: string;
creditSubjectLabel?: string; creditSubjectLabel?: string;
mediaType?: string; mediaType?: string;
@@ -870,6 +957,7 @@ export interface AdminCreditRecordQueryParams {
creditSubject?: string; creditSubject?: string;
mediaType?: string; mediaType?: string;
chargeKind?: string; chargeKind?: string;
chargeAction?: string;
sourceModule?: string; sourceModule?: string;
sourceStepCode?: string; sourceStepCode?: string;
billingScene?: string; billingScene?: string;
@@ -1293,3 +1381,48 @@ export interface PrivatePortraitSelectableAssetListOut {
page: number; page: number;
pageSize: 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 { export function formatDate(iso: string | null | undefined): string {
if (!iso) return '-'; if (!iso) return '-';
let s = iso.trim(); const s = iso.trim();
if (!s.includes('T')) s = s.replace(' ', 'T'); if (!s) return '-';
// Truncate microseconds: 2026-05-13T15:04:04.313751 → 2026-05-13T15:04:04
const dotIdx = s.indexOf('.'); // Parse the ISO string, handling timezone offset
if (dotIdx > 0) s = s.slice(0, dotIdx); // Match: 2026-05-13T15:04:04.313751+00:00 or 2026-05-13T15:04:04Z or 2026-05-13T15:04:04
// Remove any trailing timezone info (backend now sends naive datetimes) const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
s = s.replace(/[+-]\d{2}:?\d{0,2}$/, '').replace(/Z$/, ''); if (!m) return s.slice(0, 16).replace('T', ' ');
return s.replace('T', ' ').slice(0, 16);
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/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/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/generation/generationtaskresourcegrid.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
+4
View File
@@ -43,6 +43,9 @@ CAPTCHA_ENABLED=true
# CORS # CORS
CORS_ORIGINS=["*"] CORS_ORIGINS=["*"]
# Base URL (用于 favicon、回调地址等)
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
# RESOURCE # RESOURCE
RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3 RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3
RESOURCE_SIGN_EXPIRE_SECONDS=86400 RESOURCE_SIGN_EXPIRE_SECONDS=86400
@@ -58,6 +61,7 @@ VIDEO_COVER_TIMEOUT_SECONDS=15
VIDEO_COVER_FORMAT=png VIDEO_COVER_FORMAT=png
# VOLC # VOLC
VOLC_API_KEY=AKLTOWMwMjVhNzg0OGE2NDMwZWJkYWIyNzM3ZmMxMjc5NTQ
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ== VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
+3
View File
@@ -39,3 +39,6 @@ CAPTCHA_ENABLED=true
# CORS # CORS
CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"] CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"]
# Base URL (用于 favicon、回调地址等)
BASE_URL=https://your-domain.com
@@ -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,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,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")
+4
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter 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_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.resource_capacity import router as resource_capacity_router
from app.api.admin.team import router as team_router from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router from app.api.admin.home_material import router as home_material_router
@@ -8,9 +9,11 @@ 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.recharge_package import router as recharge_package_router
from app.api.admin.menu_config import router as menu_config_router from app.api.admin.menu_config import router as menu_config_router
from app.api.admin.upload import router as admin_upload_router from app.api.admin.upload import router as admin_upload_router
from app.api.admin.contact import router as admin_contact_router
router = APIRouter() router = APIRouter()
router.include_router(video_prompt_schema_config_router) router.include_router(video_prompt_schema_config_router)
router.include_router(video_upscale_router)
router.include_router(resource_capacity_router) router.include_router(resource_capacity_router)
router.include_router(team_router) router.include_router(team_router)
router.include_router(home_material_router) router.include_router(home_material_router)
@@ -18,3 +21,4 @@ router.include_router(private_portrait_router)
router.include_router(recharge_package_router) router.include_router(recharge_package_router)
router.include_router(menu_config_router) router.include_router(menu_config_router)
router.include_router(admin_upload_router) router.include_router(admin_upload_router)
router.include_router(admin_contact_router)
+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": "删除成功"}
@@ -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
+359 -257
View File
@@ -1,8 +1,8 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import json import json
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, func, select, update from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user from app.dependencies import get_db, get_admin_user
@@ -28,46 +28,55 @@ from app.schemas.admin import (
CreditAdjustRequest, CreditAdjustRequest,
ModelConfigCreate, ModelConfigCreate,
ModelConfigOut, ModelConfigOut,
SystemConfigCreate,
SystemConfigUpdate, SystemConfigUpdate,
SystemConfigOut, SystemConfigOut,
AdminUserOut, AdminUserOut,
AdminStatsOut, AdminStatsOut,
DailyCreditOut,
TeamCreditOut,
ModelUsageOut,
VideoParamOut,
CreateUserRequest, CreateUserRequest,
UpdateMenusRequest, UpdateMenusRequest,
ResetPasswordRequest, ResetPasswordRequest,
UpdateFrontendUserKindRequest, UpdateFrontendUserKindRequest,
OperationLogOut,
) )
from app.schemas.team import UpdateUserTeamRequest from app.schemas.team import UpdateUserTeamRequest
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut from app.schemas.industry import IndustryConfigCreate
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits from app.services.credits import add_credits, deduct_credits
from app.services.credit_record_meta_service import build_admin_adjust_meta from app.services.credit_record_meta_service import build_admin_adjust_meta
from app.services.admin_credit_record_service import list_admin_credit_records from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.system_config_cache import invalidate_system_config_cache
from app.services.llm_billing.config import validate_llm_system_config_value
from app.services.notification import create_notification from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation from app.services.operation_log import log_operation
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import sync_pending_orders, process_refund from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.services.generation_billing_service import (
OWNER_GENERATION_RECORD,
charge_generation_media_by_params,
get_next_credit_attempt_no,
)
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
CST = timezone(timedelta(hours=8)) CST = timezone(timedelta(hours=8))
def _safe_json_object(value: str | None) -> dict | None:
if not value:
return None
try:
parsed = json.loads(value)
except (TypeError, json.JSONDecodeError):
return None
return parsed if isinstance(parsed, dict) else None
def _iso(dt): def _iso(dt):
"""Serialize datetime as naive ISO string (UTC→CST, strip tzinfo).""" """Serialize datetime as naive ISO string (UTC→CST, strip tzinfo)."""
if dt is None: if dt is None:
@@ -495,6 +504,7 @@ async def list_credit_records(
credit_subject: str | None = Query(None), credit_subject: str | None = Query(None),
media_type: str | None = Query(None), media_type: str | None = Query(None),
charge_kind: str | None = Query(None), charge_kind: str | None = Query(None),
charge_action: str | None = Query(None),
source_module: str | None = Query(None), source_module: str | None = Query(None),
source_step_code: str | None = Query(None), source_step_code: str | None = Query(None),
billing_scene: str | None = Query(None), billing_scene: str | None = Query(None),
@@ -517,6 +527,7 @@ async def list_credit_records(
credit_subject=credit_subject, credit_subject=credit_subject,
media_type=media_type, media_type=media_type,
charge_kind=charge_kind, charge_kind=charge_kind,
charge_action=charge_action,
source_module=source_module, source_module=source_module,
source_step_code=source_step_code, source_step_code=source_step_code,
billing_scene=billing_scene, billing_scene=billing_scene,
@@ -543,7 +554,7 @@ async def list_admin_notifications(
if user_id: if user_id:
query = query.where(Notification.user_id == user_id) query = query.where(Notification.user_id == user_id)
count_query = count_query.where(Notification.user_id == user_id) count_query = count_query.where(Notification.user_id == user_id)
if is_read is not None: if is_read is not None:
query = query.where(Notification.is_read == is_read) query = query.where(Notification.is_read == is_read)
count_query = count_query.where(Notification.is_read == is_read) count_query = count_query.where(Notification.is_read == is_read)
@@ -747,11 +758,11 @@ async def get_payment_stats(
now_cst = datetime.now(CST) now_cst = datetime.now(CST)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1) today_end = today_start + timedelta(days=1)
# Default to today if no date range provided # Default to today if no date range provided
query_start = today_start query_start = today_start
query_end = today_end query_end = today_end
if start_date: if start_date:
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST) query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
if end_date: if end_date:
@@ -804,7 +815,7 @@ async def get_payment_stats(
# Monthly cumulative stats # Monthly cumulative stats
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0) month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_result = await db.execute( month_result = await db.execute(
select( select(
func.count().label("paid_count"), func.count().label("paid_count"),
@@ -836,13 +847,14 @@ async def list_payment_orders(
page_size: int = Query(20, ge=1, le=500), page_size: int = Query(20, ge=1, le=500),
payment_method: str | None = Query(None), payment_method: str | None = Query(None),
status: str | None = Query(None), status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None), start_date: str | None = Query(None),
end_date: str | None = Query(None), end_date: str | None = Query(None),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Return paginated payment orders for admin dashboard.""" """Return paginated payment orders for admin dashboard."""
query = select(PaymentOrder, User.username).join(User, PaymentOrder.user_id == User.id) query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
count_query = select(func.count(PaymentOrder.id)) count_query = select(func.count(PaymentOrder.id))
filters = [] filters = []
@@ -850,6 +862,8 @@ async def list_payment_orders(
filters.append(PaymentOrder.payment_method == payment_method) filters.append(PaymentOrder.payment_method == payment_method)
if status: if status:
filters.append(PaymentOrder.status == status) filters.append(PaymentOrder.status == status)
if phone:
filters.append(User.phone.ilike(f"%{phone.strip()}%"))
if start_date: if start_date:
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST)) filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date: if end_date:
@@ -873,6 +887,7 @@ async def list_payment_orders(
"userId": o.user_id, "userId": o.user_id,
"user_id": o.user_id, "user_id": o.user_id,
"username": username, "username": username,
"phone": user_phone,
"amount": round(float(o.amount), 2), "amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2), "credits": round(float(o.credits), 2),
"paymentMethod": o.payment_method, "paymentMethod": o.payment_method,
@@ -885,7 +900,7 @@ async def list_payment_orders(
"createdAt": o.created_at.isoformat() if o.created_at else None, "createdAt": o.created_at.isoformat() if o.created_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None, "created_at": o.created_at.isoformat() if o.created_at else None,
} }
for o, username in rows for o, username, user_phone in rows
] ]
return {"items": items, "total": total, "page": page, "page_size": page_size} return {"items": items, "total": total, "page": page, "page_size": page_size}
@@ -1111,11 +1126,15 @@ async def delete_industry_config(
@router.get("/video-engines", response_model=list[VideoEngineOut]) @router.get("/video-engines", response_model=list[VideoEngineOut])
async def list_video_engines( async def list_video_engines(
include_deleted: bool = Query(False),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
query = select(VideoEngine)
if not include_deleted:
query = query.where(VideoEngine.deleted_at.is_(None))
result = await db.execute( result = await db.execute(
select(VideoEngine).order_by(VideoEngine.priority.desc()) query.order_by(VideoEngine.priority.desc(), VideoEngine.id.desc())
) )
return result.scalars().all() return result.scalars().all()
@@ -1155,7 +1174,7 @@ async def update_video_engine(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( result = await db.execute(
select(VideoEngine).where(VideoEngine.id == engine_id).limit(1) select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
) )
engine = result.scalar_one_or_none() engine = result.scalar_one_or_none()
if not engine: if not engine:
@@ -1188,24 +1207,26 @@ async def delete_video_engine(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( result = await db.execute(
select(VideoEngine).where(VideoEngine.id == engine_id).limit(1) select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
) )
engine = result.scalar_one_or_none() engine = result.scalar_one_or_none()
if not engine: if not engine:
raise HTTPException(status_code=404, detail="视频引擎不存在") raise HTTPException(status_code=404, detail="视频引擎不存在")
await db.delete(engine) engine_name = engine.name
engine.deleted_at = datetime.now(timezone.utc)
engine.is_active = False
await db.flush() await db.flush()
await log_operation( await log_operation(
db, db,
admin.id, admin.id,
admin.username, admin.username,
f"删除视频引擎: {engine.name}", f"删除视频引擎: {engine_name}",
"DELETE", "DELETE",
f"/admin/video-engines/{engine_id}", f"/admin/video-engines/{engine_id}",
detail=json.dumps( detail=json.dumps(
{ {
"engine_id": engine_id, "engine_id": engine_id,
"name": engine.name, "name": engine_name,
}, },
ensure_ascii=False, ensure_ascii=False,
), ),
@@ -1217,11 +1238,15 @@ async def delete_video_engine(
@router.get("/image-engines", response_model=list[ImageEngineOut]) @router.get("/image-engines", response_model=list[ImageEngineOut])
async def list_image_engines( async def list_image_engines(
include_deleted: bool = Query(False),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
query = select(ImageEngine)
if not include_deleted:
query = query.where(ImageEngine.deleted_at.is_(None))
result = await db.execute( result = await db.execute(
select(ImageEngine).order_by(ImageEngine.priority.desc()) query.order_by(ImageEngine.priority.desc(), ImageEngine.id.desc())
) )
return result.scalars().all() return result.scalars().all()
@@ -1261,7 +1286,7 @@ async def update_image_engine(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( result = await db.execute(
select(ImageEngine).where(ImageEngine.id == engine_id).limit(1) select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
) )
engine = result.scalar_one_or_none() engine = result.scalar_one_or_none()
if not engine: if not engine:
@@ -1294,24 +1319,26 @@ async def delete_image_engine(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( result = await db.execute(
select(ImageEngine).where(ImageEngine.id == engine_id).limit(1) select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
) )
engine = result.scalar_one_or_none() engine = result.scalar_one_or_none()
if not engine: if not engine:
raise HTTPException(status_code=404, detail="图片引擎不存在") raise HTTPException(status_code=404, detail="图片引擎不存在")
await db.delete(engine) engine_name = engine.name
engine.deleted_at = datetime.now(timezone.utc)
engine.is_active = False
await db.flush() await db.flush()
await log_operation( await log_operation(
db, db,
admin.id, admin.id,
admin.username, admin.username,
f"删除图片引擎: {engine.name}", f"删除图片引擎: {engine_name}",
"DELETE", "DELETE",
f"/admin/image-engines/{engine_id}", f"/admin/image-engines/{engine_id}",
detail=json.dumps( detail=json.dumps(
{ {
"engine_id": engine_id, "engine_id": engine_id,
"name": engine.name, "name": engine_name,
}, },
ensure_ascii=False, ensure_ascii=False,
), ),
@@ -1336,7 +1363,7 @@ async def _validate_credit_ratio_engine(db: AsyncSession, req: CreditRatioCreate
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID") raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
model = ImageEngine if gen_type == "image" else VideoEngine model = ImageEngine if gen_type == "image" else VideoEngine
result = await db.execute(select(model).where(model.id == engine_id).limit(1)) result = await db.execute(select(model).where(model.id == engine_id, model.deleted_at.is_(None)).limit(1))
engine = result.scalar_one_or_none() engine = result.scalar_one_or_none()
if not engine: if not engine:
detail = "图片积分规则绑定的图片引擎不存在" if gen_type == "image" else "视频积分规则绑定的视频引擎不存在" detail = "图片积分规则绑定的图片引擎不存在" if gen_type == "image" else "视频积分规则绑定的视频引擎不存在"
@@ -1482,10 +1509,14 @@ async def list_credit_ratios_grouped(
@router.get("/model-configs", response_model=list[ModelConfigOut]) @router.get("/model-configs", response_model=list[ModelConfigOut])
async def list_model_configs( async def list_model_configs(
include_deleted: bool = Query(False),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute(select(ModelConfig).order_by(ModelConfig.priority.desc())) query = select(ModelConfig)
if not include_deleted:
query = query.where(ModelConfig.deleted_at.is_(None))
result = await db.execute(query.order_by(ModelConfig.priority.desc(), ModelConfig.id.desc()))
return result.scalars().all() return result.scalars().all()
@@ -1523,7 +1554,7 @@ async def update_model_config(
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id).limit(1)) result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
config = result.scalar_one_or_none() config = result.scalar_one_or_none()
if not config: if not config:
raise HTTPException(status_code=404, detail="配置不存在") raise HTTPException(status_code=404, detail="配置不存在")
@@ -1554,23 +1585,25 @@ async def delete_model_config(
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id).limit(1)) result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
config = result.scalar_one_or_none() config = result.scalar_one_or_none()
if not config: if not config:
raise HTTPException(status_code=404, detail="配置不存在") raise HTTPException(status_code=404, detail="配置不存在")
await db.delete(config) config_name = config.name
config.deleted_at = datetime.now(timezone.utc)
config.is_active = False
await db.flush() await db.flush()
await log_operation( await log_operation(
db, db,
admin.id, admin.id,
admin.username, admin.username,
f"删除模型配置: {config.name}", f"删除模型配置: {config_name}",
"DELETE", "DELETE",
f"/admin/model-configs/{config_id}", f"/admin/model-configs/{config_id}",
detail=json.dumps( detail=json.dumps(
{ {
"config_id": config_id, "config_id": config_id,
"name": config.name, "name": config_name,
}, },
ensure_ascii=False, ensure_ascii=False,
), ),
@@ -1589,6 +1622,40 @@ async def list_system_configs(
return result.scalars().all() return result.scalars().all()
@router.post("/system-configs", response_model=SystemConfigOut)
async def create_system_config(
req: SystemConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
try:
await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config = SystemConfig(
id=generate_id(),
key=req.key,
value=str(req.value),
description=req.description or "",
)
db.add(config)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建系统配置: {config.key}",
"POST",
"/admin/system-configs",
detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False),
)
await db.commit()
await invalidate_system_config_cache([req.key])
await db.refresh(config)
return config
@router.put("/system-configs/{config_id}", response_model=SystemConfigOut) @router.put("/system-configs/{config_id}", response_model=SystemConfigOut)
async def update_system_config( async def update_system_config(
config_id: str, config_id: str,
@@ -1600,6 +1667,10 @@ async def update_system_config(
config = result.scalar_one_or_none() config = result.scalar_one_or_none()
if not config: if not config:
raise HTTPException(status_code=404, detail="配置不存在") raise HTTPException(status_code=404, detail="配置不存在")
try:
await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config.value = str(req.value) config.value = str(req.value)
await db.flush() await db.flush()
await log_operation( await log_operation(
@@ -1618,7 +1689,10 @@ async def update_system_config(
ensure_ascii=False, ensure_ascii=False,
), ),
) )
updated_key = str(config.key)
await db.commit() await db.commit()
await invalidate_system_config_cache([updated_key])
await db.refresh(config)
return config return config
@@ -1655,6 +1729,15 @@ async def list_operation_logs(
} }
def _build_param_out(model_map: dict[str, dict[str, int]]) -> list[VideoParamOut]:
"""{模型: {标签: 数量}} 转为扁平列表,按模型+数量排序。"""
result: list[VideoParamOut] = []
for model, labels in model_map.items():
for label, count in sorted(labels.items(), key=lambda x: -x[1]):
result.append(VideoParamOut(model=model, label=label, count=count))
return result
# ── Stats ──────────────────────────────────────────────── # ── Stats ────────────────────────────────────────────────
@router.get("/stats", response_model=AdminStatsOut) @router.get("/stats", response_model=AdminStatsOut)
@@ -1664,21 +1747,21 @@ async def get_stats(
start_date: str = Query(None), start_date: str = Query(None),
end_date: str = Query(None), end_date: str = Query(None),
): ):
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
try: try:
if start_date: if start_date:
date_start = datetime.strptime(start_date, "%Y-%m-%d") date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
else: else:
date_start = today_start date_start = today_start
if end_date: if end_date:
date_end = datetime.strptime(end_date, "%Y-%m-%d") date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST)
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999) date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
else: else:
date_end = datetime.now() date_end = datetime.now(CST)
except: except:
date_start = today_start date_start = today_start
date_end = datetime.now() date_end = datetime.now(CST)
total_users = (await db.execute( total_users = (await db.execute(
select(func.count(User.id)).where( select(func.count(User.id)).where(
@@ -1695,14 +1778,14 @@ async def get_stats(
Project.created_at <= date_end, Project.created_at <= date_end,
) )
)).scalar() or 0 )).scalar() or 0
total_generations = (await db.execute( total_generations = (await db.execute(
select(func.count(ChatGenerationTask.id)).where( select(func.count(ChatGenerationTask.id)).where(
ChatGenerationTask.created_at >= date_start, ChatGenerationTask.created_at >= date_start,
ChatGenerationTask.created_at <= date_end, ChatGenerationTask.created_at <= date_end,
) )
)).scalar() or 0 )).scalar() or 0
total_records = (await db.execute( total_records = (await db.execute(
select(func.count(GenerationRecord.id)).where( select(func.count(GenerationRecord.id)).where(
GenerationRecord.deleted_at.is_(None), GenerationRecord.deleted_at.is_(None),
@@ -1710,7 +1793,7 @@ async def get_stats(
GenerationRecord.created_at <= date_end, GenerationRecord.created_at <= date_end,
) )
)).scalar() or 0 )).scalar() or 0
total_revenue = (await db.execute( total_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where( select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid", PaymentOrder.status == "paid",
@@ -1719,9 +1802,16 @@ async def get_stats(
) )
)).scalar() or 0 )).scalar() or 0
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。
real_credit_charge_filter = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
)
credits_consumed = (await db.execute( credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where( select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume", CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start, CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end, CreditRecord.created_at <= date_end,
) )
@@ -1746,7 +1836,7 @@ async def get_stats(
)).scalar() or 0 )).scalar() or 0
period_duration = date_end - date_start period_duration = date_end - date_start
last_period_start = date_start - period_duration last_period_start = date_start - period_duration
last_period_end = date_start last_period_end = date_start
@@ -1792,11 +1882,153 @@ async def get_stats(
last_period_credits_consumed = (await db.execute( last_period_credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where( select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume", CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= last_period_start, CreditRecord.created_at >= last_period_start,
CreditRecord.created_at <= last_period_end, CreditRecord.created_at <= last_period_end,
) )
)).scalar() or 0 )).scalar() or 0
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
from sqlalchemy import Date, cast as sa_cast
_day_expr = sa_cast(CreditRecord.created_at, Date)
# 图表固定展示 [date_end - 6天, date_end] 共7天
_chart_end_dt = date_end
_chart_start_dt = _chart_end_dt - timedelta(days=6)
_inner = (
select(
_day_expr.label('date'),
CreditRecord.source_module.label('module'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= _chart_start_dt,
CreditRecord.created_at <= _chart_end_dt,
)
.group_by(_day_expr, CreditRecord.source_module)
.subquery()
)
daily_credits_rows = (await db.execute(
select(
_inner.c.date,
func.coalesce(_inner.c.module, 'other').label('module'),
_inner.c.credits,
).order_by(_inner.c.date)
)).all()
daily_credits_by_module = [
DailyCreditOut(date=str(row.date), module=row.module, credits=float(row.credits or 0))
for row in daily_credits_rows
]
# ── 选中周期内各模块积分占比(按 source_module 分组,不拆日期)
_period_inner = (
select(
CreditRecord.source_module.label('module'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.source_module)
.subquery()
)
period_credits_rows = (await db.execute(
select(
func.coalesce(_period_inner.c.module, 'other').label('module'),
_period_inner.c.credits,
).order_by(_period_inner.c.credits.desc())
)).all()
period_credits_by_module = [
DailyCreditOut(date='', module=row.module, credits=float(row.credits or 0))
for row in period_credits_rows
]
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
team_credit_rows = (await db.execute(
select(
func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'),
CreditRecord.team_id_snapshot.label('team_id'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.team_id_snapshot, CreditRecord.team_name_snapshot)
.order_by(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).desc())
)).all()
credits_by_team = [
TeamCreditOut(team_name=row.team_name, team_id=row.team_id, credits=float(row.credits or 0))
for row in team_credit_rows
]
# ── 各模型使用次数(通过 engine 快照字段统计)
model_usage_rows = (await db.execute(
select(
func.coalesce(CreditRecord.engine_name, '未知').label('model_name'),
func.coalesce(CreditRecord.engine_provider, 'unknown').label('provider'),
func.count(CreditRecord.id).label('count'),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.engine_name, CreditRecord.engine_provider)
.order_by(func.count(CreditRecord.id).desc())
)).all()
model_usage = [
ModelUsageOut(model_name=row.model_name, provider=row.provider, count=int(row.count or 0))
for row in model_usage_rows
]
# ── 视频分辨率/比例/时长使用分布(按模型分组)
_video_gen_q = (
select(
func.coalesce(CreditRecord.engine_name, '未知').label('model'),
ChatGenerationTask.resolution,
ChatGenerationTask.aspect_ratio,
ChatGenerationTask.duration,
)
.join(CreditRecord, CreditRecord.related_id == ChatGenerationTask.id)
.where(
ChatGenerationTask.gen_type == "video",
CreditRecord.type == "consume",
real_credit_charge_filter,
ChatGenerationTask.created_at >= date_start,
ChatGenerationTask.created_at <= date_end,
ChatGenerationTask.deleted_at.is_(None),
)
)
_video_rows = (await db.execute(_video_gen_q)).all()
_res_map: dict[str, dict[str, int]] = {}
_ratio_map: dict[str, dict[str, int]] = {}
_dur_map: dict[str, dict[str, int]] = {}
for row in _video_rows:
model = row.model or '未知'
if row.resolution:
_res_map.setdefault(model, {})
_res_map[model][row.resolution] = _res_map[model].get(row.resolution, 0) + 1
if row.aspect_ratio:
_ratio_map.setdefault(model, {})
_ratio_map[model][row.aspect_ratio] = _ratio_map[model].get(row.aspect_ratio, 0) + 1
if row.duration:
_k = f"{row.duration}"
_dur_map.setdefault(model, {})
_dur_map[model][_k] = _dur_map[model].get(_k, 0) + 1
video_resolution_usage = _build_param_out(_res_map)
video_ratio_usage = _build_param_out(_ratio_map)
video_duration_usage = _build_param_out(_dur_map)
return AdminStatsOut( return AdminStatsOut(
total_users=total_users, total_users=total_users,
total_projects=total_projects, total_projects=total_projects,
@@ -1812,6 +2044,13 @@ async def get_stats(
last_period_records=last_period_records, last_period_records=last_period_records,
last_period_revenue=float(last_period_revenue), last_period_revenue=float(last_period_revenue),
last_period_credits_consumed=float(last_period_credits_consumed), last_period_credits_consumed=float(last_period_credits_consumed),
daily_credits_by_module=daily_credits_by_module,
period_credits_by_module=period_credits_by_module,
credits_by_team=credits_by_team,
model_usage=model_usage,
video_resolution_usage=video_resolution_usage,
video_ratio_usage=video_ratio_usage,
video_duration_usage=video_duration_usage,
) )
@@ -1861,6 +2100,8 @@ async def list_token_usage(
async def admin_list_generation_records( async def admin_list_generation_records(
user_id: str | None = Query(None), user_id: str | None = Query(None),
status: str | None = Query(None), status: str | None = Query(None),
engine_id: str | None = Query(None),
include_media_references: bool | None = Query(None),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500), page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
@@ -1879,13 +2120,25 @@ async def admin_list_generation_records(
query = query.where(GenerationRecord.user_id == user_id) query = query.where(GenerationRecord.user_id == user_id)
if status: if status:
query = query.where(GenerationRecord.status == status) query = query.where(GenerationRecord.status == status)
if engine_id:
query = query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
# Count total # Count total
count_query = select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None)) count_query = (
select(func.count(GenerationRecord.id))
.join(Project, GenerationRecord.project_id == Project.id)
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
)
if user_id: if user_id:
count_query = count_query.where(GenerationRecord.user_id == user_id) count_query = count_query.where(GenerationRecord.user_id == user_id)
if status: if status:
count_query = count_query.where(GenerationRecord.status == status) count_query = count_query.where(GenerationRecord.status == status)
if engine_id:
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
total_result = await db.execute(count_query) total_result = await db.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
@@ -1916,9 +2169,15 @@ async def admin_list_generation_records(
"aspect_ratio": record.aspect_ratio, "aspect_ratio": record.aspect_ratio,
"resolution": record.resolution, "resolution": record.resolution,
"status": record.status, "status": record.status,
"pipeline_stage": record.pipeline_stage,
"video_upscale_enabled": bool(record.video_upscale_enabled_snapshot),
"video_url": build_resource_signed_url(record.video_url) if record.video_url else '', "video_url": build_resource_signed_url(record.video_url) if record.video_url else '',
"video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '', "video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
"references": refs, "references": refs,
"engine_id": record.engine_id,
"engine_name": (_safe_json_object(record.engine_snapshot_json) or {}).get("name"),
"engine_snapshot": _safe_json_object(record.engine_snapshot_json),
"include_media_references": bool(record.include_media_references),
"credits_cost": record.credits_cost or 0, "credits_cost": record.credits_cost or 0,
"text_credits_cost": record.text_credits_cost or 0, "text_credits_cost": record.text_credits_cost or 0,
"text_tokens_used": record.text_tokens_used or 0, "text_tokens_used": record.text_tokens_used or 0,
@@ -1939,213 +2198,6 @@ async def admin_list_generation_records(
return {"total": total, "items": items} return {"total": total, "items": items}
@router.put("/generation-records/{record_id}/status")
async def admin_update_generation_status(
record_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Admin update generation record status (e.g., confirm/reject)."""
result = await db.execute(
select(GenerationRecord).where(
GenerationRecord.id == record_id,
GenerationRecord.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
record = result.scalar_one_or_none()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
new_status = body.get("status")
if new_status not in ("prompt_optimized", "generating", "completed", "failed"):
raise HTTPException(status_code=400, detail="无效状态")
if new_status == "failed":
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=body.get("error_message") or record.error_message or "管理员设置为失败",
)
else:
record.status = new_status
if body.get("video_url"):
record.video_url = body["video_url"]
if body.get("video_cover_url"):
record.video_cover_url = body["video_cover_url"]
if body.get("image_url"):
record.image_url = body["image_url"]
if new_status == "completed":
record.generated_at = datetime.now()
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新生成记录状态: {new_status}",
"PUT",
f"/admin/generation-records/{record_id}/status",
detail=json.dumps(
{
"record_id": record_id,
"new_status": new_status,
},
ensure_ascii=False,
),
)
return {"message": "ok"}
@router.post("/generation-records/{record_id}/generate")
async def admin_generate_video(
record_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Admin trigger video/image generation for a record with specified params."""
from app.models.project import Project
from app.services.video_queue import task_queue
result = await db.execute(
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(
GenerationRecord.id == record_id,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.with_for_update()
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="记录不存在")
record, project_name = row
type_str = "视频" if record.gen_type == GenerationType.video else "图片"
if record.status not in ("prompt_optimized", "failed"):
raise HTTPException(status_code=400, detail=f"当前状态不允许生成{type_str}")
attempt_no = await get_next_credit_attempt_no(
db,
owner_type=OWNER_GENERATION_RECORD,
owner_id=record.id,
)
if record.gen_type == GenerationType.video:
# Video Generation
aspect_ratio = body.get("aspect_ratio", "16:9")
resolution = body.get("resolution", "720p")
if aspect_ratio not in ASPECT_RATIOS:
raise HTTPException(status_code=400, detail="不支持的画面比例")
if resolution not in RESOLUTIONS:
raise HTTPException(status_code=400, detail="不支持的分辨率")
duration = record.duration or 5
media_billing = await charge_generation_media_by_params(
db,
user_id=record.user_id,
record_id=record.id,
gen_type="video",
duration=duration,
resolution=resolution,
project_name=project_name,
description_prefix="视频生成(管理后台)",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
)
record.aspect_ratio = aspect_ratio
record.resolution = resolution
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
record.video_url = None
record.video_cover_url = None
record.image_url = None
record.seedance_task_id = None
await db.flush()
try:
from app.services.video_gen import get_active_engine, submit_video_task
engine = await get_active_engine(db)
task_id = await submit_video_task(
db,
engine,
record,
include_media_references=False,
)
record.seedance_task_id = task_id
await db.flush()
await task_queue.enqueue(record_id)
except Exception as e:
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=str(e),
)
await db.flush()
elif record.gen_type == GenerationType.image:
# Image generation
post_image_size = body.get("image_size", "")
image_size = post_image_size or record.image_size or "2K"
media_billing = await charge_generation_media_by_params(
db,
user_id=record.user_id,
record_id=record.id,
gen_type="image",
image_size=image_size,
project_name=project_name,
description_prefix="图片生成(管理后台)",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
)
record.image_size = image_size
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
record.image_url = None
record.video_url = None
record.video_cover_url = None
record.seedance_task_id = None
await db.flush()
try:
await task_queue.enqueue(record_id)
except Exception as e:
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=str(e),
)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"管理员触发生成{type_str}: {record_id}",
"POST",
f"/admin/generation-records/{record_id}/generate",
detail=json.dumps(
{
"record_id": record_id,
"gen_type": record.gen_type,
"project_name": project_name,
},
ensure_ascii=False,
),
)
return {"message": "ok", "record_id": record_id}
# ── File Uploads ───────────────────────────────────────── # ── File Uploads ─────────────────────────────────────────
import os import os
@@ -2161,7 +2213,6 @@ async def upload_pdf(
): ):
"""Upload a PDF file and save URL to system config.""" """Upload a PDF file and save URL to system config."""
from app.config import settings from app.config import settings
from app.utils.id_gen import generate_id
if not file.filename: if not file.filename:
raise HTTPException(status_code=400, detail="请选择文件") raise HTTPException(status_code=400, detail="请选择文件")
@@ -2223,7 +2274,6 @@ async def upload_logo(
): ):
"""Upload a Logo image file and save URL to system config.""" """Upload a Logo image file and save URL to system config."""
from app.config import settings from app.config import settings
from app.utils.id_gen import generate_id
if not file.filename: if not file.filename:
raise HTTPException(status_code=400, detail="请选择文件") raise HTTPException(status_code=400, detail="请选择文件")
@@ -2277,6 +2327,58 @@ async def upload_logo(
return {"url": url} return {"url": url}
@router.post("/upload-login-video")
async def upload_login_video(
file: UploadFile = File(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""上传登录页背景视频/动图,保存 URL 到 system config login_bg_video。"""
from app.config import settings
if not file.filename:
raise HTTPException(status_code=400, detail="请选择文件")
content = await file.read()
if len(content) > 50 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过50MB")
ext = os.path.splitext(file.filename)[1].lower()
safe_name = f"login_bg_{generate_id()}{ext}"
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name)
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/{safe_name}"
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "login_bg_video").limit(1)
)
config = result.scalar_one_or_none()
if config:
config.value = url
else:
db.add(SystemConfig(
id=generate_id(),
key="login_bg_video",
value=url,
description="登录页背景视频",
))
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"上传登录背景视频: {file.filename}",
"POST",
"/admin/upload-login-video",
detail=json.dumps({"filename": file.filename, "url": url}, ensure_ascii=False),
)
await db.commit()
return {"url": url}
# ── Payment Stats ──────────────────────────────────────── # ── Payment Stats ────────────────────────────────────────
+28 -12
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 fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
@@ -15,6 +17,7 @@ from app.models.system_config import SystemConfig
from app.models.user import User from app.models.user import User
from app.schemas.auth import ( from app.schemas.auth import (
ChangePasswordRequest, ChangePasswordRequest,
ChangeUsernameRequest,
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
SetPasswordRequest, SetPasswordRequest,
@@ -116,8 +119,8 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
credits = int(credits_result.scalar_one_or_none() or "0") credits = int(credits_result.scalar_one_or_none() or "0")
if credits <= 0: if credits <= 0:
return return
today = datetime.now().date() today = datetime.now(CST).date()
if user.last_login_at: if user.last_login_at:
last_login_date = user.last_login_at.date() last_login_date = user.last_login_at.date()
if last_login_date >= today: 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) await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now() user.last_login_at = datetime.now(CST)
await db.flush() await db.flush()
return _token_response(user, req.remember_me) 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) await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now() user.last_login_at = datetime.now(CST)
await db.flush() await db.flush()
return _token_response(user, req.remember_me) return _token_response(user, req.remember_me)
@@ -222,7 +225,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.phone, username=req.phone,
phone=req.phone, phone=req.phone,
hashed_password=hash_password(req.password), hashed_password=hash_password(req.password),
password_set_at=datetime.now(), password_set_at=datetime.now(CST),
credits=register_credits, credits=register_credits,
is_admin=False, is_admin=False,
user_type="frontend", user_type="frontend",
@@ -294,7 +297,7 @@ async def set_password(
) )
current_user.hashed_password = hash_password(req.new_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() await db.flush()
return {"message": "密码设置成功", "must_set_password": False} 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.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() await db.flush()
return {"message": "密码修改成功"} 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") @router.get("/site-info")
async def get_site_info(db: AsyncSession = Depends(get_db)): async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name, logo, agreement and copyright info.""" """Public endpoint returning site name, logo, agreement and copyright info."""
result = await db.execute( result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([ select(SystemConfig).where(SystemConfig.key.in_([
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual" "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits"
])) ]))
) )
configs = result.scalars().all() configs = result.scalars().all()
@@ -346,11 +360,13 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
return f"{base_url}{path}" return f"{base_url}{path}"
return { return {
"site_name": info.get("site_name", "VideoGen.AI"), "site_name": info.get("site_name", "智创"),
"site_logo": to_full_url(info.get("site_logo")), "site_logo": to_full_url(info.get("site_logo")),
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")), "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", ""), "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),
} }
@@ -370,7 +386,7 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
detail="该账号不是管理员账号", detail="该账号不是管理员账号",
) )
user.last_login_at = datetime.now() user.last_login_at = datetime.now(CST)
await db.flush() await db.flush()
token = create_access_token(user.id, req.remember_me) token = create_access_token(user.id, req.remember_me)
+3 -93
View File
@@ -1,14 +1,14 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user from app.dependencies import get_db, get_current_user
from app.models.contact_request import ContactRequest from app.models.contact_request import ContactRequest
from app.models.user import User 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 from app.utils.id_gen import generate_id
router = APIRouter(prefix="/contact", tags=["contact"]) router = APIRouter(prefix="/contact", tags=["contact"])
@@ -62,94 +62,4 @@ async def create_contact_request(
detail="每个账号每天只能提交一次联系我们" detail="每个账号每天只能提交一次联系我们"
) )
return {"message": "提交成功,我们会尽快与您联系"} 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( video_engines_result = await db.execute(
select(VideoEngine.id) select(VideoEngine.id)
.where(VideoEngine.is_active == True) .where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc()) .order_by(VideoEngine.priority.desc())
) )
video_engine_ids = video_engines_result.scalars().all() video_engine_ids = video_engines_result.scalars().all()
image_engines_result = await db.execute( image_engines_result = await db.execute(
select(ImageEngine.id) select(ImageEngine.id)
.where(ImageEngine.is_active == True) .where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc()) .order_by(ImageEngine.priority.desc())
) )
image_engine_ids = image_engines_result.scalars().all() image_engine_ids = image_engines_result.scalars().all()
+361 -446
View File
@@ -1,68 +1,129 @@
import json import json
import logging import logging
import os import os
from datetime import datetime from datetime import timezone, timedelta
from types import SimpleNamespace
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_db, get_current_user from app.dependencies import get_db, get_current_user
from app.models.user import User from app.models.user import User
from app.models.project import Project from app.models.project import Project
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.schemas.generation import ( from app.schemas.generation import (
OptimizeParams, OptimizeParams,
GenerateParams,
GenerationRecordOut, GenerationRecordOut,
GenerationRecordPageListOut, GenerationRecordPageListOut,
OptimizeResult, OptimizeResult,
UpdatePromptRequest, UpdatePromptRequest,
GenerationType,
DURATIONS,
ASPECT_RATIOS,
RESOLUTIONS,
IMAGE_SIZES,
) )
from app.services.credits import deduct_credits, calc_text_credits from app.services.generation.pipeline.db_lock_service import (
from app.services.llm import optimize_prompt DatabaseRowLockBusy,
from app.services.video_url import generate_temp_url, validate_and_get_record_id, get_video_stream_url execute_with_lock_timeout,
from app.services.resource_accounting_service import (
record_generation_record_generated_resource,
safe_file_size,
) )
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
from app.services.generation_billing_service import ( from app.enums.generation_status import (
CHARGE_TEXT_PROMPT, ASPECT_RATIOS,
DURATIONS,
IMAGE_SIZES,
RESOLUTIONS,
GenerationRecordPipelineStage,
GenerationType,
)
from app.enums.common import LogEventStatusEnum
from app.enums.generation_record import (
GenerationRecordConfigSourceEnum,
GenerationRecordEventTypeEnum,
)
from app.services.generation.billing_service import (
OWNER_GENERATION_RECORD, OWNER_GENERATION_RECORD,
build_credit_biz_key,
charge_generation_media_by_params,
charge_generation_media_for_record, charge_generation_media_for_record,
get_next_credit_attempt_no, get_next_credit_attempt_no,
) )
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once from app.services.generation.ai.engine_service import (
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot get_image_engine,
from app.services.credit_record_meta_service import build_generation_record_prompt_meta get_video_engine,
from app.services.video_cover_service import async_create_video_cover_for_local_video )
from app.services.generation.pipeline.generation_record_config_service import (
ensure_generation_record_config_frozen,
frozen_generation_record_engine_view,
generation_record_config_fallback_hint,
generation_record_engine_snapshot,
is_generation_record_config_complete,
is_generation_record_config_recoverable,
log_generation_record_config_event,
)
from app.services.generation.media_reference_service import (
calculate_media_reference_usage,
validate_media_reference_usage_for_engine,
)
from app.services.generation.prompt_optimize_service import optimize_generation_prompt
from app.enums.audio_reference import ( from app.enums.audio_reference import (
AUDIO_ALLOWED_EXTENSIONS, AUDIO_ALLOWED_EXTENSIONS,
AUDIO_ALLOWED_MIME_TYPES, AUDIO_ALLOWED_MIME_TYPES,
AUDIO_MAX_FILE_SIZE_MB, AUDIO_MAX_FILE_SIZE_MB,
) )
from app.utils.id_gen import generate_id from app.utils.exceptions import RecordNotFoundError, InvalidStatusError
from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError
router = APIRouter(prefix="/generation-records", tags=["generation"]) router = APIRouter(prefix="/generation-records", tags=["generation"])
logger = logging.getLogger("videogen") logger = logging.getLogger("videogen")
def _engine_snapshot(record: GenerationRecord) -> dict | None:
return generation_record_engine_snapshot(record)
def _record_config_complete(record: GenerationRecord) -> bool:
return is_generation_record_config_complete(record)
def _record_status_view(record: GenerationRecord) -> dict[str, object]:
config_complete = _record_config_complete(record)
config_recoverable = is_generation_record_config_recoverable(record)
prompt_failure = record.status == "failed" and record.resource_generation_started_at is None
resource_failure = record.status == "failed" and record.resource_generation_started_at is not None
if record.status in {"pending", "optimizing", "settlement_pending"}:
client_status = "prompt_processing"
operation_phase = "prompt"
elif record.status == "prompt_optimized":
client_status = "ready"
operation_phase = "prompt"
elif record.status == "generating":
client_status = "generating"
operation_phase = "resource"
elif record.status == "completed":
client_status = "success"
operation_phase = "resource"
else:
client_status = "failure"
operation_phase = "prompt" if prompt_failure else "resource"
return {
"config_complete": config_complete,
"config_recoverable": config_recoverable,
"config_fallback_hint": generation_record_config_fallback_hint(record),
"can_generate": record.status == "prompt_optimized" and (config_complete or config_recoverable),
"can_retry": resource_failure and config_complete and record.pipeline_stage != GenerationRecordPipelineStage.UPSCALE_FAILED.value,
"should_poll": record.status in {"optimizing", "settlement_pending", "generating"},
"client_status": client_status,
"operation_phase": operation_phase,
}
def _frozen_engine_view(record: GenerationRecord) -> SimpleNamespace:
return frozen_generation_record_engine_view(record)
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut: def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
refs = refs_override refs = refs_override
if refs is None and record.media_references: if refs is None and record.media_references:
@@ -70,7 +131,7 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
refs = json.loads(record.media_references) refs = json.loads(record.media_references)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
refs = None refs = None
error_message = record.error_message error_message = record.error_message
if error_message: if error_message:
from app.services.error_codes import ARK_ERRORS from app.services.error_codes import ARK_ERRORS
@@ -84,7 +145,7 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
parts = error_message.split(":") parts = error_message.split(":")
if len(parts) >= 2 and parts[1].strip() in ARK_ERRORS: if len(parts) >= 2 and parts[1].strip() in ARK_ERRORS:
error_message = ARK_ERRORS[parts[1].strip()] error_message = ARK_ERRORS[parts[1].strip()]
return GenerationRecordOut( return GenerationRecordOut(
id=record.id, id=record.id,
project_id=record.project_id, project_id=record.project_id,
@@ -99,15 +160,22 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
image_proportion=record.image_proportion, image_proportion=record.image_proportion,
image_px=record.image_px, image_px=record.image_px,
status=record.status, status=record.status,
pipeline_stage=record.pipeline_stage,
video_upscale_enabled=bool(record.video_upscale_enabled_snapshot),
video_url=build_resource_signed_url(record.video_url) if record.video_url else '', video_url=build_resource_signed_url(record.video_url) if record.video_url else '',
video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '', video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
image_url=build_resource_signed_url(record.image_url) if record.image_url else '', image_url=build_resource_signed_url(record.image_url) if record.image_url else '',
references=refs, references=refs,
engine_id=record.engine_id,
engine_name=(_engine_snapshot(record) or {}).get("name"),
engine_snapshot=_engine_snapshot(record),
include_media_references=bool(record.include_media_references),
**_record_status_view(record),
text_credits_cost=round(record.text_credits_cost or 0.00, 2), text_credits_cost=round(record.text_credits_cost or 0.00, 2),
# text_tokens_used=record.text_tokens_used or 0, text_tokens_used=record.text_tokens_used or 0,
credits_cost=round(record.credits_cost or 0.00, 2), credits_cost=round(record.credits_cost or 0.00, 2),
# video_tokens_used=record.video_tokens_used or 0, video_tokens_used=record.video_tokens_used or 0,
# image_tokens_used=record.image_tokens_used or 0, image_tokens_used=record.image_tokens_used or 0,
error_message=error_message, error_message=error_message,
created_at=record.created_at, created_at=record.created_at,
generated_at=record.generated_at, generated_at=record.generated_at,
@@ -148,6 +216,8 @@ async def list_records(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
allowed_statuses = { allowed_statuses = {
"optimizing",
"settlement_pending",
"prompt_optimized", "prompt_optimized",
"generating", "generating",
"failed", "failed",
@@ -157,7 +227,7 @@ async def list_records(
if status and status not in allowed_statuses: if status and status not in allowed_statuses:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="状态参数错误,仅支持:prompt_optimized、generating、failed、completed", detail="状态参数错误,仅支持:optimizing、settlement_pending、prompt_optimized、generating、failed、completed",
) )
offset = (page - 1) * page_size offset = (page - 1) * page_size
@@ -217,303 +287,217 @@ async def optimize(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
record = None user_id_snapshot = str(current_user.id)
# Validate parameters based on generation type service_result = await optimize_generation_prompt(
if req.gen_type == GenerationType.video: db,
if req.duration not in DURATIONS: req=req,
raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一") user_id=user_id_snapshot,
if not req.duration: )
raise HTTPException(status_code=400, detail="视频生成需要指定时长") refreshed = await db.execute(
elif req.gen_type == GenerationType.image: select(GenerationRecord, Project.name)
if req.image_size not in IMAGE_SIZES: .join(Project, GenerationRecord.project_id == Project.id)
raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一") .where(
if not req.image_size: GenerationRecord.id == service_result.record_id,
raise HTTPException(status_code=400, detail="图片生成需要指定画面分辨率") GenerationRecord.user_id == user_id_snapshot,
GenerationRecord.deleted_at.is_(None),
# Idempotency check: if key provided, return existing record if found
if req.idempotency_key:
existing = await db.execute(
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
GenerationRecord.idempotency_key == req.idempotency_key,
GenerationRecord.gen_type == req.gen_type,
GenerationRecord.status == "prompt_optimized",
)
.order_by(GenerationRecord.created_at.desc())
.limit(1)
)
row = existing.first()
if row:
record, project_name = row
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return OptimizeResult(
optimized_prompt=record.optimized_prompt or "",
text_credits_cost=record.text_credits_cost or 0.00,
text_tokens_used=record.text_tokens_used or 0,
record=_record_to_out(record, project_name, refs_override=refs),
)
# Check project exists and belongs to user
proj_result = await db.execute(
select(Project).where(
Project.id == req.project_id,
Project.user_id == current_user.id,
Project.deleted_at.is_(None), Project.deleted_at.is_(None),
) )
.limit(1) .limit(1)
) )
project = proj_result.scalar_one_or_none() refreshed_row = refreshed.first()
if not project: if not refreshed_row:
raise HTTPException(status_code=404, detail="项目不存在") raise RecordNotFoundError()
record, project_name = refreshed_row
# Optimize prompt via LLM with type-specific context refs = await resolve_private_portrait_reference_display_urls(
try: db,
optimized, token_usage = await optimize_prompt( json.loads(record.media_references) if record.media_references else None,
db, req.prompt, user_id=user_id_snapshot,
user_id=current_user.id,
industry_key=project.industry,
duration=req.duration if req.gen_type == GenerationType.video else None,
image_size=req.image_size if req.gen_type == GenerationType.image else None,
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
image_px=req.image_px if req.gen_type == GenerationType.image else None,
references=req.references,
gen_type=req.gen_type,
)
# Create record BEFORE LLM call so it's visible if user refreshes
record = GenerationRecord(
id=generate_id(),
user_id=current_user.id,
project_id=req.project_id,
original_prompt=req.prompt,
gen_type=req.gen_type,
duration=req.duration,
image_size=req.image_size,
image_proportion=req.image_proportion,
image_px=req.image_px,
status="optimizing",
credits_cost=0,
text_credits_cost=0,
text_tokens_used=0,
media_references=json.dumps(req.references) if req.references else None,
idempotency_key=req.idempotency_key,
)
db.add(record)
await db.flush()
await db.commit()
except Exception as e:
from app.services.error_codes import extract_error_message
if record:
record.status = "failed"
record.error_message = extract_error_message(e, "提示词")
await db.flush()
await db.commit()
error_message = extract_error_message(e, "提示词")
raise HTTPException(
status_code=502,
detail=f"AI模型调用失败: {error_message}"
)
text_credits = await calc_text_credits(
db, token_usage["input_tokens"], token_usage["output_tokens"],
) )
failed_record_id = record.id
failed_user_id = current_user.id
try:
prompt_attempt_no = 1
prompt_biz_key = build_credit_biz_key(
owner_type=OWNER_GENERATION_RECORD,
owner_id=record.id,
attempt_no=prompt_attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
action="charge",
)
prompt_meta = await build_generation_record_prompt_meta(
db,
record_id=record.id,
attempt_no=prompt_attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
usage=token_usage,
)
await deduct_credits(
db, current_user.id, text_credits,
f"提示词优化 - {project.name}",
related_id=record.id,
biz_key=prompt_biz_key,
record_meta=prompt_meta,
)
except InsufficientCreditsError as e:
# /optimize 阶段只处理提示词优化扣费。
# 提示词积分不足时,之前已落库的 optimizing 记录必须改为 failed,避免前端长期显示生成中。
# 此阶段没有媒体生成扣费,不调用生成失败退款逻辑。
await db.rollback()
result = await db.execute(
select(GenerationRecord)
.where(
GenerationRecord.id == failed_record_id,
GenerationRecord.user_id == failed_user_id,
GenerationRecord.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
failed_record = result.scalar_one_or_none()
if failed_record:
failed_record.status = "failed"
failed_record.error_message = e.detail
failed_record.optimized_prompt = None
failed_record.text_credits_cost = 0
failed_record.credits_cost = 0
failed_record.text_tokens_used = token_usage.get("total_tokens", 0)
await db.flush()
# 这里必须主动提交,否则后续抛出 402 后 get_db 会 rollbackfailed 状态会被回滚。
await db.commit()
raise e
record.optimized_prompt = optimized
record.status = "prompt_optimized"
record.text_credits_cost = round(text_credits, 2)
record.text_tokens_used = token_usage["total_tokens"]
await db.flush()
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return OptimizeResult( return OptimizeResult(
optimized_prompt=optimized, optimized_prompt=record.optimized_prompt or "",
text_credits_cost=round(text_credits, 2), text_credits_cost=round(record.text_credits_cost or 0, 2),
# text_tokens_used=token_usage["total_tokens"], text_tokens_used=record.text_tokens_used or 0,
record=_record_to_out(record, project.name, refs_override=refs), record=_record_to_out(record, project_name, refs_override=refs),
) )
@router.post("/{record_id}/generate") @router.post("/{record_id}/generate")
async def generate( async def generate_record_resource(
record_id: str, record_id: str,
req: GenerateParams,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( user_id_snapshot = str(current_user.id)
select(GenerationRecord, Project.name) try:
.join(Project, GenerationRecord.project_id == Project.id) result = await execute_with_lock_timeout(
.where( db,
GenerationRecord.id == record_id, select(GenerationRecord, Project.name)
GenerationRecord.user_id == current_user.id, .join(Project, GenerationRecord.project_id == Project.id)
GenerationRecord.deleted_at.is_(None), .where(
Project.deleted_at.is_(None), GenerationRecord.id == record_id,
GenerationRecord.user_id == user_id_snapshot,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.with_for_update(),
) )
.with_for_update() except DatabaseRowLockBusy as exc:
) raise HTTPException(status_code=409, detail=exc.detail) from exc
row = result.first() row = result.first()
if not row: if not row:
raise RecordNotFoundError() raise RecordNotFoundError()
record, project_name = row record, project_name = row
if record.status not in ("prompt_optimized", "failed"): if record.status != "prompt_optimized":
raise InvalidStatusError("当前状态不允许生成") raise InvalidStatusError("只有提词已完成的记录可以发起资源生成")
await assert_user_resource_capacity_available(db, current_user.id) log_generation_record_config_event(
event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_START,
event_status=LogEventStatusEnum.STARTED,
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
record=record,
detail={
"record_id": record.id,
"project_id": record.project_id,
"gen_type": record.gen_type,
"config_complete_before": _record_config_complete(record),
"config_recoverable": is_generation_record_config_recoverable(record),
},
)
await ensure_generation_record_config_frozen(
db,
record,
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
)
if not _record_config_complete(record):
raise InvalidStatusError("该记录缺少冻结的生成配置,请重新生成提词")
log_generation_record_config_event(
event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_CONFIG_READY,
event_status=LogEventStatusEnum.SUCCESS,
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
record=record,
detail={
"record_id": record.id,
"project_id": record.project_id,
"gen_type": record.gen_type,
"engine_id": record.engine_id,
"duration": record.duration,
"aspect_ratio": record.aspect_ratio,
"resolution": record.resolution,
"provider_generation_resolution": record.provider_generation_resolution,
"image_size": record.image_size,
"image_proportion": record.image_proportion,
"image_px": record.image_px,
"include_media_references": bool(record.include_media_references),
},
)
await assert_user_resource_capacity_available(db, user_id_snapshot)
attempt_no = await get_next_credit_attempt_no( attempt_no = await get_next_credit_attempt_no(
db, db,
owner_type=OWNER_GENERATION_RECORD, owner_type=OWNER_GENERATION_RECORD,
owner_id=record.id, owner_id=record.id,
) )
if record.gen_type == GenerationType.video: # Confirm that the bound engine still exists and is active, but never rebuild
# Video generation # the snapshot or replace the user's frozen parameters with current defaults.
if req.aspect_ratio not in ASPECT_RATIOS: if record.gen_type == GenerationType.video.value:
raise HTTPException(status_code=400, detail="不支持的画面比例") await get_video_engine(db, record.engine_id)
if req.resolution not in RESOLUTIONS: else:
raise HTTPException(status_code=400, detail="不支持的分辨率") await get_image_engine(db, record.engine_id)
frozen_engine = _frozen_engine_view(record)
reference_usage = calculate_media_reference_usage(
record.media_references,
include=bool(record.include_media_references),
)
validate_media_reference_usage_for_engine(
reference_usage,
gen_type=record.gen_type,
engine=frozen_engine,
)
duration = record.duration or 5 billing = await charge_generation_media_for_record(
media_billing = await charge_generation_media_by_params( db,
db, record=record,
user_id=current_user.id, project_name=project_name,
record_id=record.id, description_prefix=project_name + "-",
gen_type="video", attempt_no=attempt_no,
duration=duration, engine_id=record.engine_id,
resolution=req.resolution, )
project_name=project_name, record.credits_cost = round(
description_prefix=project_name+"-", float(record.credits_cost or 0) + float(billing.total_charged or 0),
owner_type=OWNER_GENERATION_RECORD, 2,
attempt_no=attempt_no, )
) log_generation_record_config_event(
event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_BILLING_SUCCESS,
event_status=LogEventStatusEnum.SUCCESS,
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
record=record,
detail={
"record_id": record.id,
"attempt_no": attempt_no,
"engine_id": record.engine_id,
"charged": float(billing.total_charged or 0),
"credits_cost_total": record.credits_cost,
},
)
record.aspect_ratio = req.aspect_ratio from app.services.generation.pipeline.generation_record_service import (
record.resolution = req.resolution commit_and_enqueue_generation_record,
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2) prepare_generation_record_execution,
record.status = "generating" )
record.error_message = None
record.video_url = None
record.video_cover_url = None
record.image_url = None
record.seedance_task_id = None
await db.flush()
try: prepare_generation_record_execution(record, attempt_no=attempt_no)
from app.services.video_gen import get_active_engine, submit_video_task await db.flush()
from app.services.error_codes import extract_error_message record_id_snapshot = str(record.id)
from app.services.video_queue import task_queue enqueue_log_record = GenerationRecord(
id=record_id_snapshot,
user_id=user_id_snapshot,
project_id=str(record.project_id),
original_prompt=record.original_prompt or "",
gen_type=record.gen_type,
duration=record.duration,
aspect_ratio=record.aspect_ratio,
resolution=record.resolution,
image_size=record.image_size,
image_proportion=record.image_proportion,
image_px=record.image_px,
engine_id=record.engine_id,
include_media_references=bool(record.include_media_references),
media_references=record.media_references,
)
enqueue_log_detail = {
"record_id": record_id_snapshot,
"attempt_no": attempt_no,
"reason": "generation_record_api_generate",
}
await commit_and_enqueue_generation_record(
db,
record,
reason="generation_record_api_generate",
)
log_generation_record_config_event(
event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_ENQUEUE_SUCCESS,
event_status=LogEventStatusEnum.SUCCESS,
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
record=enqueue_log_record,
detail=enqueue_log_detail,
)
engine = await get_active_engine(db) refreshed = await db.execute(
task_id = await submit_video_task( select(GenerationRecord, Project.name)
db, .join(Project, GenerationRecord.project_id == Project.id)
engine, .where(GenerationRecord.id == record_id_snapshot)
record, .limit(1)
include_media_references=False, )
) refreshed_row = refreshed.first()
record.seedance_task_id = task_id if not refreshed_row:
await db.flush() raise RecordNotFoundError()
await task_queue.enqueue(record_id) record, project_name = refreshed_row
except Exception as e: refs = await resolve_private_portrait_reference_display_urls(
await mark_generation_record_failed_and_refund_once( db,
db, json.loads(record.media_references) if record.media_references else None,
record=record, user_id=user_id_snapshot,
error_message=extract_error_message(e, "视频"), )
)
await db.flush()
elif record.gen_type == GenerationType.image:
image_size = req.image_size or record.image_size or "2K"
media_billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=record.id,
gen_type="image",
image_size=image_size,
project_name=project_name,
description_prefix=project_name+"-",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
)
record.image_size = image_size
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
record.image_url = None
record.video_url = None
record.video_cover_url = None
record.seedance_task_id = None
await db.flush()
try:
from app.services.video_queue import task_queue
await task_queue.enqueue(record_id)
except Exception as e:
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=f"图片任务队列投递失败: {e}",
)
await db.flush()
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return _record_to_out(record, project_name, refs_override=refs) return _record_to_out(record, project_name, refs_override=refs)
@@ -523,74 +507,100 @@ async def retry_generation(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( user_id_snapshot = str(current_user.id)
select(GenerationRecord, Project.name) try:
.join(Project, GenerationRecord.project_id == Project.id) result = await execute_with_lock_timeout(
.where( db,
GenerationRecord.id == record_id, select(GenerationRecord, Project.name)
GenerationRecord.user_id == current_user.id, .join(Project, GenerationRecord.project_id == Project.id)
GenerationRecord.deleted_at.is_(None), .where(
Project.deleted_at.is_(None), GenerationRecord.id == record_id,
GenerationRecord.user_id == user_id_snapshot,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.with_for_update(),
) )
.with_for_update() except DatabaseRowLockBusy as exc:
) raise HTTPException(status_code=409, detail=exc.detail) from exc
row = result.first() row = result.first()
if not row: if not row:
raise RecordNotFoundError() raise RecordNotFoundError()
record, project_name = row record, project_name = row
if record.status != "failed": if record.status != "failed":
raise InvalidStatusError("只有失败的记录可以重试") raise InvalidStatusError("只有失败的记录可以重试")
if record.resource_generation_started_at is None:
raise InvalidStatusError("该记录失败于提词阶段,请重新生成提词")
if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value:
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
if not _record_config_complete(record):
raise InvalidStatusError("该记录缺少冻结的生成配置,请重新生成提词")
await assert_user_resource_capacity_available(db, current_user.id) await assert_user_resource_capacity_available(db, user_id_snapshot)
attempt_no = await get_next_credit_attempt_no( attempt_no = await get_next_credit_attempt_no(
db, db,
owner_type=OWNER_GENERATION_RECORD, owner_type=OWNER_GENERATION_RECORD,
owner_id=record.id, owner_id=record.id,
) )
media_billing = await charge_generation_media_for_record( if record.gen_type == GenerationType.video.value:
await get_video_engine(db, record.engine_id)
else:
await get_image_engine(db, record.engine_id)
frozen_engine = _frozen_engine_view(record)
reference_usage = calculate_media_reference_usage(
record.media_references,
include=bool(record.include_media_references),
)
validate_media_reference_usage_for_engine(
reference_usage,
gen_type=record.gen_type,
engine=frozen_engine,
)
billing = await charge_generation_media_for_record(
db, db,
record=record, record=record,
project_name=project_name, project_name=project_name,
description_prefix="视频重试", description_prefix="资源生成重试-",
attempt_no=attempt_no, attempt_no=attempt_no,
engine_id=record.engine_id,
)
record.credits_cost = round(
float(record.credits_cost or 0) + float(billing.total_charged or 0),
2,
)
record.manual_retry_count = int(record.manual_retry_count or 0) + 1
record.retry_count = int(record.manual_retry_count or 0)
from app.services.generation.pipeline.generation_record_service import (
commit_and_enqueue_generation_record,
prepare_generation_record_execution,
) )
record.status = "generating" prepare_generation_record_execution(record, attempt_no=attempt_no)
record.error_message = None
record.video_url = None
record.video_cover_url = None
record.image_url = None
record.seedance_task_id = None
record.generated_at = None
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
await db.flush() await db.flush()
record_id_snapshot = str(record.id)
await commit_and_enqueue_generation_record(
db,
record,
reason="generation_record_api_retry",
)
try: refreshed = await db.execute(
from app.services.video_queue import task_queue select(GenerationRecord, Project.name)
if record.gen_type == GenerationType.video: .join(Project, GenerationRecord.project_id == Project.id)
from app.services.video_gen import get_active_engine, submit_video_task, extract_error_message .where(GenerationRecord.id == record_id_snapshot)
engine = await get_active_engine(db) .limit(1)
task_id = await submit_video_task( )
db, refreshed_row = refreshed.first()
engine, if not refreshed_row:
record, raise RecordNotFoundError()
include_media_references=False, record, project_name = refreshed_row
) refs = await resolve_private_portrait_reference_display_urls(
record.seedance_task_id = task_id db,
await db.flush() json.loads(record.media_references) if record.media_references else None,
await task_queue.enqueue(record_id) user_id=user_id_snapshot,
except Exception as e: )
from app.services.error_codes import extract_error_message
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=extract_error_message(e, "重试"),
)
await db.flush()
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return _record_to_out(record, project_name, refs_override=refs) return _record_to_out(record, project_name, refs_override=refs)
@@ -663,11 +673,15 @@ async def get_queue_status(
estimated_wait_seconds = None estimated_wait_seconds = None
if record.status == "generating": if record.status == "generating":
resource_started_at = record.resource_generation_started_at or record.created_at
ahead_result = await db.execute( ahead_result = await db.execute(
select(func.count(GenerationRecord.id)).where( select(func.count(GenerationRecord.id)).where(
GenerationRecord.status == "generating", GenerationRecord.status == "generating",
GenerationRecord.deleted_at.is_(None), GenerationRecord.deleted_at.is_(None),
GenerationRecord.created_at < record.created_at, func.coalesce(
GenerationRecord.resource_generation_started_at,
GenerationRecord.created_at,
) < resource_started_at,
) )
) )
ahead = ahead_result.scalar() or 0 ahead = ahead_result.scalar() or 0
@@ -677,112 +691,13 @@ async def get_queue_status(
return { return {
"record_id": record.id, "record_id": record.id,
"status": record.status, "status": record.status,
"pipeline_stage": record.pipeline_stage,
"video_upscale_enabled": bool(record.video_upscale_enabled_snapshot),
"queue_position": queue_position, "queue_position": queue_position,
"estimated_wait_seconds": estimated_wait_seconds, "estimated_wait_seconds": estimated_wait_seconds,
} }
@router.post("/callbacks/seedance")
async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""Receive async callback from Seedance API."""
data = await request.json()
task_id = data.get("id")
task_status = data.get("status")
if not task_id:
return {"message": "ignored"}
result = await db.execute(
select(GenerationRecord).where(
GenerationRecord.seedance_task_id == task_id,
GenerationRecord.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
record = result.scalar_one_or_none()
if not record:
return {"message": "record not found"}
if task_status == "succeeded":
remote_url = data.get("content", {}).get("video_url", "")
record.status = "completed"
storage_path = None
file_size_bytes = 0
# Download video to local storage
if settings.STORAGE_TYPE == "local" and remote_url:
try:
from app.services.video_gen import download_video
date_dir = datetime.now().strftime("%Y/%m/%d")
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record.id}.mp4")
await download_video(remote_url, dest)
record.video_url = f"/generate/videos/{date_dir}/{record.id}.mp4"
cover_url, _cover_storage_path = await async_create_video_cover_for_local_video(
record_id=record.id,
video_path=dest,
date_dir=date_dir,
log_prefix=f"SeedanceCallback视频封面生成 record_id={record.id}",
)
record.video_cover_url = cover_url
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as e:
logger.warning(f"Callback download failed, using remote URL: {e}")
record.video_url = remote_url
else:
record.video_url = remote_url
record.generated_at = datetime.now()
if record.video_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.video_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
)
# Extract video token usage from callback
usage = data.get("usage", {})
if usage:
record.video_tokens_used = usage.get("total_tokens", 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=data)
# Log callback response
from app.services.video_gen import _log_video_response
_log_video_response(record.id, data)
# Notify user
from app.services.notification import create_notification
from app.api.v1.notifications import push_notification_to_user
notif = await create_notification(
db, record.user_id, "视频生成完成",
"您的视频已生成完成,可以查看了。", "video", record.id,
)
await push_notification_to_user(record.user_id, notif)
elif task_status == "failed":
error_message = data.get("error", "视频生成失败")
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=error_message,
)
# Log callback response
from app.services.video_gen import _log_video_response
_log_video_response(record.id, data, error=record.error_message)
# Notify user
from app.services.notification import create_notification
from app.api.v1.notifications import push_notification_to_user
notif = await create_notification(
db, record.user_id, "视频生成失败",
f"视频生成失败:{record.error_message}", "video", record.id,
)
await push_notification_to_user(record.user_id, notif)
await db.flush()
return {"message": "ok"}
@router.post( @router.post(
"/upload-image", "/upload-image",
summary="上传 AI 创作普通参考图片", summary="上传 AI 创作普通参考图片",
+332 -144
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 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 sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User from app.models.user import User
@@ -19,25 +21,40 @@ from app.schemas.generation_ai import (
GenerationAITaskListOut, GenerationAITaskListOut,
GenerationAITaskOut, GenerationAITaskOut,
) )
from app.services.generation_ai_service import ( from app.services.generation.pipeline.db_lock_service import (
create_async_generation_task, DatabaseRowLockBusy,
execute_with_lock_timeout,
)
from app.services.generation.ai.service import (
build_task_out_list,
list_generation_ai_engine_options, list_generation_ai_engine_options,
list_async_generation_tasks, list_async_generation_tasks,
list_generation_history_day_items, list_generation_history_day_items,
list_generation_history_grouped_days, 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, OWNER_CHAT_GENERATION_TASK,
charge_generation_media_by_params, charge_generation_media_by_params,
get_next_credit_attempt_no, get_next_credit_attempt_no,
) )
from app.services.generation_history_delete_service import batch_delete_generation_history_items 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.log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.media_reference_service import calculate_media_reference_usage
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.operation_log_service import log_operation_event
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
router = APIRouter( router = APIRouter(
@@ -146,6 +163,7 @@ async def create_task(
..., ...,
description=( description=(
"AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。" "AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。"
"generation_count 为客户端本次选择的生成数量,默认1,后端会按引擎开关和数量上限校验。"
"枚举:gen_type=image/videomedia_references[].type=image/video/audio" "枚举:gen_type=image/videomedia_references[].type=image/video/audio"
"media_references[].source=upload_resource/private_portrait_asset/空;" "media_references[].source=upload_resource/private_portrait_asset/空;"
"media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。" "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: if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
task = await create_async_generation_task(db, current_user, req) try:
await db.commit() 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( await log_task_event(
task, task_id=top_level_task_id,
event_type="TASK_CREATED", event_type=(
to_status="generating", "TASK_CREATED" if create_result.created else "IDEMPOTENCY_HIT"
to_stage="queued", ),
detail={"gen_type": task.gen_type}, 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 failed_enqueue_ids: list[str] = []
if create_result.created and enqueue_task_ids:
try: failed_enqueue_ids = await enqueue_created_generation_tasks(
chatapi_create_generation_task.delay(task.id)
except Exception as exc:
await mark_chat_generation_task_failed_and_refund_once(
db, db,
task_id=task.id, task_ids=enqueue_task_ids,
error_message=f"任务队列投递失败: {exc}",
pipeline_stage="failed",
) )
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( @router.get(
"/tasks", "/tasks",
@@ -261,10 +333,8 @@ async def list_tasks(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
is_admin = False is_admin = current_user.user_type == "admin"
if current_user.user_type == 'admin': if not is_admin:
is_admin = True
else:
user_id = current_user.id user_id = current_user.id
total, items = await list_async_generation_tasks( total, items = await list_async_generation_tasks(
@@ -280,24 +350,17 @@ async def list_tasks(
created_start=created_start, created_start=created_start,
created_end=created_end, created_end=created_end,
) )
# 同一个 API 同时服务管理后台和客户端:
# ====================== 在这里加排序(最新在前)====================== # - 管理员保持数据库倒序,最新记录在列表上方;
if not is_admin: # - 普通用户先查询最新一页,再仅反转当前页,聊天消息从旧到新排列。
# 按 created_at 降序(没有则用 id 降序) items_for_output = items if is_admin else list(reversed(items))
items_sorted = sorted( out_items = await build_task_out_list(
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(
db, db,
{item.id: record_to_out(task=item, is_admin=is_admin).media_references for item in items_sorted}, items_for_output,
user_id=None if is_admin else current_user.id, 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( @router.get(
"/history", "/history",
@@ -513,8 +576,9 @@ async def list_history_day_items(
summary="获取AI生成任务详情", summary="获取AI生成任务详情",
description=( description=(
"根据任务ID获取当前登录用户的AI生成任务详情。" "根据任务ID获取当前登录用户的AI生成任务详情。"
"只能查询当前用户自己的任务,且只查询 generation_mode=chatapi_async 的任务" "支持 chatapi_async、chatapi_main 和未删除的 chatapi_child"
"如果任务不存在或不属于当前用户,返回404" "查询 chatapi_main 时返回按 generation_index 升序排列的 child_items"
"已软删除 child 只在父任务 child_items 中保留槽位,不能通过 child ID 单独查询。"
), ),
responses={ responses={
200: { 200: {
@@ -541,17 +605,19 @@ async def get_task(
select(ChatGenerationTask).where( select(ChatGenerationTask).where(
ChatGenerationTask.id == task_id, ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id, ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async", ).limit(1)
ChatGenerationTask.deleted_at.is_(None),
)
.limit(1)
) )
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") 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) if task.deleted_at is not None:
return record_to_out(task, media_references=refs) 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( @router.delete(
"/tasks/{task_id}", "/tasks/{task_id}",
@@ -587,38 +653,33 @@ async def delete_task(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( mode_result = await db.execute(
select(ChatGenerationTask).where( select(ChatGenerationTask.generation_mode).where(
ChatGenerationTask.id == task_id, ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id, ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async", ).limit(1)
ChatGenerationTask.deleted_at.is_(None), )
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) else:
) freed_size_bytes = await soft_delete_top_level_task_group(
task = result.scalar_one_or_none() db,
if not task: task_id=task_id,
raise HTTPException(status_code=404, detail="任务不存在") user_id=current_user.id,
)
if task.status == "generating": await db.commit()
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()
return GenerationAITaskDeleteOut( return GenerationAITaskDeleteOut(
message="任务已删除", message="任务已删除",
task_id=task.id, task_id=task_id,
deleted=True, deleted=True,
freed_size_bytes=freed_size_bytes, freed_size_bytes=freed_size_bytes,
) )
@router.post( @router.post(
"/tasks/{task_id}/retry", "/tasks/{task_id}/retry",
response_model=GenerationAIRetryOut, response_model=GenerationAIRetryOut,
@@ -660,78 +721,205 @@ async def retry_task(
if celery_app is None: if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
result = await db.execute( try:
select(ChatGenerationTask).where( result = await execute_with_lock_timeout(
ChatGenerationTask.id == task_id, db,
ChatGenerationTask.user_id == current_user.id, select(ChatGenerationTask).where(
ChatGenerationTask.generation_mode == "chatapi_async", ChatGenerationTask.id == task_id,
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.deleted_at.is_(None),
).with_for_update().limit(1),
) )
.with_for_update() except DatabaseRowLockBusy as exc:
.limit(1) raise HTTPException(status_code=409, detail=exc.detail) from exc
)
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") 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) 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( is_download_retry = bool(
db, target.remote_result_url
owner_type=OWNER_CHAT_GENERATION_TASK, and target.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
owner_id=task.id, )
) if not is_download_retry:
media_billing = await charge_generation_media_by_params( attempt_no = await get_next_credit_attempt_no(
db, db,
user_id=task.user_id, owner_type=OWNER_CHAT_GENERATION_TASK,
record_id=task.id, owner_id=target.id,
gen_type=task.gen_type, )
image_size=task.image_size, quantity = int(target.generation_count or 1) if (
duration=task.duration, target.generation_mode == GenerationMode.CHATAPI_MAIN.value and target.gen_type == "image"
resolution=task.resolution, ) else 1
engine_id=task.engine_id, refs = target.media_references or "[]"
project_name="AI生成任务", if isinstance(refs, str):
description_prefix="Chat任务重试", import json
owner_type=OWNER_CHAT_GENERATION_TASK, try:
attempt_no=attempt_no, 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.status = "generating" target.status = ChatGenerationTaskStatus.GENERATING.value
task.pipeline_stage = "queued" target.error_message = None
task.error_message = None target.poll_count = 0
task.poll_count = 0 target.last_poll_at = None
task.last_poll_at = None target.generated_at = None
task.provider_task_id = None target.manual_retry_count = int(target.manual_retry_count or 0) + 1
task.seedance_task_id = None target.retry_count = int(target.manual_retry_count or 0)
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)
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() 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: requested_enqueue_count = len(enqueue_ids) + len(download_retry_ids)
chatapi_create_generation_task.delay(task.id) failed_total_count = len(failed_enqueue_ids) + len(failed_download_enqueue_ids)
except Exception as exc: if requested_enqueue_count and failed_total_count == requested_enqueue_count:
await mark_chat_generation_task_failed_and_refund_once( raise HTTPException(status_code=503, detail="任务状态已重置,但任务队列投递全部失败,将由恢复任务继续处理")
db,
task_id=task.id,
error_message=f"任务队列投递失败: {exc}",
pipeline_stage="failed",
)
await db.commit()
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( return GenerationAIRetryOut(
id=task.id, id=refreshed_task.id,
status=task.status, status=refreshed_task.status,
pipeline_stage=task.pipeline_stage, pipeline_stage=refreshed_task.pipeline_stage,
message="任务已重新扣费并重新投递", 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 datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect 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.dependencies import get_current_user, get_db
from app.models.user import User 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.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.schemas.hot_opening_replicate import ( from app.schemas.hot_opening_replicate import (
HotOpeningActionOut, HotOpeningActionOut,
@@ -41,11 +49,21 @@ from app.services.hot_opening_replicate_service import (
update_hot_opening_material_input, update_hot_opening_material_input,
update_hot_opening_video_prompt_schema, 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 ( from app.services.module_async_recovery_service import (
OBJECT_MODULE_STEP,
TASK_HOT_IMAGE_PROMPT, TASK_HOT_IMAGE_PROMPT,
TASK_HOT_VIDEO_PROMPT, TASK_HOT_VIDEO_PROMPT,
has_live_object_lock,
register_module_step_task, register_module_step_task,
remove_active_task,
) )
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum 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, 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( async def _reload_project_detail(
db: AsyncSession, db: AsyncSession,
current_user: User, current_user: User,
@@ -160,10 +219,26 @@ async def _mark_dispatch_failed_and_raise(
project_id: str, project_id: str,
step_id: str | None, step_id: str | None,
message: str, message: str,
billing_context: LlmBillingContext | None = 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: if step_id:
try: 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( await mark_hot_opening_step_dispatch_failed(
db, db,
current_user=_user_context(current_user), current_user=_user_context(current_user),
@@ -172,6 +247,23 @@ async def _mark_dispatch_failed_and_raise(
error_message=message, error_message=message,
) )
await db.commit() 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: except Exception as exc:
await db.rollback() await db.rollback()
_log_api_error( _log_api_error(
@@ -190,12 +282,89 @@ async def _mark_dispatch_failed_and_raise(
step_id=step_id, step_id=step_id,
user_id=_safe_user_id(current_user), user_id=_safe_user_id(current_user),
message=message, message=message,
detail={"reason": "celery_dispatch_failed"}, detail={"reason": "celery_dispatch_failed", "compensated": compensated},
error=message, error=message,
) )
raise HTTPException(status_code=503, detail=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( @router.get(
"/spec", "/spec",
response_model=HotOpeningSpecOut, response_model=HotOpeningSpecOut,
@@ -282,29 +451,17 @@ async def create_task(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
try: log_module_event_file(
project = await create_hot_opening_project(db, current_user, req) module=MODULE,
project_id_value = str(project.id) event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
await bind_upload_resources( user_id=current_user.id,
db, message="拦截爆款开头复刻 V1 创建请求",
user_id=current_user.id, detail={"api_version": "v1", "flow_version": "v1"},
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value, )
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value, raise HTTPException(
source_id=project_id_value, status_code=410,
resource_ids=[req.material_video_resource_id, req.material_image_resource_id], detail="V1 创建流程已停止,请使用 V2 API",
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)
@router.get( @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, 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) project_id_value = str(project.id)
step_id_value = str(step.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() await db.commit()
except HTTPException: except HTTPException:
await db.rollback() await db.rollback()
@@ -536,23 +704,19 @@ async def generate_image_prompt(
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
await register_module_step_task( log_celery_dispatch_start(billing_context)
module=MODULE, await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value, project_id=project_id_value,
step_id=step_id_value, step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
task_name=TASK_HOT_IMAGE_PROMPT, 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( return HotOpeningActionOut(
message="图片 AI 提词任务已提交", message="图片 AI 提词任务已提交",
@@ -610,14 +774,21 @@ async def generate_image(
from app.tasks.generation_create_tasks import chatapi_create_generation_task from app.tasks.generation_create_tasks import chatapi_create_generation_task
try: 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: except Exception as exc:
await _mark_dispatch_failed_and_raise( _log_api_error(
db, event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user, current_user=current_user,
project_id=project_id_value, project_id=project_id_value,
step_id=step_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( 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, 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) project_id_value = str(project.id)
step_id_value = str(step.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() await db.commit()
except HTTPException: except HTTPException:
await db.rollback() await db.rollback()
@@ -672,23 +854,19 @@ async def generate_video_prompt(
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
await register_module_step_task( log_celery_dispatch_start(billing_context)
module=MODULE, await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value, project_id=project_id_value,
step_id=step_id_value, step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
task_name=TASK_HOT_VIDEO_PROMPT, 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( return HotOpeningActionOut(
message="视频 AI 提词任务已提交", message="视频 AI 提词任务已提交",
@@ -747,14 +925,21 @@ async def generate_video(
from app.tasks.generation_create_tasks import chatapi_create_generation_task from app.tasks.generation_create_tasks import chatapi_create_generation_task
try: 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: except Exception as exc:
await _mark_dispatch_failed_and_raise( _log_api_error(
db, event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user, current_user=current_user,
project_id=project_id_value, project_id=project_id_value,
step_id=step_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( return HotOpeningActionOut(
+5 -1
View File
@@ -21,7 +21,7 @@ async def list_active_engines(
"""Public endpoint returning active image engine capabilities.""" """Public endpoint returning active image engine capabilities."""
result = await db.execute( result = await db.execute(
select(ImageEngine) select(ImageEngine)
.where(ImageEngine.is_active == True) .where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc()) .order_by(ImageEngine.priority.desc())
) )
engines = result.scalars().all() engines = result.scalars().all()
@@ -45,5 +45,9 @@ async def list_active_engines(
"supported_sizes": sizes, "supported_sizes": sizes,
"default_size": e.default_size, "default_size": e.default_size,
"max_image_count": e.max_image_count, "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} return {"items": items}
+1 -1
View File
@@ -38,10 +38,10 @@ from app.schemas.private_portrait import (
build_private_portrait_enum_meta, build_private_portrait_enum_meta,
) )
from app.services.operation_log_service import log_operation_error, log_operation_event 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 ( from app.services.private_portrait.asset_service import (
DOMAIN, DOMAIN,
asset_to_out, asset_to_out,
get_user_private_portrait_config,
get_validate_session, get_validate_session,
handle_validate_callback, handle_validate_callback,
list_assets, list_assets,
@@ -32,10 +32,10 @@ from app.schemas.private_portrait import (
build_private_portrait_enum_meta, build_private_portrait_enum_meta,
) )
from app.services.operation_log_service import log_operation_error, log_operation_event 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 ( from app.services.private_portrait.asset_service import (
DOMAIN, DOMAIN,
asset_to_out, asset_to_out,
get_user_private_portrait_config,
list_assets, list_assets,
list_selectable_assets, list_selectable_assets,
soft_delete_asset, 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.models.generation_record import GenerationRecord
from app.schemas.project import ProjectCreate, ProjectOut from app.schemas.project import ProjectCreate, ProjectOut
from app.services.resource_accounting_service import soft_delete_generation_record_resources 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 from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
@@ -88,6 +89,10 @@ async def delete_project(
) )
records = list(records_result.scalars().all()) records = list(records_result.scalars().all())
record_ids = [record.id for record in records] 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) now = datetime.now(timezone.utc)
project.deleted_at = now project.deleted_at = now
+465 -75
View File
@@ -2,14 +2,25 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.models.user import User 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 ( from app.enums.shot_replicate import (
ModuleCodeEnum, ModuleCodeEnum,
ShotAnalysisStatusEnum, ShotAnalysisStatusEnum,
@@ -23,7 +34,6 @@ from app.enums.shot_replicate import (
) )
from app.schemas.shot_replicate import ( from app.schemas.shot_replicate import (
ShotReplicateActionOut, ShotReplicateActionOut,
ShotReplicateDeleteOut,
ShotReplicateGenerateImagePromptRequest, ShotReplicateGenerateImagePromptRequest,
ShotReplicateGenerateImageRequest, ShotReplicateGenerateImageRequest,
ShotReplicateGenerateVideoPromptRequest, ShotReplicateGenerateVideoPromptRequest,
@@ -52,7 +62,6 @@ from app.schemas.shot_replicate import (
) )
from app.services.shot_replicate_flow_service import ( from app.services.shot_replicate_flow_service import (
_get_project_for_user, _get_project_for_user,
create_shot_replicate_project_from_segment,
generate_image_from_prompt, generate_image_from_prompt,
generate_video_from_prompt, generate_video_from_prompt,
mark_shot_replicate_step_dispatch_failed, mark_shot_replicate_step_dispatch_failed,
@@ -64,14 +73,17 @@ from app.services.shot_replicate_flow_service import (
update_shot_replicate_video_prompt_schema, update_shot_replicate_video_prompt_schema,
) )
from app.services.shot_replicate_taskset_service import ( from app.services.shot_replicate_taskset_service import (
build_task_set_analysis_billing_context,
create_custom_segment, create_custom_segment,
create_segments_by_ai, create_segments_by_ai,
create_task_set, create_task_set,
delete_segment, delete_segment,
delete_task_set, delete_task_set,
get_segment_for_user,
list_segments, list_segments,
list_task_sets, 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_segment,
prepare_reanalyze_task_set, prepare_reanalyze_task_set,
prepare_retry_split_segment, prepare_retry_split_segment,
@@ -79,13 +91,20 @@ from app.services.shot_replicate_taskset_service import (
task_set_detail, task_set_detail,
) )
from app.services.module_generation_log_service import log_module_error, log_module_event_file 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 ( from app.services.module_async_recovery_service import (
OBJECT_MODULE_STEP,
TASK_SHOT_IMAGE_PROMPT, TASK_SHOT_IMAGE_PROMPT,
TASK_SHOT_VIDEO_PROMPT, TASK_SHOT_VIDEO_PROMPT,
has_live_object_lock,
register_module_step_task, register_module_step_task,
register_shot_segment_analysis_task, remove_active_task,
register_shot_split_task,
register_shot_task_set_analysis_task,
) )
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum 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) 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: async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
project = await _get_project_for_user( project = await _get_project_for_user(
db, db,
@@ -208,9 +304,25 @@ async def _mark_dispatch_failed_and_raise(
project_id: str, project_id: str,
step_id: str | None, step_id: str | None,
message: str, message: str,
billing_context: LlmBillingContext | None = None,
) -> None: ) -> None:
if billing_context is not None:
log_celery_dispatch_failure(billing_context, error=message)
compensated = False
if step_id: if step_id:
try: 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( await mark_shot_replicate_step_dispatch_failed(
db, db,
current_user=_user_context(current_user), current_user=_user_context(current_user),
@@ -219,6 +331,23 @@ async def _mark_dispatch_failed_and_raise(
error_message=message, error_message=message,
) )
await db.commit() 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: except Exception as exc:
await db.rollback() await db.rollback()
_log_api_error( _log_api_error(
@@ -237,12 +366,89 @@ async def _mark_dispatch_failed_and_raise(
step_id=step_id, step_id=step_id,
user_id=_safe_user_id(current_user), user_id=_safe_user_id(current_user),
message=message, message=message,
detail={"reason": "celery_dispatch_failed"}, detail={"reason": "celery_dispatch_failed", "compensated": compensated},
error=message, error=message,
) )
raise HTTPException(status_code=503, detail=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( @router.get(
"/spec", "/spec",
response_model=ShotReplicateSpecOut, 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")) _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
try: try:
task_set = await create_task_set(db, current_user=current_user, req=req) task_set, created_new = await create_task_set(db, current_user=current_user, req=req)
task_set_id = task_set.id 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( await bind_upload_resources(
db, db,
user_id=current_user.id, user_id=current_user.id,
@@ -351,12 +565,19 @@ async def create_shot_task_set(
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}") _log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}") raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
log_celery_dispatch_start(billing_context)
try: try:
from app.tasks.shot_replicate_tasks import analyze_original_video 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(
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0) 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: except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error( _log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user, 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"}, detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc, 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}") 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) 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", "/task-sets/{task_set_id}/reanalyze",
response_model=ShotReanalyzeOut, response_model=ShotReanalyzeOut,
summary="重新投递原视频 AI 分析任务", summary="重新投递原视频 AI 分析任务",
description="用于处理原视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_original_video", description="用于重新处理原视频分析失败的数据;处理中、待处理或已完成状态均拒绝重复投递",
) )
async def reanalyze_task_set( async def reanalyze_task_set(
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
@@ -446,9 +688,18 @@ async def reanalyze_task_set(
db, db,
current_user=current_user, current_user=current_user,
task_set_id=task_set_id, task_set_id=task_set_id,
force=req.force,
reason=req.reason, 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() await db.commit()
except HTTPException as exc: except HTTPException as exc:
await db.rollback() await db.rollback()
@@ -474,11 +725,17 @@ async def reanalyze_task_set(
) )
raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}") raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}")
log_celery_dispatch_start(billing_context)
try: try:
from app.tasks.shot_replicate_tasks import analyze_original_video 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(
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0) 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( log_module_event_file(
module=MODULE, module=MODULE,
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value, 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()}, detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()},
) )
except Exception as exc: except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error( _log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user, current_user=current_user,
@@ -496,6 +754,27 @@ async def reanalyze_task_set(
detail={"task_set_id": task_set_id, "task": "analyze_original_video"}, detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc, 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}") raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}")
out.message = "原视频再次分析任务已提交" out.message = "原视频再次分析任务已提交"
return out return out
@@ -532,8 +811,7 @@ async def split_by_ai(
from app.tasks.shot_replicate_tasks import split_one_segment from app.tasks.shot_replicate_tasks import split_one_segment
for segment_id in segment_ids: 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=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
return out return out
@@ -564,8 +842,38 @@ async def split_custom(
from app.tasks.shot_replicate_tasks import split_one_segment from app.tasks.shot_replicate_tasks import split_one_segment
await register_shot_split_task(segment_id, task_set_id=task_set_id) try:
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)
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 return out
@@ -617,7 +925,7 @@ async def get_segment(
"/segments/{segment_id}/reanalyze", "/segments/{segment_id}/reanalyze",
response_model=ShotReanalyzeOut, response_model=ShotReanalyzeOut,
summary="重新投递切片视频 AI 分析任务", summary="重新投递切片视频 AI 分析任务",
description="用于处理自定义切片视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_custom_segment_video", description="用于重新处理自定义切片视频分析失败的数据;处理中、待处理或已完成状态均拒绝重复投递",
) )
async def reanalyze_segment( async def reanalyze_segment(
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"), segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
@@ -631,10 +939,19 @@ async def reanalyze_segment(
db, db,
current_user=current_user, current_user=current_user,
segment_id=segment_id, segment_id=segment_id,
force=req.force,
reason=req.reason, 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() await db.commit()
except HTTPException as exc: except HTTPException as exc:
await db.rollback() await db.rollback()
@@ -660,11 +977,17 @@ async def reanalyze_segment(
) )
raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}") raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}")
log_celery_dispatch_start(billing_context)
try: try:
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video 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(
analyze_custom_segment_video.apply_async(args=[segment_id], queue="gen_chatapi_create", countdown=0) 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( log_module_event_file(
module=MODULE, module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value, 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()}, detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()},
) )
except Exception as exc: except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error( _log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user, 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"}, detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
exc=exc, 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}") raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}")
out.message = "切片视频再次分析任务已提交" out.message = "切片视频再次分析任务已提交"
return out return out
@@ -730,10 +1076,9 @@ async def retry_split_segment(
try: try:
from app.tasks.shot_replicate_tasks import split_one_segment 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( split_one_segment.apply_async(
args=[segment_id], args=[segment_id],
queue="gen_result_download", queue=CeleryQueue.GEN_SHOT_SPLIT.value,
countdown=0, countdown=0,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
) )
@@ -748,7 +1093,7 @@ async def retry_split_segment(
"segment_id": segment_id, "segment_id": segment_id,
"task_set_id": task_set_id, "task_set_id": task_set_id,
"task": "split_one_segment", "task": "split_one_segment",
"queue": "gen_result_download", "queue": CeleryQueue.GEN_SHOT_SPLIT.value,
"request": req.model_dump(), "request": req.model_dump(),
}, },
) )
@@ -861,24 +1206,17 @@ async def create_replication_project_from_segment(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
try: log_module_event_file(
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True) module=MODULE,
project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req) event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
project_id = project.id user_id=current_user.id,
await db.commit() step_id=segment_id,
except HTTPException: message="拦截拆镜复刻 V1 创建请求",
await db.rollback() detail={"api_version": "v1", "flow_version": "v1", "segment_id": segment_id},
raise )
except Exception as exc: raise HTTPException(
await db.rollback() status_code=410,
_log_api_exception_from_locals(exc, locals(), f"创建拆镜复刻项目失败: {exc}") detail="V1 创建流程已停止,请使用 V2 API",
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),
) )
@@ -1001,7 +1339,18 @@ async def generate_image_prompt(
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try: 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, 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() await db.commit()
except HTTPException: except HTTPException:
await db.rollback() await db.rollback()
@@ -1011,19 +1360,21 @@ async def generate_image_prompt(
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}") _log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=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( log_celery_dispatch_start(billing_context)
module=MODULE, await _dispatch_prompt_task(
project_id=project_id_value, db,
step_id=step_id_value, current_user=current_user,
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, project_id=project_id_value,
task_name=TASK_SHOT_IMAGE_PROMPT, step_id=step_id_value,
) step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0) task_name=TASK_SHOT_IMAGE_PROMPT,
except Exception as exc: celery_task=start_image_prompt_optimize,
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}") 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)) 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: try:
from app.tasks.generation_create_tasks import chatapi_create_generation_task 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: 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)) 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) _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try: 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, 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() await db.commit()
except HTTPException: except HTTPException:
await db.rollback() await db.rollback()
@@ -1098,19 +1473,21 @@ async def generate_video_prompt(
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}") _log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=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( log_celery_dispatch_start(billing_context)
module=MODULE, await _dispatch_prompt_task(
project_id=project_id_value, db,
step_id=step_id_value, current_user=current_user,
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, project_id=project_id_value,
task_name=TASK_SHOT_VIDEO_PROMPT, step_id=step_id_value,
) step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0) task_name=TASK_SHOT_VIDEO_PROMPT,
except Exception as exc: celery_task=start_video_prompt_optimize,
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}") 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)) 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: try:
from app.tasks.generation_create_tasks import chatapi_create_generation_task 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: 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)) return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
+4 -2
View File
@@ -1,6 +1,8 @@
from __future__ import annotations 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 fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import select
@@ -405,7 +407,7 @@ async def export_team_credit_records(
from urllib.parse import quote from urllib.parse import quote
output.seek(0) output.seek(0)
safe_team_name = team.name or "team" 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) encoded_filename = quote(filename)
return StreamingResponse( return StreamingResponse(
iter([output.getvalue()]), iter([output.getvalue()]),
+3 -1
View File
@@ -21,7 +21,7 @@ async def list_active_engines(
"""Public endpoint returning active video engine capabilities.""" """Public endpoint returning active video engine capabilities."""
result = await db.execute( result = await db.execute(
select(VideoEngine) select(VideoEngine)
.where(VideoEngine.is_active == True) .where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc()) .order_by(VideoEngine.priority.desc())
) )
engines = result.scalars().all() engines = result.scalars().all()
@@ -52,6 +52,8 @@ async def list_active_engines(
"max_image_count": e.max_image_count, "max_image_count": e.max_image_count,
"max_video_count": e.max_video_count, "max_video_count": e.max_video_count,
"max_audio_count": e.max_audio_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_first_last_frame": e.supports_first_last_frame,
"supports_universal_reference": e.supports_universal_reference, "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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
from __future__ import annotations
import argparse
import asyncio
import json
from datetime import datetime, timezone
from sqlalchemy import or_, select
from app.config import settings
from app.enums.video_upscale import LOCAL_PROCESSOR_KEYS, VideoUpscaleTaskStatus
from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.video_upscale_task import VideoUpscaleTask
from app.services.video_upscale.media_service import is_valid_file
from app.services.video_upscale.task_service import reset_failed_upscale_task_for_manual_retry
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="人工恢复视频超分任务")
parser.add_argument("--task-id", action="append", default=[], help="ChatGenerationTask.id,可重复传入")
parser.add_argument("--task-ids", default="", help="逗号分隔的 ChatGenerationTask.id")
parser.add_argument("--generation-record-id", action="append", default=[], help="GenerationRecord.id,可重复传入")
parser.add_argument("--generation-record-ids", default="", help="逗号分隔的 GenerationRecord.id")
parser.add_argument("--project-id", action="append", default=[], help="Project.id,可重复传入")
parser.add_argument("--generation-mode", default="", help="按 ChatGenerationTask.generation_mode 筛选")
parser.add_argument("--module-owner-id", default="", help="ModuleGenerationProject.id")
parser.add_argument("--shot-task-set-id", default="", help="ShotReplicateTaskSet.id")
parser.add_argument("--shot-segment-id", action="append", default=[], help="ShotReplicateSegment.id,可重复传入")
parser.add_argument("--failed-only", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--limit", type=int, default=100)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--enqueue", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--force-resubmit", action="store_true", help="远程任务清空 provider task/result 后从 source.mp4 重新提交")
return parser
async def _collect_chat_task_ids(db, args: argparse.Namespace) -> list[str]:
ids = [str(item).strip() for item in args.task_id if str(item).strip()]
ids.extend(item.strip() for item in str(args.task_ids or "").split(",") if item.strip())
project_ids: list[str] = [str(args.module_owner_id).strip()] if args.module_owner_id else []
segment_ids = [str(item).strip() for item in args.shot_segment_id if str(item).strip()]
if args.shot_task_set_id or segment_ids:
query = select(ShotReplicateSegment.module_project_id).where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.module_project_id.is_not(None),
)
if args.shot_task_set_id:
query = query.where(ShotReplicateSegment.task_set_id == str(args.shot_task_set_id).strip())
if segment_ids:
query = query.where(ShotReplicateSegment.id.in_(segment_ids))
result = await db.execute(query)
project_ids.extend(str(value) for value in result.scalars().all() if value)
project_ids = list(dict.fromkeys(item for item in project_ids if item))
if project_ids:
result = await db.execute(
select(ModuleGenerationStep.chat_task_id).where(
ModuleGenerationStep.project_id.in_(project_ids),
ModuleGenerationStep.chat_task_id.isnot(None),
ModuleGenerationStep.deleted_at.is_(None),
)
)
ids.extend(str(value) for value in result.scalars().all() if value)
return list(dict.fromkeys(ids))
async def _collect_generation_record_ids(db, args: argparse.Namespace) -> list[str]:
ids = [str(item).strip() for item in args.generation_record_id if str(item).strip()]
ids.extend(item.strip() for item in str(args.generation_record_ids or "").split(",") if item.strip())
project_ids = [str(item).strip() for item in args.project_id if str(item).strip()]
if project_ids:
result = await db.execute(
select(GenerationRecord.id).where(
GenerationRecord.project_id.in_(project_ids),
GenerationRecord.deleted_at.is_(None),
)
)
ids.extend(str(value) for value in result.scalars().all() if value)
return list(dict.fromkeys(ids))
def _owner_preview(upscale: VideoUpscaleTask, chat: ChatGenerationTask | None, record: GenerationRecord | None) -> dict:
owner = chat or record
return {
"upscale_task_id": upscale.id,
"owner_type": "chat_generation_task" if chat else "generation_record",
"owner_id": owner.id if owner else None,
"chat_task_id": chat.id if chat else None,
"generation_record_id": record.id if record else None,
"project_id": record.project_id if record else None,
"generation_mode": chat.generation_mode if chat else None,
"status": upscale.status,
"stage": upscale.stage,
"processor_key": upscale.processor_key,
"source_local_path": upscale.source_local_path,
"provider_task_id": upscale.provider_task_id,
"provider_output_url_expires_at": upscale.provider_output_url_expires_at,
}
async def _run(args: argparse.Namespace) -> dict:
async with async_session() as db:
chat_ids = await _collect_chat_task_ids(db, args)
record_ids = await _collect_generation_record_ids(db, args)
query = (
select(VideoUpscaleTask, ChatGenerationTask, GenerationRecord)
.outerjoin(ChatGenerationTask, ChatGenerationTask.id == VideoUpscaleTask.chat_generation_task_id)
.outerjoin(GenerationRecord, GenerationRecord.id == VideoUpscaleTask.generation_record_id)
.where(
or_(
(ChatGenerationTask.id.isnot(None) & ChatGenerationTask.deleted_at.is_(None)),
(GenerationRecord.id.isnot(None) & GenerationRecord.deleted_at.is_(None)),
)
)
.order_by(VideoUpscaleTask.updated_at.asc())
.limit(max(1, min(int(args.limit or 100), 1000)))
)
owner_filters = []
if chat_ids:
owner_filters.append(VideoUpscaleTask.chat_generation_task_id.in_(chat_ids))
if record_ids:
owner_filters.append(VideoUpscaleTask.generation_record_id.in_(record_ids))
if owner_filters:
query = query.where(or_(*owner_filters))
if args.generation_mode:
query = query.where(ChatGenerationTask.generation_mode == args.generation_mode)
if args.failed_only:
query = query.where(VideoUpscaleTask.status == VideoUpscaleTaskStatus.FAILED.value)
result = await db.execute(query)
rows = result.all()
preview = [_owner_preview(upscale, chat, record) for upscale, chat, record in rows]
if args.dry_run or not args.enqueue:
return {"dry_run": True, "matched": len(preview), "items": preview}
from app.tasks.video_upscale_tasks import download_remote_result, execute_local, finalize, poll_remote, submit_remote
enqueued = []
for upscale, chat, record in rows:
reset = await reset_failed_upscale_task_for_manual_retry(
db,
upscale_task_id=upscale.id,
force_resubmit=bool(args.force_resubmit),
)
if is_valid_file(reset.final_local_path) and not args.force_resubmit:
action = "finalize"
finalize.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
elif reset.processor_key in LOCAL_PROCESSOR_KEYS:
action = "local"
execute_local.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
else:
expires_at = reset.provider_output_url_expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
remaining = (expires_at - datetime.now(timezone.utc)).total_seconds() if expires_at else None
if reset.provider_output_url and remaining is not None and remaining >= 2 * 3600 and not args.force_resubmit:
action = "download"
download_remote_result.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
elif reset.provider_task_id and not args.force_resubmit:
action = "poll"
poll_remote.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
else:
action = "submit"
submit_remote.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
owner = chat or record
enqueued.append(
{
"upscale_task_id": reset.id,
"owner_type": "chat_generation_task" if chat else "generation_record",
"owner_id": owner.id if owner else None,
"action": action,
}
)
return {"dry_run": False, "matched": len(preview), "enqueued": enqueued}
def main() -> None:
args = _parser().parse_args()
result = asyncio.run(_run(args))
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
if __name__ == "__main__":
main()
+102 -10
View File
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
LLM_API_KEY: str = "" LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o" LLM_MODEL: str = "gpt-4o"
LLM_MOCK: bool = True LLM_MOCK: bool = True
LLM_MEDIA_AS_BASE64: bool = True
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!" ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
@@ -95,6 +96,33 @@ class Settings(BaseSettings):
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。 # - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
# - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。 # - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。
FFMPEG_BIN: str = "" FFMPEG_BIN: str = ""
# 视频超分配置。
# 本地处理器始终复用 FFMPEG_BIN,不允许由管理后台覆盖可执行文件路径。
VOLC_API_KEY: str = ""
VOLC_MEDIAKIT_API_BASE: str = "https://mediakit.cn-beijing.volces.com"
VIDEO_UPSCALE_LOCAL_QUEUE: str = "gen_video_upscale_local"
VIDEO_UPSCALE_REMOTE_QUEUE: str = "gen_video_upscale_remote"
VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS: int = 3600
VIDEO_UPSCALE_MAX_ATTEMPTS: int = 3
VIDEO_UPSCALE_REMOTE_POLL_INTERVAL_SECONDS: int = 30
VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS: int = 7200
VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS: int = 30
VIDEO_UPSCALE_REMOTE_RESULT_DOWNLOAD_TIMEOUT_SECONDS: int = 600
VIDEO_UPSCALE_REMOTE_URL_PROBE_THRESHOLD_SECONDS: int = 600
VIDEO_UPSCALE_REMOTE_URL_PROBE_CONNECT_TIMEOUT_SECONDS: int = 3
VIDEO_UPSCALE_REMOTE_URL_PROBE_READ_TIMEOUT_SECONDS: int = 5
VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS: int = 7200
VIDEO_UPSCALE_TASK_LEASE_SECONDS: int = 30 * 60
VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS: int = 60
VIDEO_UPSCALE_RECOVERY_BATCH_SIZE: int = 50
VIDEO_UPSCALE_RECOVERY_LOCK_KEY: str = "vg:celery:video_upscale_recovery_lock"
VIDEO_UPSCALE_EXECUTION_LOCK_KEY_PREFIX: str = "vg:lock:upscale:execute"
VIDEO_UPSCALE_EXECUTION_LOCK_TTL_SECONDS: int = 30 * 60
VIDEO_UPSCALE_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:video_upscale:active"
VIDEO_UPSCALE_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:video_upscale:active_index"
VIDEO_UPSCALE_STAGE_HANDOFF_DELAY_SECONDS: int = 2
VIDEO_COVER_SEEK_TIME: str = "00:00:01" VIDEO_COVER_SEEK_TIME: str = "00:00:01"
VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00" VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00"
VIDEO_COVER_WIDTH: int = 720 VIDEO_COVER_WIDTH: int = 720
@@ -103,7 +131,7 @@ class Settings(BaseSettings):
CAPTCHA_ENABLED: bool = True CAPTCHA_ENABLED: bool = True
BASE_URL: str = "http://ceshi.apiforeign.minzhong.cn" BASE_URL: str = ""
CORS_ORIGINS: list[str] = ["*"] CORS_ORIGINS: list[str] = ["*"]
@@ -112,19 +140,42 @@ class Settings(BaseSettings):
# ChatAPI async generation pipeline settings # ChatAPI async generation pipeline settings
CELERY_BROKER_URL: str = "" CELERY_BROKER_URL: str = ""
CELERY_RESULT_BACKEND: str = "" CELERY_RESULT_BACKEND: str = ""
# Celery result backend 仅保留近期排障状态;业务恢复以数据库状态和业务日志为准。
CELERY_RESULT_EXPIRES_SECONDS: int = 7200
# Celery async 兼容配置。 # Celery async 兼容配置。
# single_loop:每个 Celery 子进程一个专用 event loop,推荐线上/本地统一使用。 # single_loop:每个 Celery 子进程一个专用 event loop,推荐线上/本地统一使用。
# direct:旧版线程本地 loop 降级模式,建议配合 CELERY_DB_USE_NULLPOOL=true。 # direct:旧版线程本地 loop 降级模式,建议配合 CELERY_DB_USE_NULLPOOL=true。
CELERY_ASYNC_RUNNER_MODE: str = "single_loop" CELERY_ASYNC_RUNNER_MODE: str = "single_loop"
CELERY_DB_USE_NULLPOOL: bool = False CELERY_DB_USE_NULLPOOL: bool = False
CELERY_STARTUP_RECOVERY_ENABLED: bool = True CELERY_STARTUP_RECOVERY_ENABLED: bool = True
CELERY_STARTUP_RECOVERY_DELAY_SECONDS: int = 30
CELERY_RUNTIME_STARTUP_BARRIER_KEY: str = "vg:celery:recovery:startup_barrier"
CELERY_RUNTIME_STARTUP_BARRIER_TTL_SECONDS: int = 120
CELERY_RUNTIME_GLOBAL_RECOVERY_LOCK_KEY: str = "vg:celery:recovery:global"
# Celery Runtime V2 Worker identity。
# worker_instance_id 只由 worker_name + host_boot_id + 主进程启动 token 构成,
# 与 prefork 子进程 PID、threads 线程数和 --concurrency 完全解耦。
CELERY_RUNTIME_SCHEMA_VERSION: int = 2
CELERY_RUNTIME_WORKER_INSTANCE_PREFIX: str = "vg:celery:v2:worker_instance"
CELERY_RUNTIME_WORKER_NAME_INSTANCE_ZSET_PREFIX: str = "vg:celery:v2:worker_name_instances"
CELERY_RUNTIME_WORKER_TASK_SET_PREFIX: str = "vg:celery:v2:worker_tasks"
CELERY_RUNTIME_LOCATION_HASH_KEY: str = "vg:celery:runtime:locations"
CELERY_RUNTIME_WORKER_HEARTBEAT_INTERVAL_SECONDS: int = 30
CELERY_RUNTIME_WORKER_HEARTBEAT_TTL_SECONDS: int = 90
CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS: int = 24 * 60 * 60
CELERY_RUNTIME_WORKER_STALE_GRACE_SECONDS: int = 120
CELERY_RUNTIME_WORKER_STALE_SCAN_INTERVAL_SECONDS: int = 120
CELERY_RUNTIME_WORKER_HEARTBEAT_FAILURE_LOG_THRESHOLD: int = 3
CELERY_RUNTIME_RECONCILE_INTERVAL_SECONDS: int = 300
CELERY_RUNTIME_GC_INTERVAL_SECONDS: int = 600
CELERY_RUNTIME_GC_BATCH_SIZE: int = 500
CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 180 CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 600
CHATAPI_VIDEO_FPS: float = 0.5 CHATAPI_VIDEO_FPS: float = 0.5
CHATAPI_ASYNC_MAX_RETRIES: int = 3 CHATAPI_ASYNC_MAX_RETRIES: int = 3
CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS: int = 30 CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS: int = 30
CHATAPI_ASYNC_POLL_INTERVAL_SECONDS: int = 30 CHATAPI_ASYNC_POLL_INTERVAL_SECONDS: int = 30
CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES: int = 10 CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES: int = 30
# 视频异步生成不再使用 30 分钟最终超时;前 10 分钟高频轮询,之后降频,24 小时最后判定失败才退款。 # 视频异步生成不再使用 30 分钟最终超时;前 10 分钟高频轮询,之后降频,24 小时最后判定失败才退款。
CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS: int = 24 CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS: int = 24
CHATAPI_ASYNC_VIDEO_HIGH_FREQ_MINUTES: int = 10 CHATAPI_ASYNC_VIDEO_HIGH_FREQ_MINUTES: int = 10
@@ -158,18 +209,15 @@ class Settings(BaseSettings):
DOWNLOAD_TASK_LEASE_SECONDS: int = 10 * 60 DOWNLOAD_TASK_LEASE_SECONDS: int = 10 * 60
DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS: int = 5 * 60 DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS: int = 5 * 60
DOWNLOAD_RECOVERY_BATCH_SIZE: int = 20 DOWNLOAD_RECOVERY_BATCH_SIZE: int = 20
DOWNLOAD_RECOVERY_STARTUP_DELAY_SECONDS: int = 3
# 下载恢复自循环:不依赖 Celery beat,不新增 worker;由 gen_result_download 队列周期扫描 DB/Redis。
DOWNLOAD_RECOVERY_LOOP_ENABLED: bool = False
DOWNLOAD_RECOVERY_INTERVAL_SECONDS: int = 60 DOWNLOAD_RECOVERY_INTERVAL_SECONDS: int = 60
DOWNLOAD_RECOVERY_LOOP_LOCK_KEY: str = "vg:celery:download_recovery_loop_lock"
DOWNLOAD_RECOVERY_LOOP_LOCK_TTL_SECONDS: int = 55
DOWNLOAD_RETRY_COUNTDOWN_EXTRA_SECONDS: int = 1 DOWNLOAD_RETRY_COUNTDOWN_EXTRA_SECONDS: int = 1
DOWNLOAD_NON_RETRYABLE_LOCAL_ERRORS: bool = True DOWNLOAD_NON_RETRYABLE_LOCAL_ERRORS: bool = True
DOWNLOAD_EVENT_VERBOSE_ENABLED: bool = True DOWNLOAD_EVENT_VERBOSE_ENABLED: bool = True
MEDIA_TOKEN_SNAPSHOT_ENABLED: bool = True MEDIA_TOKEN_SNAPSHOT_ENABLED: bool = True
DOWNLOAD_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:download:active" DOWNLOAD_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:download:active"
DOWNLOAD_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:download:active_index" DOWNLOAD_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:download:active_index"
GENERATION_CREATE_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:generation_create:active"
GENERATION_CREATE_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:generation_create:active_index"
# Celery 生成链路 / provider poll 容灾配置。 # Celery 生成链路 / provider poll 容灾配置。
# 说明: # 说明:
@@ -178,6 +226,9 @@ class Settings(BaseSettings):
# - poll active 使用独立 Redis key,避免影响稳定的下载 active 注册表。 # - poll active 使用独立 Redis key,避免影响稳定的下载 active 注册表。
GENERATION_RECOVERY_BATCH_SIZE: int = 20 GENERATION_RECOVERY_BATCH_SIZE: int = 20
GENERATION_RECOVERY_MAX_ROUNDS: int = 1 GENERATION_RECOVERY_MAX_ROUNDS: int = 1
GENERATION_CREATE_RECOVERY_INTERVAL_SECONDS: int = 60
GENERATION_CREATE_QUEUE_TIMEOUT_SECONDS: int = 5 * 60
MODULE_ASYNC_RECOVERY_INTERVAL_SECONDS: int = 60
POLL_RECOVERY_BATCH_SIZE: int = 20 POLL_RECOVERY_BATCH_SIZE: int = 20
POLL_TASK_LEASE_SECONDS: int = 5 * 60 POLL_TASK_LEASE_SECONDS: int = 5 * 60
POLL_TASK_QUEUE_TIMEOUT_SECONDS: int = 2 * 60 POLL_TASK_QUEUE_TIMEOUT_SECONDS: int = 2 * 60
@@ -194,6 +245,7 @@ class Settings(BaseSettings):
DOWNLOAD_RECOVERY_LOCK_KEY: str = "vg:celery:download_recovery_lock" DOWNLOAD_RECOVERY_LOCK_KEY: str = "vg:celery:download_recovery_lock"
MODULE_ASYNC_RECOVERY_LOCK_KEY: str = "vg:celery:module_async_recovery_lock" MODULE_ASYNC_RECOVERY_LOCK_KEY: str = "vg:celery:module_async_recovery_lock"
SHOT_SPLIT_RECOVERY_LOCK_KEY: str = "vg:celery:shot_split_recovery_lock" SHOT_SPLIT_RECOVERY_LOCK_KEY: str = "vg:celery:shot_split_recovery_lock"
SHOT_ANALYSIS_RECOVERY_LOCK_KEY: str = "vg:celery:shot_analysis_recovery_lock"
# 视频到期轮询调度。 # 视频到期轮询调度。
# Celery Beat 每分钟投递轻量 dispatcher 到 gen_recoverydispatcher 只扫描 next_poll_at 到期的视频任务。 # Celery Beat 每分钟投递轻量 dispatcher 到 gen_recoverydispatcher 只扫描 next_poll_at 到期的视频任务。
@@ -203,6 +255,22 @@ class Settings(BaseSettings):
POLL_DUE_DISPATCH_LOCK_KEY: str = "vg:celery:poll_due_dispatch_lock" POLL_DUE_DISPATCH_LOCK_KEY: str = "vg:celery:poll_due_dispatch_lock"
POLL_DUE_DISPATCH_LOCK_TTL_SECONDS: int = 55 POLL_DUE_DISPATCH_LOCK_TTL_SECONDS: int = 55
# Redis execution locks. These locks are fail-closed: when Redis is
# unavailable, the current Celery task retries and does not fall back to an
# unlocked database-only execution path.
GENERATION_CREATE_LOCK_KEY_PREFIX: str = "vg:lock:generation:create"
GENERATION_POLL_LOCK_KEY_PREFIX: str = "vg:lock:generation:poll"
GENERATION_DOWNLOAD_LOCK_KEY_PREFIX: str = "vg:lock:generation:download"
GENERATION_CREATE_LOCK_TTL_SECONDS: int = 10 * 60
GENERATION_POLL_LOCK_TTL_SECONDS: int = 5 * 60
GENERATION_DOWNLOAD_LOCK_TTL_SECONDS: int = 10 * 60
REDIS_EXECUTION_LOCK_RENEW_INTERVAL_SECONDS: int = 30
# PostgreSQL short row-lock wait and whole ordinary download timeout.
# The lock timeout is transaction-local (SET LOCAL), not a global DB setting.
GENERATION_DB_LOCK_TIMEOUT_SECONDS: int = 5
GENERATION_DOWNLOAD_TOTAL_TIMEOUT_SECONDS: int = 8 * 60
# 模块异步任务容灾配置。 # 模块异步任务容灾配置。
# 覆盖 ModuleGenerationStep 提词任务、shot 原视频/片段分析、shot ffmpeg 切割 active 注册。 # 覆盖 ModuleGenerationStep 提词任务、shot 原视频/片段分析、shot ffmpeg 切割 active 注册。
# 恢复扫描走 CELERY_RECOVERY_QUEUE,真实业务任务回到原始队列。 # 恢复扫描走 CELERY_RECOVERY_QUEUE,真实业务任务回到原始队列。
@@ -229,8 +297,17 @@ class Settings(BaseSettings):
# 拆镜复刻配置。 # 拆镜复刻配置。
# 原始上传视频和拆镜片段都属于 uploads 素材域;只有 generate 生成结果走 token 验签。 # 原始上传视频和拆镜片段都属于 uploads 素材域;只有 generate 生成结果走 token 验签。
SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 3600 SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 3600
SHOT_ANALYSIS_SOFT_TIME_LIMIT_SECONDS: int = 3720
SHOT_ANALYSIS_TIME_LIMIT_SECONDS: int = 3900
SHOT_ANALYSIS_QUEUE: str = "gen_shot_analysis"
SHOT_ANALYSIS_LOCK_TTL_SECONDS: int = 180
SHOT_ANALYSIS_LEASE_SECONDS: int = 180
SHOT_ANALYSIS_HEARTBEAT_INTERVAL_SECONDS: int = 30
SHOT_ANALYSIS_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:shot_analysis:active"
SHOT_ANALYSIS_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:shot_analysis:active_index"
SHOT_ANALYSIS_LOCK_KEY_PREFIX: str = "vg:lock:shot_analysis"
SHOT_ANALYSIS_TEMPERATURE: float = 0.1 SHOT_ANALYSIS_TEMPERATURE: float = 0.1
SHOT_ANALYSIS_MAX_TOKENS: int = 5000 SHOT_ANALYSIS_MAX_TOKENS: int = 10000
SHOT_ANALYSIS_VIDEO_FPS: float = 1.0 SHOT_ANALYSIS_VIDEO_FPS: float = 1.0
SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB: int = 45 SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB: int = 45
@@ -244,7 +321,10 @@ class Settings(BaseSettings):
SHOT_FFPROBE_TIMEOUT_SECONDS: int = 20 SHOT_FFPROBE_TIMEOUT_SECONDS: int = 20
FFPROBE_BIN: str = "" FFPROBE_BIN: str = ""
# 继续复用 gen_result_download 队列,但限制 ffmpeg 并发,避免拖慢 Chat 下载 # 拆镜切片使用独立队列,避免 FFmpeg 占用用户结果下载 worker
SHOT_SPLIT_QUEUE: str = "gen_shot_split"
SHOT_SPLIT_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:shot_split:active"
SHOT_SPLIT_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:shot_split:active_index"
SHOT_SPLIT_MAX_CONCURRENT: int = 1 SHOT_SPLIT_MAX_CONCURRENT: int = 1
SHOT_SPLIT_MAX_RETRY_COUNT: int = 3 SHOT_SPLIT_MAX_RETRY_COUNT: int = 3
SHOT_SPLIT_RETRY_BACKOFF_SECONDS: int = 30 SHOT_SPLIT_RETRY_BACKOFF_SECONDS: int = 30
@@ -254,6 +334,18 @@ class Settings(BaseSettings):
SHOT_SPLIT_LOCK_KEY_PREFIX: str = "vg:shot_replicate:split:lock" SHOT_SPLIT_LOCK_KEY_PREFIX: str = "vg:shot_replicate:split:lock"
SHOT_SPLIT_SEMAPHORE_KEY_PREFIX: str = "vg:shot_replicate:split:semaphore" SHOT_SPLIT_SEMAPHORE_KEY_PREFIX: str = "vg:shot_replicate:split:semaphore"
# 私域素材 Celery Runtime。轮询与远程删除使用对象级原子锁,Redis 不可用时停止外部调用。
PRIVATE_PORTRAIT_POLL_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:private_portrait_poll:active"
PRIVATE_PORTRAIT_POLL_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:private_portrait_poll:active_index"
PRIVATE_PORTRAIT_POLL_LOCK_KEY_PREFIX: str = "vg:lock:private_portrait:poll"
PRIVATE_PORTRAIT_DELETE_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:private_portrait_delete:active"
PRIVATE_PORTRAIT_DELETE_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:private_portrait_delete:active_index"
PRIVATE_PORTRAIT_DELETE_LOCK_KEY_PREFIX: str = "vg:lock:private_portrait:delete"
PRIVATE_PORTRAIT_RUNTIME_LOCK_TTL_SECONDS: int = 180
PRIVATE_PORTRAIT_RUNTIME_HEARTBEAT_SECONDS: int = 30
PRIVATE_PORTRAIT_DISPATCH_LOCK_KEY: str = "vg:celery:private_portrait:dispatch_lock"
PRIVATE_PORTRAIT_DELETE_RECOVERY_LOCK_KEY: str = "vg:celery:private_portrait:delete_recovery_lock"
SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4 SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4
SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16" SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16"
SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p" SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p"
+9 -2
View File
@@ -98,10 +98,16 @@ async def get_optional_current_user(
async def get_admin_user( async def get_admin_user(
current_user: User = Depends(get_current_user_allow_password_pending), current_user: User = Depends(get_current_user_allow_password_pending),
) -> User: ) -> User:
if not current_user.is_admin or current_user.user_type != "admin": """验证后台用户权限。
- user_type="admin" 的后台用户即可通过含非管理员子账号
- 前端通过 allowed_menus 控制非管理员子账号的菜单可见性
- 非后台用户: 403 拒绝
"""
if current_user.user_type != "admin":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限", detail="需要后台用户权限",
) )
return current_user return current_user
@@ -109,6 +115,7 @@ async def get_admin_user(
async def get_backend_user( async def get_backend_user(
current_user: User = Depends(get_current_user_allow_password_pending), current_user: User = Depends(get_current_user_allow_password_pending),
) -> User: ) -> User:
"""与 get_admin_user 等价: 验证 user_type="admin" 的后台用户。"""
if current_user.user_type != "admin": if current_user.user_type != "admin":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
+3
View File
@@ -18,3 +18,6 @@ from app.enums.celery_queue import *
from app.enums.audio_reference import * from app.enums.audio_reference import *
from app.enums.private_portrait import * from app.enums.private_portrait import *
from app.enums.generation_provider import *
from app.enums.generation_record import *
+18
View File
@@ -5,8 +5,12 @@ class CeleryQueue(str, Enum):
GEN_CHATAPI_CREATE = "gen_chatapi_create" GEN_CHATAPI_CREATE = "gen_chatapi_create"
GEN_PROVIDER_POLL = "gen_provider_poll" GEN_PROVIDER_POLL = "gen_provider_poll"
GEN_RESULT_DOWNLOAD = "gen_result_download" GEN_RESULT_DOWNLOAD = "gen_result_download"
GEN_VIDEO_UPSCALE_LOCAL = "gen_video_upscale_local"
GEN_VIDEO_UPSCALE_REMOTE = "gen_video_upscale_remote"
GEN_RECOVERY = "gen_recovery" GEN_RECOVERY = "gen_recovery"
GEN_PRIVATE_PORTRAIT = "gen_private_portrait" GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
GEN_SHOT_ANALYSIS = "gen_shot_analysis"
GEN_SHOT_SPLIT = "gen_shot_split"
DEFAULT = "default" DEFAULT = "default"
@@ -16,12 +20,26 @@ class CeleryTaskName(str, Enum):
DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task" DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task"
RECOVER_DOWNLOAD = "generation.recover_download_tasks_once" RECOVER_DOWNLOAD = "generation.recover_download_tasks_once"
RECOVER_GENERATION = "generation.recover_generation_tasks_once" RECOVER_GENERATION = "generation.recover_generation_tasks_once"
RECOVER_CREATE = "generation.recover_create_tasks_once"
VIDEO_UPSCALE_EXECUTE_LOCAL = "video_upscale.execute_local"
VIDEO_UPSCALE_SUBMIT_REMOTE = "video_upscale.submit_remote"
VIDEO_UPSCALE_POLL_REMOTE = "video_upscale.poll_remote"
VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT = "video_upscale.download_remote_result"
VIDEO_UPSCALE_FINALIZE = "video_upscale.finalize"
VIDEO_UPSCALE_RECOVER = "video_upscale.recover_once"
DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks" DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks"
STARTUP_RECOVERY = "recovery.startup_recovery_once" STARTUP_RECOVERY = "recovery.startup_recovery_once"
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once" MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
SHOT_SPLIT_RECOVERY = "shot_replicate.recover_split_tasks_once" SHOT_SPLIT_RECOVERY = "shot_replicate.recover_split_tasks_once"
SHOT_ANALYSIS_RECOVERY = "shot_replicate.recover_analysis_tasks_once"
CELERY_RUNTIME_RECONCILE = "celery_runtime.reconcile_once"
CELERY_RUNTIME_GC = "celery_runtime.registry_gc_once"
SHOT_ANALYZE_ORIGINAL = "shot_replicate.analyze_original_video"
SHOT_ANALYZE_CUSTOM_SEGMENT = "shot_replicate.analyze_custom_segment_video"
SHOT_SPLIT_ONE = "shot_replicate.split_one_segment"
PRIVATE_PORTRAIT_POLL_ASSET = "private_portrait.poll_asset_status" PRIVATE_PORTRAIT_POLL_ASSET = "private_portrait.poll_asset_status"
PRIVATE_PORTRAIT_SYNC_DUE_ASSETS = "private_portrait.sync_due_assets" PRIVATE_PORTRAIT_SYNC_DUE_ASSETS = "private_portrait.sync_due_assets"
PRIVATE_PORTRAIT_DELETE_ASSET = "private_portrait.delete_asset_remote" PRIVATE_PORTRAIT_DELETE_ASSET = "private_portrait.delete_asset_remote"
PRIVATE_PORTRAIT_DELETE_GROUP = "private_portrait.delete_group_remote"
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote" PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes" PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from enum import StrEnum
class CeleryRuntimeDomain(StrEnum):
GENERATION_CREATE = "generation_create"
GENERATION_POLL = "generation_poll"
GENERATION_DOWNLOAD = "generation_download"
MODULE_ASYNC = "module_async"
SHOT_ANALYSIS = "shot_analysis"
SHOT_SPLIT = "shot_split"
VIDEO_UPSCALE = "video_upscale"
PRIVATE_PORTRAIT_POLL = "private_portrait_poll"
PRIVATE_PORTRAIT_DELETE = "private_portrait_delete"
class CeleryRuntimeState(StrEnum):
ACTIVE = "active"
WAITING_LOCK_EXPIRE = "waiting_lock_expire"
WAITING_DB_LEASE = "waiting_db_lease"
RECOVERY_CANDIDATE = "recovery_candidate"
class WorkerIdentityQuality(StrEnum):
FULL = "full"
INSTANCE_TOKEN_ONLY = "instance_token_only"
SINGLE_PROCESS = "single_process"
FALLBACK = "fallback"
class CeleryRuntimeEvent(StrEnum):
WORKER_IDENTITY_INITIALIZED = "CELERY_WORKER_IDENTITY_INITIALIZED"
WORKER_IDENTITY_FALLBACK = "CELERY_WORKER_IDENTITY_FALLBACK"
WORKER_REGISTERED = "CELERY_WORKER_REGISTERED"
WORKER_HEARTBEAT_LOST = "CELERY_WORKER_HEARTBEAT_LOST"
WORKER_DUPLICATE_NAME = "CELERY_WORKER_DUPLICATE_NAME"
WORKER_STALE_INSTANCE_FOUND = "CELERY_WORKER_STALE_INSTANCE_FOUND"
WORKER_INSTANCE_RECOVERY_START = "CELERY_WORKER_INSTANCE_RECOVERY_START"
WORKER_INSTANCE_RECOVERY_DONE = "CELERY_WORKER_INSTANCE_RECOVERY_DONE"
WORKER_SHUTDOWN = "CELERY_WORKER_SHUTDOWN"
RUNTIME_ACQUIRE_START = "CELERY_RUNTIME_ACQUIRE_START"
RUNTIME_ACQUIRED = "CELERY_RUNTIME_ACQUIRED"
RUNTIME_LOCK_HELD = "CELERY_RUNTIME_LOCK_HELD"
RUNTIME_REDIS_UNAVAILABLE = "CELERY_RUNTIME_REDIS_UNAVAILABLE"
RUNTIME_HEARTBEAT_LOST = "CELERY_RUNTIME_HEARTBEAT_LOST"
RUNTIME_DB_LEASE_LOST = "CELERY_RUNTIME_DB_LEASE_LOST"
RUNTIME_RESULT_DISCARDED = "CELERY_RUNTIME_RESULT_DISCARDED"
RUNTIME_COMPLETED = "CELERY_RUNTIME_COMPLETED"
RECOVERY_BARRIER_SKIPPED = "CELERY_RECOVERY_BARRIER_SKIPPED"
RECOVERY_LIVE_LOCK_SKIPPED = "CELERY_RECOVERY_LIVE_LOCK_SKIPPED"
RECOVERY_WAIT_DB_LEASE = "CELERY_RECOVERY_WAIT_DB_LEASE"
RECOVERY_CAS_ACQUIRED = "CELERY_RECOVERY_CAS_ACQUIRED"
RECOVERY_REQUEUED = "CELERY_RECOVERY_REQUEUED"
REGISTRY_RECONCILE_DONE = "CELERY_REGISTRY_RECONCILE_DONE"
REGISTRY_GC_DONE = "CELERY_REGISTRY_GC_DONE"
+33
View File
@@ -26,6 +26,14 @@ class LogSourceEnum(StrEnum):
CELERY = "celery" CELERY = "celery"
RECOVERY = "recovery" RECOVERY = "recovery"
REMOTE_API = "remote_api" REMOTE_API = "remote_api"
CLI = "cli"
class ModuleGenerationFlowVersionEnum(StrEnum):
"""模块生成项目流程版本。"""
V1 = "v1"
V2 = "v2"
class ModuleProjectStatusEnum(StrEnum): class ModuleProjectStatusEnum(StrEnum):
"""通用模块项目状态。""" """通用模块项目状态。"""
@@ -72,6 +80,19 @@ class ModuleEventTypeEnum(StrEnum):
MEDIA_REFUND = "MEDIA_REFUND" MEDIA_REFUND = "MEDIA_REFUND"
PROMPT_BILLING_SUCCESS = "PROMPT_BILLING_SUCCESS" PROMPT_BILLING_SUCCESS = "PROMPT_BILLING_SUCCESS"
PROMPT_BILLING_FAILED = "PROMPT_BILLING_FAILED" PROMPT_BILLING_FAILED = "PROMPT_BILLING_FAILED"
V1_CREATE_BLOCKED = "V1_CREATE_BLOCKED"
FLOW_VERSION_MISMATCH = "FLOW_VERSION_MISMATCH"
V2_PROJECT_CREATED = "V2_PROJECT_CREATED"
V2_VIDEO_PROMPT_AUTO_CREATED = "V2_VIDEO_PROMPT_AUTO_CREATED"
V2_VIDEO_PROMPT_REGENERATED = "V2_VIDEO_PROMPT_REGENERATED"
V2_VIDEO_PROMPT_DISPATCHED = "V2_VIDEO_PROMPT_DISPATCHED"
V2_VIDEO_PROMPT_REGISTRY_FAILED = "V2_VIDEO_PROMPT_REGISTRY_FAILED"
V2_VIDEO_PROMPT_DISPATCH_FAILED = "V2_VIDEO_PROMPT_DISPATCH_FAILED"
STEP_SUPERSEDED = "STEP_SUPERSEDED"
STALE_STEP_RESULT_DISCARDED = "STALE_STEP_RESULT_DISCARDED"
GENERATION_REFERENCE_OPTION_SAVED = "GENERATION_REFERENCE_OPTION_SAVED"
GENERATION_REFERENCE_INCLUDED = "GENERATION_REFERENCE_INCLUDED"
GENERATION_REFERENCE_EXCLUDED = "GENERATION_REFERENCE_EXCLUDED"
class ModulePromptTypeEnum(StrEnum): class ModulePromptTypeEnum(StrEnum):
@@ -100,3 +121,15 @@ VIDEO_SCHEMA_MAX_SECTION_COUNT = 40
VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION = 80 VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION = 80
VIDEO_SCHEMA_MAX_TIME_RULE_COUNT = 30 VIDEO_SCHEMA_MAX_TIME_RULE_COUNT = 30
VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE = 12 VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE = 12
MIN_GENERATION_COUNT = 1
MAX_GENERATION_COUNT = 5
class BillingBlockEventEnum(StrEnum):
"""通用账务拦截日志事件。"""
INSUFFICIENT_CREDITS = "BILLING_BLOCKED_INSUFFICIENT_CREDITS"
NEGATIVE_BALANCE = "BILLING_BLOCKED_NEGATIVE_BALANCE"
+10
View File
@@ -56,6 +56,8 @@ class CreditRecordMediaType(str, Enum):
class CreditRecordAction(str, Enum): class CreditRecordAction(str, Enum):
CHARGE = "charge" CHARGE = "charge"
REFUND = "refund" REFUND = "refund"
HOLD = "hold"
HOLD_RELEASE = "hold_release"
class CreditRecordSourceModule(str, Enum): class CreditRecordSourceModule(str, Enum):
@@ -112,6 +114,14 @@ class CreditRecordBillingScene(str, Enum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
CREDIT_RECORD_ACTION_LABELS = {
CreditRecordAction.CHARGE.value: "真实扣费",
CreditRecordAction.REFUND.value: "真实退款",
CreditRecordAction.HOLD.value: "预扣占用",
CreditRecordAction.HOLD_RELEASE.value: "预扣释放",
}
CREDIT_RECORD_TYPE_LABELS = { CREDIT_RECORD_TYPE_LABELS = {
CreditRecordType.RECHARGE.value: "充值", CreditRecordType.RECHARGE.value: "充值",
CreditRecordType.CONSUME.value: "消费", CreditRecordType.CONSUME.value: "消费",
+28 -7
View File
@@ -45,17 +45,28 @@ GENERATION_HISTORY_MODULE_SOURCES: tuple[GenerationHistorySourceEnum, ...] = (
"""需要回填 module_generation_projects/module_generation_steps 的模块来源集合。""" """需要回填 module_generation_projects/module_generation_steps 的模块来源集合。"""
GENERATION_HISTORY_SOURCE_TO_TASK_MODE: dict[GenerationHistorySourceEnum, GenerationMode] = { GENERATION_HISTORY_SOURCE_TO_TASK_MODES: dict[GenerationHistorySourceEnum, tuple[GenerationMode, ...]] = {
GenerationHistorySourceEnum.CHAT_TASK: GenerationMode.CHATAPI_ASYNC, GenerationHistorySourceEnum.CHAT_TASK: (
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE, GenerationMode.CHATAPI_ASYNC,
GenerationHistorySourceEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE, GenerationMode.CHATAPI_CHILD,
),
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE: (GenerationMode.HOT_OPENING_REPLICATE,),
GenerationHistorySourceEnum.SHOT_REPLICATE: (GenerationMode.SHOT_REPLICATE,),
} }
"""history_source 到 ChatGenerationTask.generation_mode 的映射。""" """history_source 到 ChatGenerationTask.generation_mode 集合的映射。"""
GENERATION_HISTORY_SOURCE_TO_TASK_MODE: dict[GenerationHistorySourceEnum, GenerationMode] = {
history_source: task_modes[0]
for history_source, task_modes in GENERATION_HISTORY_SOURCE_TO_TASK_MODES.items()
}
"""兼容旧调用的单一模式映射;新查询应使用 GENERATION_HISTORY_SOURCE_TO_TASK_MODES。"""
GENERATION_HISTORY_TASK_MODE_VALUE_TO_SOURCE: dict[str, GenerationHistorySourceEnum] = { GENERATION_HISTORY_TASK_MODE_VALUE_TO_SOURCE: dict[str, GenerationHistorySourceEnum] = {
task_mode.value: history_source task_mode.value: history_source
for history_source, task_mode in GENERATION_HISTORY_SOURCE_TO_TASK_MODE.items() for history_source, task_modes in GENERATION_HISTORY_SOURCE_TO_TASK_MODES.items()
for task_mode in task_modes
} }
"""ChatGenerationTask.generation_mode 字符串值到 history_source 的映射。""" """ChatGenerationTask.generation_mode 字符串值到 history_source 的映射。"""
@@ -103,11 +114,17 @@ def get_generation_history_source_label(source: GenerationHistorySourceEnum | st
def get_generation_history_task_mode(source: GenerationHistorySourceEnum) -> GenerationMode | None: def get_generation_history_task_mode(source: GenerationHistorySourceEnum) -> GenerationMode | None:
"""获取 history_source 对应的 ChatGenerationTask.generation_mode""" """兼容旧调用:返回 history_source 对应的第一个任务模式"""
return GENERATION_HISTORY_SOURCE_TO_TASK_MODE.get(source) return GENERATION_HISTORY_SOURCE_TO_TASK_MODE.get(source)
def get_generation_history_task_modes(source: GenerationHistorySourceEnum) -> tuple[GenerationMode, ...]:
"""获取 history_source 对应的全部 ChatGenerationTask.generation_mode。"""
return GENERATION_HISTORY_SOURCE_TO_TASK_MODES.get(source, ())
def is_generation_history_chat_task_source(source: GenerationHistorySourceEnum) -> bool: def is_generation_history_chat_task_source(source: GenerationHistorySourceEnum) -> bool:
"""判断当前来源是否走 chat_generation_tasks 表。""" """判断当前来源是否走 chat_generation_tasks 表。"""
@@ -121,3 +138,7 @@ def is_generation_history_module_source(source: GenerationHistorySourceEnum) ->
MAX_BATCH_DELETE_COUNT = 30 MAX_BATCH_DELETE_COUNT = 30
HISTORY_DAY_PAGE_SIZE_MAX = 10
HISTORY_GROUP_ITEM_LIMIT = 10
@@ -0,0 +1,40 @@
from enum import StrEnum
class GenerationProviderResultType(StrEnum):
IMAGE = "image"
VIDEO = "video"
class GenerationProviderTaskPhase(StrEnum):
SUBMITTED = "submitted"
POLLING = "polling"
RESULT_READY = "result_ready"
DOWNLOAD_PENDING = "download_pending"
COMPLETED = "completed"
FAILED = "failed"
class ImageProviderErrorType(StrEnum):
TIMEOUT = "timeout"
NETWORK = "network"
RATE_LIMIT = "rate_limit"
AUTH = "auth"
INVALID_REQUEST = "invalid_request"
CAPABILITY_MISMATCH = "capability_mismatch"
CONTENT_REJECTED = "content_rejected"
PROVIDER_INTERNAL = "provider_internal"
INVALID_RESPONSE = "invalid_response"
UNKNOWN = "unknown"
IMAGE_MULTI_OUTPUT_MIN = 1
IMAGE_MULTI_OUTPUT_MAX = 15
IMAGE_MULTI_REFERENCE_MAX = 14
IMAGE_PROVIDER_CLAIM_LEASE_SECONDS = 10 * 60
MULTI_IMAGE_PROMPT_TEMPLATE = (
"请严格生成恰好{count}张内容相关但画面具有明显差异的图片。"
"每张图片必须作为独立图片分别输出,不要把多个画面拼接到同一张图片中,"
"不要生成九宫格、分镜图、组合图或包含多张子图的单张图片。"
)
@@ -0,0 +1,37 @@
from __future__ import annotations
from enum import StrEnum
class GenerationRecordConfigSourceEnum(StrEnum):
"""生成记录配置冻结来源。"""
PROMPT_OPTIMIZE = "prompt_optimize"
LEGACY_GENERATE_FALLBACK = "legacy_generate_fallback"
EXISTING_FROZEN_CONFIG = "existing_frozen_config"
class GenerationRecordEventTypeEnum(StrEnum):
"""GenerationRecord 用户生成链路事件。"""
PROMPT_CONFIG_VALIDATE_START = "PROMPT_CONFIG_VALIDATE_START"
PROMPT_CONFIG_VALIDATE_SUCCESS = "PROMPT_CONFIG_VALIDATE_SUCCESS"
PROMPT_CONFIG_VALIDATE_FAILED = "PROMPT_CONFIG_VALIDATE_FAILED"
PROMPT_CONFIG_FREEZE_START = "PROMPT_CONFIG_FREEZE_START"
PROMPT_CONFIG_FREEZE_SUCCESS = "PROMPT_CONFIG_FREEZE_SUCCESS"
PROMPT_CONFIG_FREEZE_FAILED = "PROMPT_CONFIG_FREEZE_FAILED"
PROMPT_OPTIMIZE_PLACEHOLDER_CREATED = "PROMPT_OPTIMIZE_PLACEHOLDER_CREATED"
PROMPT_OPTIMIZE_IDEMPOTENCY_HIT = "PROMPT_OPTIMIZE_IDEMPOTENCY_HIT"
PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED = "PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED"
PROMPT_OPTIMIZE_SETTLEMENT_PENDING = "PROMPT_OPTIMIZE_SETTLEMENT_PENDING"
PROMPT_OPTIMIZE_SETTLEMENT_SUCCESS = "PROMPT_OPTIMIZE_SETTLEMENT_SUCCESS"
PROMPT_OPTIMIZE_FAILED_RELEASED = "PROMPT_OPTIMIZE_FAILED_RELEASED"
LEGACY_CONFIG_FALLBACK_START = "LEGACY_CONFIG_FALLBACK_START"
LEGACY_CONFIG_FALLBACK_SUCCESS = "LEGACY_CONFIG_FALLBACK_SUCCESS"
LEGACY_CONFIG_FALLBACK_FAILED = "LEGACY_CONFIG_FALLBACK_FAILED"
LEGACY_CONFIG_FALLBACK_SKIPPED = "LEGACY_CONFIG_FALLBACK_SKIPPED"
GENERATION_SUBMIT_START = "GENERATION_SUBMIT_START"
GENERATION_SUBMIT_CONFIG_READY = "GENERATION_SUBMIT_CONFIG_READY"
GENERATION_SUBMIT_BILLING_SUCCESS = "GENERATION_SUBMIT_BILLING_SUCCESS"
GENERATION_SUBMIT_ENQUEUE_SUCCESS = "GENERATION_SUBMIT_ENQUEUE_SUCCESS"
GENERATION_SUBMIT_FAILED = "GENERATION_SUBMIT_FAILED"
+30 -1
View File
@@ -3,12 +3,41 @@ from enum import Enum
class GenerationStatus(str, Enum): class GenerationStatus(str, Enum):
"""生成状态。""" """生成状态。"""
optimizing = "optimizing"
settlement_pending = "settlement_pending"
prompt_optimized = "prompt_optimized" prompt_optimized = "prompt_optimized"
generating = "generating" generating = "generating"
completed = "completed" completed = "completed"
failed = "failed" failed = "failed"
class GenerationRecordPipelineStage(str, Enum):
"""GenerationRecord 图片/视频生成、下载与超分流水线阶段。"""
QUEUED = "queued"
PREPARING = "preparing"
CREATING_PROVIDER_TASK = "creating_provider_task"
PROVIDER_RESULT_STAGED = "provider_result_staged"
WAITING_REMOTE = "waiting_remote"
POLLING = "polling"
RESULT_READY = "result_ready"
DOWNLOAD_QUEUED = "download_queued"
DOWNLOADING = "downloading"
RETRY_WAITING = "retry_waiting"
RECOVERY_INCONSISTENT = "recovery_inconsistent"
UPSCALE_QUEUED = "upscale_queued"
UPSCALE_PROCESSING = "upscale_processing"
UPSCALE_POLLING = "upscale_polling"
UPSCALE_DOWNLOADING = "upscale_downloading"
UPSCALE_FINALIZING = "upscale_finalizing"
UPSCALE_RETRY_WAITING = "upscale_retry_waiting"
UPSCALE_FAILED = "upscale_failed"
DONE = "done"
FAILED = "failed"
TIMEOUT = "timeout"
DOWNLOAD_FAILED = "download_failed"
class GenerationType(str, Enum): class GenerationType(str, Enum):
"""生成类型。""" """生成类型。"""
video = "video" video = "video"
@@ -19,4 +48,4 @@ class GenerationType(str, Enum):
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"] ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
RESOLUTIONS = ["480p", "720p", "1080p"] RESOLUTIONS = ["480p", "720p", "1080p"]
IMAGE_SIZES = ["2K", "4K"] IMAGE_SIZES = ["1K", "2K", "4K"]
+85 -1
View File
@@ -1,10 +1,18 @@
from enum import Enum from enum import Enum
class GenerationOwnerType(str, Enum):
CHAT_GENERATION_TASK = "chat_generation_task"
GENERATION_RECORD = "generation_record"
class GenerationMode(str, Enum): class GenerationMode(str, Enum):
CHATAPI_ASYNC = "chatapi_async" CHATAPI_ASYNC = "chatapi_async"
CHATAPI_MAIN = "chatapi_main"
CHATAPI_CHILD = "chatapi_child"
HOT_OPENING_REPLICATE = "hot_opening_replicate" HOT_OPENING_REPLICATE = "hot_opening_replicate"
SHOT_REPLICATE = "shot_replicate" SHOT_REPLICATE = "shot_replicate"
GENERATION_RECORD = "generation_record"
class GenerationType(str, Enum): class GenerationType(str, Enum):
@@ -19,16 +27,34 @@ class ChatGenerationTaskStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
class ChatGenerationDisplayStatus(str, Enum):
PENDING = "pending"
GENERATING = "generating"
COMPLETED = "completed"
FAILED = "failed"
DOWNLOAD_FAILED = "download_failed"
DELETED = "deleted"
class ChatGenerationPipelineStage(str, Enum): class ChatGenerationPipelineStage(str, Enum):
QUEUED = "queued" QUEUED = "queued"
PREPARING = "preparing" PREPARING = "preparing"
CREATING_PROVIDER_TASK = "creating_provider_task" CREATING_PROVIDER_TASK = "creating_provider_task"
PROVIDER_RESULT_STAGED = "provider_result_staged"
WAITING_REMOTE = "waiting_remote" WAITING_REMOTE = "waiting_remote"
POLLING = "polling" POLLING = "polling"
RESULT_READY = "result_ready" RESULT_READY = "result_ready"
DOWNLOAD_QUEUED = "download_queued" DOWNLOAD_QUEUED = "download_queued"
DOWNLOADING = "downloading" DOWNLOADING = "downloading"
RETRY_WAITING = "retry_waiting" RETRY_WAITING = "retry_waiting"
RECOVERY_INCONSISTENT = "recovery_inconsistent"
UPSCALE_QUEUED = "upscale_queued"
UPSCALE_PROCESSING = "upscale_processing"
UPSCALE_POLLING = "upscale_polling"
UPSCALE_DOWNLOADING = "upscale_downloading"
UPSCALE_FINALIZING = "upscale_finalizing"
UPSCALE_RETRY_WAITING = "upscale_retry_waiting"
UPSCALE_FAILED = "upscale_failed"
DONE = "done" DONE = "done"
FAILED = "failed" FAILED = "failed"
TIMEOUT = "timeout" TIMEOUT = "timeout"
@@ -36,6 +62,31 @@ class ChatGenerationPipelineStage(str, Enum):
class ChatGenerationTaskEventType(str, Enum): class ChatGenerationTaskEventType(str, Enum):
TASK_CREATED = "TASK_CREATED"
IDEMPOTENCY_HIT = "IDEMPOTENCY_HIT"
BATCH_CREATE_START = "BATCH_CREATE_START"
BATCH_MAIN_CREATED = "BATCH_MAIN_CREATED"
BATCH_CHILDREN_CREATED = "BATCH_CHILDREN_CREATED"
BATCH_BILLING_SUCCESS = "BATCH_BILLING_SUCCESS"
BATCH_COMMIT_SUCCESS = "BATCH_COMMIT_SUCCESS"
CHILD_ENQUEUE_START = "CHILD_ENQUEUE_START"
CHILD_ENQUEUE_SUCCESS = "CHILD_ENQUEUE_SUCCESS"
CHILD_ENQUEUE_FAILED = "CHILD_ENQUEUE_FAILED"
IMAGE_MAIN_CLAIM_ACQUIRED = "IMAGE_MAIN_CLAIM_ACQUIRED"
IMAGE_MAIN_CLAIM_REJECTED = "IMAGE_MAIN_CLAIM_REJECTED"
IMAGE_MAIN_CLAIM_EXPIRED = "IMAGE_MAIN_CLAIM_EXPIRED"
IMAGE_BATCH_PROVIDER_START = "IMAGE_BATCH_PROVIDER_START"
IMAGE_BATCH_PROVIDER_SUCCESS = "IMAGE_BATCH_PROVIDER_SUCCESS"
IMAGE_BATCH_PROVIDER_FAILED = "IMAGE_BATCH_PROVIDER_FAILED"
IMAGE_BATCH_SPLIT_START = "IMAGE_BATCH_SPLIT_START"
IMAGE_BATCH_SPLIT_SUCCESS = "IMAGE_BATCH_SPLIT_SUCCESS"
IMAGE_BATCH_SPLIT_FAILED = "IMAGE_BATCH_SPLIT_FAILED"
MAIN_STATUS_AGGREGATED = "MAIN_STATUS_AGGREGATED"
CHILD_RESOURCE_DELETE_START = "CHILD_RESOURCE_DELETE_START"
CHILD_RESOURCE_DELETE_SUCCESS = "CHILD_RESOURCE_DELETE_SUCCESS"
BATCH_GROUP_DELETE_SUCCESS = "BATCH_GROUP_DELETE_SUCCESS"
BATCH_RECOVERY_RECONCILED = "BATCH_RECOVERY_RECONCILED"
PROMPT_CONCAT_START = "PROMPT_CONCAT_START" PROMPT_CONCAT_START = "PROMPT_CONCAT_START"
PROMPT_CONCAT_SUCCESS = "PROMPT_CONCAT_SUCCESS" PROMPT_CONCAT_SUCCESS = "PROMPT_CONCAT_SUCCESS"
@@ -57,6 +108,7 @@ class ChatGenerationTaskEventType(str, Enum):
FINAL_POLL_BEFORE_TIMEOUT_PENDING = "FINAL_POLL_BEFORE_TIMEOUT_PENDING" FINAL_POLL_BEFORE_TIMEOUT_PENDING = "FINAL_POLL_BEFORE_TIMEOUT_PENDING"
GENERATION_RECOVERY_ENQUEUE = "GENERATION_RECOVERY_ENQUEUE" GENERATION_RECOVERY_ENQUEUE = "GENERATION_RECOVERY_ENQUEUE"
GENERATION_RECOVERY_TIMEOUT = "GENERATION_RECOVERY_TIMEOUT" GENERATION_RECOVERY_TIMEOUT = "GENERATION_RECOVERY_TIMEOUT"
GENERATION_RECOVERY_INCONSISTENT = "GENERATION_RECOVERY_INCONSISTENT"
DOWNLOAD_ENQUEUE = "DOWNLOAD_ENQUEUE" DOWNLOAD_ENQUEUE = "DOWNLOAD_ENQUEUE"
DOWNLOAD_ENQUEUE_FAILED = "DOWNLOAD_ENQUEUE_FAILED" DOWNLOAD_ENQUEUE_FAILED = "DOWNLOAD_ENQUEUE_FAILED"
@@ -72,6 +124,19 @@ class ChatGenerationTaskEventType(str, Enum):
DOWNLOAD_FAILED = "DOWNLOAD_FAILED" DOWNLOAD_FAILED = "DOWNLOAD_FAILED"
DOWNLOAD_FAILED_NON_RETRYABLE = "DOWNLOAD_FAILED_NON_RETRYABLE" DOWNLOAD_FAILED_NON_RETRYABLE = "DOWNLOAD_FAILED_NON_RETRYABLE"
UPSCALE_SNAPSHOT_MATCHED = "UPSCALE_SNAPSHOT_MATCHED"
UPSCALE_SNAPSHOT_BYPASSED = "UPSCALE_SNAPSHOT_BYPASSED"
UPSCALE_SOURCE_READY = "UPSCALE_SOURCE_READY"
UPSCALE_ENQUEUE = "UPSCALE_ENQUEUE"
UPSCALE_START = "UPSCALE_START"
UPSCALE_REMOTE_SUBMIT = "UPSCALE_REMOTE_SUBMIT"
UPSCALE_REMOTE_POLL = "UPSCALE_REMOTE_POLL"
UPSCALE_REMOTE_RESULT_READY = "UPSCALE_REMOTE_RESULT_READY"
UPSCALE_RETRY_WAITING = "UPSCALE_RETRY_WAITING"
UPSCALE_SUCCESS = "UPSCALE_SUCCESS"
UPSCALE_FAILED = "UPSCALE_FAILED"
UPSCALE_RECOVERY_ENQUEUE = "UPSCALE_RECOVERY_ENQUEUE"
DOWNLOAD_SKIP_TASK_MISSING = "DOWNLOAD_SKIP_TASK_MISSING" DOWNLOAD_SKIP_TASK_MISSING = "DOWNLOAD_SKIP_TASK_MISSING"
DOWNLOAD_SKIP_INVALID_MODE = "DOWNLOAD_SKIP_INVALID_MODE" DOWNLOAD_SKIP_INVALID_MODE = "DOWNLOAD_SKIP_INVALID_MODE"
DOWNLOAD_SKIP_NOT_GENERATING = "DOWNLOAD_SKIP_NOT_GENERATING" DOWNLOAD_SKIP_NOT_GENERATING = "DOWNLOAD_SKIP_NOT_GENERATING"
@@ -82,13 +147,31 @@ class ChatGenerationTaskEventType(str, Enum):
DOWNLOAD_SKIP_RETRY_NOT_DUE = "DOWNLOAD_SKIP_RETRY_NOT_DUE" DOWNLOAD_SKIP_RETRY_NOT_DUE = "DOWNLOAD_SKIP_RETRY_NOT_DUE"
DOWNLOAD_SKIP_FINAL_STATE = "DOWNLOAD_SKIP_FINAL_STATE" DOWNLOAD_SKIP_FINAL_STATE = "DOWNLOAD_SKIP_FINAL_STATE"
DOWNLOAD_SKIP_DISABLED = "DOWNLOAD_SKIP_DISABLED" DOWNLOAD_SKIP_DISABLED = "DOWNLOAD_SKIP_DISABLED"
STALE_ATTEMPT_MESSAGE_SKIPPED = "STALE_ATTEMPT_MESSAGE_SKIPPED"
GENERATION_RECORD_ENQUEUE_SUCCESS = "GENERATION_RECORD_ENQUEUE_SUCCESS"
GENERATION_RECORD_ENQUEUE_FAILED = "GENERATION_RECORD_ENQUEUE_FAILED"
TASK_TIMEOUT = "TASK_TIMEOUT" TASK_TIMEOUT = "TASK_TIMEOUT"
TASK_FAILED = "TASK_FAILED" TASK_FAILED = "TASK_FAILED"
ALLOWED_GENERATION_MODES = { CHAT_TOP_LEVEL_MODES = {
GenerationMode.CHATAPI_ASYNC.value, GenerationMode.CHATAPI_ASYNC.value,
GenerationMode.CHATAPI_MAIN.value,
}
CHAT_RESOURCE_MODES = {
GenerationMode.CHATAPI_ASYNC.value,
GenerationMode.CHATAPI_CHILD.value,
}
CHAT_EXECUTABLE_MODES = {
GenerationMode.CHATAPI_ASYNC.value,
GenerationMode.CHATAPI_CHILD.value,
}
ALLOWED_GENERATION_MODES = {
*CHAT_EXECUTABLE_MODES,
GenerationMode.HOT_OPENING_REPLICATE.value, GenerationMode.HOT_OPENING_REPLICATE.value,
GenerationMode.SHOT_REPLICATE.value, GenerationMode.SHOT_REPLICATE.value,
} }
@@ -98,6 +181,7 @@ FINAL_CHAT_GENERATION_STAGES = {
ChatGenerationPipelineStage.FAILED.value, ChatGenerationPipelineStage.FAILED.value,
ChatGenerationPipelineStage.TIMEOUT.value, ChatGenerationPipelineStage.TIMEOUT.value,
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value, ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
ChatGenerationPipelineStage.UPSCALE_FAILED.value,
} }
DOWNLOAD_RECOVERABLE_STAGES = { DOWNLOAD_RECOVERABLE_STAGES = {
@@ -30,6 +30,7 @@ class HotOpeningStepIOSchemaVersionEnum(StrEnum):
"""爆款开头复刻子任务 input_json/output_json 结构版本。""" """爆款开头复刻子任务 input_json/output_json 结构版本。"""
V1 = "hot_opening_step_io_v1" V1 = "hot_opening_step_io_v1"
V2 = "hot_opening_step_io_v2"
class HotOpeningLogEventEnum(StrEnum): class HotOpeningLogEventEnum(StrEnum):
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from enum import StrEnum
class LlmBillingConfigKey(StrEnum):
ENABLED = "llm_billing_enabled"
HOLD_DEFAULT = "llm_hold_credits_default"
HOLD_GENERATION_RECORD_PROMPT = "llm_hold_credits_generation_record_prompt"
HOLD_MODULE_IMAGE_PROMPT = "llm_hold_credits_module_image_prompt"
HOLD_MODULE_VIDEO_PROMPT = "llm_hold_credits_module_video_prompt"
HOLD_SHOT_VIDEO_ANALYSIS = "llm_hold_credits_shot_video_analysis"
LEGACY_OPTIMIZE_HOLD = "optimize_hold_credits"
class LlmBillingLedgerState(StrEnum):
BILLING_BYPASSED = "billing_bypassed"
MISSING = "missing"
ACTIVE = "active"
RELEASED = "released"
CHARGED = "charged"
INVALID = "invalid"
class LlmBillingEvent(StrEnum):
HOLD_START = "LLM_HOLD_START"
HOLD_SUCCESS = "LLM_HOLD_SUCCESS"
HOLD_BYPASSED = "LLM_HOLD_BYPASSED"
HOLD_INSUFFICIENT = "LLM_HOLD_INSUFFICIENT"
HOLD_CONFIG_INVALID = "LLM_HOLD_CONFIG_INVALID"
HOLD_MISSING = "LLM_HOLD_MISSING"
HOLD_RELEASE_START = "LLM_HOLD_RELEASE_START"
HOLD_RELEASE_SUCCESS = "LLM_HOLD_RELEASE_SUCCESS"
HOLD_RELEASE_SKIPPED = "LLM_HOLD_RELEASE_SKIPPED"
FAILURE_RELEASE_START = "LLM_FAILURE_RELEASE_START"
FAILURE_RELEASE_SUCCESS = "LLM_FAILURE_RELEASE_SUCCESS"
FAILURE_RELEASE_SKIPPED = "LLM_FAILURE_RELEASE_SKIPPED"
EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START"
EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS"
EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED"
PROVIDER_START = "LLM_PROVIDER_START"
PROVIDER_SUCCESS = "LLM_PROVIDER_SUCCESS"
PROVIDER_FAILURE = "LLM_PROVIDER_FAILURE"
SETTLE_START = "LLM_SETTLE_START"
SETTLE_SUCCESS = "LLM_SETTLE_SUCCESS"
CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
CHARGE_NEGATIVE_BALANCE = "LLM_CHARGE_NEGATIVE_BALANCE"
SETTLE_FAILED = "LLM_SETTLE_FAILED"
CELERY_DISPATCH_START = "LLM_CELERY_DISPATCH_START"
CELERY_DISPATCH_SUCCESS = "LLM_CELERY_DISPATCH_SUCCESS"
CELERY_DISPATCH_FAILURE = "LLM_CELERY_DISPATCH_FAILURE"
CELERY_DISPATCH_COMPENSATED = "LLM_CELERY_DISPATCH_COMPENSATED"
RETRY_PREVIOUS_ATTEMPT_VALIDATE_START = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_START"
RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS"
RETRY_PREVIOUS_ATTEMPT_BLOCKED = "LLM_RETRY_PREVIOUS_ATTEMPT_BLOCKED"
USAGE_INVALID = "LLM_USAGE_INVALID"
TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
class LlmBillingDomain(StrEnum):
LLM_BILLING = "llm_billing"
@@ -24,3 +24,4 @@ class ModuleGenerationFlowConfig:
cancel_chat_task_error_message: str cancel_chat_task_error_message: str
material_video_url_editable: bool = True material_video_url_editable: bool = True
step_io_schema_version: str = "module_generation_step_io_v1" step_io_schema_version: str = "module_generation_step_io_v1"
expected_flow_version: str | None = None
+17 -6
View File
@@ -38,17 +38,28 @@ RECENT_GENERATION_CHAT_TASK_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
"""来自 chat_generation_tasks 表的模块集合。""" """来自 chat_generation_tasks 表的模块集合。"""
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = { RECENT_GENERATION_MODULE_TO_TASK_MODES: dict[RecentGenerationModuleEnum, tuple[GenerationMode, ...]] = {
RecentGenerationModuleEnum.CHAT_AI: GenerationMode.CHATAPI_ASYNC, RecentGenerationModuleEnum.CHAT_AI: (
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE, GenerationMode.CHATAPI_ASYNC,
RecentGenerationModuleEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE, GenerationMode.CHATAPI_CHILD,
),
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: (GenerationMode.HOT_OPENING_REPLICATE,),
RecentGenerationModuleEnum.SHOT_REPLICATE: (GenerationMode.SHOT_REPLICATE,),
} }
"""最近生成记录模块枚举到 ChatGenerationTask.generation_mode 的映射。""" """最近生成记录模块枚举到 ChatGenerationTask.generation_mode 集合的映射。"""
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = {
module: task_modes[0]
for module, task_modes in RECENT_GENERATION_MODULE_TO_TASK_MODES.items()
}
"""兼容旧调用的单一任务模式映射。"""
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = { RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = {
task_mode.value: module task_mode.value: module
for module, task_mode in RECENT_GENERATION_MODULE_TO_TASK_MODE.items() for module, task_modes in RECENT_GENERATION_MODULE_TO_TASK_MODES.items()
for task_mode in task_modes
} }
"""ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。""" """ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。"""
@@ -29,6 +29,7 @@ class ShotReplicateStepIOSchemaVersionEnum(StrEnum):
"""拆镜复刻子任务 input_json/output_json 结构版本。""" """拆镜复刻子任务 input_json/output_json 结构版本。"""
V1 = "shot_replicate_step_io_v1" V1 = "shot_replicate_step_io_v1"
V2 = "shot_replicate_step_io_v2"
class ShotTaskSetStatusEnum(StrEnum): class ShotTaskSetStatusEnum(StrEnum):
@@ -117,6 +118,7 @@ class ShotReplicateLogEventEnum(StrEnum):
ANALYSIS_REMOTE_API_FAILED = "SHOT_ANALYSIS_REMOTE_API_FAILED" ANALYSIS_REMOTE_API_FAILED = "SHOT_ANALYSIS_REMOTE_API_FAILED"
ANALYSIS_RESPONSE_PARSE_FAILED = "SHOT_ANALYSIS_RESPONSE_PARSE_FAILED" ANALYSIS_RESPONSE_PARSE_FAILED = "SHOT_ANALYSIS_RESPONSE_PARSE_FAILED"
ANALYSIS_RESPONSE_EMPTY = "SHOT_ANALYSIS_RESPONSE_EMPTY" ANALYSIS_RESPONSE_EMPTY = "SHOT_ANALYSIS_RESPONSE_EMPTY"
ANALYSIS_STALE_ATTEMPT_SKIPPED = "SHOT_ANALYSIS_STALE_ATTEMPT_SKIPPED"
SEGMENT_REANALYZE_RECEIVED = "SHOT_SEGMENT_REANALYZE_RECEIVED" SEGMENT_REANALYZE_RECEIVED = "SHOT_SEGMENT_REANALYZE_RECEIVED"
SEGMENT_REANALYZE_SUBMITTED = "SHOT_SEGMENT_REANALYZE_SUBMITTED" SEGMENT_REANALYZE_SUBMITTED = "SHOT_SEGMENT_REANALYZE_SUBMITTED"
@@ -130,6 +132,7 @@ class ShotReplicateLogEventEnum(StrEnum):
SEGMENT_ANALYSIS_REMOTE_API_FAILED = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_FAILED" SEGMENT_ANALYSIS_REMOTE_API_FAILED = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_FAILED"
SPLIT_STATUS_CHANGED = "SHOT_SPLIT_STATUS_CHANGED" SPLIT_STATUS_CHANGED = "SHOT_SPLIT_STATUS_CHANGED"
SPLIT_SUMMARY_REPAIRED = "SHOT_SPLIT_SUMMARY_REPAIRED"
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED" SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED" SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED" SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from enum import Enum
VIDEO_UPSCALE_CONFIG_KEY = "video_upscale_config"
VIDEO_UPSCALE_CONFIG_DESCRIPTION = "视频生成超分全局配置"
VIDEO_UPSCALE_CONFIG_VERSION = 1
VIDEO_UPSCALE_SOURCE_RETAINED_MARKER = "retained_by_snapshot_config"
class VideoUpscaleProcessorKey(str, Enum):
LOCAL_FFMPEG_CROP_V1 = "local_ffmpeg_crop_v1"
VOLC_LARGE_MODEL_V1 = "volc_large_model_v1"
VOLC_STANDARD_V1 = "volc_standard_v1"
VOLC_PROFESSIONAL_V1 = "volc_professional_v1"
class VideoUpscaleTaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
RETRY_WAITING = "retry_waiting"
COMPLETED = "completed"
FAILED = "failed"
class VideoUpscaleStage(str, Enum):
QUEUED = "upscale_queued"
SOURCE_READY = "upscale_source_ready"
LOCAL_PROCESSING = "upscale_local_processing"
REMOTE_SUBMITTING = "upscale_remote_submitting"
REMOTE_POLLING = "upscale_remote_polling"
RESULT_READY = "upscale_result_ready"
RESULT_DOWNLOADING = "upscale_result_downloading"
VALIDATING = "upscale_validating"
GENERATING_COVER = "upscale_generating_cover"
FINALIZING = "upscale_finalizing"
RETRY_WAITING = "upscale_retry_waiting"
COMPLETED = "upscale_completed"
FAILED = "upscale_failed"
class VideoUpscaleInputSourceType(str, Enum):
PROVIDER_REMOTE = "provider_remote"
LOCAL_SIGNED = "local_signed"
class VideoUpscaleProbeStatus(str, Enum):
NOT_CHECKED = "not_checked"
SUCCESS = "success"
FAILED = "failed"
EXPIRED = "expired"
UNPARSABLE = "unparsable"
VIDEO_UPSCALE_RESOLUTIONS = ("480p", "720p", "1080p", "2K", "4K")
VIDEO_UPSCALE_RESOLUTION_SHORT_EDGE = {
"480p": 480,
"720p": 720,
"1080p": 1080,
"2K": 1440,
"4K": 2160,
}
VIDEO_UPSCALE_RESOLUTION_RANK = {
resolution: index for index, resolution in enumerate(VIDEO_UPSCALE_RESOLUTIONS, start=1)
}
def normalize_video_upscale_resolution(value: str | None) -> str:
text = str(value or "").strip()
normalized = text.lower()
aliases = {
"480p": "480p",
"720p": "720p",
"1080p": "1080p",
"2k": "2K",
"4k": "4K",
}
return aliases.get(normalized, text)
def video_upscale_short_edge_pixels(value: str | None) -> int:
normalized = normalize_video_upscale_resolution(value)
try:
return VIDEO_UPSCALE_RESOLUTION_SHORT_EDGE[normalized]
except KeyError as exc:
supported = "".join(VIDEO_UPSCALE_RESOLUTIONS)
raise ValueError(f"不支持的视频超分分辨率: {value},仅支持 {supported}") from exc
LOCAL_PROCESSOR_KEYS = {VideoUpscaleProcessorKey.LOCAL_FFMPEG_CROP_V1.value}
REMOTE_PROCESSOR_KEYS = {
VideoUpscaleProcessorKey.VOLC_LARGE_MODEL_V1.value,
VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value,
VideoUpscaleProcessorKey.VOLC_PROFESSIONAL_V1.value,
}
ALL_PROCESSOR_KEYS = LOCAL_PROCESSOR_KEYS | REMOTE_PROCESSOR_KEYS
+25 -9
View File
@@ -12,6 +12,7 @@ from app.config import settings
from app.models import init_database, close_database from app.models import init_database, close_database
from app.utils.redis import init_redis, close_redis from app.utils.redis import init_redis, close_redis
from app.api.v1 import api_router from app.api.v1 import api_router
from app.api.v2 import api_router_v2
from app.middleware.logging import RequestLoggingMiddleware from app.middleware.logging import RequestLoggingMiddleware
from app.middleware.anti_crawler import AntiCrawlerMiddleware from app.middleware.anti_crawler import AntiCrawlerMiddleware
from app.middleware.rate_limit import RateLimitMiddleware from app.middleware.rate_limit import RateLimitMiddleware
@@ -31,11 +32,6 @@ async def lifespan(app: FastAPI):
await init_redis() await init_redis()
# await _seed_data() # await _seed_data()
# Start task queue (handles both video and image generation)
from app.services.video_queue import task_queue
await task_queue.recover()
queue_task = asyncio.create_task(task_queue.run())
# Background task: auto-expire pending payment orders and sync status # Background task: auto-expire pending payment orders and sync status
async def _order_expiry_loop(): async def _order_expiry_loop():
from app.services.payment import expire_all_pending_orders, sync_pending_orders from app.services.payment import expire_all_pending_orders, sync_pending_orders
@@ -104,8 +100,6 @@ async def lifespan(app: FastAPI):
yield yield
task_queue.stop()
await queue_task
upload_queue.stop() upload_queue.stop()
await upload_queue_task await upload_queue_task
pre_test_queue.stop() pre_test_queue.stop()
@@ -191,6 +185,13 @@ async def _seed_data():
("user_login_credits_enabled", "false", "启用每日登录赠送积分"), ("user_login_credits_enabled", "false", "启用每日登录赠送积分"),
# Operation manual # Operation manual
("operation_manual", "", "操作手册链接"), ("operation_manual", "", "操作手册链接"),
("optimize_hold_credits", "5", "AI创作预扣积分数量(防止并发超卖)"),
("llm_billing_enabled", "true", "是否启用 LLM 统一预扣与真实扣费结算"),
("llm_hold_credits_default", "5", "LLM 默认预扣积分数量"),
("llm_hold_credits_generation_record_prompt", "5", "AI创作提示词优化预扣积分数量"),
("llm_hold_credits_module_image_prompt", "5", "模块图片 AI 提词优化预扣积分数量"),
("llm_hold_credits_module_video_prompt", "10", "模块视频 AI 提词优化预扣积分数量"),
("llm_hold_credits_shot_video_analysis", "10", "拆镜视频分析预扣积分数量"),
] ]
for key, value, desc in configs: for key, value, desc in configs:
existing = await db.execute( existing = await db.execute(
@@ -293,6 +294,20 @@ async def _seed_data():
) )
) )
# 文字模型媒体使用 base64 开关
existing_media_format = await db.execute(
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
)
if not existing_media_format.scalar_one_or_none():
db.add(
SystemConfig(
id=generate_id(),
key="llm_media_as_base64",
value="true",
description="文字模型请求时图片/视频使用 base64 编码(而非 URL 链接)",
)
)
# Seed credit ratios - model_config_id is kept as a compatible field name, # Seed credit ratios - model_config_id is kept as a compatible field name,
# but now stores the actual engine id: # but now stores the actual engine id:
# - gen_type=video -> video_engines.id # - gen_type=video -> video_engines.id
@@ -301,7 +316,7 @@ async def _seed_data():
default_video_engine_result = await db.execute( default_video_engine_result = await db.execute(
select(VideoEngine) select(VideoEngine)
.where(VideoEngine.is_active == True) .where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc(), VideoEngine.id.desc()) .order_by(VideoEngine.priority.desc(), VideoEngine.id.desc())
.limit(1) .limit(1)
) )
@@ -334,7 +349,7 @@ async def _seed_data():
default_image_engine_result = await db.execute( default_image_engine_result = await db.execute(
select(ImageEngine) select(ImageEngine)
.where(ImageEngine.is_active == True) .where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc(), ImageEngine.id.desc()) .order_by(ImageEngine.priority.desc(), ImageEngine.id.desc())
.limit(1) .limit(1)
) )
@@ -538,6 +553,7 @@ def create_app() -> FastAPI:
# Routes # Routes
application.include_router(api_router, prefix="/api") application.include_router(api_router, prefix="/api")
application.include_router(api_router_v2, prefix="/api/v2")
# Static files for uploads # Static files for uploads
upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH) upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH)
+2 -1
View File
@@ -21,6 +21,7 @@ from app.models.operation_log import OperationLog
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.chat_generation_task_event import ChatGenerationTaskEvent from app.models.chat_generation_task_event import ChatGenerationTaskEvent
from app.models.chat_provider_call_log import ChatProviderCallLog from app.models.chat_provider_call_log import ChatProviderCallLog
from app.models.video_upscale_task import VideoUpscaleTask
from app.models.generated_resource import GeneratedResource from app.models.generated_resource import GeneratedResource
from app.models.upload_resource import UploadResource from app.models.upload_resource import UploadResource
from app.models.user_resource_month_stat import UserResourceMonthStat from app.models.user_resource_month_stat import UserResourceMonthStat
@@ -44,7 +45,7 @@ __all__ = [
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder", "ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio", "TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest", "MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat", "GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig", "UserResourceCapacityConfig",
"ModuleGenerationProject", "ModuleGenerationStep", "ModuleGenerationProject", "ModuleGenerationStep",
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -26,6 +26,19 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
unique=True, unique=True,
postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"), postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"),
), ),
# AI 创作顶层任务在 chatapi_async/chatapi_main 之间切换时,
# 同一个前端幂等键也只能创建一组任务。
Index(
"uq_chat_generation_tasks_user_chat_idempotency",
"user_id",
"idempotency_key",
unique=True,
postgresql_where=text(
"deleted_at IS NULL "
"AND idempotency_key IS NOT NULL "
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
),
),
# 视频 24 小时降频轮询调度使用。 # 视频 24 小时降频轮询调度使用。
Index( Index(
"idx_chat_generation_tasks_next_poll_at", "idx_chat_generation_tasks_next_poll_at",
@@ -37,6 +50,17 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
"AND next_poll_at IS NOT NULL" "AND next_poll_at IS NOT NULL"
), ),
), ),
Index(
"uq_chat_generation_tasks_parent_index",
"parent_task_id",
"generation_index",
unique=True,
postgresql_where=text("parent_task_id IS NOT NULL AND generation_index IS NOT NULL"),
),
Index("idx_chat_generation_tasks_parent", "parent_task_id"),
Index("idx_chat_generation_tasks_user_mode_created", "user_id", "generation_mode", "created_at"),
CheckConstraint("generation_count BETWEEN 1 AND 5", name="ck_chat_generation_tasks_generation_count"),
CheckConstraint("generation_index IS NULL OR generation_index > 0", name="ck_chat_generation_tasks_generation_index"),
) )
@@ -52,6 +76,9 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
duration: Mapped[int | None] = mapped_column(Integer, nullable=True) duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True) aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
resolution: Mapped[str | None] = mapped_column(String(8), nullable=True) resolution: Mapped[str | None] = mapped_column(String(8), nullable=True)
provider_generation_resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
video_upscale_enabled_snapshot: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
video_upscale_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
image_size: Mapped[str | None] = mapped_column(String(16), nullable=True) image_size: Mapped[str | None] = mapped_column(String(16), nullable=True)
image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True) image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_px: Mapped[str | None] = mapped_column(String(16), nullable=True) image_px: Mapped[str | None] = mapped_column(String(16), nullable=True)
@@ -59,6 +86,19 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
status: Mapped[str] = mapped_column(String(32), default="generating", index=True) status: Mapped[str] = mapped_column(String(32), default="generating", index=True)
pipeline_stage: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) pipeline_stage: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
generation_mode: Mapped[str] = mapped_column(String(32), default="chatapi_async", index=True) generation_mode: Mapped[str] = mapped_column(String(32), default="chatapi_async", index=True)
parent_task_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("chat_generation_tasks.id", ondelete="RESTRICT"), nullable=True
)
generation_count: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
generation_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
generation_attempt_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
resource_generation_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
# 图片主任务同步调用供应商时的分布式执行租约。
# 防止重复 Celery 消息或恢复任务同时触发多次组图请求。
provider_create_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
provider_create_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
provider_create_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
media_references: Mapped[str | None] = mapped_column(Text, nullable=True) media_references: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) provider_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
@@ -80,7 +120,10 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
video_tokens_used: Mapped[int] = mapped_column(Integer, default=0) video_tokens_used: Mapped[int] = mapped_column(Integer, default=0)
image_tokens_used: Mapped[int] = mapped_column(Integer, default=0) image_tokens_used: Mapped[int] = mapped_column(Integer, default=0)
# retry_count is retained as a compatibility mirror of manual retries.
retry_count: Mapped[int] = mapped_column(Integer, default=0) retry_count: Mapped[int] = mapped_column(Integer, default=0)
manual_retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
poll_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
poll_count: Mapped[int] = mapped_column(Integer, default=0) poll_count: Mapped[int] = mapped_column(Integer, default=0)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 视频降频轮询调度字段。 # 视频降频轮询调度字段。
@@ -88,6 +131,8 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
poll_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) poll_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=0) poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=0)
poll_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
poll_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -97,6 +142,7 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
download_celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True) download_celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
download_enqueued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) download_enqueued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) download_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
download_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) download_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
download_next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) download_next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
download_attempt_count: Mapped[int] = mapped_column(Integer, default=0) download_attempt_count: Mapped[int] = mapped_column(Integer, default=0)
@@ -1,4 +1,4 @@
from sqlalchemy import DateTime, ForeignKey, String, Text, func from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -6,20 +6,31 @@ from app.utils.id_gen import generate_id
class ChatGenerationTaskEvent(Base): class ChatGenerationTaskEvent(Base):
"""Append-only event log for project-independent chat generation tasks.""" """Append-only event log shared by ChatGenerationTask and GenerationRecord."""
__tablename__ = "chat_generation_task_events" __tablename__ = "chat_generation_task_events"
__table_args__ = (
CheckConstraint(
"(owner_type = 'chat_generation_task' AND task_id IS NOT NULL AND generation_record_id IS NULL) "
"OR (owner_type = 'generation_record' AND task_id IS NULL AND generation_record_id IS NOT NULL)",
name="ck_chat_generation_task_events_owner",
),
Index("idx_chat_generation_task_events_task_created", "owner_type", "task_id", "created_at"),
Index("idx_chat_generation_task_events_record_created", "owner_type", "generation_record_id", "created_at"),
Index("idx_chat_generation_task_events_attempt_created", "owner_type", "generation_attempt_no", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=generate_id) id: Mapped[str] = mapped_column(String(32), primary_key=True, default=generate_id)
task_id: Mapped[str] = mapped_column( owner_type: Mapped[str] = mapped_column(String(32), nullable=False, default="chat_generation_task", server_default="chat_generation_task", index=True)
String(32), ForeignKey("chat_generation_tasks.id", ondelete="CASCADE"), index=True task_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("chat_generation_tasks.id", ondelete="CASCADE"), nullable=True, index=True)
) generation_record_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("generation_records.id", ondelete="CASCADE"), nullable=True, index=True)
generation_attempt_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1", index=True)
generation_mode: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) generation_mode: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
event_type: Mapped[str] = mapped_column(String(64), index=True) event_type: Mapped[str] = mapped_column(String(64), index=True)
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True) from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
to_status: Mapped[str | None] = mapped_column(String(32), nullable=True) to_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
from_stage: Mapped[str | None] = mapped_column(String(32), nullable=True) from_stage: Mapped[str | None] = mapped_column(String(48), nullable=True)
to_stage: Mapped[str | None] = mapped_column(String(32), nullable=True) to_stage: Mapped[str | None] = mapped_column(String(48), nullable=True)
message: Mapped[str | None] = mapped_column(String(512), nullable=True) message: Mapped[str | None] = mapped_column(Text, nullable=True)
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True) detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) created_at = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
@@ -1,4 +1,4 @@
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -6,14 +6,25 @@ from app.utils.id_gen import generate_id
class ChatProviderCallLog(Base): class ChatProviderCallLog(Base):
"""Provider call audit log for chat_generation_tasks.""" """Provider call audit log shared by both generation owner models."""
__tablename__ = "chat_provider_call_logs" __tablename__ = "chat_provider_call_logs"
__table_args__ = (
CheckConstraint(
"(owner_type = 'chat_generation_task' AND task_id IS NOT NULL AND generation_record_id IS NULL) "
"OR (owner_type = 'generation_record' AND task_id IS NULL AND generation_record_id IS NOT NULL)",
name="ck_chat_provider_call_logs_owner",
),
Index("idx_chat_provider_call_logs_task_created", "owner_type", "task_id", "created_at"),
Index("idx_chat_provider_call_logs_record_created", "owner_type", "generation_record_id", "created_at"),
Index("idx_chat_provider_call_logs_attempt_created", "owner_type", "generation_attempt_no", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=generate_id) id: Mapped[str] = mapped_column(String(32), primary_key=True, default=generate_id)
task_id: Mapped[str] = mapped_column( owner_type: Mapped[str] = mapped_column(String(32), nullable=False, default="chat_generation_task", server_default="chat_generation_task", index=True)
String(32), ForeignKey("chat_generation_tasks.id", ondelete="CASCADE"), index=True task_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("chat_generation_tasks.id", ondelete="CASCADE"), nullable=True, index=True)
) generation_record_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("generation_records.id", ondelete="CASCADE"), nullable=True, index=True)
generation_attempt_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1", index=True)
generation_mode: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) generation_mode: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
provider: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) provider: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
api_type: Mapped[str] = mapped_column(String(64), index=True) api_type: Mapped[str] = mapped_column(String(64), index=True)
+8 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Float, ForeignKey, Index, Integer, String from sqlalchemy import Float, ForeignKey, Index, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -11,6 +11,13 @@ class CreditRecord(Base, TimestampMixin):
# PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。 # PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。
Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True), Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True),
Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"), Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"),
Index(
"uq_credit_records_user_refund_target",
"user_id",
"refund_for_biz_key",
unique=True,
postgresql_where=text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
),
Index("ix_credit_records_related_type", "related_id", "type"), Index("ix_credit_records_related_type", "related_id", "type"),
Index("ix_credit_records_owner", "owner_type", "owner_id"), Index("ix_credit_records_owner", "owner_type", "owner_id"),
Index("ix_credit_records_subject_media", "credit_subject", "media_type"), Index("ix_credit_records_subject_media", "credit_subject", "media_type"),
@@ -1,6 +1,6 @@
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import BigInteger, Date, DateTime, ForeignKey, Index, String, Text from sqlalchemy import BigInteger, Date, DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -44,4 +44,12 @@ class GeneratedResource(Base, TimestampMixin, SoftDeleteMixin):
Index("ix_generated_resources_user_month", GeneratedResource.user_id, GeneratedResource.resource_month) Index("ix_generated_resources_user_month", GeneratedResource.user_id, GeneratedResource.resource_month)
Index("ix_generated_resources_source", GeneratedResource.source_model, GeneratedResource.source_id) Index("ix_generated_resources_source", GeneratedResource.source_model, GeneratedResource.source_id)
Index(
"uq_generated_resources_active_source_type",
GeneratedResource.source_model,
GeneratedResource.source_id,
GeneratedResource.resource_type,
unique=True,
postgresql_where=text("deleted_at IS NULL"),
)
Index("ix_generated_resources_active_user", GeneratedResource.user_id, GeneratedResource.deleted_at) Index("ix_generated_resources_active_user", GeneratedResource.user_id, GeneratedResource.deleted_at)
+60 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float, Index from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Float, Index, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -18,19 +18,29 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
) )
original_prompt: Mapped[str] = mapped_column(Text) original_prompt: Mapped[str] = mapped_column(Text)
optimized_prompt: Mapped[str | None] = mapped_column(Text, nullable=True) optimized_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
prompt_usage_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
gen_type: Mapped[str] = mapped_column(String(16), default="video") gen_type: Mapped[str] = mapped_column(String(16), default="video")
duration: Mapped[int | None] = mapped_column(Integer, nullable=True) duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True) aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
resolution: Mapped[str | None] = mapped_column(String(8), nullable=True) resolution: Mapped[str | None] = mapped_column(String(8), nullable=True)
provider_generation_resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
video_upscale_enabled_snapshot: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
video_upscale_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
image_size: Mapped[str | None] = mapped_column(String(8), nullable=True) image_size: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True) image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_px: Mapped[str | None] = mapped_column(String(10), nullable=True) image_px: Mapped[str | None] = mapped_column(String(10), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="prompt_optimized") status: Mapped[str] = mapped_column(String(32), default="prompt_optimized")
pipeline_stage: Mapped[str | None] = mapped_column(String(48), nullable=True, index=True)
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True) video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True) video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True) image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
media_references: Mapped[str | None] = mapped_column(Text, nullable=True) media_references: Mapped[str | None] = mapped_column(Text, nullable=True)
include_media_references: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
video_url_expires_at: Mapped[datetime | None] = mapped_column( video_url_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
@@ -46,7 +56,56 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# Unified Celery generation pipeline state.
generation_attempt_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
resource_generation_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
remote_result_url: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_create_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
provider_create_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
provider_create_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# retry_count is retained for API/backward compatibility. New business
# logic uses manual_retry_count and poll_error_count separately.
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
manual_retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
poll_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
poll_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
poll_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
poll_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
download_celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
download_enqueued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
download_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
download_next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
download_attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
download_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
download_storage_date_dir: Mapped[str | None] = mapped_column(String(16), nullable=True)
__table_args__ = ( __table_args__ = (
Index('idx_genrec_user_status_created', 'user_id', 'status', 'created_at'), Index('idx_genrec_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_genrec_project_status', 'project_id', 'status'), Index('idx_genrec_project_status', 'project_id', 'status'),
Index(
'uq_genrec_user_idempotency_active',
'user_id',
'idempotency_key',
unique=True,
postgresql_where=text("idempotency_key IS NOT NULL AND deleted_at IS NULL"),
),
Index(
'idx_genrec_next_poll_at',
'next_poll_at',
postgresql_where=text("deleted_at IS NULL AND status = 'generating' AND gen_type = 'video' AND next_poll_at IS NOT NULL"),
),
) )
+28 -3
View File
@@ -1,11 +1,16 @@
from sqlalchemy import Boolean, Integer, String, Text from sqlalchemy import Boolean, CheckConstraint, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class ImageEngine(Base, TimestampMixin): class ImageEngine(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "image_engines" __tablename__ = "image_engines"
__table_args__ = (
CheckConstraint("max_generation_count BETWEEN 1 AND 5", name="ck_image_engines_max_generation_count"),
CheckConstraint("multi_image_max_images BETWEEN 1 AND 15", name="ck_image_engines_multi_image_max_images"),
CheckConstraint("max_reference_image_count BETWEEN 0 AND 14", name="ck_image_engines_max_reference_image_count"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False) name: Mapped[str] = mapped_column(String(64), nullable=False)
@@ -18,6 +23,26 @@ class ImageEngine(Base, TimestampMixin):
supported_sizes: Mapped[str] = mapped_column(Text, default='{}') supported_sizes: Mapped[str] = mapped_column(Text, default='{}')
default_size: Mapped[str] = mapped_column(String(32), default="2K") default_size: Mapped[str] = mapped_column(String(32), default="2K")
max_image_count: Mapped[int] = mapped_column(Integer, default=0) max_image_count: Mapped[int] = mapped_column(Integer, default=0)
# 管理后台只配置能力开关与数量上限;本次实际生成数量保存在 ChatGenerationTask.generation_count。
multi_generation_enabled: Mapped[bool] = mapped_column(
Boolean,
default=False,
server_default="false",
nullable=False,
)
max_generation_count: Mapped[int] = mapped_column(
Integer,
default=1,
server_default="1",
nullable=False,
)
# 火山组图接口能力约束。多份图片始终只调用一次 sequential_image_generation=auto 接口。
multi_image_max_images: Mapped[int] = mapped_column(Integer, default=15, server_default="15", nullable=False)
max_reference_image_count: Mapped[int] = mapped_column(Integer, default=14, server_default="14", nullable=False)
# 留空表示不向供应商传 output_format;用于兼容不支持该参数的模型。
output_format: Mapped[str] = mapped_column(String(16), default="", server_default="", nullable=False)
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="") generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0) priority: Mapped[int] = mapped_column(Integer, default=0)
+2 -2
View File
@@ -1,10 +1,10 @@
from sqlalchemy import Boolean, Float, Integer, String from sqlalchemy import Boolean, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class ModelConfig(Base, TimestampMixin): class ModelConfig(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "model_configs" __tablename__ = "model_configs"
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -5,6 +5,7 @@ from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.common import ModuleGenerationFlowVersionEnum
from app.models.base import Base, SoftDeleteMixin, TimestampMixin from app.models.base import Base, SoftDeleteMixin, TimestampMixin
@@ -36,6 +37,12 @@ class ModuleGenerationProject(Base, TimestampMixin, SoftDeleteMixin):
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
) )
module: Mapped[str] = mapped_column(String(64), index=True, nullable=False) module: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
flow_version: Mapped[str] = mapped_column(
String(16),
nullable=False,
default=ModuleGenerationFlowVersionEnum.V1.value,
server_default=ModuleGenerationFlowVersionEnum.V1.value,
)
title: Mapped[str | None] = mapped_column(String(160), nullable=True) title: Mapped[str | None] = mapped_column(String(160), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="pending", index=True) status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
current_step_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) current_step_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
@@ -15,12 +15,12 @@ _STEP_JSON_TYPE = JSON().with_variant(JSONB, "postgresql")
class ModuleGenerationStep(Base, TimestampMixin, SoftDeleteMixin): class ModuleGenerationStep(Base, TimestampMixin, SoftDeleteMixin):
"""通用模块生成步骤表。 """通用模块生成步骤表。
爆款开头复刻固定步骤 V1 固定五步material_input / image_prompt_optimize / image_generate /
1 material_input video_prompt_optimize / video_generate
2 image_prompt_optimize
3 image_generate V2 固定三步material_input / video_prompt_optimize / video_generate
4 video_prompt_optimize version 只表示同一步骤的重建版本不表示项目流程版本项目版本由
5 video_generate ModuleGenerationProject.flow_version 保存
input_json / output_json 使用 JSON/JSONB 存储 input_json / output_json 使用 JSON/JSONB 存储
建议结构 建议结构

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