Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# Runtime
NODE_ENV=development
PORT=3000
ADMIN_PORT=5173
USER_APP_PORT=5174
# Transport security
# Production should terminate TLS at Nginx/Caddy/Load Balancer and forward X-Forwarded-Proto=https.
HTTPS_REQUIRED=false
HTTPS_ALLOW_LOCAL_HTTP=true
TRUST_PROXY=false
CORS_ORIGINS=http://127.0.0.1:5173,http://127.0.0.1:5174
API_CRYPTO_ENABLED=auto
API_CRYPTO_SESSION_TTL_SECONDS=900
VITE_API_CRYPTO_ENABLED=auto
# Database
DATABASE_URL=mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga
# Queue
REDIS_URL=redis://127.0.0.1:6379
# Object storage
MINIO_ENDPOINT=127.0.0.1
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET_PRIVATE=ai-manga-private
MINIO_BUCKET_PUBLIC=ai-manga-public
# Auth
JWT_SECRET=change_me_in_local_env
JWT_EXPIRES_IN=7d
# Media
FFMPEG_PATH=ffmpeg
LOCAL_STORAGE_ROOT=../storage
STORAGE_DRIVER=local
# AI providers are mocked until the MVP flow is complete.
AI_PROVIDER_MODE=mock
OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_TEXT_MODEL=
OPENAI_NOVEL_MODEL=
OPENAI_IMAGE_MODEL=
OPENAI_VIDEO_MODEL=
OPENAI_TTS_MODEL=
OPENAI_MODERATION_MODEL=
OPENAI_EMBEDDING_MODEL=
# Must stay stable after saving Provider API keys in the admin console.
PROVIDER_SECRET_KEY=
PROVIDER_MAX_COST_PER_CALL=
PROVIDER_DAILY_COST_LIMIT=
WORKER_SECRET=
+53
View File
@@ -0,0 +1,53 @@
# Dependencies
node_modules/
**/node_modules/
# Build outputs
dist/
**/dist/
.output/
**/.output/
.vite/
**/.vite/
coverage/
**/coverage/
# Runtime logs / temp files
*.log
*.pid
*.tmp
*.temp
*.swp
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Local environment and secrets
.env
.env.*
!.env.example
# Runtime storage: uploads, generated media, private assets, provider outputs
storage/*
!storage/.gitkeep
backend/storage/
admin/storage/
user-app/storage/
workers/storage/
# Generated archives / local exports
*.zip
*.tar
*.tar.gz
*.tgz
*.bak
*.dump
*.sql
!backend/prisma/migrations/**/*.sql
# Local database files
*.sqlite
*.sqlite3
*.db
+100
View File
@@ -0,0 +1,100 @@
# AGENTS.md
## 项目说明
本项目是 AI 漫剧 / AI 写真视频生成平台,包含两套业务系统:
1. 系统 A:原创小说 / 上传小说 -> 韩漫 / 漫剧生成系统
2. 系统 B:真人照片 -> 多人生主题写真 / 婚礼 / 纪念视频生成系统
当前优先开发系统 A。
## 当前开发基线
系统 A 开发基线:
- docs/system_a/00_上下文难点清单_已接入设计.md
- docs/system_a/01_系统A总需求文档_v2_生产级基线.md
- docs/system_a/02_业务流程_状态流转_权限设计.md
- docs/system_a/03_功能清单_页面清单.md
- docs/system_a/04_技术架构设计_模块拆分.md
- docs/system_a/05_数据库表结构设计.md
- docs/system_a/06_API接口设计文档.md
- docs/system_a/09_AI生成流水线_Provider抽象设计.md
- docs/system_a/11_角色一致性专项设计.md
- docs/system_a/12_长篇连载记忆_剧情一致性专项设计.md
- docs/system_a/14_分镜镜头_视频合成_特效设计.md
- docs/system_a/15_任务队列_错误重试_稳定性设计.md
- docs/system_a/20_测试用例_验收标准.md
- docs/system_a/21_Codex开发任务拆解文档.md
系统 B 暂时只做架构预留,不先开发完整业务。
## 技术栈约定
- 后端:Node.js + NestJS
- 数据库:MySQL 8
- 队列:Redis + BullMQ
- 对象存储:MinIO
- 用户端:uni-app
- 后台端:Geeker-Admin
- 视频合成:FFmpeg
- AI Provider:必须抽象,先 mock,后续再接真实模型
## 强制开发规则
1. 不允许一次性开发完整系统。
2. 每次只完成一个明确阶段。
3. 每个阶段完成后必须等待人工审核。
4. 不允许擅自修改 docs 目录。
5. 不允许把真实 API Key 写进代码。
6. 所有密钥必须放在 .env。
7. 所有 AI Provider 必须先实现 mock。
8. 不允许把 OpenAI 或任何模型写死在业务逻辑里。
9. 图片、视频、TTS、文本、审核都必须走 Provider 抽象层。
10. 所有生成任务必须进入任务队列。
11. 所有任务必须有状态、重试次数、错误信息、成本记录。
12. 所有用户上传小说和素材默认私有。
13. 上传小说必须有版权确认记录。
14. 公开案例必须有单独授权记录。
15. 角色一致性、剧情记忆、分镜确认、任务恢复是系统 A 的核心,不允许省略。
16. 每个阶段必须更新 CODEX_PROGRESS.md。
17. 每个阶段必须说明:完成了什么、改了哪些文件、如何测试、下一步是什么。
18. 如果发现文档冲突,先停下来汇报,不要擅自决定。
19. 开发前先查看 Git 状态。
20. 开发后运行 lint/typecheck/test,失败必须说明原因。
## MVP 完成标准
系统 A MVP 必须实现:
1. 用户注册登录。
2. 创建小说漫剧项目。
3. AI 原创小说 mock 流程。
4. 上传小说解析流程。
5. 版权确认流程。
6. 故事圣经。
7. 角色圣经。
8. 角色锚点图 mock。
9. 分集计划。
10. 单集脚本。
11. 分镜脚本。
12. AI Provider mock。
13. BullMQ 任务队列。
14. 图片生成 mock。
15. TTS / 字幕 mock。
16. FFmpeg 视频合成。
17. 后台项目管理。
18. 后台任务管理。
19. 用户端查看进度和下载成品。
20. 任务失败可重试。
## 最终目标
完整落地目标是:
AI 原创小说 -> 生成 3 集基础漫剧 MP4
上传小说 -> 生成 1-3 集基础漫剧 MP4
后台可查看项目、角色、分镜、任务、成本、审核
任务失败可恢复
系统后续可扩展到 20-100 集长篇连载
+323
View File
@@ -0,0 +1,323 @@
# AI 真人/漫剧视频流水线测试踩坑记录 V1
更新时间:2026-06-12
用途:
- 记录测试阶段已经遇到的问题、原因判断、系统优化点。
- 后续接入 Kling、Seedance、Wan、Vidu、Runway、Veo 等平台时,按同一套准入标准测试,避免重复烧钱。
- 本文件是运营/开发测试手册,不替代 `docs/` 里的系统需求文档。
## 当前阶段定位
当前仍是测试磨合阶段,不追求一次性自动发布。
目标顺序:
1. 跑通自动化流水线。
2. 找到各 Provider 的能力边界。
3. 把失败原因转成系统规则。
4. 形成可恢复、可路由、可统计成本的生产流程。
5. 最后再逐步提高自动化比例。
## 已验证结论
### Hailuo 适合做什么
Hailuo / MiniMax Hailuo 2.3 Fast 当前更适合:
- 都市真人短剧普通镜头。
- 中景、远景、走路、转身、递物、沉默、反应镜头。
- 咖啡厅、街道、办公室、豪车、酒店、家庭等现实场景。
- 旁白 + 字幕 + BGM 推动剧情的短剧。
- 成本敏感的批量生产。
当前实测成本:
- `minimax_hailuo_23_fast`:约 `$0.317 / 10秒`
- `minimax_hailuo_23`:约 `$0.467 / 10秒`
### Hailuo 不适合硬扛什么
Hailuo 对以下镜头可控性不足:
- 凌空翻滚、复杂武打、连续打斗。
- 精准手诀、结印、舞蹈式手部动作。
- 法相天地、千臂法身、巨型神像复杂动作。
- 一镜中塞太多高复杂动作。
- 正脸强台词口型同步。
处理原则:
- 不要在同一失败动作上无脑重跑 Hailuo。
- 如果同类复杂动作 1-2 次失败,优先切换 Provider 或补动作参考/姿势关键帧。
- 修仙、法相、打斗、次元壁穿越等镜头默认标记为高价值/高复杂度,不按普通都市镜头处理。
## 分镜时长经验
### Hailuo 时长设计规则
Hailuo 当前按真实可用规格优先设计:
- 常规可用镜头时长:6 秒 / 10 秒。
- 真人短剧主流程优先按 10 秒长镜头设计。
- 只有非常简单的过渡镜头、环境镜头、无台词动作镜头,才考虑 6 秒。
- 不再设计 7 秒、8 秒这种无法直接匹配 Hailuo 输出规格的镜头。
系统规则:
- 导演分镜上限必须允许 10 秒。
- 如果原分镜明确是 10 秒,prepare 阶段不得自动压缩成 5 秒或 8 秒。
- 负面约束里的“避免嘴部特写”不能导致镜头被误判成 insert 特写。
- “手里的道具”不能被误判成“手部特写”,只有手部/手指/手掌/手腕/手势等明确关键词才算 insert。
### 3 秒碎片问题
法相天地三段式测试暴露问题:
- 镜头切太碎,像图片拼接。
- Hailuo 实际输出常按 6 秒返回,系统再裁成 3/3/4 秒时,动作容易被截断。
- 复杂动作被拆太短,会失去电影感和动作连贯性。
结论:
- 普通反应镜头:3-5 秒可以。
- 都市剧情镜头:5-6 秒更稳。
- 复杂动作/爆点镜头:优先 8-10 秒一镜到底。
- 不能把 6 秒 Provider 输出硬裁成 3 秒作为长期策略。
### 10 秒一镜到底结论
法相天地 10 秒 Hailuo 测试已验证:
- Hailuo API 可以返回 10 秒。
- 10 秒一镜到底比 3 段裁切更连贯。
- 人物、服装、场景稳定性明显提升。
- 但复杂动作本身仍受 Provider 能力限制。
系统规则:
- `action_score >= 8``importance >= 8` 的镜头,优先生成 8-10 秒单镜头。
- 如果 Provider 不支持目标时长,再由系统拆分,不要随意裁切关键动作。
## Prompt Engine 经验
直接把剧情发给视频模型,质量不稳定。
必须通过 Prompt Engine 组装:
- 角色。
- 场景。
- 主动作。
- 镜头大小。
- 运镜。
- 动作时间轴。
- 情绪表演。
- 灯光。
- 特效时机。
- 声音卡点。
- 负面约束。
已落地:
- Motion Director Prompt。
- 10 秒仙侠一镜到底时间轴。
- Hailuo 长镜头 prompt 上限提升。
仍需注意:
- Prompt 只能提高概率,不能保证复杂动作精确执行。
- 复杂动作需要动作参考图、姿势关键帧或更强 Provider。
- 如果同一个 Prompt 多次失败,继续加形容词意义不大。
## 角色一致性经验
当前测试发现:
- 没有固定角色定妆图/锚点图时,人物容易漂移。
- 修仙/奇幻镜头更容易因为特效导致人物脸和服装变化。
- 都市短剧也需要固定林凡、陈雪、王伯这类角色锚点。
系统规则:
- 真人短剧正式验收前必须先做角色锚点图。
- 新剧首轮至少固定主角、女主、关键配角。
- Provider 小样要使用同一角色锚点和同一关键帧,避免测试结果不可比。
## 声音与字幕经验
没有声音、没有字幕、没有 BGM 的视频判定为失败。
已经遇到的问题:
- 屏幕女孩测试:无声音、无字幕,发布感不足。
- 口型镜头:语音先到,嘴型后动。
- 部分合成音量偏低,视觉还可以但情绪不够。
系统规则:
- 每条可发布视频必须包含字幕。
- 每条可发布视频必须包含 BGM 或环境底噪。
- 情绪镜头必须有 SFX:雨声、脚步、门声、心跳、低频冲击、sting 等。
- 合成后必须跑音量检测。
- 音量偏低时重新后期合成,不一定重跑视频。
## 台词与 lip-sync 经验
没有稳定 lip-sync Provider 前,不要把关键台词做成正脸大嘴型特写。
当前策略:
- `lip_sync_required=true` 但没有真实 lip-sync Provider 时:
- 改成旁白。
- 改成字幕。
- 改成中景/侧脸/轻口型。
- 避免正脸嘴部特写。
未来接入 lip-sync Provider 后:
- 只给关键正脸台词使用。
- 不全片 lip-sync,避免成本翻倍。
- lip-sync 放在视频片段生成后、FFmpeg 合成前。
## 成本控制经验
默认策略:
- 每个镜头默认只生成 1 条。
- 不默认生成 2-3 条候选。
- 只有封面级、爆点、最后反转、人工验收阶段才允许候选 2 条。
- 任何候选数增加都必须进入成本预估和审计日志。
当前后台已支持:
- AI 接入列表展示每 10 秒成本。
- Provider `cost_summary`
- 单次成本上限。
- 当日成本上限。
- `price_per_second=0` 不当成免费,而是提示需账单回填。
## Provider 路由经验
推荐基础路由:
```text
普通都市镜头
=> Hailuo
都市高价值镜头
=> Hailuo 优先,必要时 Kling / Vidu / Wan 对比
修仙 / 法相 / 打斗 / 次元壁 / 复杂动作
=> Premium Provider 候选
正脸台词
=> 有 lip-sync Provider 才允许正脸特写,否则中景/旁白/字幕
成本超预算
=> Premium -> Hailuo -> Seedance/Mock
```
不要把“平台选择”写死在业务代码里,必须走 Router。
## Provider 准入测试标准
每接入一个新视频 Provider,都按固定小样测试:
1. 同一项目。
2. 同一角色锚点图。
3. 同一关键帧。
4. 同一镜头文本。
5. 同一目标时长。
6. 同一分辨率/比例。
7. 记录真实成本。
8. 记录失败原因。
9. 记录生成耗时。
10. 记录人工观感评分。
必须记录:
- `provider_code`
- `model_name`
- `clip_id`
- `asset_id`
- `duration`
- `cost_actual`
- `quality_score`
- `human_acceptance`
- `failure_reason`
- `prompt_version`
- `keyframe_asset_id`
## 人工验收标准
当前 mock-qc 分数只能说明流程没坏,不能代表可发布。
真人视频上线前必须人工看:
- 人物是否稳定。
- 动作是否连贯。
- 镜头是否像真实拍摄。
- 字幕是否准确。
- 声音是否完整。
- BGM/SFX 是否有情绪。
- 口型是否明显穿帮。
- 是否有明显 AI 手、脸、身体变形。
- 成本是否在预算内。
## 不要重复踩的坑
- 不要把 mock-qc 通过当成真实质量通过。
- 不要无音频/无字幕就判断视频流程合格。
- 不要用 3 秒碎切测试复杂动作。
- 不要在 Hailuo 上反复烧复杂修仙动作。
- 不要把 `price_per_second=0` 当成免费。
- 不要默认生成 2-3 条候选。
- 不要让正脸台词在没有 lip-sync 时直出。
- 不要只看最终成片,要保留脚本、分镜、prompt、关键帧、音频、视频片段、合成记录。
- 不要默认认为 `mock-video` 就是全链路零成本;如果 TTS Provider 已启用真实平台,Mock 视频合成仍可能调用真实 TTS。零成本压测要显式指定 `mock-voice` 或关闭音频。
- 真实 Hailuo / Kling / Wan 等图生视频前必须先跑 preflight,确认关键帧是 PNG/JPG/WebPMock SVG 只能测流程,不能直接发给真实视频 Provider。
- Hailuo Fast 跑都市真人 10 秒镜头可用,5 条 10 秒顺序生成约 9 分钟,总成本记录 `$1.585`;后续应进入队列并在后台显示耗时。
- 只用单张临时关键帧会改善画面质感,但不能彻底解决同脸一致性;要上架必须补“角色锚点图 / 定妆图 / 同脸参考图”流程。
- 都市短剧 Hailuo 长镜头比 3-6 秒碎切更像真人短剧,但部分镜头仍会像关键帧慢推;Prompt 里要继续强化明确动作、人物走位、视线、反应和镜头结束动作。
- 角色锚点图 V1 对关键帧生成有效:同一角色的脸、服装、气质会比临时起图更稳;但 Hailuo Fast 当前按单首帧图生视频跑,不能直接吃多角色参考图,所以运动中仍可能轻微改脸。
- 锚点重跑应优先挑第 1 镜、第 3 镜、第 5 镜这类“角色出场 / 冲突 / 反转”镜头;默认候选仍为 1 条,避免成本翻倍。
- 真人短剧对白不能一镜一段 TTS 直接读完;只要 `dialogue_text` 里出现 `角色名:台词`,必须拆成多段 TTS,每段记录 `speaker_name` / `character_id` / `voice_id` / `voice_style`
- 角色声线是发布级基础配置:顾辰、林雨薇、周浩、管家这类核心角色必须先在 Character 表绑定声音;否则即使画面像真人,声音也会变成解说感。
- 主持人、旁白也要当作独立声线处理;如果没有角色记录,至少要走固定 narration / host voice,不能复用男主或女主声音。
- 多声线 TTS 只解决“谁在说话”的问题,不解决“嘴型同步”;正脸台词仍要继续走 lip-sync 或中景轻口型策略。
- TTS 声音 ID 不能只看名字想当然:`Chinese (Mandarin)_Gentle_Senior` 在老管家场景听感偏女,不适合作为中老年男管家默认声线;当前老管家默认改为 `Chinese (Mandarin)_Gentleman`,但仍需人工听感确认。
- 核心角色上线前要做“角色声线小样验收”:每个角色先生成 1-2 句固定台词试听,确认性别、年龄、气质,再进入整集成片,避免整集重合成。
- Hailuo 2.3 标准版 1080P 比 Fast 768P 清晰度和雨夜质感略好,但第 5 镜 A/B 证明它不能单独解决“像图片动”的问题;高价值反转镜头更需要三关键帧/动作参考/更强 Provider,而不是只把 Fast 换成标准版。
- Provider A/B 测试必须保护当前成片:生成候选后要恢复 `storyboard_shots.video_clip_asset_id`,避免实验 clip 自动替换正式整集。
- 动作节拍链式生成 V1 对高价值反转镜头有效:第 5 镜用 2 段 Fast 生成,第二段用第一段结尾帧作为首帧,比单首帧慢推更能呈现“递卡 -> 看卡震惊”的动作链。
- action beat 不能默认全片开启:成本、耗时都会增加,适合反转/封面/爆点/高价值动作镜头;普通对话镜头仍用单条生成。
- preflight 必须按 action beat 的真实分段估算成本,否则后台会低估费用;第 5 镜 action beat 从单条 `$0.317` 变为 2 段 `$0.3804`
- action beat 只解决“动作链更连续”,不解决“同一角色像同一个演员”;整集样片必须单独检查人物脸、年龄感、发型、服装是否跨镜稳定。
- 真实视频生成不能只传角色名字;必须把本镜出现角色的 ActorProfile 注入 provider prompt,并在 task input 记录 `actor_lock`,否则后台无法审计“这条视频到底有没有走锁脸策略”。
- prepare 阶段不要把全项目角色描述混进单镜 prompt;单镜只允许注入本镜出现的人物,否则多人项目会增加串脸概率。
- Hailuo 当前配置不是强角色参考模型,`supports_character_reference=false`;它可以做低成本图生视频,但发布级真人短剧要靠“角色锚点图生成首帧 + 强一致性 prompt + 人工抽检”,必要时横测支持角色参考的 Provider。
- 如果整集都换脸,不要继续盲目重跑全片;应先重跑第 1 / 3 / 5 镜这种出场、冲突、反转镜头,对比抽帧确认角色锁定是否有效,再决定是否整集重跑。
- 多角色音频不能把跨平台 voice_id 混用:`coral` 属于 OpenAI TTS,不能直接发给 MiniMaxProvider 层必须做 voice_id 适配或降级到当前 Provider 默认声线。
- MiniMax TTS 返回 JSON 时必须先检查 `base_resp.status_code`;否则账号权限、voice_id、group_id 等真实错误会被误报成 `EMPTY_AUDIO`
- 小说上传走 encrypted JSON/base64 时,实际 HTTP body 会比原文件大约 33%;后端 body limit 必须高于文件限制,否则会先被 `request entity too large` 拦截。
## 下一步建议
短期优先:
1. 继续围绕都市真人短剧做 Hailuo 量产质量优化。
2. 做角色锚点图/定妆图 V1。
3. 做动作参考/姿势关键帧 V1。
4. 用同一固定镜头横测 Kling / Seedance / Wan / Vidu。
5. 建 Provider 准入表:质量、成本、失败率、耗时。
6. 做 BGM/SFX 发布级音量标准。
7. lip-sync 只做关键台词小样,不先全片接入。
长期原则:
- Hailuo 做低成本量产。
- 高价值镜头交给高质 Provider。
- Router 负责自动选模型。
- QA + 人工抽检负责质量闭环。
- 成本、失败、重试、降级全部进入审计。
+12031
View File
File diff suppressed because it is too large Load Diff
+826
View File
@@ -0,0 +1,826 @@
# AI 漫剧平台小白使用手册
本文给第一次使用系统的人看,目标是讲清楚两件事:
- 用户端怎么从一个想法或一段小说,生成一集漫剧成品。
- 后台运营怎么给用户加额度、看资源、查问题、管 AI 接入。
当前建议先用 mock 或低成本配置熟悉流程。不要在不理解成本和任务状态时直接打开“生产任务优先使用 OpenAI”。
## 1. 先理解几个核心概念
### 项目
项目是一部漫剧的制作单元。一个项目可以是:
- AI 原创小说:你给题材、人设、卖点,AI 先写小说内容,再改成漫剧。
- 上传小说:你粘贴或上传已有小说,系统解析章节,再改成漫剧。
项目里会逐步产生:小说源、故事圣经、角色、分集、脚本、分镜、图片、音频、字幕、视频。
### 故事圣经
故事圣经不是最终小说,也不是一集脚本。它是整部作品的“规则说明书”。
它主要回答:
- 这个故事是什么类型,例如都市逆袭、甜宠、悬疑。
- 故事的核心冲突是什么。
- 主角要达成什么目标。
- 世界观和风格是什么。
- 什么内容不能乱写,例如不能突然科幻、不能低俗、不能侵权。
- 结尾方向、爽点、反转和长线伏笔是什么。
为什么需要它:后面的角色、分集、脚本、分镜都会参考故事圣经。没有故事圣经,AI 容易前后风格不一致。
故事圣经一般由 AI 先生成,人再检查。确认后,后续流程才会继续。
### 角色圣经
角色圣经是角色库,也就是每个重要人物的设定卡。
它会记录:
- 姓名
- 角色类型:主角、反派、配角、次要角色
- 性格
- 背景
- 目标和动机
- 与其他角色的关系
- 外貌特征
- 服装规则
- 说话风格
- 锚点图
角色怎么定:
- AI 原创项目会结合你填写的主角设定、题材、卖点和故事圣经自动抽取。
- 上传小说项目会结合小说章节和故事圣经自动抽取。
- 运营或用户可以在确认前改名、改关系、补外貌、删除不需要的角色,也可以手动新增角色。
确认角色后,角色会被锁定。后续分镜和图片生成会尽量遵守这些角色设定。
### 全局角色资产库
全局角色资产库是后台给运营用的“演员库”。
它和项目里的角色不一样:
- 全局角色:可跨项目复用的固定演员,例如“都市冷感女主模板”“霸总男主模板”“绿茶反派模板”。
- 项目角色:某个项目里的具体人物,例如这一部剧里叫“林晚”、身份是设计师、剧情目标是夺回项目。
正式运营时,建议先沉淀一批全局角色:
- 固定脸:锚点图、脸型、发型、身形。
- 服装规则:日常装、职场装、礼服、古装、校服等变体。
- 声音:每个角色自己的 voice id、模型、声线、语速和情绪风格。
- 表演风格:眼神、表情、动作习惯。
- 授权范围:内部测试、公司自有、授权、限制使用。
这样不同项目可以复用同一批主角、配角、反派,只在项目里改名字、身份、关系和服装变体,不需要每次重新设计。
后台路径:
```text
后台 -> 角色资产库
```
项目角色绑定路径:
```text
后台 -> 角色资源 -> 全局角色 -> 选择角色资产 -> 绑定
```
绑定后,如果项目角色还没有锚点图、声音或默认服装,系统会自动带入全局角色资产里的默认配置。项目角色已有的差异化设置不会被强行覆盖。
### 锚点图
锚点图是角色的一张参考图,用来稳定角色外观。
简单理解:以后生成这个角色的分镜图时,系统会尽量让人物长得像这张图。
内部测试时可以先用 mock 图熟悉流程;真实上线时再接真实图片 Provider。
### 长篇记忆
长篇记忆用于保证多集连续性。
它会记录:
- 已发生的重要事件
- 角色关系变化
- 未回收的伏笔
- 不能打破的设定
- 前几集结尾留下的悬念
如果做 1 集短测试,长篇记忆看起来存在感不强;如果做 20 集、100 集,它非常关键。
### 分集计划
分集计划就是把故事拆成第 1 集、第 2 集、第 3 集。
每集会有:
- 标题
- 剧情摘要
- 开头钩子
- 中段冲突
- 结尾悬念
- 关联章节
- 预计时长
你说“一章一个集数”,在系统里可以理解为:一个章节内容对应一个 episode,也就是一集。
### 单集脚本
单集脚本是一集的文字剧本。
它主要包含:
- 旁白
- 关键对白
- 剧情推进
- 情绪节奏
脚本确认后,才会进入分镜。
### 分镜
分镜是把一集拆成一个个镜头。
当前默认每集约 10 个镜头。每个镜头会有:
- 镜头序号
- 场景名,例如“开局压迫”“主角反击”“结尾钩子”
- 地点描述,例如“会议室中心”“走廊阴影”
- 出场角色
- 画面描述
- 动作描述
- 对白或旁白
- 镜头运动
- 特效类型
- 时长
- 图片生成 Prompt
- 负面 Prompt
场景是不是 AI 生成:是。当前场景不是单独的“场景库”,而是在分集、脚本、分镜里由 AI 自动生成场景名、地点、画面和动作。确认前可以编辑分镜字段,也可以重新生成 Prompt。
### 分镜图
分镜图是每个镜头对应的一张画面。
系统会根据分镜 Prompt、角色设定和风格生成图片。当前一集默认约 10 张正式分镜图。
### 音频、字幕和视频
音频是 TTS 声音,字幕是 SRT 字幕,视频是最终 MP4。
当前推荐上线链路是:
分镜图 + TTS 音频 + SRT 字幕 + FFmpeg 本地合成 MP4
这样成本更可控。Sora 视频可以作为高级能力,不建议默认打开。
现在音频默认走“多角色音频”:
- 旁白使用旁白声线。
- 角色对白优先使用角色资产库里的 `voice_id`
- 如果项目角色绑定了全局角色,会继承全局角色的声音配置。
- 如果角色没有单独声音,才使用默认 TTS 声音。
- 系统会按分镜顺序把旁白和对白拆成多个片段,逐段合成。
- 每个片段会带上起始秒数、结束秒数和目标时长,再按分镜时间轴铺成一条整集音轨。
- 如果某句真实 TTS 比分配时间更长,接口会返回时间轴告警,运营需要缩短台词、增加镜头时长或调整语速。
现在字幕默认走“对白级字幕”:
- 一句旁白或一句角色台词对应一条 SRT 字幕。
- 字幕时间和多角色音频使用同一套分镜时间轴。
- 如果需要旧版“每个分镜一条字幕”,技术接口可以传 `subtitle_mode: "shot"`
生成完成后,在用户端“图片、音频和视频”区域可以看“音频字幕时间轴”:
- 每一行是一句旁白或一句角色对白。
- 可以看到镜头号、说话人、起止秒、目标时长、实际 TTS 时长和声音 ID。
- 如果某句 TTS 超出分配时长,会出现红色提示。
- 这时优先处理台词:缩短句子、拆成两个镜头、增加镜头时长,或换更快的声线。
- 面板里的“试听音频”是整集音频试听;“下载字幕”可以下载 SRT 文件。
- 每句后面可以点“重试此句”,只重合成这一句 TTS,再重新铺成整集音频。
- 单句重试可以换声音 ID、语速和语气说明。
- 老版本生成的音频如果缺少逐句片段文件,页面会提示先“重生成音频”;真实 TTS 模式下这一步会重新产生费用。
成本预估说明:
- 页面会显示本集大约多少句、多少个声音、多少 TTS 字符。
- 这不是最终账单金额。
- 真实金额以后台 Provider 日志和 AI 平台账单为准。
旧的一条旁白音频模式仍保留,技术接口可以传:
```json
{
"dialogue_mode": "narration"
}
```
正式测试真实 TTS 前要注意:多角色音频会按片段多次调用 TTS,成本高于一条旁白音频。第一次建议只测 1 集、少量分镜。
## 2. 用户端从 0 到 1 操作流程
用户端地址:
```text
http://152.53.37.118:5174
```
本机地址:
```text
http://127.0.0.1:5174
```
### 2.1 登录或注册
打开用户端后,先登录或注册。
如果是内部测试用户,需要后台先给这个用户加额度。当前用户端不展示真实支付入口,额度由后台人工增加。
### 2.2 新建项目
进入“新建”页面。
主要字段:
- 标题:项目名,方便后台和自己识别。
- 输入方式:AI 原创小说或上传小说。
- 类型:例如都市逆袭、甜宠、悬疑。
- 风格:例如韩漫风。
- 目标集数:想生成几集。
- 单集时长:一集预计多长。
新手建议:
- 第一次测试选 1 集。
- 单集时长填 40-60 秒。
- 风格先保持默认。
### 2.3 AI 原创小说流程
如果输入方式选择“AI 原创小说”,继续填写原创信息。
常见字段:
- 目标受众:给谁看。
- 主角姓名:可以填,也可以留空让 AI 起名。
- 主角设定:身份、性格、困境。
- 故事氛围:高能反击、甜虐、悬疑等。
- 卖点:强钩子、快节奏、反转爽点。
- 禁忌规则:不希望出现的内容。
建议写法:
```text
主角设定:女主是被豪门退婚的设计师,表面温和,实际非常冷静,有隐藏实力。
卖点:退婚现场反击、身份反转、前任后悔、女主独立成长。
禁忌规则:不要血腥,不要低俗,不要真实品牌。
```
然后按页面顺序生成:
1. 创意或小说内容
2. 故事圣经
3. 角色
4. 长篇记忆
5. 分集计划
6. 单集脚本
7. 分镜
8. 分镜图
9. 音频
10. 字幕
11. 视频
每一步生成后,都要先看结果是否合理,再确认进入下一步。
### 2.4 上传小说流程
如果输入方式选择“上传小说”,流程是:
1. 新建项目时选择上传小说。
2. 粘贴小说文本,或后续用文件上传入口。
3. 填标题、作者名。
4. 做版权确认。
5. 解析小说章节。
6. 生成故事圣经。
7. 抽取角色。
8. 后续与 AI 原创流程相同。
版权确认是什么意思:确认你有权使用这段文本做 AI 改编。没有确认版权,系统不应该继续改编。
### 2.5 故事圣经怎么检查
看到故事圣经后,重点检查:
- 类型对不对。
- 主角目标是否清楚。
- 核心冲突是否够强。
- 风格是否符合你要的漫剧方向。
- 有没有明显跑题。
- 禁忌规则有没有写进去。
可以接受时再确认。
如果不满意:
- 用户端当前以生成和确认为主,细改能力相对轻。
- 后台或 API 可做更细的编辑。
- 简单测试可以重新建项目或回到前一步调整输入。
### 2.6 角色怎么检查
角色生成后,重点看:
- 主角是否正确。
- 反派是否明确。
- 配角是否过多。
- 人物关系是否合理。
- 外貌和服装是否适合后续图片生成。
- 是否有不需要的人物。
确认角色前,尽量把主角、反派、关键配角定清楚。
角色锚点图建议:
- 主角必须有锚点图。
- 反派和重要配角最好也有。
- 次要角色可以后面再补。
### 2.7 分集计划怎么检查
每集都要看:
- 开头钩子是否吸引人。
- 中段冲突是否推进。
- 结尾悬念是否让人想看下一集。
- 每集信息量是否太少或太多。
- 是否符合“一章一个集数”的目标。
确认后再生成脚本。
### 2.8 单集脚本怎么检查
看三个点:
- 旁白是否顺。
- 对白是否符合角色。
- 剧情是否能在目标时长内讲清楚。
如果一集 40-60 秒,脚本不要太长。旁白越长,TTS 音频越长,视频时长也会变长。
### 2.9 分镜怎么检查
分镜是最容易影响成片质量的地方。
重点看:
- 是否有 8-12 个镜头。
- 每个镜头是否有清楚画面。
- 出场角色是否合理。
- 画面描述是否适合生成图片。
- 镜头顺序是否能讲清故事。
- 结尾镜头是否有悬念。
场景字段由 AI 生成,包括场景名、地点、画面、动作、镜头运动。确认前可以修改,确认后再生成分镜图。
### 2.10 生成分镜图
分镜确认后,生成分镜图。
如果是真实图片 Provider
- 每张图都会产生费用。
- 当前一集约 10 张分镜图。
- 建议第一次真实测试只开一集,并设置成本上限。
如果是 mock
- 不消耗真实 AI 额度。
- 适合熟悉流程和验收页面。
### 2.11 生成音频、字幕和视频
顺序建议:
1. 点击“多角色音频”,生成角色音频和对白级字幕。
2. 检查音频、字幕素材是否都已出现。
3. 渲染视频。
4. 去成品页预览或下载。
视频渲染前需要额度可用。当前内部测试由后台人工加余额。
### 2.12 成品下载和审核
视频生成后,到“成品”页面看预览和下载。
如果要做公开案例,需要提交公开案例授权。后台审核通过后才可以公开展示。
## 3. 后台运营操作流程
后台地址:
```text
http://152.53.37.118:5175
```
本机地址:
```text
http://127.0.0.1:5175
```
本地 seed 默认管理员:
```text
邮箱:admin@example.com
密码:Admin123!
```
生产环境必须改密码。
### 3.1 仪表盘
仪表盘用于看整体情况:
- 今日用户
- 今日项目
- 今日生成集数
- 任务状态
- 项目状态
- 队列状态
运营每天先看这里,确认有没有大量失败任务或异常队列。
### 3.2 项目管理
项目管理用于查看用户项目。
你可以看到:
- 项目标题
- 用户
- 当前状态
- 分集数量
- 角色数量
- 分镜数量
- 最近任务
- 成本
常用操作:
- 转人工:项目卡住或内容有风险。
- 取消项目:内部测试废弃项目。
- 查看项目详情:排查用户说“生成不了”的问题。
### 3.3 小说管理
小说管理包含小说源和章节。
小说源是用户上传或 AI 原创产生的原始文本。章节是解析后的结构化内容。
看这里可以判断:
- 上传文本有没有解析成功。
- 章节数量是否正常。
- 字数是否异常。
- 内容预览是否乱码。
### 3.4 角色资源
角色资源页用于看所有项目里的角色。
运营重点看:
- 主角是否生成。
- 角色状态是否已确认。
- 是否有锚点图。
- 图片数量是否正常。
- 长篇记忆数量是否正常。
用户反馈“人物长得不一致”,先看这里有没有锚点图。
### 3.5 分镜资源
分镜资源页用于看镜头。
运营重点看:
- 一集是否有足够分镜。
- 每个镜头的场景名是否合理。
- 画面描述是否清楚。
- 是否已经生成图片。
- 最新图片能否预览。
用户反馈“画面不对”,先看分镜字段,再看分镜图。
### 3.6 成品漫剧
成品漫剧页看最终视频。
你可以看:
- 视频资产 ID
- 所属项目
- 所属分集
- 文件路径
- 时长
- 渲染任务
- 预览
用户反馈“视频下载不了”或“成品不对”,从这里开始查。
### 3.7 用户管理
用户管理是当前内部测试最常用的后台页面。
常用操作:
- 人工加余额
- 查看用户详情
- 看用户余额流水
- 看用户订单
- 看用户项目
- 看用户素材
- 看最近操作
- 禁用或启用用户
- 修改角色
- 重置密码
- 额度冲正
当前用户端不展示真实支付入口,所以测试用户要先在后台加余额。
建议:
- 内部测试用户先加少量额度。
- 真实 AI 测试前再加额外额度。
- 额度冲正要写清楚原因。
### 3.8 订单额度
订单额度页用于看额度账户和历史订单。
当前内部测试模式下:
- 用户端不展示套餐和支付。
- 后台人工加余额是主流程。
- 历史 mock 支付接口还在,但不作为正式入口。
### 3.9 内容审核
内容审核页用于处理文本、素材、成品视频、公开案例。
常见状态:
- 通过
- 需修改
- 驳回
- 屏蔽
- 转人工
用户反馈“项目被卡住”,要看这里有没有人工审核项。
### 3.10 任务管理
任务管理用于处理生成失败。
常见操作:
- 重试
- 取消
- 转人工
任务失败时先看:
- 任务类型
- 错误信息
- 重试次数
- 是否达到上限
- 项目是否缺前置资源
例如视频失败,可能是分镜图不足、音频未生成、字幕缺失或 FFmpeg 异常。
### 3.11 AI 接入
AI 接入用于配置 OpenAI 或 mock Provider。
顶部“OpenAI 统一接入”是运营日常使用区域。
“初始化视频接入”会写入真实视频 Provider 预设,包含 MiniMax Hailuo、阿里 Wan、Vidu、Seedance、Runway 和 Kling。它们默认都是关闭状态,只是把配置位放进后台,不会自动调用、不自动扣费。
重要规则:
- 保存 Key 不等于开始花钱。
- 只有勾选“生产任务优先使用 OpenAI”,业务链路才会优先使用真实 OpenAI。
- “检查连接(不生成内容)”只查 Key 和网络,不生成内容。
- 真实 Provider 的付费测试需要二次确认。
- 真实视频测试默认禁用,避免误触发高成本视频任务。
- 真实视频只能在用户端真人短剧流程里显式勾选确认后调用。
建议第一次真实测试配置:
- 单次成本上限:3
- 当日成本上限:10
- 先不要打开真实视频 Provider。
- 先用真实文本和图片小样确认链路。
### 3.12 成本日志
成本页用于看 Provider 调用日志和估算成本。
如果发现费用异常:
- 先看哪个 Provider 调用多。
- 再看是文本、图片、TTS 还是视频。
- 视频按秒计费,最容易变贵。
- 图片按张数累积。
### 3.13 系统配置
系统配置当前包含 API 加密开关。
测试默认不开启。上线后可以手动开启。
注意:应用层加密不能替代 HTTPS。正式上线必须使用 HTTPS。
### 3.14 审计日志
审计日志记录后台高危操作。
例如:
- 给用户加余额
- 扣减或冲正额度
- 禁用用户
- 改角色
- 重置密码
- 修改系统配置
- 导出日志
出现运营争议或客服问题时,先查审计日志。
## 4. 一集从头到尾的小白流程
这是最推荐的新手演练流程,先用 mock 熟悉,不产生真实 AI 生成费用。
1. 后台给测试用户加余额。
2. 用户端登录测试用户。
3. 新建项目,选择 AI 原创小说。
4. 目标集数填 1。
5. 填主角设定和卖点。
6. 生成原创内容。
7. 生成故事圣经。
8. 检查故事圣经,确认。
9. 抽取角色。
10. 检查主角、反派、配角,生成锚点图,确认角色。
11. 生成长篇记忆。
12. 生成分集计划,确认。
13. 选择第 1 集,生成脚本,确认。
14. 生成分镜,检查 10 个镜头,确认。
15. 生成分镜图。
16. 生成音频。
17. 生成字幕。
18. 渲染视频。
19. 到成品页预览和下载。
20. 后台看项目、分镜资源、成品漫剧、任务和成本日志。
## 5. 哪些内容是 AI 生成,哪些需要人确认
| 阶段 | AI 会生成什么 | 人要看什么 |
|---|---|---|
| 原创小说 | 创意、章节、大纲、自检 | 是否符合题材和禁忌 |
| 故事圣经 | 世界观、冲突、风格、结尾方向 | 是否跑题,规则是否清楚 |
| 角色圣经 | 角色列表、人设、关系、外貌 | 主角/反派是否正确,关系是否合理 |
| 角色图 | 候选图、锚点图、表情图 | 外观是否稳定,锚点是否合适 |
| 长篇记忆 | 事件、伏笔、角色记忆 | 是否和前文冲突 |
| 分集计划 | 每集标题、摘要、钩子、悬念 | 节奏是否适合短剧 |
| 单集脚本 | 旁白、对白、剧情推进 | 语言是否顺,角色是否不崩 |
| 分镜 | 场景、地点、画面、动作、镜头、Prompt | 画面是否能生成,镜头是否连贯 |
| 分镜图 | 每个镜头的图片 | 人物一致性、画面质量 |
| TTS | 多角色音频 | 声音、语速、时长、角色是否匹配 |
| 字幕 | 对白级 SRT 字幕 | 每句台词时间轴和文字 |
| 视频 | MP4 成品 | 是否可播放、画音字幕是否对齐 |
## 6. 新手最容易误解的地方
### 故事圣经不是让用户看的简介
它更像制作规则。用户未必需要理解每个字段,但运营要知道它会影响后面所有生成。
### 角色不是只靠一张图定
角色由文字设定和锚点图共同决定。文字设定决定“是谁”,锚点图决定“长什么样”。
### 场景目前不是独立资产库
当前场景主要在分镜里体现。AI 会生成场景名、地点、画面描述。后续如果要做场景库,可以把常用地点、背景图、风格规则单独管理。
### 确认不是摆设
确认的意思是:这一步结果可以作为后续生成依据。
如果故事圣经错了就确认,后面角色、分集、脚本都会跟着错。
### mock 不等于没用
mock 用来验证流程、页面、状态、素材落库、任务重试和下载。真实 AI 只是在 Provider 层替换生成能力。
### Sora 视频不要默认打开
Sora 按秒计费。当前漫剧落地更适合先用分镜图 + TTS + FFmpeg 合成,成本更可控。
### 生成类型要先选清楚
新建项目现在有三种生成类型:
| 生成类型 | 当前作用 | 适合场景 |
|---|---|---|
| 图片漫剧版 | 分镜图 + 配音字幕 + FFmpeg 合成 | 低成本批量测试 |
| 动态漫画版 | 先预留入口 | 后续做局部动效 |
| AI 真人短剧版 | 演员定妆、真人分镜、关键帧、视频片段、片段质检、合成 | 先验证真人短剧数据流,再做真实视频小样 |
AI 真人短剧版默认使用 mock video clip 跑通流程。后台已经支持 MiniMax Hailuo、阿里 Wan、Vidu、Seedance、Runway、Kling 等 image-to-video Provider,但真实视频默认禁用,必须运营启用 Provider、填写成本阈值,并且用户端勾选“确认使用真实视频生成并承担费用”后才会调用。
真人短剧的顺序是:
1. 先完成故事、角色、分集、脚本、分镜。
2. 点“演员定妆”,把角色圣经转成真人演员设定。
3. 点“真人分镜”,把漫画分镜改写成真人短剧镜头。
4. 点“关键帧”,生成每个镜头的真人短剧关键帧 mock 图。
5. 在“视频 Provider”里保持 mock,先点“估算”,确认成本为 0。
6. 点“视频片段”,生成每个镜头的 mock MP4 片段。
7. 预览片段,点“质检”;不满意可以点“重试”。
8. 点“合成”,把视频片段拼成真人短剧 mock 成片。
真实 Hailuo/Wan/Vidu/Seedance/Runway/Kling/Sora 等视频模型建议只做 1 个镜头小样。真实小样前要注意:
- 后台 AI 接入页先点“初始化视频接入”。
- 只启用一个视频 Provider。第一轮建议优先试 `minimax_hailuo_23_fast`,速度和成本更适合小样验证。
- 填 API Key、Base URL、模型、单次成本上限、当日成本上限。
-`price_per_second`,否则页面只能显示 0 美元估算。
- 真实视频需要 PNG/JPG/WebP 关键帧;mock SVG 关键帧不能直接用于真实视频 Provider。
- 真实视频测试不要在后台 Provider 测试里做,后台仍禁用真实视频测试,避免误扣费。
- 阿里 Wan、Seedance 等不同渠道接口字段差异较大;后台预设是可配置模板,正式上线前必须用 1 个镜头核对请求字段、返回视频 URL、耗时和实际账单。
推荐真实视频小样顺序:
1. MiniMax Hailuo 2.3 Fast:先看“人物会动”和中文短剧感是否接近目标。
2. 阿里 Wan2.6 I2V Flash:对比速度、稳定性和成本。
3. Vidu Q3 Turbo Reference:重点看角色一致性、表情和音画能力。
4. Seedance/即梦:重点看真人感、镜头语言和中文短剧风格。
5. Kling/Runway:作为备用对比。
每次只测同一个项目、同一个角色、同一个镜头,这样才看得出哪个 Provider 更适合。
## 7. 建议的真实 AI 验收顺序
等你熟悉流程后,再做真实 AI 验收。
建议顺序:
1. 后台 AI 接入页设置单次成本上限和当日成本上限。
2. 点“检查连接(不生成内容)”。
3. 只打开真实文本或真实图片中的一个能力。
4. 跑 1 个项目、1 集、10 个分镜。
5. 看 Provider 日志和成本。
6. 确认结果可控后,再打开 TTS。
7. 视频继续先用 FFmpeg。
8. 最后再考虑 Sora 小样。
建议成本阈值:
- 单次成本上限:3 美元
- 当日成本上限:10 美元
- 第一轮不打开 Sora
## 8. 运营排查问题速查
| 用户反馈 | 先看哪里 |
|---|---|
| 登录不了 | 后台用户管理、用户状态 |
| 没额度 | 后台用户管理、额度账户、额度流水 |
| 项目卡住 | 后台项目详情、任务管理 |
| 小说解析失败 | 小说管理、小说源预览、章节数量 |
| 角色不对 | 角色资源、故事圣经 |
| 人物长得不一致 | 角色锚点图、分镜图 |
| 场景乱 | 分镜资源里的场景名、地点、画面描述 |
| 视频生成失败 | 成品漫剧、任务管理、分镜图、音频、字幕 |
| 审核不过 | 内容审核页 |
| 费用异常 | 成本日志、AI 接入优先级、成本阈值 |
## 9. 当前系统状态提醒
- 用户端是 H5 优先,PC 自适应;微信小程序/App 后续再适配。
- 当前内部测试不展示真实支付入口。
- 额度由后台人工增加。
- OpenAI Key 已支持后台保存并加密。
- 保存 Key 不会自动消耗额度。
- OpenAI Provider 默认优先级低于 mock。
- 真实视频测试已禁用,避免误触发高成本任务。
- 当前适合先做 mock 全流程熟悉,再做真实 AI 小样。
+622
View File
@@ -0,0 +1,622 @@
# AI Manga Platform
AI 漫剧 / AI 写真视频生成平台。当前优先开发系统 A:原创小说 / 上传小说 -> 韩漫 / 漫剧生成系统。
## 当前阶段
阶段 23:API 加密传输已完成,当前进入生产化补齐。AI 原创小说 3 集 MP4 与上传小说 1 集 MP4 两条闭环已跑通,并在 HTTPS 基础上叠加前后端应用层加密信封;真实图片/TTS/视频资产落库、OpenAI Sora 视频 Provider 驱动、后台用户高危操作审计、细粒度 RBAC 和审计导出已补齐。
当前已完成后端基础骨架、数据库 schema、JWT 认证、私有文件上传、项目创建流程、上传小说解析、AI 原创小说 mock、故事圣经、角色圣经、长篇记忆、分集计划、单集脚本和分镜、BullMQ 队列与任务状态流转、AI Provider 抽象层、真实 AI Provider 接入、真实图片/TTS/视频资产落库、Provider 成本阈值、worker 队列消费、TTS 音频、SRT 字幕、FFmpeg 视频合成、后台管理、用户端 H5 制作台、订单额度、内容审核、MVP 验收和 API 加密传输。后台管理已覆盖项目、任务、小说源、章节、角色资源、分镜资源、成品漫剧、订单额度、内容审核、Provider、成本、用户、素材、版权记录和审计日志,并补充资源详情/媒体预览、用户人工加余额、额度冲正、用户禁用/启用、改角色、重置密码和用户详情抽屉,高危用户操作带二次确认,后台接口按 admin/operator/finance/auditor 做细粒度权限校验。用户端已支持登录注册、项目创建、原创/上传入口、版权确认、故事/角色/记忆/分集/脚本/分镜/媒体生成、额度查看/冻结、内容审核、公开案例授权、进度查看和私有成品下载;当前内部测试不展示支付/套餐入口,额度由后台人工增加。AI Provider 已支持 OpenAI 真实调用,后台可配置 API Key、Base URL、模型、优先级、启停状态、单次成本上限和当日成本上限;API Key 使用服务端密钥加密落库,页面不回显明文。图片、TTS 和 OpenAI Sora 视频生产链路已能保存真实 Provider 返回的二进制素材,默认优先级仍可保持 mock,避免未确认成本前误消耗真实模型。
新手操作请先看 [OPERATION_GUIDE.md](./OPERATION_GUIDE.md),里面按用户端和后台分别说明从项目创建到成品视频、额度、审核、AI 接入和常见问题排查。产品内也已补充教程入口:后台左侧“使用教程”,用户端导航“教程”,并预留 `src/pages/help/tutorial` 独立页面。
## 目录结构
```text
backend/ NestJS API 服务
admin/ Geeker-Admin 可接入的后台前端骨架
user-app/ uni-app 路由预留、H5 优先的用户端制作台
workers/ BullMQ / FFmpeg worker 入口
deploy/ 本地依赖、Docker、Nginx、部署脚本预留
docs/ 系统 A / B 需求和工程文档
storage/ 本地 mock 存储目录
```
## 本地启动
安装依赖:
```bash
npm install
```
后端启动会自动加载根目录 `.env``backend/.env`;系统环境变量优先级最高。`PROVIDER_SECRET_KEY` 必须长期保持稳定,用于解密后台保存的 AI Provider Key。
启动后端空服务:
```bash
npm run dev:backend
```
后端默认监听:
```text
http://127.0.0.1:3000/api/health
```
启动后台骨架:
```bash
npm run dev:admin
```
启动用户端 H5
```bash
npm run dev:user
```
## 验证命令
```bash
npm run lint
npm run typecheck
npm test
npm run build
```
## API 加密传输
阶段 23 已实现前后端应用层加密信封,并保留 HTTPS 作为生产必需底座。阶段 23 补充了后台配置开关:测试默认关闭,上线后可在后台“配置管理”手动开启。
实现范围:
- 前端先通过 `GET /api/client-config` 读取 `api_crypto_enabled`,默认不开启加密。
- 开关开启后,前端通过 `GET /api/crypto/handshake` 获取短期 ECDH 会话参数。
- 前端和后端使用 `ECDH P-256 + HKDF-SHA256` 派生会话 AES key。
- JSON API 请求体使用 `AES-256-GCM` 加密后发送。
- JSON API 响应体、异常响应体使用 `AES-256-GCM` 加密后返回。
- 用户端小说上传改为加密 JSON 文件 payload,不再裸传 multipart 正文。
- 私有素材下载在加密请求下返回加密 JSON 文件 payload,前端解密后再生成 Blob。
- 后台和用户端生产构建默认使用同源 `/api`;显式配置 `VITE_API_BASE_URL` 时生产环境禁止 `http://`
- 后台管理新增“配置管理”,可维护 `security.api_crypto_enabled`
- `API_CRYPTO_ENABLED=true/false` 可强制覆盖数据库配置;默认 `auto` 表示读取后台配置。
生产建议:
```bash
NODE_ENV=production
HTTPS_REQUIRED=true
HTTPS_ALLOW_LOCAL_HTTP=false
TRUST_PROXY=true
CORS_ORIGINS=https://manga.example.com,https://admin.manga.example.com
API_CRYPTO_ENABLED=auto
API_CRYPTO_SESSION_TTL_SECONDS=900
VITE_API_CRYPTO_ENABLED=auto
VITE_API_BASE_URL=/api
```
开启方式:
1. 保持 `API_CRYPTO_ENABLED=auto``VITE_API_CRYPTO_ENABLED=auto`
2. 用管理员账号进入后台“配置管理”。
3. 点击“开启 API 加密”,之后普通 API 会强制要求加密信封。
注意:应用层加密保护的是请求体和响应体内容。HTTP 方法、路径、域名和查询字符串不能被前端 JS 在应用层隐藏,所以生产环境仍必须使用 HTTPS,并应避免把敏感业务内容放进 query string。多实例部署时,当前内存态加密会话需要粘性会话或迁移到 Redis。
## 数据库
阶段 02 选择 Prisma 作为 MySQL 8 ORM。原因是当前系统 A 表数量多、JSON 字段多、后续迁移频繁,Prisma 的 schema、migration 和类型生成更适合分阶段落地。
核心文件:
```text
backend/prisma/schema.prisma
backend/prisma/migrations/20260531093000_init_system_a/migration.sql
backend/prisma/seed.ts
```
常用命令:
```bash
cp .env.example .env
DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:validate
DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:generate
DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:migrate
DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:deploy
DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:seed
```
`db:migrate` 用于开发环境生成/演进迁移,可能需要创建 shadow database 的权限。已有 migration 文件时,服务器环境优先使用 `db:deploy`
## 认证接口
阶段 03 已实现基础 JWT 认证:
```text
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/profile
GET /api/profile
```
`/api/auth/profile``/api/profile` 都需要 `Authorization: Bearer <token>`
## 项目接口
阶段 05 已实现登录用户的项目创建、列表、详情、更新、取消和软删除流程:
```text
POST /api/projects
GET /api/projects
GET /api/projects/:id
PATCH /api/projects/:id
POST /api/projects/:id/cancel
DELETE /api/projects/:id
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- `input_mode` 当前支持 `ai_original``upload``admin_import` 预留给后台导入。
- 新项目默认进入 `source_selecting` 状态。
- `DELETE /api/projects/:id` 当前为软删除,会把可删除项目归档为 `archived`
## 文件上传
阶段 04 已实现私有资产上传。当前本机未启动 MinIO 时,默认使用本地 mock 私有存储;后续将 `STORAGE_DRIVER=minio` 并配置 `MINIO_*` 即可切换。
```text
POST /api/assets/upload
POST /api/projects/:projectId/novel/upload
GET /api/assets/:assetId
GET /api/assets/:assetId/download
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 上传字段名为 `file`
- 小说上传当前支持 `txt``md``docx` 和文本型 `pdf`
- 上传资产默认 `visibility=private`
- 普通 asset 查询只返回内部 asset 信息,不返回原始文件公网地址。
- `/api/assets/:assetId/download` 需要 Bearer Token,会校验资产归属后返回私有文件流,用户端成品下载和视频预览使用该接口。
## 小说解析与版权
阶段 06 已实现上传小说解析链路:
```text
POST /api/projects/:projectId/copyright/confirm
GET /api/projects/:projectId/copyright
POST /api/projects/:projectId/novel/paste
POST /api/projects/:projectId/novel/parse
GET /api/projects/:projectId/novel/parse-result
PATCH /api/novel-chapters/:chapterId
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 上传小说解析前必须先确认版权,否则 `/novel/parse` 返回 400。
- `authorization_type` 支持 `author_self``licensed``public_domain``internal_test`
- `/novel/parse` 支持从已上传 asset 解析,也支持从粘贴文本 source 解析。
- 解析会清洗常见广告/水印行,识别章节标题,失败时按字数切分。
- 解析结果写入 `novel_sources``novel_chapters`
- 章节可手动编辑,编辑后状态标记为 `edited`
## AI 原创小说 Mock
阶段 07 已实现 AI 原创小说 mock 链路:
```text
POST /api/projects/:projectId/original/idea
POST /api/projects/:projectId/original/outline
POST /api/projects/:projectId/original/chapters
POST /api/projects/:projectId/original/self-check
GET /api/projects/:projectId/original/result
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 项目 `input_mode` 必须为 `ai_original`,上传小说项目调用原创接口会返回 400。
- 当前实现为 deterministic mock`parse_report.provider=mock_novel_provider`,不调用真实 AI Provider。
- 生成内容写入 `novel_sources``novel_chapters`
- 默认按项目 `target_episode_count` 生成章节,MVP 场景通常为 3 章。
- 自检覆盖主角一致性、主线、冲突、可视化摘要和短视频钩子。
## 故事圣经
阶段 08 已实现故事圣经链路:
```text
POST /api/projects/:projectId/story-bible/generate
GET /api/projects/:projectId/story-bible
PATCH /api/projects/:projectId/story-bible
POST /api/projects/:projectId/story-bible/confirm
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 生成前必须已有小说来源和章节,支持上传解析链路和 AI 原创 mock 链路。
- 生成结果写入 `story_bibles`,状态为 `waiting_confirm`
- 编辑故事圣经会创建新版本,不覆盖旧版本。
- 确认后故事圣经状态变为 `confirmed`,项目状态变为 `story_confirmed`
- `GET /story-bible?version=2` 可查询指定版本;不传 version 返回最新版本和版本列表。
## 角色圣经
阶段 09 已实现角色圣经链路:
```text
POST /api/projects/:projectId/characters/extract
GET /api/projects/:projectId/characters
POST /api/projects/:projectId/characters
POST /api/projects/:projectId/characters/confirm
PATCH /api/characters/:characterId
DELETE /api/characters/:characterId
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 抽取角色前必须已有已确认故事圣经,否则 `/characters/extract` 返回 400。
- 角色抽取当前为 deterministic mock,从已确认故事圣经和小说章节生成主角、反派、配角等角色草稿。
- 角色数据写入 `characters` 表,支持列表、手动新增、编辑和软删除。
- 确认角色库会把 `draft``generated``edited` 状态角色锁定为 `locked`,项目状态变为 `character_confirmed`
- `locked` 角色不可修改姓名、角色类型、性别、年龄、身份和核心外观字段;仍可补充服装规则、表情风格等非核心描述。
- 本阶段不生成角色图片、不生成 anchor 图,也不接入 ImageProvider 或队列。
## 长篇记忆
阶段 10 已实现长篇记忆链路:
```text
GET /api/projects/:projectId/plot-memories
POST /api/projects/:projectId/plot-memories/generate
POST /api/projects/:projectId/plot-memories
PATCH /api/plot-memories/:memoryId
GET /api/projects/:projectId/memory-context?episode_no=5
GET /api/characters/:characterId/memories
GET /api/projects/:projectId/plot-threads
POST /api/projects/:projectId/plot-threads
PATCH /api/plot-threads/:threadId
POST /api/episodes/:episodeId/continuity-check
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 生成长篇记忆前必须已有 confirmed 故事圣经和 locked 角色库。
- 记忆生成当前为 deterministic mock,会写入 `plot_memories``plot_threads``character_memories`
- 剧情记忆支持手动新增、标记 resolved/archived、按类型/状态/分集筛选。
- 剧情线支持新增、查询、更新状态,类型覆盖主线、反派计划、悬疑线、角色成长线等。
- `GET /memory-context?episode_no=N` 会返回故事圣经、锁定角色、活跃剧情记忆、开放剧情线、前 3 集摘要和上一集结尾钩子,供后续分集/脚本生成使用。
- locked 角色的非核心资料补充会自动记录 `character_memories.profile_adjustment`
- 连续性检查当前为规则版 mock,可发现角色未承接、伏笔未推进、上一集钩子未承接、缺少结尾钩子、开放剧情线未推进和明显破坏世界观的内容。
- 本阶段不接入 EmbeddingProvider、不做向量检索、不进队列,也不调用真实 AI Provider。
## 分集计划
阶段 11 已实现分集计划链路:
```text
POST /api/projects/:projectId/episodes/generate-plan
GET /api/projects/:projectId/episodes
PATCH /api/episodes/:episodeId
POST /api/projects/:projectId/episodes/confirm
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 生成分集前必须已有 confirmed 故事圣经、locked 角色库、小说章节和 active 长篇记忆。
- 分集生成当前为 deterministic mock,会写入 `episodes` 表。
- 每集包含标题、剧情摘要、开头钩子、中段冲突、结尾悬念、关联章节和预计时长。
- 生成时项目状态流转为 `episode_planning`,完成后为 `waiting_episode_confirm`
- 分集可在确认前编辑,编辑后状态为 `edited`
- 确认分集会把 `draft``generated``edited` 状态分集锁定为 `confirmed`,项目状态变为 `episode_confirmed`
- 已确认分集不可继续编辑;如需重做,后续会通过返工/修改申请流程处理。
- 本阶段不生成单集脚本、不生成分镜、不进队列,也不调用真实 AI Provider。
## 脚本和分镜
阶段 12 已实现单集脚本和分镜链路:
```text
POST /api/episodes/:episodeId/script/generate
GET /api/episodes/:episodeId/script
PATCH /api/episodes/:episodeId/script
POST /api/episodes/:episodeId/script/confirm
POST /api/episodes/:episodeId/storyboard/generate
GET /api/episodes/:episodeId/storyboard
PATCH /api/storyboard-shots/:shotId
DELETE /api/storyboard-shots/:shotId
POST /api/episodes/:episodeId/storyboard/confirm
POST /api/storyboard-shots/:shotId/regenerate-prompt
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 生成单集脚本前必须已有 confirmed 分集、confirmed 故事圣经和 locked 角色库。
- 单集脚本当前为 deterministic mock,会写入 `episode_scripts`,包含脚本文本、旁白和结构化台词。
- 脚本生成后项目状态进入 `waiting_script_confirm`;确认后脚本状态为 `confirmed`,项目状态为 `script_confirmed`
- 生成分镜前必须已有 confirmed 单集脚本。
- 分镜生成当前为 deterministic mock,会写入 `storyboard_shots`,默认每集 10 个镜头。
- 每个镜头包含画面描述、人物、场景、动作、台词/旁白、镜头运动、特效、2-5 秒时长、Prompt 和负面 Prompt。
- Prompt 会带入角色固定描述,并包含防混脸、年龄/发色漂移、复杂多人镜头等负面约束。
- 分镜可在确认前编辑、删除和重生 Prompt;确认后镜头状态为 `confirmed`,项目状态为 `storyboard_confirmed`
- 本阶段不生成分镜图片、不接入 ImageProvider、不进队列,也不调用真实 AI Provider。
## BullMQ 队列
阶段 13 已实现任务记录、幂等入队、管理员重试/取消/人工介入和队列监控:
```text
POST /api/projects/:projectId/tasks
GET /api/projects/:projectId/tasks
GET /api/tasks/:taskId
GET /api/admin/tasks
POST /api/admin/tasks/recover-stale
POST /api/admin/tasks/:taskId/retry
POST /api/admin/tasks/:taskId/cancel
POST /api/admin/tasks/:taskId/manual-required
GET /api/admin/queues
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 普通用户只能操作自己的项目任务,admin 可查看和管理全部任务。
- 创建任务会先写入 `render_tasks`,再尝试入 BullMQRedis 或 BullMQ 不可用时,任务仍保留为 `pending` / `retrying`,接口返回 `queue_backend=bullmq_unavailable`
- 幂等 key 默认由 `project_id + episode_id + shot_id + task_type + input_hash` 组成,`input_hash` 基于稳定 JSON 序列化生成。
- 已支持 novel、parse、story、character、episode、script、storyboard、image、audio、subtitle、video、qc、review、analytics 等队列映射。
- 默认重试次数按任务类型区分:文本类 2 次、图片类 3 次、视频 2 次、TTS/字幕 2 次、QC 1 次、人工审核 0 次。
- 管理员可把失败任务重试为 `retrying`,可取消任务为 `cancelled`,可标记为 `manual_required`,可恢复过久未完成的 `running` / `retrying` 任务。
- `/api/admin/queues` 会返回 BullMQ 每个队列的 waiting、active、delayed、failed、completed、paused 计数。
- worker 包已接入 BullMQ 消费器,会订阅全部队列并调用后端内部接口 `POST /api/internal/worker/tasks/:taskId/execute`,通过 `WORKER_SECRET` 鉴权。
- worker 执行失败时后端会按 `max_retry` 自动重入队;达到上限后把任务转为 `manual_required`,后台任务页可继续人工处理。
- worker 当前执行的是通用 Provider 任务委托:按任务类型映射到 Text/Novel/Image/Voice/Video/Moderation/QC/FileParse Provider;图片、音频、视频资产生成的业务接口仍保留同步链路。
## AI Provider 抽象
阶段 14 已实现 mock-first Provider 抽象、管理员配置入口、执行日志和成本聚合;阶段 21 已补充 OpenAI / OpenAI 兼容真实驱动:
```text
GET /api/admin/providers
POST /api/admin/providers/bootstrap-mocks
POST /api/admin/providers/bootstrap-openai
POST /api/admin/providers/bootstrap-video
POST /api/admin/providers/execute
PATCH /api/admin/providers/:providerId
PATCH /api/admin/providers/openai/runtime-config
PATCH /api/admin/providers/:providerId/runtime-config
POST /api/admin/providers/:providerId/test
GET /api/admin/provider-logs
GET /api/admin/costs
```
要求:
- 请求必须带 `Authorization: Bearer <token>`;后台接口按 RBAC 权限校验,admin 拥有全部权限,operator/finance/auditor 只开放对应读写范围。
- 已声明 `TextProvider``NovelProvider``ImageProvider``VideoProvider``VoiceProvider``ModerationProvider``QualityCheckProvider``FileParseProvider``EmbeddingProvider`
- `/bootstrap-mocks` 会写入或更新 9 个默认 mock provider 配置。
- `/bootstrap-openai` 会写入或更新 OpenAI real provider 配置,包含 Responses、Moderation、Embeddings、Image Generation、Sora Video 和 Text to Speech 驱动。
- 后台默认显示“OpenAI 统一接入”:运营只需填写一个 OpenAI API Key`PATCH /admin/providers/openai/runtime-config` 会批量应用到全部 OpenAI 能力;未勾选“生产任务优先使用 OpenAI”时会把 OpenAI 优先级保持为 50,低于 mock,避免保存 Key 后立刻消耗真实额度。
- `GET /admin/providers/openai/connection-check` 只请求 OpenAI `/models` 检查 Key/网络,不生成文本、图片、语音或视频,不写 `provider_logs`
- `/execute` 会从 `provider_configs` 选择启用 provider,执行 mock 或 real driver,写入 `provider_logs`
- 如果传入 `task_id`,执行时会把对应 `render_tasks` 标记为 `running`,成功后回写 `success``provider_id``provider_request_id``cost_estimate``cost_actual`
- primary provider 失败时会按 fallback provider 或同类型优先级继续尝试,并记录失败日志。
- provider 输入、配置输出和日志输出会拒绝/脱敏 `api_key``secret``token``password``credential` 等疑似密钥字段;后台运行配置接口会把 API Key 加密为 `api_key_secure` 后保存,列表只显示已配置状态。
- 真实 Provider 优先读取后台加密保存的 API Key;未配置时仍可回退读取 `OPENAI_API_KEY`。后台可配置 `base_url` 接入 OpenAI 兼容服务,也可修改模型名、优先级和启停状态。`PROVIDER_SECRET_KEY` 用于加密后台保存的 Provider 密钥,未设置时回退 `JWT_SECRET`;生产环境必须保持该值稳定,否则已保存 Key 无法解密,需要重新保存。
- 重复点击“初始化 OpenAI 接入”会刷新默认 Provider 定义,但会保留后台已加密保存的 API Key、Base URL、超时和成本阈值,避免误清空线上配置。
- 后台真实 Provider 的“付费测试”必须二次确认,后端 `/admin/providers/:providerId/test` 也要求 `confirm_paid_test=true`;真实视频 Provider 测试接口默认禁用,避免误触发高成本视频任务。
- Provider 可配置 `max_cost_per_call``daily_cost_limit` 成本阈值,也可通过 `PROVIDER_MAX_COST_PER_CALL``PROVIDER_DAILY_COST_LIMIT` 设置全局保护;超过阈值会在调用前拦截并写失败日志。
- `openai_image_generation``openai_video_generation``openai_audio_speech` 的日志只保存 URL/大小/hash 等元数据,不把 base64 图片、视频或音频字节写入 `provider_logs`;真实二进制只在业务生成链路中短暂返回,用于立即写入私有素材。
- OpenAI 图片、Sora 视频和 TTS real provider 默认优先级低于 mock;后台可提高优先级或禁用 mock 后让业务生产链路保存真实图片、真实视频或 TTS 音频资产。
- `openai_video_generation` 按 OpenAI Videos API 的异步流程执行:创建视频任务、轮询完成状态,业务链路需要二进制时再下载 MP4 并落库。可用 `OPENAI_VIDEO_MODEL` 覆盖默认 `sora-2`
- 真人短剧链路已新增可替换 image-to-video Provider`minimax_hailuo_23_fast``minimax_hailuo_23``alibaba_wan26_i2v_flash``alibaba_wan26_i2v``vidu_q3_turbo_reference``vidu_q3_pro``jimeng_seedance``runway-image-to-video``kling-image-to-video`。真实视频 Provider 默认禁用,用户端真实视频生成必须传 `confirm_real_video=true`,并受单片段成本上限、Provider 单次/每日成本阈值保护。
- `configurable_image_to_video` 驱动支持通用异步图生视频流程:创建任务、轮询任务、提取视频 URL;如果供应商只返回 `file_id`,可通过 `output_url_endpoint_template` 再取下载链接。MiniMax Hailuo、阿里 Wan、Vidu 和 Seedance 预设都走这类可配置模板,正式启用前应先用 1 个镜头小样核对字段、速度、质量和账单。
- `VideoProvider` 成本规则支持 `unit=video_seconds``price_per_second``price_per_clip`。后台配置真实视频价格后,用户端真人短剧面板会显示片段级成本预估。
## 图片生成 Mock
阶段 15 已实现角色图、锚点图、分镜预览图和正式图的 mock 生成:
```text
POST /api/characters/:characterId/generate-images
GET /api/characters/:characterId/images
POST /api/characters/:characterId/set-anchor
POST /api/storyboard-shots/:shotId/images/generate
GET /api/storyboard-shots/:shotId/images
POST /api/episodes/:episodeId/shot-images/generate
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 角色图生成要求角色已 `locked`
- 默认生成 `front_reference``anchor``expression_pack`,也可指定 `image_types`
- 角色锚点会写入 `character_images.is_anchor=true`,并回写 `characters.anchor_asset_id`
- 分镜图生成要求 `storyboard_shots.status=confirmed`
- 分镜图 Prompt 会引用角色固定描述和 `anchor_asset_id`,降低混脸和漂移风险。
- 图片生成会创建 `render_tasks`,通过 `ImageProvider` 执行,写入 `provider_logs`,并保存本地/MinIO 私有图片资产。
- 生成结果写入 `character_images``shot_images`,图片资产写入 `assets``visibility=private`
- 当 Provider 返回 `content_base64` 或可下载的 HTTP(S) `asset_url` 时,会保存真实位图;否则回退生成 SVG mock 占位图。图片任务会回填 `render_tasks.output_asset_id`,方便后台追踪产物。
## TTS / 字幕 / FFmpeg
阶段 16 已实现单集音频、字幕和 FFmpeg 视频合成链路:
```text
POST /api/episodes/:episodeId/audio/generate
POST /api/episodes/:episodeId/subtitle/generate
POST /api/episodes/:episodeId/video/render
GET /api/episodes/:episodeId/media-assets
```
要求:
- 请求必须带 `Authorization: Bearer <token>`
- 音频生成要求已有 `confirmed` 单集脚本,会调用 `VoiceProvider`,写入 `render_tasks``provider_logs` 和私有音频资产;真实 TTS 返回 `content_base64` 或可下载 URL 时保存真实音频,否则回退静音 WAV。
- 字幕生成要求已有 `confirmed` 分镜,会按镜头时长生成 SRT cues,写入本地私有 `.srt` 资产。
- 视频渲染要求已有 `confirmed` 分镜和已生成的分镜图;默认会复用或自动生成最新音频和字幕。
- 视频渲染要求项目已支付或冻结额度,否则会返回错误;阶段 19 用户端会在合成前调用额度冻结。
- 视频渲染会调用 `VideoProvider` 记录 provider 执行日志;若启用 `openai-video`,后端会创建 Sora 视频任务、轮询完成并下载 MP4,随后直接保存 Provider 渲染出的 MP4;若 Provider 未返回二进制,则读取私有分镜图、音频和 SRT 字幕,使用 FFmpeg 生成本地私有 MP4 资产。
- 默认优先使用 FFmpeg,成功时返回 `ffmpeg_used=true``render_backend=ffmpeg`;传入 `prefer_ffmpeg=false` 或本机缺少 FFmpeg 时会走 mock fallback。
- 生成结果写入 `assets`asset_type 分别为 `audio``subtitle``video``visibility=private`
- 当前不生成真实 BGM;图片、TTS 和 Sora 视频可切换真实 Provider 落库,视频也可保留 FFmpeg 本地合成,音频、字幕和视频目前仍为同步接口执行,worker 消费器后续接入。
## 后台管理
阶段 17 已实现后台管理 API 和可用的 Vue/Vite 管理端:
```text
GET /api/admin/dashboard
GET /api/admin/rbac/me
GET /api/admin/projects
GET /api/admin/projects/:projectId
PATCH /api/admin/projects/:projectId/status
GET /api/admin/users
GET /api/admin/assets
GET /api/admin/novel-sources
GET /api/admin/novel-chapters
GET /api/admin/characters
GET /api/admin/storyboard-shots
GET /api/admin/works
GET /api/admin/copyright-records
GET /api/admin/operation-logs
GET /api/admin/operation-logs/export
```
已接入已有管理接口:
```text
GET /api/admin/tasks
POST /api/admin/tasks/:taskId/retry
POST /api/admin/tasks/:taskId/cancel
POST /api/admin/tasks/:taskId/manual-required
GET /api/admin/queues
GET /api/admin/providers
POST /api/admin/providers/bootstrap-mocks
GET /api/admin/provider-logs
GET /api/admin/costs
GET /api/admin/orders
GET /api/admin/quota-accounts
GET /api/admin/users/:userId/detail
POST /api/admin/users/:userId/quota/grant
GET /api/admin/content-reviews
PATCH /api/admin/content-reviews/:reviewId
GET /api/admin/case-showcases
PATCH /api/admin/case-showcases/:showcaseId
GET /api/admin/system-configs
PATCH /api/admin/system-configs/:configKey
```
要求:
- 请求必须带 `Authorization: Bearer <token>`;后台支持 `admin``operator``finance``auditor` 角色,接口按 `projects/users/billing/reviews/tasks/providers/costs/settings/audit` 等权限校验。
- `npm run db:seed` 会创建或更新本地 `admin@example.com`,默认开发密码为 `Admin123!`,可用 `SEED_ADMIN_PASSWORD` 覆盖。
- 管理端默认运行在 `http://127.0.0.1:5175`API 默认指向 `http://127.0.0.1:3000/api`,可用 `VITE_API_BASE_URL` 覆盖。
- 当前后台已支持登录、仪表盘、项目管理、项目详情、项目状态调整、小说源/章节管理、角色资源、分镜资源、成品漫剧、订单额度、内容审核、公开案例授权、任务重试/取消/转人工、队列统计、Provider 配置、Provider 成本阈值、成本日志、用户列表、用户详情抽屉、用户人工加余额、额度冲正、禁用/启用用户、改角色、重置密码、素材列表、版权记录、系统配置管理和审计日志导出;小说、章节、角色、分镜、素材和成品资源支持详情/预览,高危用户操作在页面上会弹出二次确认。
- 模板管理深水区留到后续对应阶段继续扩展。
## 订单额度
阶段 19 已实现订单、额度账户和项目正式生成前冻结/扣减:
```text
GET /api/billing/packages
GET /api/billing/quota
GET /api/billing/quota/logs
GET /api/billing/orders
POST /api/billing/orders
POST /api/billing/orders/:orderId/mock-pay
GET /api/projects/:projectId/quota/estimate
POST /api/projects/:projectId/quota/freeze
POST /api/projects/:projectId/quota/release
```
要求:
- `/api/billing/packages` 可公开查看,其他用户额度接口需要 Bearer Token。
- 当前为内部测试额度模式:用户端不展示支付/套餐入口,运营在后台用户管理中人工增加额度。历史 mock 支付接口仍保留用于接口回归,不作为当前用户端入口。
- 项目额度预估按输入模式、目标集数和默认每集 6 个镜头估算。
- `quota/freeze` 会扣减可用额度、增加冻结额度,并把项目 `payment_status` 标记为 `quota_frozen`
- 视频合成成功后会把冻结额度扣为已用额度,并把项目 `payment_status` 标记为 `paid`
- 管理端新增“订单额度”页,可查看订单和额度账户;用户管理页支持管理员手动给用户增加余额/额度和执行额度冲正,并写入额度流水和操作日志,用户详情抽屉可查看额度流水、订单、项目、素材和最近操作。
## 内容审核
阶段 20 已实现文本、素材、成品视频和公开案例授权的审核闭环:
```text
POST /api/projects/:projectId/reviews/text
GET /api/projects/:projectId/reviews
POST /api/assets/:assetId/review
POST /api/projects/:projectId/showcase/authorize
GET /api/projects/:projectId/showcase
GET /api/admin/content-reviews
PATCH /api/admin/content-reviews/:reviewId
GET /api/admin/case-showcases
PATCH /api/admin/case-showcases/:showcaseId
```
要求:
- 用户接口必须带 Bearer Token,并校验项目或素材归属。
- 文本审核会聚合项目中的小说、故事圣经、角色、分集、脚本和分镜文本,也支持请求体直接传入 `content`
- 素材审核覆盖 image、video、audio、subtitle 和 document/text 类资产;用户端当前接入文本审核和成品视频审核按钮。
- 审核调用现有 `ModerationProvider`;未初始化真实 Provider 时使用 mock,初始化 OpenAI 接入并在后台保存 API Key 后会优先调用 `openai-moderation`,失败时回退 mock。
- 需要人工处理的审核会把项目状态标记为 `manual_required`,后台可执行通过、修改、驳回、屏蔽和转人工。
- 用户端可提交公开案例授权,后台可将案例授权、发布为 public 或驳回。
- 当前不接入真实内容安全平台,不做真实版权库比对;商业发布前仍需人工确认版权授权、平台规则和内容合规。
- mock moderation 会识别“不得/禁止/避免违法内容”这类安全规则提示,不再把合规约束本身误判为风险文本。
## MVP 验收
阶段 22 已完成系统 A MVP 端到端验收:
- AI 原创小说链路:原创构思、章节、故事圣经、角色、角色锚点、长篇记忆、3 集分集、脚本、分镜、正式分镜图、音频、字幕、FFmpeg MP4、私有下载、视频审核和案例授权。
- 上传小说链路:TXT 上传、版权确认、小说解析、故事圣经、角色、角色锚点、长篇记忆、1 集分集、脚本、分镜、正式分镜图、音频、字幕、FFmpeg MP4、私有下载、视频审核和案例授权。
- 验收用户 `mvp-1780243409631@example.com`,原创项目 ID `28`,上传项目 ID `29`
- 原创 3 个 MP4 与上传 1 个 MP4 均通过私有下载校验,返回 `video/mp4`,文件大小均大于 300 KB。
- 额度流程通过 mock 支付、项目冻结、视频成功后扣减;验收后账户 `used_quota=196``available_quota=1004`
- 验收时发现并修复 mock 文本审核误伤安全规则提示的问题;复审记录 `13``14` 均为 `passed`
## 用户端 H5
阶段 18 已实现 H5 优先、PC 自适应的用户端制作台,并保留 uni-app `pages.json``manifest.json` 和页面路由文件,后续微信小程序/App 可继续迁移:
```text
登录 / 注册
新建项目
我的项目
制作台
生成进度
成品漫剧
用户中心
```
要求:
- 用户端 API 可用 `VITE_API_BASE_URL` 覆盖;未配置时,本机访问会请求 `http://127.0.0.1:3000/api`,外网 IP/域名访问会自动请求同主机的 `:3000/api`
- 用户端默认运行在 `http://127.0.0.1:5174`
- 公网调试访问需要服务器防火墙/安全组放行 TCP `5174``3000`;当前本机 firewalld 已放行这两个端口。
- H5 为主布局,移动端底部导航和单列长表单优先;PC 宽屏自动切换为左侧导航和两栏制作台。
- 当前用户端已接入真实 API:认证、项目列表/创建、AI 原创小说、上传小说粘贴/文件入口、版权确认、故事圣经、角色库、长篇记忆、分集计划、单集脚本、分镜、分镜图、音频、字幕、FFmpeg 视频合成、任务进度、私有视频预览和下载。
- 阶段 19 已接入额度中心;当前用户端隐藏套餐、模拟支付和订单入口,仅展示额度账户、项目额度预估和合成前冻结额度。
- 阶段 20 已接入内容审核中心:项目文本审核、成品视频审核、审核状态列表和公开案例授权。
- 微信小程序/App 的文件选择、下载保存、支付能力、分享能力留到后续平台适配阶段;当前内部测试不开放用户端支付入口,图片/TTS 生产链路可切换真实 Provider,视频默认仍可用 FFmpeg 本地合成。
## 开发约束
-`AGENTS.md``docs/system_a/21_Codex开发任务拆解文档.md` 分阶段开发。
- 真实 AI Provider 已接入执行层;原创小说、故事圣经、角色、记忆、分集、脚本、分镜等 deterministic 生成服务后续可逐步迁移到统一 Provider 调用。
- 密钥只放 `.env`,不要提交真实密钥。
- 用户上传小说和素材默认私有。
- 每个阶段完成后更新 `CODEX_PROGRESS.md`
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Manga Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{
"name": "admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --host 0.0.0.0 --port ${ADMIN_PORT:-5173}",
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
"lint": "vue-tsc --noEmit -p tsconfig.json",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"vue": "^3.5.16"
}
}
+6264
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
+175
View File
@@ -0,0 +1,175 @@
import { ApiCryptoClient, base64ToBytes } from './crypto';
export interface ApiEnvelope<T> {
code: number;
message: string;
data: T;
request_id: string;
}
export interface AuthResult {
access_token: string;
token_type: 'Bearer';
expires_in: string;
user: AdminUser;
}
export interface AdminUser {
id: string;
email: string | null;
nickname: string | null;
role: string;
status: string;
created_at: string;
}
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, '');
}
function assertSecureProductionApiUrl(value: string) {
if (import.meta.env.PROD && /^http:\/\//i.test(value)) {
throw new Error('生产环境 VITE_API_BASE_URL 必须使用 HTTPS,或使用同源 /api。');
}
return value;
}
function resolveApiBaseUrl() {
const configured = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim();
if (configured) {
return assertSecureProductionApiUrl(trimTrailingSlash(configured));
}
if (import.meta.env.PROD) {
return '/api';
}
if (typeof window !== 'undefined') {
const { protocol, hostname, origin } = window.location;
if (protocol === 'https:') {
return `${origin}/api`;
}
if (hostname && hostname !== 'localhost' && hostname !== '127.0.0.1') {
return `${protocol}//${hostname}:3000/api`;
}
}
return 'http://127.0.0.1:3000/api';
}
const API_BASE_URL = resolveApiBaseUrl();
const apiCrypto = new ApiCryptoClient(API_BASE_URL);
export class ApiClient {
constructor(private token: string | null) {}
setToken(token: string | null) {
this.token = token;
}
async get<T>(path: string) {
return this.request<T>(path);
}
async post<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body)
});
}
async patch<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'PATCH',
body: body === undefined ? undefined : JSON.stringify(body)
});
}
async downloadAssetBlob(assetId: string) {
const response = await fetch(`${API_BASE_URL}/assets/${assetId}/download`, {
headers: {
...(await apiCrypto.encryptionHeaders()),
...(this.token ? { authorization: `Bearer ${this.token}` } : {})
}
});
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
const text = await response.text();
const rawPayload = text ? (JSON.parse(text) as unknown) : null;
const payload = rawPayload
? await apiCrypto.decryptResponse<
ApiEnvelope<{
filename: string;
mime_type: string;
size: number;
content_base64: string;
}>
>(rawPayload)
: null;
if (!response.ok || !payload || payload.code !== 0) {
throw new Error(payload?.message || `下载失败:HTTP ${response.status}`);
}
const bytes = base64ToBytes(payload.data.content_base64);
const blobPart = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return {
blob: new Blob([blobPart], { type: payload.data.mime_type || 'application/octet-stream' }),
filename: payload.data.filename || `asset-${assetId}`
};
}
if (!response.ok) {
throw new Error(`下载失败:HTTP ${response.status}`);
}
const blob = await response.blob();
const disposition = response.headers.get('content-disposition') ?? '';
const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `asset-${assetId}`;
return { blob, filename };
}
private async request<T>(path: string, init: RequestInit = {}) {
const method = (init.method ?? 'GET').toUpperCase();
const shouldSendEmptyJsonBody =
init.body === undefined && method !== 'GET' && method !== 'HEAD';
const bodyPayload =
typeof init.body === 'string'
? (JSON.parse(init.body) as unknown)
: shouldSendEmptyJsonBody
? {}
: undefined;
const encryptedBody =
bodyPayload !== undefined ? await apiCrypto.encryptBody(bodyPayload) : null;
const encryptedHeaders = encryptedBody?.headers ?? (await apiCrypto.encryptionHeaders());
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
body: encryptedBody ? JSON.stringify(encryptedBody.body) : init.body,
headers: {
...encryptedHeaders,
'content-type': 'application/json',
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
...(init.headers ?? {})
}
});
const rawPayload = (await response.json().catch(() => null)) as unknown;
const payload = rawPayload
? await apiCrypto.decryptResponse<ApiEnvelope<T>>(rawPayload)
: null;
if (!response.ok || !payload || payload.code !== 0) {
throw new Error(payload?.message || `Request failed: ${response.status}`);
}
return payload.data;
}
}
export const apiBaseUrl = API_BASE_URL;
+294
View File
@@ -0,0 +1,294 @@
interface ApiEnvelope<T> {
code: number;
message: string;
data: T;
request_id: string;
}
interface ApiCryptoHandshake {
version: number;
algorithm: string;
session_id: string;
server_public_key: JsonWebKey;
salt: string;
expires_at: string;
}
interface ClientConfig {
api_crypto_enabled: boolean;
api_crypto_mode: string;
api_crypto_session_ttl_seconds: number;
}
interface ApiCryptoEnvelope {
encrypted?: boolean;
version: number;
session_id: string;
client_public_key?: JsonWebKey;
iv: string;
ciphertext: string;
}
interface ApiCryptoSession {
sessionId: string;
clientPublicKey: JsonWebKey;
aesKey: CryptoKey;
expiresAt: number;
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function getWebCrypto() {
if (!globalThis.crypto?.subtle) {
throw new Error('当前浏览器不支持 API 加密所需的 WebCrypto。');
}
return globalThis.crypto;
}
function bytesToBinary(bytes: Uint8Array) {
let binary = '';
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
}
return binary;
}
function binaryToBytes(value: string) {
const bytes = new Uint8Array(value.length);
for (let index = 0; index < value.length; index += 1) {
bytes[index] = value.charCodeAt(index);
}
return bytes;
}
function bytesToBase64Url(bytes: Uint8Array) {
return btoa(bytesToBinary(bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function base64UrlToBytes(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export function base64ToBytes(value: string) {
return binaryToBytes(atob(value));
}
function jsonToBase64Url(value: unknown) {
return bytesToBase64Url(encoder.encode(JSON.stringify(value)));
}
function isEncryptedEnvelope(value: unknown): value is ApiCryptoEnvelope {
if (typeof value !== 'object' || value === null) return false;
const record = value as Record<string, unknown>;
return (
record.version === 1 &&
typeof record.session_id === 'string' &&
typeof record.iv === 'string' &&
typeof record.ciphertext === 'string'
);
}
export class ApiCryptoClient {
private session: ApiCryptoSession | null = null;
private pendingSession: Promise<ApiCryptoSession> | null = null;
private enabledCache: { value: boolean; expiresAt: number } | null = null;
constructor(private readonly baseUrl: string) {}
async encryptionHeaders() {
if (!(await this.isEnabled())) {
return {};
}
const session = await this.getSession();
return this.buildHeaders(session);
}
async encryptBody(body: unknown) {
if (!(await this.isEnabled())) {
return null;
}
const session = await this.getSession();
const crypto = getWebCrypto();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
session.aesKey,
encoder.encode(JSON.stringify(body ?? null))
);
return {
body: {
version: 1,
session_id: session.sessionId,
client_public_key: session.clientPublicKey,
iv: bytesToBase64Url(iv),
ciphertext: bytesToBase64Url(new Uint8Array(ciphertext))
},
headers: this.buildHeaders(session)
};
}
async isEnabled() {
const mode = this.configuredMode();
if (['1', 'true', 'yes', 'on'].includes(mode)) return true;
if (['0', 'false', 'no', 'off'].includes(mode)) return false;
const now = Date.now();
if (this.enabledCache && this.enabledCache.expiresAt > now) {
return this.enabledCache.value;
}
try {
const response = await fetch(`${this.baseUrl}/client-config`, {
headers: { accept: 'application/json' }
});
const envelope = (await response.json()) as ApiEnvelope<ClientConfig>;
const value = Boolean(response.ok && envelope.code === 0 && envelope.data.api_crypto_enabled);
this.enabledCache = {
value,
expiresAt: now + 3000
};
return value;
} catch {
this.enabledCache = {
value: false,
expiresAt: now + 3000
};
return false;
}
}
async decryptResponse<T>(payload: unknown) {
if (!isEncryptedEnvelope(payload)) {
return payload as T;
}
const session = await this.getSession(payload.session_id);
const plaintext = await getWebCrypto().subtle.decrypt(
{ name: 'AES-GCM', iv: base64UrlToBytes(payload.iv) },
session.aesKey,
base64UrlToBytes(payload.ciphertext)
);
return JSON.parse(decoder.decode(plaintext)) as T;
}
private async getSession(expectedSessionId?: string) {
const now = Date.now();
if (
this.session &&
this.session.expiresAt > now &&
(!expectedSessionId || this.session.sessionId === expectedSessionId)
) {
return this.session;
}
if (expectedSessionId) {
throw new Error('API 加密会话已失效,请刷新页面后重试。');
}
if (!this.pendingSession) {
this.pendingSession = this.createSession().finally(() => {
this.pendingSession = null;
});
}
this.session = await this.pendingSession;
return this.session;
}
private async createSession() {
const response = await fetch(`${this.baseUrl}/crypto/handshake`, {
headers: { accept: 'application/json' }
});
const envelope = (await response.json()) as ApiEnvelope<ApiCryptoHandshake>;
if (!response.ok || envelope.code !== 0) {
throw new Error(envelope.message || `API 加密握手失败:HTTP ${response.status}`);
}
const crypto = getWebCrypto();
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveBits']
);
const serverPublicKey = await crypto.subtle.importKey(
'jwk',
envelope.data.server_public_key,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: serverPublicKey },
keyPair.privateKey,
256
);
const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, [
'deriveKey'
]);
const aesKey = await crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: base64UrlToBytes(envelope.data.salt),
info: encoder.encode(`ai-manga-api-v1:${envelope.data.session_id}`)
},
hkdfKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
const clientPublicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
return {
sessionId: envelope.data.session_id,
clientPublicKey,
aesKey,
expiresAt: Date.parse(envelope.data.expires_at) - 30_000
};
}
private buildHeaders(session: ApiCryptoSession) {
return {
'x-api-encrypted': 'v1',
'x-api-session-id': session.sessionId,
'x-api-client-public-key': jsonToBase64Url(session.clientPublicKey)
};
}
private configuredMode() {
return ((import.meta.env.VITE_API_CRYPTO_ENABLED as string | undefined) || 'auto')
.trim()
.toLowerCase();
}
}
+6
View File
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).mount('#app');
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+2054
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"jsx": "preserve",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client",
"vitest"
],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"vite.config.ts"
]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: Number(process.env.ADMIN_PORT ?? 5173)
}
});
+41
View File
@@ -0,0 +1,41 @@
{
"name": "backend",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node dist/main.js",
"start:dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.build.json",
"lint": "tsc --noEmit -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"live-action:acceptance": "tsx src/live-action/live-action-provider-acceptance.ts",
"live-action:testcase:import": "tsx src/live-action/import-live-action-testcase.ts",
"prisma:generate": "prisma generate",
"prisma:validate": "prisma validate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.4.20",
"@nestjs/core": "^10.4.20",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^10.4.20",
"@prisma/client": "^6.19.3",
"bcryptjs": "^3.0.3",
"bullmq": "^5.77.6",
"ioredis": "^5.11.0",
"mammoth": "^1.12.0",
"minio": "^8.0.7",
"multer": "^2.1.1",
"pdf-parse": "^2.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/multer": "^2.1.0",
"prisma": "^6.19.3"
}
}
@@ -0,0 +1,629 @@
-- CreateTable
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`email` VARCHAR(191) NULL,
`phone` VARCHAR(50) NULL,
`password_hash` VARCHAR(255) NOT NULL,
`nickname` VARCHAR(100) NULL,
`avatar_url` VARCHAR(500) NULL,
`role` VARCHAR(50) NOT NULL DEFAULT 'user',
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`wechat_openid` VARCHAR(191) NULL,
`last_login_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `users_email_key`(`email`),
UNIQUE INDEX `users_phone_key`(`phone`),
UNIQUE INDEX `users_wechat_openid_key`(`wechat_openid`),
INDEX `users_role_status_idx`(`role`, `status`),
INDEX `users_created_at_idx`(`created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `projects` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`title` VARCHAR(255) NULL,
`input_mode` VARCHAR(50) NOT NULL,
`genre` VARCHAR(100) NULL,
`style_code` VARCHAR(100) NULL,
`output_type` VARCHAR(50) NULL,
`target_episode_count` INTEGER NULL,
`episode_duration` INTEGER NULL,
`status` VARCHAR(80) NOT NULL DEFAULT 'draft',
`copyright_status` VARCHAR(80) NOT NULL DEFAULT 'pending',
`payment_status` VARCHAR(80) NOT NULL DEFAULT 'unpaid',
`quality_level` VARCHAR(50) NULL,
`is_long_series` BOOLEAN NOT NULL DEFAULT false,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
`completed_at` DATETIME(3) NULL,
INDEX `projects_user_id_status_idx`(`user_id`, `status`),
INDEX `projects_genre_status_idx`(`genre`, `status`),
INDEX `projects_created_at_idx`(`created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `novel_sources` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`source_type` VARCHAR(50) NOT NULL,
`title` VARCHAR(255) NULL,
`author_name` VARCHAR(100) NULL,
`raw_asset_id` BIGINT NULL,
`raw_text` LONGTEXT NULL,
`clean_text` LONGTEXT NULL,
`word_count` INTEGER NULL,
`chapter_count` INTEGER NULL,
`parse_status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`parse_report` JSON NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `novel_sources_project_id_idx`(`project_id`),
INDEX `novel_sources_parse_status_idx`(`parse_status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `novel_chapters` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`chapter_no` INTEGER NOT NULL,
`title` VARCHAR(255) NULL,
`content` LONGTEXT NOT NULL,
`summary` TEXT NULL,
`visual_summary` TEXT NULL,
`word_count` INTEGER NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `novel_chapters_project_id_chapter_no_idx`(`project_id`, `chapter_no`),
INDEX `novel_chapters_project_id_status_idx`(`project_id`, `status`),
UNIQUE INDEX `novel_chapters_novel_source_id_chapter_no_key`(`novel_source_id`, `chapter_no`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `copyright_records` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`authorization_type` VARCHAR(50) NOT NULL,
`statement_text` TEXT NOT NULL,
`ip` VARCHAR(80) NULL,
`user_agent` TEXT NULL,
`confirmed_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `copyright_records_project_id_idx`(`project_id`),
INDEX `copyright_records_user_id_idx`(`user_id`),
INDEX `copyright_records_authorization_type_idx`(`authorization_type`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `story_bibles` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`title` VARCHAR(255) NULL,
`logline` TEXT NULL,
`main_plot` TEXT NULL,
`core_conflict` TEXT NULL,
`selling_points` TEXT NULL,
`tone` VARCHAR(100) NULL,
`world_summary` TEXT NULL,
`ending_direction` TEXT NULL,
`taboo_rules` TEXT NULL,
`version` INTEGER NOT NULL DEFAULT 1,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `story_bibles_project_id_status_idx`(`project_id`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `world_bibles` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`world_type` VARCHAR(100) NULL,
`setting_text` TEXT NULL,
`rules_text` TEXT NULL,
`power_system` TEXT NULL,
`social_structure` TEXT NULL,
`time_period` TEXT NULL,
`visual_rules` TEXT NULL,
`forbidden_rules` TEXT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `world_bibles_project_id_status_idx`(`project_id`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `characters` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`alias_names` JSON NULL,
`role_type` VARCHAR(50) NOT NULL,
`gender_label` VARCHAR(50) NULL,
`age_group` VARCHAR(50) NULL,
`identity_desc` TEXT NULL,
`appearance_desc` TEXT NULL,
`face_desc` TEXT NULL,
`hair_desc` TEXT NULL,
`eye_desc` TEXT NULL,
`body_desc` TEXT NULL,
`costume_rules` TEXT NULL,
`special_props` TEXT NULL,
`personality_desc` TEXT NULL,
`speech_style` TEXT NULL,
`relationship_desc` TEXT NULL,
`character_arc` TEXT NULL,
`negative_rules` TEXT NULL,
`anchor_asset_id` BIGINT NULL,
`importance_level` INTEGER NOT NULL DEFAULT 0,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `characters_project_id_role_type_idx`(`project_id`, `role_type`),
INDEX `characters_project_id_status_idx`(`project_id`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `character_images` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`asset_id` BIGINT NULL,
`image_type` VARCHAR(50) NOT NULL,
`prompt_text` TEXT NULL,
`negative_prompt` TEXT NULL,
`is_anchor` BOOLEAN NOT NULL DEFAULT false,
`quality_score` DECIMAL(5, 2) NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `character_images_project_id_idx`(`project_id`),
INDEX `character_images_character_id_image_type_idx`(`character_id`, `image_type`),
INDEX `character_images_character_id_is_anchor_idx`(`character_id`, `is_anchor`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `character_memories` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`episode_id` BIGINT NULL,
`memory_type` VARCHAR(50) NOT NULL,
`content` TEXT NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `character_memories_project_id_character_id_idx`(`project_id`, `character_id`),
INDEX `character_memories_project_id_episode_id_idx`(`project_id`, `episode_id`),
INDEX `character_memories_memory_type_idx`(`memory_type`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `episodes` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_no` INTEGER NOT NULL,
`source_chapter_ids` JSON NULL,
`title` VARCHAR(255) NULL,
`summary` TEXT NULL,
`opening_hook` TEXT NULL,
`middle_conflict` TEXT NULL,
`ending_hook` TEXT NULL,
`target_duration` INTEGER NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `episodes_project_id_status_idx`(`project_id`, `status`),
UNIQUE INDEX `episodes_project_id_episode_no_key`(`project_id`, `episode_no`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `episode_scripts` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NOT NULL,
`script_text` LONGTEXT NULL,
`narration_text` LONGTEXT NULL,
`dialogue_json` JSON NULL,
`version` INTEGER NOT NULL DEFAULT 1,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `episode_scripts_project_id_idx`(`project_id`),
INDEX `episode_scripts_episode_id_version_idx`(`episode_id`, `version`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `storyboard_shots` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NOT NULL,
`shot_no` INTEGER NOT NULL,
`scene_name` VARCHAR(255) NULL,
`location_desc` TEXT NULL,
`characters_json` JSON NULL,
`visual_desc` TEXT NULL,
`action_desc` TEXT NULL,
`dialogue_text` TEXT NULL,
`narration_text` TEXT NULL,
`camera_motion` VARCHAR(100) NULL,
`effect_type` VARCHAR(100) NULL,
`duration` DECIMAL(6, 2) NULL,
`prompt_text` TEXT NULL,
`negative_prompt` TEXT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `storyboard_shots_project_id_episode_id_shot_no_idx`(`project_id`, `episode_id`, `shot_no`),
INDEX `storyboard_shots_project_id_status_idx`(`project_id`, `status`),
UNIQUE INDEX `storyboard_shots_episode_id_shot_no_key`(`episode_id`, `shot_no`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `shot_images` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NULL,
`shot_id` BIGINT NOT NULL,
`asset_id` BIGINT NULL,
`image_type` VARCHAR(50) NOT NULL DEFAULT 'preview',
`prompt_text` TEXT NULL,
`negative_prompt` TEXT NULL,
`quality_score` DECIMAL(5, 2) NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `shot_images_project_id_episode_id_idx`(`project_id`, `episode_id`),
INDEX `shot_images_shot_id_image_type_idx`(`shot_id`, `image_type`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `plot_memories` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NULL,
`chapter_id` BIGINT NULL,
`memory_type` VARCHAR(50) NOT NULL,
`content` TEXT NOT NULL,
`importance_level` INTEGER NOT NULL DEFAULT 0,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `plot_memories_project_id_episode_id_idx`(`project_id`, `episode_id`),
INDEX `plot_memories_project_id_memory_type_idx`(`project_id`, `memory_type`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `plot_threads` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`thread_name` VARCHAR(255) NOT NULL,
`thread_type` VARCHAR(80) NOT NULL,
`description` TEXT NULL,
`start_episode_no` INTEGER NULL,
`expected_resolve_episode_no` INTEGER NULL,
`resolved_episode_no` INTEGER NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'open',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `plot_threads_project_id_status_idx`(`project_id`, `status`),
INDEX `plot_threads_project_id_thread_type_idx`(`project_id`, `thread_type`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `continuity_checks` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NULL,
`check_type` VARCHAR(80) NOT NULL,
`result_status` VARCHAR(50) NOT NULL,
`issue_text` TEXT NULL,
`suggestion_text` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `continuity_checks_project_id_episode_id_idx`(`project_id`, `episode_id`),
INDEX `continuity_checks_result_status_idx`(`result_status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `assets` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NULL,
`project_id` BIGINT NULL,
`asset_type` VARCHAR(50) NOT NULL,
`file_path` VARCHAR(500) NOT NULL,
`file_url` VARCHAR(500) NULL,
`mime_type` VARCHAR(100) NULL,
`width` INTEGER NULL,
`height` INTEGER NULL,
`duration` DECIMAL(10, 2) NULL,
`size` BIGINT NULL,
`hash` VARCHAR(128) NULL,
`visibility` VARCHAR(30) NOT NULL DEFAULT 'private',
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `assets_project_id_asset_type_idx`(`project_id`, `asset_type`),
INDEX `assets_user_id_asset_type_idx`(`user_id`, `asset_type`),
INDEX `assets_hash_idx`(`hash`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `render_tasks` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NULL,
`shot_id` BIGINT NULL,
`task_type` VARCHAR(80) NOT NULL,
`provider_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`input_json` JSON NULL,
`input_hash` VARCHAR(128) NULL,
`idempotency_key` VARCHAR(191) NULL,
`output_asset_id` BIGINT NULL,
`provider_request_id` VARCHAR(255) NULL,
`retry_count` INTEGER NOT NULL DEFAULT 0,
`max_retry` INTEGER NOT NULL DEFAULT 0,
`cost_estimate` DECIMAL(12, 4) NULL,
`cost_actual` DECIMAL(12, 4) NULL,
`error_code` VARCHAR(100) NULL,
`error_message` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`started_at` DATETIME(3) NULL,
`finished_at` DATETIME(3) NULL,
UNIQUE INDEX `render_tasks_idempotency_key_key`(`idempotency_key`),
INDEX `render_tasks_project_id_status_idx`(`project_id`, `status`),
INDEX `render_tasks_task_type_status_idx`(`task_type`, `status`),
INDEX `render_tasks_project_id_episode_id_idx`(`project_id`, `episode_id`),
INDEX `render_tasks_input_hash_idx`(`input_hash`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `provider_configs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`provider_type` VARCHAR(80) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`display_name` VARCHAR(100) NULL,
`mode` VARCHAR(50) NOT NULL DEFAULT 'mock',
`model_name` VARCHAR(100) NULL,
`config_json` JSON NULL,
`fallback_provider_id` BIGINT NULL,
`is_enabled` BOOLEAN NOT NULL DEFAULT true,
`priority` INTEGER NOT NULL DEFAULT 0,
`rate_limit_json` JSON NULL,
`cost_rule_json` JSON NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `provider_configs_provider_type_is_enabled_idx`(`provider_type`, `is_enabled`),
UNIQUE INDEX `provider_configs_provider_type_provider_code_key`(`provider_type`, `provider_code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `provider_logs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`provider_id` BIGINT NULL,
`task_id` BIGINT NULL,
`project_id` BIGINT NULL,
`provider_type` VARCHAR(80) NOT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`request_json` JSON NULL,
`response_json` JSON NULL,
`input_size` INTEGER NULL,
`output_size` INTEGER NULL,
`cost_estimate` DECIMAL(12, 4) NULL,
`cost_actual` DECIMAL(12, 4) NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'success',
`error_code` VARCHAR(100) NULL,
`error_message` TEXT NULL,
`started_at` DATETIME(3) NULL,
`finished_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `provider_logs_provider_type_status_idx`(`provider_type`, `status`),
INDEX `provider_logs_project_id_idx`(`project_id`),
INDEX `provider_logs_task_id_idx`(`task_id`),
INDEX `provider_logs_created_at_idx`(`created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `orders` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`project_id` BIGINT NULL,
`order_no` VARCHAR(100) NOT NULL,
`package_code` VARCHAR(100) NULL,
`amount` DECIMAL(12, 2) NOT NULL DEFAULT 0,
`currency` VARCHAR(20) NOT NULL DEFAULT 'CNY',
`payment_method` VARCHAR(50) NULL,
`payment_status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`paid_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `orders_order_no_key`(`order_no`),
INDEX `orders_user_id_payment_status_idx`(`user_id`, `payment_status`),
INDEX `orders_project_id_idx`(`project_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `quota_accounts` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`total_quota` DECIMAL(12, 2) NOT NULL DEFAULT 0,
`available_quota` DECIMAL(12, 2) NOT NULL DEFAULT 0,
`frozen_quota` DECIMAL(12, 2) NOT NULL DEFAULT 0,
`used_quota` DECIMAL(12, 2) NOT NULL DEFAULT 0,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `quota_accounts_user_id_key`(`user_id`),
INDEX `quota_accounts_status_idx`(`status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `quota_logs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`project_id` BIGINT NULL,
`task_id` BIGINT NULL,
`change_type` VARCHAR(50) NOT NULL,
`amount` DECIMAL(12, 2) NOT NULL,
`balance_after` DECIMAL(12, 2) NULL,
`reason` VARCHAR(255) NULL,
`metadata_json` JSON NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `quota_logs_user_id_created_at_idx`(`user_id`, `created_at`),
INDEX `quota_logs_project_id_idx`(`project_id`),
INDEX `quota_logs_task_id_idx`(`task_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `revision_requests` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`revision_type` VARCHAR(50) NOT NULL,
`target_type` VARCHAR(80) NULL,
`target_id` BIGINT NULL,
`description` TEXT NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `revision_requests_project_id_status_idx`(`project_id`, `status`),
INDEX `revision_requests_user_id_status_idx`(`user_id`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `content_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NULL,
`user_id` BIGINT NULL,
`target_type` VARCHAR(80) NOT NULL,
`target_id` BIGINT NULL,
`review_type` VARCHAR(80) NOT NULL,
`result_status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`risk_level` VARCHAR(50) NULL,
`issue_text` TEXT NULL,
`suggestion_text` TEXT NULL,
`reviewer_id` BIGINT NULL,
`reviewed_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `content_reviews_project_id_result_status_idx`(`project_id`, `result_status`),
INDEX `content_reviews_target_type_target_id_idx`(`target_type`, `target_id`),
INDEX `content_reviews_review_type_result_status_idx`(`review_type`, `result_status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `case_showcases` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`user_id` BIGINT NULL,
`title` VARCHAR(255) NOT NULL,
`cover_asset_id` BIGINT NULL,
`video_asset_id` BIGINT NULL,
`authorization_status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`visibility` VARCHAR(30) NOT NULL DEFAULT 'private',
`sort_order` INTEGER NOT NULL DEFAULT 0,
`published_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `case_showcases_visibility_sort_order_idx`(`visibility`, `sort_order`),
INDEX `case_showcases_project_id_idx`(`project_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `analytics_events` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NULL,
`event_type` VARCHAR(80) NOT NULL,
`platform` VARCHAR(80) NULL,
`metric_json` JSON NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `analytics_events_project_id_idx`(`project_id`),
INDEX `analytics_events_episode_id_idx`(`episode_id`),
INDEX `analytics_events_event_type_created_at_idx`(`event_type`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `system_configs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`config_key` VARCHAR(191) NOT NULL,
`config_value` JSON NULL,
`description` TEXT NULL,
`is_public` BOOLEAN NOT NULL DEFAULT false,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `system_configs_config_key_key`(`config_key`),
INDEX `system_configs_is_public_idx`(`is_public`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `operation_logs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NULL,
`operator_role` VARCHAR(50) NULL,
`action` VARCHAR(100) NOT NULL,
`target_type` VARCHAR(80) NULL,
`target_id` BIGINT NULL,
`ip` VARCHAR(80) NULL,
`user_agent` TEXT NULL,
`metadata_json` JSON NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `operation_logs_user_id_created_at_idx`(`user_id`, `created_at`),
INDEX `operation_logs_target_type_target_id_idx`(`target_type`, `target_id`),
INDEX `operation_logs_action_created_at_idx`(`action`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,55 @@
ALTER TABLE `projects`
ADD COLUMN `output_mode` VARCHAR(50) NOT NULL DEFAULT 'image_manga' AFTER `output_type`,
ADD COLUMN `visual_mode` VARCHAR(100) NULL AFTER `output_mode`,
ADD COLUMN `video_generation_level` VARCHAR(50) NULL AFTER `visual_mode`;
ALTER TABLE `storyboard_shots`
ADD COLUMN `live_action_desc` TEXT NULL AFTER `negative_prompt`,
ADD COLUMN `actor_action` TEXT NULL AFTER `live_action_desc`,
ADD COLUMN `camera_instruction` TEXT NULL AFTER `actor_action`,
ADD COLUMN `performance_instruction` TEXT NULL AFTER `camera_instruction`,
ADD COLUMN `video_prompt` TEXT NULL AFTER `performance_instruction`,
ADD COLUMN `keyframe_asset_id` BIGINT NULL AFTER `video_prompt`,
ADD COLUMN `video_clip_asset_id` BIGINT NULL AFTER `keyframe_asset_id`,
ADD COLUMN `video_status` VARCHAR(50) NULL AFTER `video_clip_asset_id`;
CREATE TABLE `actor_profiles` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`actor_desc` TEXT NULL,
`appearance_rules` TEXT NULL,
`wardrobe_rules` TEXT NULL,
`performance_style` TEXT NULL,
`voice_style` TEXT NULL,
`reference_asset_ids` JSON NULL,
`anchor_asset_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `actor_profiles_project_id_character_id_key`(`project_id`, `character_id`),
INDEX `actor_profiles_project_id_status_idx`(`project_id`, `status`),
INDEX `actor_profiles_character_id_idx`(`character_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `video_clips` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NOT NULL,
`shot_id` BIGINT NOT NULL,
`provider_id` BIGINT NULL,
`input_asset_id` BIGINT NULL,
`output_asset_id` BIGINT NULL,
`duration` DECIMAL(6, 2) NULL,
`prompt_text` TEXT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`cost_actual` DECIMAL(12, 4) NULL,
`retry_count` INTEGER NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `video_clips_project_id_episode_id_idx`(`project_id`, `episode_id`),
INDEX `video_clips_shot_id_status_idx`(`shot_id`, `status`),
INDEX `video_clips_provider_id_idx`(`provider_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,88 @@
ALTER TABLE `video_clips`
ADD COLUMN `quality_status` VARCHAR(50) NULL AFTER `retry_count`,
ADD COLUMN `quality_score` DECIMAL(5, 2) NULL AFTER `quality_status`,
ADD COLUMN `quality_issues` JSON NULL AFTER `quality_score`;
INSERT INTO `provider_configs`
(`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`)
VALUES
(
'VideoProvider',
'runway-image-to-video',
'Runway Image-to-Video',
'real',
'gen4.5',
JSON_OBJECT(
'driver', 'runway_image_to_video',
'api_key_env', 'RUNWAYML_API_SECRET',
'base_url', 'https://api.dev.runwayml.com',
'api_version', '2024-11-06',
'timeout_ms', 180000,
'create_endpoint', '/v1/image_to_video',
'task_endpoint_template', '/v1/tasks/{task_id}',
'poll_interval_ms', 10000,
'max_poll_attempts', 90,
'ratio', '720:1280',
'duration', 5,
'note', '默认禁用。启用后必须在业务侧显式确认真实视频生成,避免误扣费。'
),
false,
40,
JSON_OBJECT('rpm', 5, 'concurrency', 1),
JSON_OBJECT(
'flat_cost', 0,
'unit', 'video_seconds',
'price_per_second', 0,
'currency', 'USD',
'max_cost_per_call', 0,
'daily_cost_limit', 0,
'note', '请按 Runway 实际账单填写 price_per_second 或单次/每日成本上限。'
),
NOW(3),
NOW(3)
),
(
'VideoProvider',
'kling-image-to-video',
'Kling Image-to-Video',
'real',
'kling-v3',
JSON_OBJECT(
'driver', 'kling_image_to_video',
'api_key_env', 'KLING_API_KEY',
'base_url', 'https://api-singapore.klingai.com',
'timeout_ms', 180000,
'create_endpoint', '/v1/videos/image2video',
'task_endpoint_template', '/v1/videos/image2video/{task_id}',
'poll_interval_ms', 10000,
'max_poll_attempts', 90,
'image_field', 'image',
'prompt_field', 'prompt',
'duration_field', 'duration',
'aspect_ratio_field', 'aspect_ratio',
'aspect_ratio', '9:16',
'duration', 5,
'note', '默认禁用。不同 Kling 官方/网关接口字段可能不同,可在高级配置里调整字段名和 Base URL。'
),
false,
40,
JSON_OBJECT('rpm', 5, 'concurrency', 1),
JSON_OBJECT(
'flat_cost', 0,
'unit', 'video_seconds',
'price_per_second', 0,
'currency', 'USD',
'max_cost_per_call', 0,
'daily_cost_limit', 0,
'note', '请按 Kling 实际账单填写 price_per_second 或单次/每日成本上限。'
),
NOW(3),
NOW(3)
)
ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`mode` = VALUES(`mode`),
`model_name` = VALUES(`model_name`),
`priority` = VALUES(`priority`),
`rate_limit_json` = VALUES(`rate_limit_json`),
`updated_at` = NOW(3);
@@ -0,0 +1,108 @@
INSERT INTO `provider_configs`
(`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`)
VALUES
(
'VideoProvider',
'minimax_hailuo_23_fast',
'MiniMax Hailuo 2.3 Fast 图生视频',
'real',
'MiniMax-Hailuo-2.3-Fast',
'{"driver":"configurable_image_to_video","error_prefix":"MINIMAX_VIDEO","api_key_env":"MINIMAX_API_KEY","base_url":"https://api.minimax.io","timeout_ms":300000,"create_endpoint":"/v1/video_generation","task_endpoint_template":"/v1/query/video_generation?task_id={task_id}","output_url_endpoint_template":"/v1/files/retrieve?file_id={file_id}","poll_interval_ms":10000,"max_poll_attempts":120,"image_field":"first_frame_image","prompt_field":"prompt","model_field":"model","duration_field":"duration","resolution_field":"resolution","duration":6,"resolution":"768P","extra_body_json":{"prompt_optimizer":true},"supports_reference_image":true,"supports_start_end_frame":false,"supports_audio":false,"supports_lipsync":false,"supports_character_reference":false,"note":"默认禁用。优先用于低成本快速验证真人短剧动效;成功后 MiniMax 返回 file_id,系统会再取 download_url 落库。"}',
false,
80,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0.0317,"currency":"USD","max_cost_per_call":1,"daily_cost_limit":10,"estimated_seconds":6,"note":"预估价仅用于后台试算,请按 MiniMax 控制台实时价格和账单调整。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'minimax_hailuo_23',
'MiniMax Hailuo 2.3 图生视频',
'real',
'MiniMax-Hailuo-2.3',
'{"driver":"configurable_image_to_video","error_prefix":"MINIMAX_VIDEO","api_key_env":"MINIMAX_API_KEY","base_url":"https://api.minimax.io","timeout_ms":300000,"create_endpoint":"/v1/video_generation","task_endpoint_template":"/v1/query/video_generation?task_id={task_id}","output_url_endpoint_template":"/v1/files/retrieve?file_id={file_id}","poll_interval_ms":10000,"max_poll_attempts":120,"image_field":"first_frame_image","prompt_field":"prompt","model_field":"model","duration_field":"duration","resolution_field":"resolution","duration":6,"resolution":"1080P","extra_body_json":{"prompt_optimizer":true},"supports_reference_image":true,"supports_start_end_frame":false,"supports_audio":false,"supports_lipsync":false,"supports_character_reference":false,"note":"默认禁用。质量优先于 Fast,适合正式样片对比;真实调用前必须在业务侧确认成本。"}',
false,
78,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0.0467,"currency":"USD","max_cost_per_call":1.5,"daily_cost_limit":15,"estimated_seconds":6,"note":"预估价仅用于后台试算,请按 MiniMax 控制台实时价格和账单调整。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'alibaba_wan26_i2v_flash',
'阿里 Wan2.6 I2V Flash',
'real',
'wan2.6-i2v-flash',
'{"driver":"configurable_image_to_video","error_prefix":"DASHSCOPE_VIDEO","api_key_env":"ALIBABA_DASHSCOPE_API_KEY","base_url":"https://dashscope.aliyuncs.com","timeout_ms":300000,"create_endpoint":"/api/v1/services/aigc/video-generation/video-synthesis","task_endpoint_template":"/api/v1/tasks/{task_id}","poll_interval_ms":10000,"max_poll_attempts":120,"body_style":"dashscope_legacy_i2v","headers":{"X-DashScope-Async":"enable"},"duration":5,"resolution":"720P","prompt_extend":true,"watermark":false,"supports_reference_image":true,"supports_start_end_frame":false,"supports_audio":true,"supports_lipsync":false,"supports_character_reference":false,"note":"默认禁用。阿里/百炼不同模型版本字段可能变化,必要时在高级配置中调整 endpoint、body_style 或 extra_body_json。"}',
false,
74,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0.0215,"currency":"USD","max_cost_per_call":1,"daily_cost_limit":10,"estimated_seconds":5,"note":"Flash 预估价仅用于试算,请按阿里云/百炼实际账单调整。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'alibaba_wan26_i2v',
'阿里 Wan2.6 I2V 标准',
'real',
'wan2.6-i2v',
'{"driver":"configurable_image_to_video","error_prefix":"DASHSCOPE_VIDEO","api_key_env":"ALIBABA_DASHSCOPE_API_KEY","base_url":"https://dashscope.aliyuncs.com","timeout_ms":300000,"create_endpoint":"/api/v1/services/aigc/video-generation/video-synthesis","task_endpoint_template":"/api/v1/tasks/{task_id}","poll_interval_ms":10000,"max_poll_attempts":120,"body_style":"dashscope_legacy_i2v","headers":{"X-DashScope-Async":"enable"},"duration":5,"resolution":"1080P","prompt_extend":true,"watermark":false,"supports_reference_image":true,"supports_start_end_frame":false,"supports_audio":true,"supports_lipsync":false,"supports_character_reference":false,"note":"默认禁用。标准模式适合正式出片对比;真实启用前请先小样本验证字段、速度和账单。"}',
false,
72,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0.086,"currency":"USD","max_cost_per_call":2,"daily_cost_limit":20,"estimated_seconds":5,"note":"标准模式预估价仅用于试算,请按阿里云/百炼实际账单调整。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'vidu_q3_turbo_reference',
'Vidu Q3 Turbo 参考图生视频',
'real',
'viduq3-turbo',
'{"driver":"configurable_image_to_video","error_prefix":"VIDU_VIDEO","api_key_env":"VIDU_API_KEY","base_url":"https://api.vidu.com","auth_scheme":"Token","timeout_ms":300000,"create_endpoint":"/ent/v2/reference2video","task_endpoint_template":"/ent/v2/tasks/{task_id}/creations","poll_interval_ms":10000,"max_poll_attempts":120,"body_style":"vidu_reference","duration":5,"resolution":"720p","aspect_ratio":"9:16","extra_body_json":{"audio":true,"movement_amplitude":"auto"},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。适合做人物一致性和中文短剧感对比,参考图需要外部可访问 URL。"}',
false,
70,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0.05,"currency":"USD","max_cost_per_call":1.5,"daily_cost_limit":15,"estimated_seconds":5,"note":"Vidu Q3 Turbo 预估价仅用于试算,请按 Vidu 控制台实时价格和 credits 消耗调整。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'vidu_q3_pro',
'Vidu Q3 Pro 参考图生视频',
'real',
'viduq3',
'{"driver":"configurable_image_to_video","error_prefix":"VIDU_VIDEO","api_key_env":"VIDU_API_KEY","base_url":"https://api.vidu.com","auth_scheme":"Token","timeout_ms":300000,"create_endpoint":"/ent/v2/reference2video","task_endpoint_template":"/ent/v2/tasks/{task_id}/creations","poll_interval_ms":10000,"max_poll_attempts":120,"body_style":"vidu_reference","duration":5,"resolution":"1080p","aspect_ratio":"9:16","extra_body_json":{"audio":true,"movement_amplitude":"auto"},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。质量优先,适合正式样片;真实启用前请先限制单次成本。"}',
false,
68,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0.12,"currency":"USD","max_cost_per_call":3,"daily_cost_limit":30,"estimated_seconds":5,"note":"Vidu Q3 Pro 预估价仅用于试算,请按 Vidu 控制台实时价格和 credits 消耗调整。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'jimeng_seedance',
'即梦/Seedance 图生视频',
'real',
'doubao-seedance-1-5-pro-251215',
'{"driver":"configurable_image_to_video","error_prefix":"SEEDANCE_VIDEO","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":300000,"create_endpoint":"/videos/generations","task_endpoint_template":"/videos/generations/{task_id}","poll_interval_ms":10000,"max_poll_attempts":120,"image_field":"image_url","prompt_field":"prompt","model_field":"model","duration_field":"duration","resolution_field":"resolution","aspect_ratio_field":"ratio","duration":5,"resolution":"720p","aspect_ratio":"9:16","extra_body_json":{"fps":24,"watermark":false,"camerafixed":false},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。不同火山/即梦/网关 API 字段差异较大,此配置作为可改模板,正式接入前必须用小样本验证。"}',
false,
66,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0,"currency":"USD","max_cost_per_call":0,"daily_cost_limit":0,"estimated_seconds":5,"note":"Seedance/即梦价格按具体开通渠道差异较大,启用前请手动填写 price_per_second 和成本上限。"}',
NOW(3),
NOW(3)
)
ON DUPLICATE KEY UPDATE
`display_name` = VALUES(`display_name`),
`mode` = VALUES(`mode`),
`model_name` = VALUES(`model_name`),
`priority` = VALUES(`priority`),
`rate_limit_json` = VALUES(`rate_limit_json`),
`updated_at` = NOW(3);
@@ -0,0 +1,62 @@
CREATE TABLE `global_characters` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`display_name` VARCHAR(100) NULL,
`role_archetype` VARCHAR(50) NOT NULL DEFAULT 'lead',
`gender_label` VARCHAR(50) NULL,
`age_group` VARCHAR(50) NULL,
`identity_desc` TEXT NULL,
`appearance_desc` TEXT NULL,
`face_desc` TEXT NULL,
`hair_desc` TEXT NULL,
`eye_desc` TEXT NULL,
`body_desc` TEXT NULL,
`default_costume_rules` TEXT NULL,
`wardrobe_json` JSON NULL,
`special_props` TEXT NULL,
`personality_desc` TEXT NULL,
`speech_style` TEXT NULL,
`voice_provider_code` VARCHAR(100) NULL,
`voice_model` VARCHAR(100) NULL,
`voice_id` VARCHAR(100) NULL,
`voice_style` TEXT NULL,
`performance_style` TEXT NULL,
`negative_rules` TEXT NULL,
`anchor_asset_id` BIGINT NULL,
`voice_sample_asset_id` BIGINT NULL,
`commercial_status` VARCHAR(50) NOT NULL DEFAULT 'internal_test',
`usage_scope` VARCHAR(50) NOT NULL DEFAULT 'internal',
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX `global_characters_status_role_archetype_idx`(`status`, `role_archetype`),
INDEX `global_characters_commercial_status_idx`(`commercial_status`),
INDEX `global_characters_created_by_user_id_idx`(`created_by_user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `global_character_assets` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`global_character_id` BIGINT NOT NULL,
`asset_id` BIGINT NULL,
`asset_type` VARCHAR(50) NOT NULL,
`label` VARCHAR(100) NULL,
`prompt_text` TEXT NULL,
`is_primary` BOOLEAN NOT NULL DEFAULT false,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `global_character_assets_global_character_id_asset_type_idx`(`global_character_id`, `asset_type`),
INDEX `global_character_assets_asset_id_idx`(`asset_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `characters`
ADD COLUMN `global_character_id` BIGINT NULL,
ADD COLUMN `wardrobe_variant` VARCHAR(100) NULL,
ADD COLUMN `voice_provider_code` VARCHAR(100) NULL,
ADD COLUMN `voice_model` VARCHAR(100) NULL,
ADD COLUMN `voice_id` VARCHAR(100) NULL,
ADD COLUMN `voice_style` TEXT NULL,
ADD COLUMN `performance_style` TEXT NULL,
ADD INDEX `characters_global_character_id_idx`(`global_character_id`);
@@ -0,0 +1,8 @@
ALTER TABLE `storyboard_shots`
ADD COLUMN `scene_type` VARCHAR(50) NULL AFTER `duration`,
ADD COLUMN `importance_score` INT NULL AFTER `scene_type`,
ADD COLUMN `emotion_score` INT NULL AFTER `importance_score`,
ADD COLUMN `action_score` INT NULL AFTER `emotion_score`,
ADD COLUMN `route_tier` VARCHAR(50) NULL AFTER `action_score`,
ADD INDEX `storyboard_shots_scene_type_idx`(`scene_type`),
ADD INDEX `storyboard_shots_route_tier_idx`(`route_tier`);
@@ -0,0 +1,79 @@
CREATE TABLE `hit_analysis_cases` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`source_platform` VARCHAR(100) NULL,
`source_url` VARCHAR(500) NULL,
`content_type` VARCHAR(80) NOT NULL DEFAULT 'short_drama',
`genre` VARCHAR(100) NULL,
`language` VARCHAR(30) NOT NULL DEFAULT 'zh-CN',
`target_audience` VARCHAR(255) NULL,
`duration_seconds` INT NULL,
`episode_count` INT NULL,
`tags_json` JSON NULL,
`metrics_json` JSON NULL,
`transcript_text` LONGTEXT NULL,
`summary_text` TEXT NULL,
`analysis_json` JSON NULL,
`diagnosis_score` DECIMAL(5, 2) NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
INDEX `hit_analysis_cases_source_platform_idx`(`source_platform`),
INDEX `hit_analysis_cases_genre_status_idx`(`genre`, `status`),
INDEX `hit_analysis_cases_status_diagnosis_score_idx`(`status`, `diagnosis_score`),
INDEX `hit_analysis_cases_created_at_idx`(`created_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `hit_analysis_segments` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`case_id` BIGINT NOT NULL,
`segment_no` INT NOT NULL,
`start_second` INT NULL,
`end_second` INT NULL,
`scene_type` VARCHAR(80) NULL,
`hook_type` VARCHAR(100) NULL,
`emotion` VARCHAR(80) NULL,
`conflict_type` VARCHAR(100) NULL,
`plot_function` VARCHAR(120) NULL,
`visual_strategy` VARCHAR(120) NULL,
`dialogue_pattern` VARCHAR(120) NULL,
`camera_notes` TEXT NULL,
`importance_score` INT NULL,
`emotion_score` INT NULL,
`action_score` INT NULL,
`tags_json` JSON NULL,
`summary_text` TEXT NULL,
`prompt_seed` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `hit_analysis_segments_case_id_segment_no_key`(`case_id`, `segment_no`),
INDEX `hit_analysis_segments_case_id_idx`(`case_id`),
INDEX `hit_analysis_segments_scene_type_idx`(`scene_type`),
INDEX `hit_analysis_segments_hook_type_idx`(`hook_type`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `creative_patterns` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`source_case_id` BIGINT NULL,
`pattern_type` VARCHAR(80) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`genre` VARCHAR(100) NULL,
`language` VARCHAR(30) NOT NULL DEFAULT 'zh-CN',
`description` TEXT NULL,
`structure_json` JSON NULL,
`prompt_template` TEXT NULL,
`negative_prompt` TEXT NULL,
`tags_json` JSON NULL,
`usage_count` INT NOT NULL DEFAULT 0,
`effectiveness_score` DECIMAL(5, 2) NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
INDEX `creative_patterns_pattern_type_status_idx`(`pattern_type`, `status`),
INDEX `creative_patterns_genre_status_idx`(`genre`, `status`),
INDEX `creative_patterns_source_case_id_idx`(`source_case_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,13 @@
CREATE TABLE `project_creative_patterns` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`creative_pattern_id` BIGINT NOT NULL,
`source` VARCHAR(50) NOT NULL DEFAULT 'user_selected',
`snapshot_json` JSON NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `project_creative_patterns_project_id_creative_pattern_id_key`(`project_id`, `creative_pattern_id`),
INDEX `project_creative_patterns_project_id_sort_order_idx`(`project_id`, `sort_order`),
INDEX `project_creative_patterns_creative_pattern_id_idx`(`creative_pattern_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+826
View File
@@ -0,0 +1,826 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id BigInt @id @default(autoincrement())
email String? @unique @db.VarChar(191)
phone String? @unique @db.VarChar(50)
password_hash String @db.VarChar(255)
nickname String? @db.VarChar(100)
avatar_url String? @db.VarChar(500)
role String @default("user") @db.VarChar(50)
status String @default("active") @db.VarChar(50)
wechat_openid String? @unique @db.VarChar(191)
last_login_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([role, status])
@@index([created_at])
@@map("users")
}
model Project {
id BigInt @id @default(autoincrement())
user_id BigInt
title String? @db.VarChar(255)
input_mode String @db.VarChar(50)
genre String? @db.VarChar(100)
style_code String? @db.VarChar(100)
output_type String? @db.VarChar(50)
output_mode String @default("image_manga") @db.VarChar(50)
visual_mode String? @db.VarChar(100)
video_generation_level String? @db.VarChar(50)
target_episode_count Int?
episode_duration Int?
status String @default("draft") @db.VarChar(80)
copyright_status String @default("pending") @db.VarChar(80)
payment_status String @default("unpaid") @db.VarChar(80)
quality_level String? @db.VarChar(50)
is_long_series Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
completed_at DateTime?
@@index([user_id, status])
@@index([genre, status])
@@index([created_at])
@@map("projects")
}
model NovelSource {
id BigInt @id @default(autoincrement())
project_id BigInt
source_type String @db.VarChar(50)
title String? @db.VarChar(255)
author_name String? @db.VarChar(100)
raw_asset_id BigInt?
raw_text String? @db.LongText
clean_text String? @db.LongText
word_count Int?
chapter_count Int?
parse_status String @default("pending") @db.VarChar(50)
parse_report Json?
created_at DateTime @default(now())
@@index([project_id])
@@index([parse_status])
@@map("novel_sources")
}
model NovelChapter {
id BigInt @id @default(autoincrement())
project_id BigInt
novel_source_id BigInt?
chapter_no Int
title String? @db.VarChar(255)
content String @db.LongText
summary String? @db.Text
visual_summary String? @db.Text
word_count Int?
status String @default("draft") @db.VarChar(50)
created_at DateTime @default(now())
@@unique([novel_source_id, chapter_no])
@@index([project_id, chapter_no])
@@index([project_id, status])
@@map("novel_chapters")
}
model CopyrightRecord {
id BigInt @id @default(autoincrement())
project_id BigInt
user_id BigInt
authorization_type String @db.VarChar(50)
statement_text String @db.Text
ip String? @db.VarChar(80)
user_agent String? @db.Text
confirmed_at DateTime @default(now())
@@index([project_id])
@@index([user_id])
@@index([authorization_type])
@@map("copyright_records")
}
model StoryBible {
id BigInt @id @default(autoincrement())
project_id BigInt
title String? @db.VarChar(255)
logline String? @db.Text
main_plot String? @db.Text
core_conflict String? @db.Text
selling_points String? @db.Text
tone String? @db.VarChar(100)
world_summary String? @db.Text
ending_direction String? @db.Text
taboo_rules String? @db.Text
version Int @default(1)
status String @default("draft") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([project_id, status])
@@map("story_bibles")
}
model WorldBible {
id BigInt @id @default(autoincrement())
project_id BigInt
world_type String? @db.VarChar(100)
setting_text String? @db.Text
rules_text String? @db.Text
power_system String? @db.Text
social_structure String? @db.Text
time_period String? @db.Text
visual_rules String? @db.Text
forbidden_rules String? @db.Text
status String @default("draft") @db.VarChar(50)
created_at DateTime @default(now())
@@index([project_id, status])
@@map("world_bibles")
}
model Character {
id BigInt @id @default(autoincrement())
project_id BigInt
global_character_id BigInt?
name String @db.VarChar(100)
alias_names Json?
role_type String @db.VarChar(50)
gender_label String? @db.VarChar(50)
age_group String? @db.VarChar(50)
identity_desc String? @db.Text
appearance_desc String? @db.Text
face_desc String? @db.Text
hair_desc String? @db.Text
eye_desc String? @db.Text
body_desc String? @db.Text
costume_rules String? @db.Text
special_props String? @db.Text
personality_desc String? @db.Text
speech_style String? @db.Text
relationship_desc String? @db.Text
character_arc String? @db.Text
negative_rules String? @db.Text
anchor_asset_id BigInt?
wardrobe_variant String? @db.VarChar(100)
voice_provider_code String? @db.VarChar(100)
voice_model String? @db.VarChar(100)
voice_id String? @db.VarChar(100)
voice_style String? @db.Text
performance_style String? @db.Text
importance_level Int @default(0)
status String @default("draft") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([project_id, role_type])
@@index([project_id, status])
@@index([global_character_id])
@@map("characters")
}
model GlobalCharacter {
id BigInt @id @default(autoincrement())
name String @db.VarChar(100)
display_name String? @db.VarChar(100)
role_archetype String @default("lead") @db.VarChar(50)
gender_label String? @db.VarChar(50)
age_group String? @db.VarChar(50)
identity_desc String? @db.Text
appearance_desc String? @db.Text
face_desc String? @db.Text
hair_desc String? @db.Text
eye_desc String? @db.Text
body_desc String? @db.Text
default_costume_rules String? @db.Text
wardrobe_json Json?
special_props String? @db.Text
personality_desc String? @db.Text
speech_style String? @db.Text
voice_provider_code String? @db.VarChar(100)
voice_model String? @db.VarChar(100)
voice_id String? @db.VarChar(100)
voice_style String? @db.Text
performance_style String? @db.Text
negative_rules String? @db.Text
anchor_asset_id BigInt?
voice_sample_asset_id BigInt?
commercial_status String @default("internal_test") @db.VarChar(50)
usage_scope String @default("internal") @db.VarChar(50)
status String @default("active") @db.VarChar(50)
created_by_user_id BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([status, role_archetype])
@@index([commercial_status])
@@index([created_by_user_id])
@@map("global_characters")
}
model GlobalCharacterAsset {
id BigInt @id @default(autoincrement())
global_character_id BigInt
asset_id BigInt?
asset_type String @db.VarChar(50)
label String? @db.VarChar(100)
prompt_text String? @db.Text
is_primary Boolean @default(false)
status String @default("active") @db.VarChar(50)
created_at DateTime @default(now())
@@index([global_character_id, asset_type])
@@index([asset_id])
@@map("global_character_assets")
}
model CharacterImage {
id BigInt @id @default(autoincrement())
project_id BigInt
character_id BigInt
asset_id BigInt?
image_type String @db.VarChar(50)
prompt_text String? @db.Text
negative_prompt String? @db.Text
is_anchor Boolean @default(false)
quality_score Decimal? @db.Decimal(5, 2)
status String @default("pending") @db.VarChar(50)
created_at DateTime @default(now())
@@index([project_id])
@@index([character_id, image_type])
@@index([character_id, is_anchor])
@@map("character_images")
}
model CharacterMemory {
id BigInt @id @default(autoincrement())
project_id BigInt
character_id BigInt
episode_id BigInt?
memory_type String @db.VarChar(50)
content String @db.Text
created_at DateTime @default(now())
@@index([project_id, character_id])
@@index([project_id, episode_id])
@@index([memory_type])
@@map("character_memories")
}
model Episode {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_no Int
source_chapter_ids Json?
title String? @db.VarChar(255)
summary String? @db.Text
opening_hook String? @db.Text
middle_conflict String? @db.Text
ending_hook String? @db.Text
target_duration Int?
status String @default("draft") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([project_id, episode_no])
@@index([project_id, status])
@@map("episodes")
}
model EpisodeScript {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_id BigInt
script_text String? @db.LongText
narration_text String? @db.LongText
dialogue_json Json?
version Int @default(1)
status String @default("draft") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([project_id])
@@index([episode_id, version])
@@map("episode_scripts")
}
model StoryboardShot {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_id BigInt
shot_no Int
scene_name String? @db.VarChar(255)
location_desc String? @db.Text
characters_json Json?
visual_desc String? @db.Text
action_desc String? @db.Text
dialogue_text String? @db.Text
narration_text String? @db.Text
camera_motion String? @db.VarChar(100)
effect_type String? @db.VarChar(100)
duration Decimal? @db.Decimal(6, 2)
scene_type String? @db.VarChar(50)
importance_score Int?
emotion_score Int?
action_score Int?
route_tier String? @db.VarChar(50)
prompt_text String? @db.Text
negative_prompt String? @db.Text
live_action_desc String? @db.Text
actor_action String? @db.Text
camera_instruction String? @db.Text
performance_instruction String? @db.Text
video_prompt String? @db.Text
keyframe_asset_id BigInt?
video_clip_asset_id BigInt?
video_status String? @db.VarChar(50)
status String @default("draft") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([episode_id, shot_no])
@@index([project_id, episode_id, shot_no])
@@index([project_id, status])
@@index([scene_type])
@@index([route_tier])
@@map("storyboard_shots")
}
model ShotImage {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_id BigInt?
shot_id BigInt
asset_id BigInt?
image_type String @default("preview") @db.VarChar(50)
prompt_text String? @db.Text
negative_prompt String? @db.Text
quality_score Decimal? @db.Decimal(5, 2)
status String @default("pending") @db.VarChar(50)
created_at DateTime @default(now())
@@index([project_id, episode_id])
@@index([shot_id, image_type])
@@map("shot_images")
}
model ActorProfile {
id BigInt @id @default(autoincrement())
project_id BigInt
character_id BigInt
actor_desc String? @db.Text
appearance_rules String? @db.Text
wardrobe_rules String? @db.Text
performance_style String? @db.Text
voice_style String? @db.Text
reference_asset_ids Json?
anchor_asset_id BigInt?
status String @default("draft") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([project_id, character_id])
@@index([project_id, status])
@@index([character_id])
@@map("actor_profiles")
}
model VideoClip {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_id BigInt
shot_id BigInt
provider_id BigInt?
input_asset_id BigInt?
output_asset_id BigInt?
duration Decimal? @db.Decimal(6, 2)
prompt_text String? @db.Text
status String @default("pending") @db.VarChar(50)
cost_actual Decimal? @db.Decimal(12, 4)
retry_count Int @default(0)
quality_status String? @db.VarChar(50)
quality_score Decimal? @db.Decimal(5, 2)
quality_issues Json?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([project_id, episode_id])
@@index([shot_id, status])
@@index([provider_id])
@@map("video_clips")
}
model PlotMemory {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_id BigInt?
chapter_id BigInt?
memory_type String @db.VarChar(50)
content String @db.Text
importance_level Int @default(0)
status String @default("active") @db.VarChar(50)
created_at DateTime @default(now())
@@index([project_id, episode_id])
@@index([project_id, memory_type])
@@map("plot_memories")
}
model PlotThread {
id BigInt @id @default(autoincrement())
project_id BigInt
thread_name String @db.VarChar(255)
thread_type String @db.VarChar(80)
description String? @db.Text
start_episode_no Int?
expected_resolve_episode_no Int?
resolved_episode_no Int?
status String @default("open") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([project_id, status])
@@index([project_id, thread_type])
@@map("plot_threads")
}
model ContinuityCheck {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_id BigInt?
check_type String @db.VarChar(80)
result_status String @db.VarChar(50)
issue_text String? @db.Text
suggestion_text String? @db.Text
created_at DateTime @default(now())
@@index([project_id, episode_id])
@@index([result_status])
@@map("continuity_checks")
}
model Asset {
id BigInt @id @default(autoincrement())
user_id BigInt?
project_id BigInt?
asset_type String @db.VarChar(50)
file_path String @db.VarChar(500)
file_url String? @db.VarChar(500)
mime_type String? @db.VarChar(100)
width Int?
height Int?
duration Decimal? @db.Decimal(10, 2)
size BigInt?
hash String? @db.VarChar(128)
visibility String @default("private") @db.VarChar(30)
status String @default("active") @db.VarChar(50)
created_at DateTime @default(now())
@@index([project_id, asset_type])
@@index([user_id, asset_type])
@@index([hash])
@@map("assets")
}
model RenderTask {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_id BigInt?
shot_id BigInt?
task_type String @db.VarChar(80)
provider_id BigInt?
status String @default("pending") @db.VarChar(50)
input_json Json?
input_hash String? @db.VarChar(128)
idempotency_key String? @unique @db.VarChar(191)
output_asset_id BigInt?
provider_request_id String? @db.VarChar(255)
retry_count Int @default(0)
max_retry Int @default(0)
cost_estimate Decimal? @db.Decimal(12, 4)
cost_actual Decimal? @db.Decimal(12, 4)
error_code String? @db.VarChar(100)
error_message String? @db.Text
created_at DateTime @default(now())
started_at DateTime?
finished_at DateTime?
@@index([project_id, status])
@@index([task_type, status])
@@index([project_id, episode_id])
@@index([input_hash])
@@map("render_tasks")
}
model ProviderConfig {
id BigInt @id @default(autoincrement())
provider_type String @db.VarChar(80)
provider_code String @db.VarChar(100)
display_name String? @db.VarChar(100)
mode String @default("mock") @db.VarChar(50)
model_name String? @db.VarChar(100)
config_json Json?
fallback_provider_id BigInt?
is_enabled Boolean @default(true)
priority Int @default(0)
rate_limit_json Json?
cost_rule_json Json?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([provider_type, provider_code])
@@index([provider_type, is_enabled])
@@map("provider_configs")
}
model ProviderLog {
id BigInt @id @default(autoincrement())
provider_id BigInt?
task_id BigInt?
project_id BigInt?
provider_type String @db.VarChar(80)
provider_code String? @db.VarChar(100)
model_name String? @db.VarChar(100)
request_json Json?
response_json Json?
input_size Int?
output_size Int?
cost_estimate Decimal? @db.Decimal(12, 4)
cost_actual Decimal? @db.Decimal(12, 4)
status String @default("success") @db.VarChar(50)
error_code String? @db.VarChar(100)
error_message String? @db.Text
started_at DateTime?
finished_at DateTime?
created_at DateTime @default(now())
@@index([provider_type, status])
@@index([project_id])
@@index([task_id])
@@index([created_at])
@@map("provider_logs")
}
model Order {
id BigInt @id @default(autoincrement())
user_id BigInt
project_id BigInt?
order_no String @unique @db.VarChar(100)
package_code String? @db.VarChar(100)
amount Decimal @default(0) @db.Decimal(12, 2)
currency String @default("CNY") @db.VarChar(20)
payment_method String? @db.VarChar(50)
payment_status String @default("pending") @db.VarChar(50)
paid_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([user_id, payment_status])
@@index([project_id])
@@map("orders")
}
model QuotaAccount {
id BigInt @id @default(autoincrement())
user_id BigInt @unique
total_quota Decimal @default(0) @db.Decimal(12, 2)
available_quota Decimal @default(0) @db.Decimal(12, 2)
frozen_quota Decimal @default(0) @db.Decimal(12, 2)
used_quota Decimal @default(0) @db.Decimal(12, 2)
status String @default("active") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([status])
@@map("quota_accounts")
}
model QuotaLog {
id BigInt @id @default(autoincrement())
user_id BigInt
project_id BigInt?
task_id BigInt?
change_type String @db.VarChar(50)
amount Decimal @db.Decimal(12, 2)
balance_after Decimal? @db.Decimal(12, 2)
reason String? @db.VarChar(255)
metadata_json Json?
created_at DateTime @default(now())
@@index([user_id, created_at])
@@index([project_id])
@@index([task_id])
@@map("quota_logs")
}
model RevisionRequest {
id BigInt @id @default(autoincrement())
project_id BigInt
user_id BigInt
revision_type String @db.VarChar(50)
target_type String? @db.VarChar(80)
target_id BigInt?
description String @db.Text
status String @default("pending") @db.VarChar(50)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([project_id, status])
@@index([user_id, status])
@@map("revision_requests")
}
model ContentReview {
id BigInt @id @default(autoincrement())
project_id BigInt?
user_id BigInt?
target_type String @db.VarChar(80)
target_id BigInt?
review_type String @db.VarChar(80)
result_status String @default("pending") @db.VarChar(50)
risk_level String? @db.VarChar(50)
issue_text String? @db.Text
suggestion_text String? @db.Text
reviewer_id BigInt?
reviewed_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([project_id, result_status])
@@index([target_type, target_id])
@@index([review_type, result_status])
@@map("content_reviews")
}
model CaseShowcase {
id BigInt @id @default(autoincrement())
project_id BigInt
user_id BigInt?
title String @db.VarChar(255)
cover_asset_id BigInt?
video_asset_id BigInt?
authorization_status String @default("pending") @db.VarChar(50)
visibility String @default("private") @db.VarChar(30)
sort_order Int @default(0)
published_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([visibility, sort_order])
@@index([project_id])
@@map("case_showcases")
}
model HitAnalysisCase {
id BigInt @id @default(autoincrement())
title String @db.VarChar(255)
source_platform String? @db.VarChar(100)
source_url String? @db.VarChar(500)
content_type String @default("short_drama") @db.VarChar(80)
genre String? @db.VarChar(100)
language String @default("zh-CN") @db.VarChar(30)
target_audience String? @db.VarChar(255)
duration_seconds Int?
episode_count Int?
tags_json Json?
metrics_json Json?
transcript_text String? @db.LongText
summary_text String? @db.Text
analysis_json Json?
diagnosis_score Decimal? @db.Decimal(5, 2)
status String @default("draft") @db.VarChar(50)
created_by_user_id BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([source_platform])
@@index([genre, status])
@@index([status, diagnosis_score])
@@index([created_at])
@@map("hit_analysis_cases")
}
model HitAnalysisSegment {
id BigInt @id @default(autoincrement())
case_id BigInt
segment_no Int
start_second Int?
end_second Int?
scene_type String? @db.VarChar(80)
hook_type String? @db.VarChar(100)
emotion String? @db.VarChar(80)
conflict_type String? @db.VarChar(100)
plot_function String? @db.VarChar(120)
visual_strategy String? @db.VarChar(120)
dialogue_pattern String? @db.VarChar(120)
camera_notes String? @db.Text
importance_score Int?
emotion_score Int?
action_score Int?
tags_json Json?
summary_text String? @db.Text
prompt_seed String? @db.Text
created_at DateTime @default(now())
@@unique([case_id, segment_no])
@@index([case_id])
@@index([scene_type])
@@index([hook_type])
@@map("hit_analysis_segments")
}
model CreativePattern {
id BigInt @id @default(autoincrement())
source_case_id BigInt?
pattern_type String @db.VarChar(80)
title String @db.VarChar(255)
genre String? @db.VarChar(100)
language String @default("zh-CN") @db.VarChar(30)
description String? @db.Text
structure_json Json?
prompt_template String? @db.Text
negative_prompt String? @db.Text
tags_json Json?
usage_count Int @default(0)
effectiveness_score Decimal? @db.Decimal(5, 2)
status String @default("active") @db.VarChar(50)
created_by_user_id BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([pattern_type, status])
@@index([genre, status])
@@index([source_case_id])
@@map("creative_patterns")
}
model ProjectCreativePattern {
id BigInt @id @default(autoincrement())
project_id BigInt
creative_pattern_id BigInt
source String @default("user_selected") @db.VarChar(50)
snapshot_json Json?
sort_order Int @default(0)
created_at DateTime @default(now())
@@unique([project_id, creative_pattern_id])
@@index([project_id, sort_order])
@@index([creative_pattern_id])
@@map("project_creative_patterns")
}
model AnalyticsEvent {
id BigInt @id @default(autoincrement())
project_id BigInt
episode_id BigInt?
event_type String @db.VarChar(80)
platform String? @db.VarChar(80)
metric_json Json?
created_at DateTime @default(now())
@@index([project_id])
@@index([episode_id])
@@index([event_type, created_at])
@@map("analytics_events")
}
model SystemConfig {
id BigInt @id @default(autoincrement())
config_key String @unique @db.VarChar(191)
config_value Json?
description String? @db.Text
is_public Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([is_public])
@@map("system_configs")
}
model OperationLog {
id BigInt @id @default(autoincrement())
user_id BigInt?
operator_role String? @db.VarChar(50)
action String @db.VarChar(100)
target_type String? @db.VarChar(80)
target_id BigInt?
ip String? @db.VarChar(80)
user_agent String? @db.Text
metadata_json Json?
created_at DateTime @default(now())
@@index([user_id, created_at])
@@index([target_type, target_id])
@@index([action, created_at])
@@map("operation_logs")
}
+147
View File
@@ -0,0 +1,147 @@
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcryptjs';
import { DEFAULT_AI_ROUTER_CONFIG } from '../src/ai-router/ai-router.types';
const prisma = new PrismaClient();
const mockProviders = [
['TextProvider', 'mock-text', 'Mock Text Provider', 'mock-text-v1'],
['NovelProvider', 'mock-novel', 'Mock Novel Provider', 'mock-novel-v1'],
['ImageProvider', 'mock-image', 'Mock Image Provider', 'mock-image-v1'],
['VideoProvider', 'mock-video', 'Mock Video Provider', 'mock-video-v1'],
['VoiceProvider', 'mock-voice', 'Mock Voice Provider', 'mock-voice-v1'],
['LipSyncProvider', 'mock-lipsync', 'Mock Lip Sync Provider', 'mock-lipsync-v1'],
['ModerationProvider', 'mock-moderation', 'Mock Moderation Provider', 'mock-moderation-v1'],
['QualityCheckProvider', 'mock-qc', 'Mock Quality Check Provider', 'mock-qc-v1'],
['FileParseProvider', 'mock-file-parse', 'Mock File Parse Provider', 'mock-file-parse-v1'],
['EmbeddingProvider', 'mock-embedding', 'Mock Embedding Provider', 'mock-embedding-v1']
] as const;
async function main() {
const adminPassword = process.env.SEED_ADMIN_PASSWORD || 'Admin123!';
const adminPasswordHash = await hash(adminPassword, 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@example.com' },
update: {
password_hash: adminPasswordHash,
role: 'admin',
status: 'active'
},
create: {
email: 'admin@example.com',
password_hash: adminPasswordHash,
nickname: 'System Admin',
role: 'admin',
status: 'active'
}
});
for (const [providerType, providerCode, displayName, modelName] of mockProviders) {
const isEnabled = providerCode !== 'mock-lipsync';
await prisma.providerConfig.upsert({
where: {
provider_type_provider_code: {
provider_type: providerType,
provider_code: providerCode
}
},
update: {
display_name: displayName,
mode: 'mock',
model_name: modelName,
is_enabled: isEnabled,
priority: 100,
rate_limit_json: {
rpm: 120,
concurrency: 8
},
cost_rule_json: {
flat_cost: 0,
unit: 'mock'
}
},
create: {
provider_type: providerType,
provider_code: providerCode,
display_name: displayName,
mode: 'mock',
model_name: modelName,
is_enabled: isEnabled,
priority: 100,
config_json: {
note: 'Used until the MVP flow is complete.'
},
rate_limit_json: {
rpm: 120,
concurrency: 8
},
cost_rule_json: {
flat_cost: 0,
unit: 'mock'
}
}
});
}
await prisma.systemConfig.upsert({
where: { config_key: 'system_a.current_stage' },
update: {
config_value: {
stage: 'stage-02-database-schema'
}
},
create: {
config_key: 'system_a.current_stage',
config_value: {
stage: 'stage-02-database-schema'
},
description: 'Tracks current System A development stage.'
}
});
await prisma.systemConfig.upsert({
where: { config_key: 'security.api_crypto_enabled' },
update: {},
create: {
config_key: 'security.api_crypto_enabled',
config_value: {
enabled: false
},
description: 'Controls frontend/backend API payload encryption. Default off for testing; enable manually in production.',
is_public: true
}
});
await prisma.systemConfig.upsert({
where: { config_key: 'ai.router.v1' },
update: {},
create: {
config_key: 'ai.router.v1',
config_value: DEFAULT_AI_ROUTER_CONFIG,
description: 'AI Router V1 route config for automatic provider selection by language, shot score and budget.',
is_public: false
}
});
await prisma.quotaAccount.upsert({
where: { user_id: admin.id },
update: {},
create: {
user_id: admin.id,
total_quota: 100,
available_quota: 100,
status: 'active'
}
});
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});
+320
View File
@@ -0,0 +1,320 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Query,
UseGuards
} from '@nestjs/common';
import type { AuthRequestUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
AdminListAssetsQueryDto,
AdminListCharactersQueryDto,
AdminListCopyrightRecordsQueryDto,
AdminListGlobalCharactersQueryDto,
AdminListHitAnalysesQueryDto,
AdminListNovelChaptersQueryDto,
AdminListNovelSourcesQueryDto,
AdminListOperationLogsQueryDto,
AdminBindCharacterGlobalDto,
AdminAnalyzeHitCaseDto,
AdminCreateHitAnalysisCaseDto,
AdminListProjectsQueryDto,
AdminListRouterAuditsQueryDto,
AdminListStoryboardShotsQueryDto,
AdminListUsersQueryDto,
AdminListWorksQueryDto,
AdminListCreativePatternsQueryDto,
AdminPromoteHitCasePatternsDto,
AdminResetUserPasswordDto,
AdminSaveGlobalCharacterDto,
AdminUpdateRouterAuditQualityDto,
AdminUpdateCreativePatternDto,
AdminUpdateCreativePatternStatusDto,
AdminUpdateProjectStatusDto,
AdminUpdateUserRoleDto,
AdminUpdateUserStatusDto,
AdminUpdateSystemConfigDto
} from './admin.dto';
import { AdminService } from './admin.service';
@Controller('admin')
@UseGuards(JwtAuthGuard)
export class AdminController {
constructor(@Inject(AdminService) private readonly adminService: AdminService) {}
@Get('dashboard')
getDashboard(@CurrentUser() user: AuthRequestUser) {
return this.adminService.getDashboard(user);
}
@Get('rbac/me')
getRbacProfile(@CurrentUser() user: AuthRequestUser) {
return this.adminService.getRbacProfile(user);
}
@Get('projects')
listProjects(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListProjectsQueryDto) {
return this.adminService.listProjects(user, query);
}
@Get('projects/:projectId')
getProjectDetail(@CurrentUser() user: AuthRequestUser, @Param('projectId') projectId: string) {
return this.adminService.getProjectDetail(user, projectId);
}
@Patch('projects/:projectId/status')
updateProjectStatus(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: AdminUpdateProjectStatusDto
) {
return this.adminService.updateProjectStatus(user, projectId, dto);
}
@Get('users')
listUsers(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListUsersQueryDto) {
return this.adminService.listUsers(user, query);
}
@Get('users/:userId/detail')
getUserDetail(@CurrentUser() user: AuthRequestUser, @Param('userId') userId: string) {
return this.adminService.getUserDetail(user, userId);
}
@Patch('users/:userId/status')
updateUserStatus(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminUpdateUserStatusDto
) {
return this.adminService.updateUserStatus(user, userId, dto);
}
@Patch('users/:userId/role')
updateUserRole(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminUpdateUserRoleDto
) {
return this.adminService.updateUserRole(user, userId, dto);
}
@Post('users/:userId/reset-password')
resetUserPassword(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminResetUserPasswordDto
) {
return this.adminService.resetUserPassword(user, userId, dto);
}
@Get('assets')
listAssets(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListAssetsQueryDto) {
return this.adminService.listAssets(user, query);
}
@Get('novel-sources')
listNovelSources(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListNovelSourcesQueryDto
) {
return this.adminService.listNovelSources(user, query);
}
@Get('novel-chapters')
listNovelChapters(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListNovelChaptersQueryDto
) {
return this.adminService.listNovelChapters(user, query);
}
@Get('characters')
listCharacters(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListCharactersQueryDto) {
return this.adminService.listCharacters(user, query);
}
@Get('global-characters')
listGlobalCharacters(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListGlobalCharactersQueryDto
) {
return this.adminService.listGlobalCharacters(user, query);
}
@Post('global-characters')
createGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Body() dto: AdminSaveGlobalCharacterDto
) {
return this.adminService.createGlobalCharacter(user, dto);
}
@Patch('global-characters/:globalCharacterId')
updateGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: AdminSaveGlobalCharacterDto
) {
return this.adminService.updateGlobalCharacter(user, globalCharacterId, dto);
}
@Post('characters/:characterId/bind-global')
bindCharacterGlobal(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: AdminBindCharacterGlobalDto
) {
return this.adminService.bindCharacterGlobal(user, characterId, dto);
}
@Get('storyboard-shots')
listStoryboardShots(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListStoryboardShotsQueryDto
) {
return this.adminService.listStoryboardShots(user, query);
}
@Get('router-audits')
listRouterAudits(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListRouterAuditsQueryDto
) {
return this.adminService.listRouterAudits(user, query);
}
@Get('hit-analyses')
listHitAnalyses(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListHitAnalysesQueryDto
) {
return this.adminService.listHitAnalyses(user, query);
}
@Post('hit-analyses')
createHitAnalysisCase(
@CurrentUser() user: AuthRequestUser,
@Body() dto: AdminCreateHitAnalysisCaseDto
) {
return this.adminService.createHitAnalysisCase(user, dto);
}
@Post('hit-analyses/:caseId/analyze')
analyzeHitCase(
@CurrentUser() user: AuthRequestUser,
@Param('caseId') caseId: string,
@Body() dto: AdminAnalyzeHitCaseDto
) {
return this.adminService.analyzeHitCase(user, caseId, dto);
}
@Post('hit-analyses/:caseId/patterns')
promoteHitCasePatterns(
@CurrentUser() user: AuthRequestUser,
@Param('caseId') caseId: string,
@Body() dto: AdminPromoteHitCasePatternsDto
) {
return this.adminService.promoteHitCasePatterns(user, caseId, dto);
}
@Get('creative-patterns')
listCreativePatterns(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListCreativePatternsQueryDto
) {
return this.adminService.listCreativePatterns(user, query);
}
@Patch('creative-patterns/:patternId')
updateCreativePattern(
@CurrentUser() user: AuthRequestUser,
@Param('patternId') patternId: string,
@Body() dto: AdminUpdateCreativePatternDto
) {
return this.adminService.updateCreativePattern(user, patternId, dto);
}
@Patch('creative-patterns/:patternId/status')
updateCreativePatternStatus(
@CurrentUser() user: AuthRequestUser,
@Param('patternId') patternId: string,
@Body() dto: AdminUpdateCreativePatternStatusDto
) {
return this.adminService.updateCreativePatternStatus(user, patternId, dto);
}
@Post('creative-patterns/:patternId/refresh-metrics')
refreshCreativePatternMetrics(
@CurrentUser() user: AuthRequestUser,
@Param('patternId') patternId: string
) {
return this.adminService.refreshCreativePatternMetrics(user, patternId);
}
@Get('router-audits/video-clips/:clipId/timeline')
getRouterAuditClipTimeline(
@CurrentUser() user: AuthRequestUser,
@Param('clipId') clipId: string
) {
return this.adminService.getRouterAuditClipTimeline(user, clipId);
}
@Patch('router-audits/video-clips/:clipId/quality')
updateRouterAuditClipQuality(
@CurrentUser() user: AuthRequestUser,
@Param('clipId') clipId: string,
@Body() dto: AdminUpdateRouterAuditQualityDto
) {
return this.adminService.updateRouterAuditClipQuality(user, clipId, dto);
}
@Get('works')
listWorks(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListWorksQueryDto) {
return this.adminService.listWorks(user, query);
}
@Get('copyright-records')
listCopyrightRecords(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListCopyrightRecordsQueryDto
) {
return this.adminService.listCopyrightRecords(user, query);
}
@Get('operation-logs')
listOperationLogs(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListOperationLogsQueryDto
) {
return this.adminService.listOperationLogs(user, query);
}
@Get('operation-logs/export')
exportOperationLogs(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListOperationLogsQueryDto
) {
return this.adminService.exportOperationLogs(user, query);
}
@Get('system-configs')
listSystemConfigs(@CurrentUser() user: AuthRequestUser) {
return this.adminService.listSystemConfigs(user);
}
@Patch('system-configs/:configKey')
updateSystemConfig(
@CurrentUser() user: AuthRequestUser,
@Param('configKey') configKey: string,
@Body() dto: AdminUpdateSystemConfigDto
) {
return this.adminService.updateSystemConfig(user, configKey, dto);
}
}
+218
View File
@@ -0,0 +1,218 @@
export class AdminListProjectsQueryDto {
status?: string;
input_mode?: string;
user_id?: string;
limit?: string;
}
export class AdminListUsersQueryDto {
role?: string;
status?: string;
limit?: string;
}
export class AdminListAssetsQueryDto {
asset_type?: string;
status?: string;
project_id?: string;
user_id?: string;
limit?: string;
}
export class AdminListNovelSourcesQueryDto {
project_id?: string;
user_id?: string;
source_type?: string;
parse_status?: string;
limit?: string;
}
export class AdminListNovelChaptersQueryDto {
project_id?: string;
novel_source_id?: string;
status?: string;
limit?: string;
}
export class AdminListCharactersQueryDto {
project_id?: string;
global_character_id?: string;
status?: string;
role_type?: string;
limit?: string;
}
export class AdminListGlobalCharactersQueryDto {
status?: string;
role_archetype?: string;
commercial_status?: string;
limit?: string;
}
export class AdminSaveGlobalCharacterDto {
name?: string;
display_name?: string;
role_archetype?: string;
gender_label?: string;
age_group?: string;
identity_desc?: string;
appearance_desc?: string;
face_desc?: string;
hair_desc?: string;
eye_desc?: string;
body_desc?: string;
default_costume_rules?: string;
wardrobe_json?: unknown;
special_props?: string;
personality_desc?: string;
speech_style?: string;
voice_provider_code?: string;
voice_model?: string;
voice_id?: string;
voice_style?: string;
performance_style?: string;
negative_rules?: string;
anchor_asset_id?: string;
voice_sample_asset_id?: string;
commercial_status?: string;
usage_scope?: string;
status?: string;
}
export class AdminBindCharacterGlobalDto {
global_character_id?: string;
reason?: string;
}
export class AdminListStoryboardShotsQueryDto {
project_id?: string;
episode_id?: string;
status?: string;
limit?: string;
}
export class AdminListRouterAuditsQueryDto {
project_id?: string;
episode_id?: string;
provider_code?: string;
quality_status?: string;
route_tier?: string;
limit?: string;
}
export class AdminListHitAnalysesQueryDto {
source_platform?: string;
genre?: string;
status?: string;
limit?: string;
}
export class AdminCreateHitAnalysisCaseDto {
title?: string;
source_platform?: string;
source_url?: string;
content_type?: string;
genre?: string;
language?: string;
target_audience?: string;
duration_seconds?: number | string | null;
episode_count?: number | string | null;
tags?: string[] | string;
metrics_json?: unknown;
transcript_text?: string;
summary_text?: string;
auto_analyze?: boolean;
}
export class AdminAnalyzeHitCaseDto {
min_segment_seconds?: number | string | null;
segment_count?: number | string | null;
}
export class AdminPromoteHitCasePatternsDto {
pattern_types?: string[] | string;
}
export class AdminListCreativePatternsQueryDto {
pattern_type?: string;
genre?: string;
status?: string;
limit?: string;
}
export class AdminUpdateCreativePatternDto {
pattern_type?: string;
title?: string;
genre?: string | null;
language?: string;
description?: string | null;
structure_json?: unknown;
prompt_template?: string | null;
negative_prompt?: string | null;
tags?: string[] | string | null;
effectiveness_score?: number | string | null;
status?: string;
reason?: string;
}
export class AdminUpdateCreativePatternStatusDto {
status?: string;
reason?: string;
}
export class AdminUpdateRouterAuditQualityDto {
result_status?: string;
reason?: string;
quality_score?: number | string | null;
}
export class AdminListWorksQueryDto {
project_id?: string;
user_id?: string;
status?: string;
limit?: string;
}
export class AdminListCopyrightRecordsQueryDto {
project_id?: string;
user_id?: string;
authorization_type?: string;
limit?: string;
}
export class AdminListOperationLogsQueryDto {
user_id?: string;
operator_role?: string;
action?: string;
target_type?: string;
target_id?: string;
date_from?: string;
date_to?: string;
limit?: string;
}
export class AdminUpdateProjectStatusDto {
status?: string;
reason?: string;
}
export class AdminUpdateUserStatusDto {
status?: string;
reason?: string;
}
export class AdminUpdateUserRoleDto {
role?: string;
reason?: string;
}
export class AdminResetUserPasswordDto {
new_password?: string;
reason?: string;
}
export class AdminUpdateSystemConfigDto {
config_value?: unknown;
description?: string;
is_public?: boolean;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService]
})
export class AdminModule {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
import type {
CopyrightRecord,
CreativePattern,
HitAnalysisCase,
HitAnalysisSegment,
NovelChapter,
NovelSource,
OperationLog,
SystemConfig
} from '@prisma/client';
export function toSafeNovelSource(source: NovelSource) {
return {
id: source.id.toString(),
project_id: source.project_id.toString(),
source_type: source.source_type,
title: source.title,
author_name: source.author_name,
raw_asset_id: source.raw_asset_id?.toString() ?? null,
word_count: source.word_count,
chapter_count: source.chapter_count,
parse_status: source.parse_status,
parse_report: source.parse_report,
text_preview: createTextPreview(source.clean_text || source.raw_text),
created_at: source.created_at.toISOString()
};
}
export function toSafeNovelChapter(chapter: NovelChapter) {
return {
id: chapter.id.toString(),
project_id: chapter.project_id.toString(),
novel_source_id: chapter.novel_source_id?.toString() ?? null,
chapter_no: chapter.chapter_no,
title: chapter.title,
summary: chapter.summary,
visual_summary: chapter.visual_summary,
content_preview: createTextPreview(chapter.content, 3000),
word_count: chapter.word_count,
status: chapter.status,
created_at: chapter.created_at.toISOString()
};
}
function createTextPreview(value: string | null, maxLength = 2000) {
if (!value) {
return null;
}
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
}
export function toSafeCopyrightRecord(record: CopyrightRecord) {
return {
id: record.id.toString(),
project_id: record.project_id.toString(),
user_id: record.user_id.toString(),
authorization_type: record.authorization_type,
statement_text: record.statement_text,
ip: record.ip,
confirmed_at: record.confirmed_at.toISOString()
};
}
export function toSafeOperationLog(log: OperationLog) {
return {
id: log.id.toString(),
user_id: log.user_id?.toString() ?? null,
operator_role: log.operator_role,
action: log.action,
target_type: log.target_type,
target_id: log.target_id?.toString() ?? null,
metadata_json: log.metadata_json,
created_at: log.created_at.toISOString()
};
}
export function toSafeSystemConfig(config: SystemConfig) {
return {
id: config.id.toString(),
config_key: config.config_key,
config_value: config.config_value,
description: config.description,
is_public: config.is_public,
created_at: config.created_at.toISOString(),
updated_at: config.updated_at.toISOString()
};
}
export function toSafeHitAnalysisCase(item: HitAnalysisCase) {
return {
id: item.id.toString(),
title: item.title,
source_platform: item.source_platform,
source_url: item.source_url,
content_type: item.content_type,
genre: item.genre,
language: item.language,
target_audience: item.target_audience,
duration_seconds: item.duration_seconds,
episode_count: item.episode_count,
tags_json: item.tags_json,
metrics_json: item.metrics_json,
summary_text: item.summary_text,
transcript_preview: createTextPreview(item.transcript_text, 3000),
analysis_json: item.analysis_json,
diagnosis_score: item.diagnosis_score ? Number(item.diagnosis_score.toString()) : null,
status: item.status,
created_by_user_id: item.created_by_user_id?.toString() ?? null,
created_at: item.created_at.toISOString(),
updated_at: item.updated_at.toISOString()
};
}
export function toSafeHitAnalysisSegment(item: HitAnalysisSegment) {
return {
id: item.id.toString(),
case_id: item.case_id.toString(),
segment_no: item.segment_no,
start_second: item.start_second,
end_second: item.end_second,
scene_type: item.scene_type,
hook_type: item.hook_type,
emotion: item.emotion,
conflict_type: item.conflict_type,
plot_function: item.plot_function,
visual_strategy: item.visual_strategy,
dialogue_pattern: item.dialogue_pattern,
camera_notes: item.camera_notes,
importance_score: item.importance_score,
emotion_score: item.emotion_score,
action_score: item.action_score,
tags_json: item.tags_json,
summary_text: item.summary_text,
prompt_seed: item.prompt_seed,
created_at: item.created_at.toISOString()
};
}
export function toSafeCreativePattern(item: CreativePattern) {
return {
id: item.id.toString(),
source_case_id: item.source_case_id?.toString() ?? null,
pattern_type: item.pattern_type,
title: item.title,
genre: item.genre,
language: item.language,
description: item.description,
structure_json: item.structure_json,
prompt_template: item.prompt_template,
negative_prompt: item.negative_prompt,
tags_json: item.tags_json,
usage_count: item.usage_count,
effectiveness_score: item.effectiveness_score ? Number(item.effectiveness_score.toString()) : null,
status: item.status,
created_by_user_id: item.created_by_user_id?.toString() ?? null,
created_at: item.created_at.toISOString(),
updated_at: item.updated_at.toISOString()
};
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AiRouterService } from './ai-router.service';
@Module({
imports: [PrismaModule],
providers: [AiRouterService],
exports: [AiRouterService]
})
export class AiRouterModule {}
@@ -0,0 +1,226 @@
import { Prisma, type Project, type ProviderConfig, type StoryboardShot } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PrismaService } from '../prisma/prisma.service';
import { DEFAULT_AI_ROUTER_CONFIG } from './ai-router.types';
import { AiRouterService } from './ai-router.service';
const now = new Date('2026-06-09T00:00:00.000Z');
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: 'AI Router 测试项目',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'live_action',
output_type: 'short_video',
output_mode: 'live_action_ai',
visual_mode: 'live_action',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'live_action_shots_prepared',
copyright_status: 'ai_original',
payment_status: 'paid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createShot(overrides: Partial<StoryboardShot> = {}): StoryboardShot {
return {
id: 20n,
project_id: 10n,
episode_id: 30n,
shot_no: 1,
scene_name: '会议室反击',
location_desc: '高层会议室',
characters_json: [{ id: '1', name: '林晚' }],
visual_desc: '林晚站在会议桌前。',
action_desc: '林晚播放录音证据。',
dialogue_text: '这一回,我不会再退。',
narration_text: '局势开始反转。',
camera_motion: 'zoom_in',
effect_type: 'flash',
duration: new Prisma.Decimal(4),
scene_type: null,
importance_score: null,
emotion_score: null,
action_score: null,
route_tier: null,
prompt_text: '真人短剧会议室反击',
negative_prompt: '低清晰度',
live_action_desc: null,
actor_action: null,
camera_instruction: null,
performance_instruction: null,
video_prompt: null,
keyframe_asset_id: 40n,
video_clip_asset_id: null,
video_status: 'keyframe_generated',
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
return {
id: 100n,
provider_type: 'VideoProvider',
provider_code: 'mock-video',
display_name: 'Mock Video',
mode: 'mock',
model_name: 'mock-video-v1',
config_json: {},
fallback_provider_id: null,
is_enabled: true,
priority: 100,
rate_limit_json: {},
cost_rule_json: { flat_cost: 0, unit: 'mock' },
created_at: now,
updated_at: now,
...overrides
};
}
describe('AiRouterService', () => {
let prisma: any;
let service: AiRouterService;
beforeEach(() => {
prisma = {
systemConfig: {
upsert: vi.fn().mockResolvedValue({
config_key: 'ai.router.v1',
config_value: DEFAULT_AI_ROUTER_CONFIG
})
},
providerConfig: {
findMany: vi.fn()
},
providerLog: {
aggregate: vi.fn().mockResolvedValue({
_sum: { cost_actual: new Prisma.Decimal(0) }
})
}
};
service = new AiRouterService(prisma as PrismaService);
});
it('routes normal Chinese live-action shots to Hailuo when enabled', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
id: 101n,
provider_code: 'minimax_hailuo_23_fast',
display_name: 'Hailuo Fast',
mode: 'real',
is_enabled: true,
cost_rule_json: { unit: 'video_seconds', price_per_second: 0.03, currency: 'USD' }
}),
createProvider()
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject(),
shot: createShot({ importance_score: 3, action_score: 1, route_tier: 'normal' }),
duration: 5
});
expect(decision.provider_code).toBe('minimax_hailuo_23_fast');
expect(decision.route_tier).toBe('normal');
expect(decision.estimated_cost).toBe(0.15);
expect(decision.decision_reason).toBe('auto_normal_route');
});
it('routes high-value or complex shots to Kling when enabled', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
id: 102n,
provider_code: 'kling-image-to-video',
display_name: 'Kling',
mode: 'real',
is_enabled: true,
cost_rule_json: { unit: 'video_seconds', price_per_second: 0.12, currency: 'USD' }
}),
createProvider({
id: 101n,
provider_code: 'minimax_hailuo_23_fast',
mode: 'real',
is_enabled: true
}),
createProvider()
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject(),
shot: createShot({
action_desc: '女主在雨夜追车,真相曝光,高潮打脸。',
importance_score: 9,
action_score: 7
}),
duration: 5
});
expect(decision.provider_code).toBe('kling-image-to-video');
expect(decision.route_tier).toBe('premium');
expect(decision.fallback_chain).toContain('minimax_hailuo_23_fast');
});
it('falls back through disabled providers to mock video', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
provider_code: 'minimax_hailuo_23_fast',
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'jimeng_seedance',
mode: 'real',
is_enabled: false
}),
createProvider()
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject(),
shot: createShot({ importance_score: 3, action_score: 1, route_tier: 'normal' }),
duration: 5
});
expect(decision.provider_code).toBe('mock-video');
expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([
'provider_disabled',
'provider_disabled',
'auto_normal_route'
]);
});
it('keeps admin manual override as an explicit router decision', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
provider_code: 'jimeng_seedance',
mode: 'real',
is_enabled: true
})
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject(),
shot: createShot(),
duration: 5,
manual_provider_code: 'jimeng_seedance',
allow_manual_override: true
});
expect(decision.provider_code).toBe('jimeng_seedance');
expect(decision.manual_override).toBe(true);
expect(decision.decision_reason).toBe('manual_provider_override');
});
});
+374
View File
@@ -0,0 +1,374 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import type { Prisma, Project, ProviderConfig, StoryboardShot } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
AI_ROUTER_CONFIG_KEY,
AI_ROUTER_DEFAULT_LANGUAGE,
DEFAULT_AI_ROUTER_CONFIG,
type AiRouteDecision,
type AiRouteTier,
type AiRouterShotScores
} from './ai-router.types';
const ROUTER_MAX_PROVIDER_CLIP_SECONDS = 10;
@Injectable()
export class AiRouterService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
scoreLiveActionShot(shot: StoryboardShot): AiRouterShotScores {
const sceneType = this.normalizeSceneType(shot.scene_type) ?? this.inferSceneType(shot);
const importanceScore = this.clampScore(shot.importance_score ?? this.inferImportanceScore(shot, sceneType));
const emotionScore = this.clampScore(shot.emotion_score ?? this.inferEmotionScore(shot));
const actionScore = this.clampScore(shot.action_score ?? this.inferActionScore(shot));
const routeTier = this.normalizeRouteTier(shot.route_tier) ?? this.routeTierForScores(importanceScore, actionScore);
return {
scene_type: sceneType,
importance_score: importanceScore,
emotion_score: emotionScore,
action_score: actionScore,
route_tier: routeTier
};
}
async resolveLiveActionVideoRoute(input: {
project: Project;
shot: StoryboardShot;
duration: number;
language?: string | null;
manual_provider_code?: string | null;
allow_manual_override?: boolean;
max_cost_per_clip?: number | null;
}): Promise<AiRouteDecision> {
const scores = this.scoreLiveActionShot(input.shot);
const language = this.normalizeText(input.language) ?? (await this.resolveDefaultLanguage());
const manualProviderCode = this.normalizeText(input.manual_provider_code);
if (manualProviderCode && input.allow_manual_override) {
return this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores);
}
if (manualProviderCode && !input.allow_manual_override) {
throw new BadRequestException('AI_ROUTER_MANUAL_OVERRIDE_FORBIDDEN');
}
const config = await this.loadRouterConfig();
const languageConfig = this.resolveLiveActionLanguageConfig(config, language);
const tierConfig = this.jsonObject(languageConfig[scores.route_tier]);
const primaryProviderCode =
this.normalizeText(tierConfig.provider_code) ??
(scores.route_tier === 'premium' ? 'kling-image-to-video' : 'minimax_hailuo_23_fast');
const fallbackChain = this.uniqueStrings([
primaryProviderCode,
...this.stringArray(tierConfig.fallback_chain),
'mock-video'
]);
return this.selectVideoProviderFromCandidates({
language,
duration: input.duration,
scores,
fallbackChain,
maxCostPerClip: input.max_cost_per_clip ?? null,
dailyBudget: this.numberFromJson(this.jsonObject(config).daily_budget),
manualOverride: false,
defaultReason: `auto_${scores.route_tier}_route`
});
}
private async resolveManualVideoProvider(
providerCode: string,
language: string,
duration: number,
scores: AiRouterShotScores
): Promise<AiRouteDecision> {
return this.selectVideoProviderFromCandidates({
language,
duration,
scores,
fallbackChain: [providerCode],
maxCostPerClip: null,
dailyBudget: 0,
manualOverride: true,
defaultReason: 'manual_provider_override'
});
}
private async selectVideoProviderFromCandidates(input: {
language: string;
duration: number;
scores: AiRouterShotScores;
fallbackChain: string[];
maxCostPerClip: number | null;
dailyBudget: number;
manualOverride: boolean;
defaultReason: string;
}): Promise<AiRouteDecision> {
const providers = await this.prisma.providerConfig.findMany({
where: {
provider_type: 'VideoProvider',
provider_code: { in: input.fallbackChain }
}
});
const providerByCode = new Map(providers.map((provider) => [provider.provider_code, provider]));
const usedToday = input.dailyBudget > 0 ? await this.getTodayProviderCost() : 0;
const candidates: AiRouteDecision['candidates'] = [];
for (const providerCode of input.fallbackChain) {
const provider = providerByCode.get(providerCode);
const estimatedCost = provider
? this.estimateVideoCost(provider.cost_rule_json, input.duration)
: 0;
if (!provider) {
candidates.push({
provider_code: providerCode,
status: 'skipped',
reason: 'provider_not_found',
estimated_cost: estimatedCost
});
continue;
}
if (!provider.is_enabled) {
candidates.push({
provider_code: providerCode,
status: 'skipped',
reason: 'provider_disabled',
estimated_cost: estimatedCost
});
continue;
}
if (input.maxCostPerClip && input.maxCostPerClip > 0 && estimatedCost > input.maxCostPerClip) {
candidates.push({
provider_code: providerCode,
status: 'skipped',
reason: 'max_cost_per_clip_exceeded',
estimated_cost: estimatedCost
});
continue;
}
if (input.dailyBudget > 0 && usedToday + estimatedCost > input.dailyBudget) {
candidates.push({
provider_code: providerCode,
status: 'skipped',
reason: 'router_daily_budget_exceeded',
estimated_cost: estimatedCost
});
continue;
}
candidates.push({
provider_code: providerCode,
status: 'selected',
reason: input.defaultReason,
estimated_cost: estimatedCost
});
return {
config_key: AI_ROUTER_CONFIG_KEY,
task_type: 'live_action_video_clip_generate',
language: input.language,
provider_code: provider.provider_code,
provider_id: provider.id.toString(),
provider_mode: provider.mode,
route_tier: input.scores.route_tier,
fallback_chain: input.fallbackChain,
candidates,
decision_reason: input.defaultReason,
estimated_cost: estimatedCost,
manual_override: input.manualOverride,
scores: input.scores
};
}
throw new BadRequestException({
message: 'AI_ROUTER_NO_VIDEO_PROVIDER_AVAILABLE',
candidates
});
}
private async loadRouterConfig() {
const config = await this.prisma.systemConfig.upsert({
where: { config_key: AI_ROUTER_CONFIG_KEY },
update: {},
create: {
config_key: AI_ROUTER_CONFIG_KEY,
config_value: DEFAULT_AI_ROUTER_CONFIG,
description: 'AI Router V1 route config for automatic provider selection by language, shot score and budget.',
is_public: false
}
});
return this.jsonObject(config.config_value ?? DEFAULT_AI_ROUTER_CONFIG);
}
private async resolveDefaultLanguage() {
const config = await this.loadRouterConfig();
return this.normalizeText(config.default_language) ?? AI_ROUTER_DEFAULT_LANGUAGE;
}
private resolveLiveActionLanguageConfig(config: Record<string, unknown>, language: string) {
const liveAction = this.jsonObject(config.live_action_video);
const current = this.jsonObject(liveAction[language]);
if (Object.keys(current).length > 0) return current;
return this.jsonObject(liveAction[AI_ROUTER_DEFAULT_LANGUAGE]);
}
private estimateVideoCost(rule: Prisma.JsonValue | null, duration: number) {
const costRule = this.jsonObject(rule);
const flatCost = this.numberFromJson(costRule.flat_cost);
const pricePerSecond = this.numberFromJson(costRule.price_per_second);
const pricePerClip = this.numberFromJson(costRule.price_per_clip);
const durations = this.splitProviderClipDurations(duration);
const cost = durations.reduce(
(sum, clipDuration) => sum + flatCost + pricePerClip + clipDuration * pricePerSecond,
0
);
return Number(cost.toFixed(4));
}
private splitProviderClipDurations(duration: number) {
const normalized = Number(Math.max(1, duration).toFixed(2));
if (normalized <= ROUTER_MAX_PROVIDER_CLIP_SECONDS) {
return [normalized];
}
const count = Math.ceil(normalized / ROUTER_MAX_PROVIDER_CLIP_SECONDS);
const base = Number((normalized / count).toFixed(2));
const durations = Array.from({ length: count }, () => base);
const total = Number(durations.reduce((sum, item) => sum + item, 0).toFixed(2));
const diff = Number((normalized - total).toFixed(2));
durations[durations.length - 1] = Number((durations[durations.length - 1] + diff).toFixed(2));
return durations;
}
private async getTodayProviderCost() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const result = await this.prisma.providerLog.aggregate({
where: {
status: 'success',
created_at: { gte: today }
},
_sum: { cost_actual: true }
});
return result._sum.cost_actual ? Number(result._sum.cost_actual.toString()) : 0;
}
private inferSceneType(shot: StoryboardShot) {
const text = this.shotText(shot);
if (/(打|追|跑|撞|爆|战|枪|刀|车祸|逃|搏斗|扇|摔)/.test(text)) return 'action';
if (/(哭|崩溃|表白|分手|争吵|怒|吻|求婚|告白)/.test(text)) return 'emotion';
if (shot.dialogue_text && shot.dialogue_text.length >= (shot.narration_text?.length ?? 0)) return 'dialog';
if (/(远景|空镜|转场|环境|街道|夜景|大楼)/.test(text)) return 'establishing';
return 'dialog';
}
private inferImportanceScore(shot: StoryboardShot, sceneType: string) {
const text = this.shotText(shot);
let score = sceneType === 'establishing' ? 2 : 3;
if (shot.shot_no === 1) score += 1;
if (/(主角|男主|女主|第一次|登场|相遇|重逢)/.test(text)) score += 2;
if (/(打脸|反转|真相|高潮|大结局|求婚|婚礼|分手|车祸|死亡|曝光|证据)/.test(text)) score += 3;
if (/(吻|接吻|表白|崩溃|哭|下跪|复仇|救人)/.test(text)) score += 2;
if (shot.effect_type && shot.effect_type !== 'none') score += 1;
return score;
}
private inferEmotionScore(shot: StoryboardShot) {
const text = this.shotText(shot);
let score = 2;
if (/(争吵|愤怒|怒|质问|冷笑|羞辱)/.test(text)) score += 3;
if (/(哭|崩溃|绝望|心碎|分手)/.test(text)) score += 5;
if (/(表白|告白|求婚|拥抱|吻|接吻)/.test(text)) score += 5;
if (/[!]{1,}/.test(text)) score += 1;
return score;
}
private inferActionScore(shot: StoryboardShot) {
const text = this.shotText(shot);
let score = 1;
if (/(走|转身|推门|靠近)/.test(text)) score += 1;
if (/(跑|追|开车|车|摔|扇|打|抢|逃)/.test(text)) score += 4;
if (/(打架|搏斗|爆炸|枪|刀|车祸|坠落|火灾)/.test(text)) score += 6;
if (/(多人|群像|人群)/.test(text)) score += 2;
return score;
}
private shotText(shot: StoryboardShot) {
return [
shot.scene_name,
shot.location_desc,
shot.visual_desc,
shot.action_desc,
shot.dialogue_text,
shot.narration_text,
shot.actor_action,
shot.performance_instruction
]
.filter(Boolean)
.join(' ');
}
private routeTierForScores(importanceScore: number, actionScore: number): AiRouteTier {
return importanceScore > 7 || actionScore > 5 ? 'premium' : 'normal';
}
private normalizeRouteTier(value: string | null): AiRouteTier | null {
return value === 'premium' || value === 'normal' ? value : null;
}
private normalizeSceneType(value: string | null) {
const normalized = this.normalizeText(value);
return normalized ? normalized.slice(0, 50) : null;
}
private clampScore(value: number) {
if (!Number.isFinite(value)) return 1;
return Math.max(1, Math.min(10, Math.round(value)));
}
private uniqueStrings(values: string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
private stringArray(value: unknown) {
return Array.isArray(value)
? value.map((item) => this.normalizeText(item)).filter((item): item is string => Boolean(item))
: [];
}
private normalizeText(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
private numberFromJson(value: unknown) {
const numberValue = Number(value ?? 0);
return Number.isFinite(numberValue) ? numberValue : 0;
}
private jsonObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Prisma } from '@prisma/client';
export const AI_ROUTER_CONFIG_KEY = 'ai.router.v1';
export const AI_ROUTER_DEFAULT_LANGUAGE = 'zh-CN';
export const DEFAULT_AI_ROUTER_CONFIG = {
version: 1,
enabled: true,
default_language: AI_ROUTER_DEFAULT_LANGUAGE,
daily_budget: 500,
live_action_video: {
'zh-CN': {
thresholds: {
premium_importance_gt: 7,
premium_action_gt: 5
},
normal: {
provider_code: 'minimax_hailuo_23_fast',
fallback_chain: ['minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video']
},
premium: {
provider_code: 'kling-image-to-video',
fallback_chain: ['kling-image-to-video', 'minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video']
}
}
}
} satisfies Prisma.InputJsonObject;
export type AiRouteTier = 'normal' | 'premium';
export interface AiRouterShotScores {
scene_type: string;
importance_score: number;
emotion_score: number;
action_score: number;
route_tier: AiRouteTier;
}
export interface AiRouteDecision {
config_key: string;
task_type: 'live_action_video_clip_generate';
language: string;
provider_code: string;
provider_id: string | null;
provider_mode: string | null;
route_tier: AiRouteTier;
fallback_chain: string[];
candidates: Array<{
provider_code: string;
status: 'selected' | 'skipped';
reason: string;
estimated_cost: number;
}>;
decision_reason: string;
estimated_cost: number;
manual_override: boolean;
scores: AiRouterShotScores;
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { AppController } from './app.controller';
describe('AppController', () => {
it('returns health status', () => {
const controller = new AppController();
expect(controller.getHealth()).toEqual({
status: 'ok',
service: 'backend-api'
});
});
});
+20
View File
@@ -0,0 +1,20 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get()
getRoot() {
return {
service: 'ai-manga-backend',
stage: 'stage-03-auth'
};
}
@Get('health')
getHealth() {
return {
status: 'ok',
service: 'backend-api'
};
}
}
+72
View File
@@ -0,0 +1,72 @@
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AssetsModule } from './assets/assets.module';
import { AuthModule } from './auth/auth.module';
import { BillingModule } from './billing/billing.module';
import { CharactersModule } from './characters/characters.module';
import { ApiCryptoController, ClientConfigController } from './common/api-crypto.controller';
import { ApiCryptoService } from './common/api-crypto.service';
import { AllExceptionsFilter } from './common/all-exceptions.filter';
import { ApiResponseInterceptor } from './common/api-response.interceptor';
import { EncryptedRequestMiddleware } from './common/encrypted-request.middleware';
import { RequestIdMiddleware } from './common/request-id.middleware';
import { SecureTransportMiddleware } from './common/secure-transport.middleware';
import { EpisodesModule } from './episodes/episodes.module';
import { ImagesModule } from './images/images.module';
import { LiveActionModule } from './live-action/live-action.module';
import { MediaModule } from './media/media.module';
import { MemoriesModule } from './memories/memories.module';
import { NovelsModule } from './novels/novels.module';
import { ProjectsModule } from './projects/projects.module';
import { ProvidersModule } from './providers/providers.module';
import { PrismaModule } from './prisma/prisma.module';
import { QueuesModule } from './queues/queues.module';
import { ReviewsModule } from './reviews/reviews.module';
import { ScriptsModule } from './scripts/scripts.module';
import { StoryBiblesModule } from './story-bibles/story-bibles.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
PrismaModule,
UsersModule,
AuthModule,
BillingModule,
ProjectsModule,
AssetsModule,
NovelsModule,
StoryBiblesModule,
CharactersModule,
MemoriesModule,
EpisodesModule,
ScriptsModule,
QueuesModule,
ProvidersModule,
ReviewsModule,
ImagesModule,
LiveActionModule,
MediaModule,
AdminModule
],
controllers: [AppController, ApiCryptoController, ClientConfigController],
providers: [
ApiCryptoService,
{
provide: APP_INTERCEPTOR,
useClass: ApiResponseInterceptor
},
{
provide: APP_FILTER,
useClass: AllExceptionsFilter
}
]
})
export class AppModule {
configure(consumer: import('@nestjs/common').MiddlewareConsumer) {
consumer
.apply(RequestIdMiddleware, SecureTransportMiddleware, EncryptedRequestMiddleware)
.forRoutes('*');
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { Asset } from '@prisma/client';
export type AssetType = 'novel_text' | 'image' | 'audio' | 'video' | 'document';
export interface StoredObject {
file_path: string;
size: bigint;
hash: string;
backend: 'local' | 'minio';
}
export interface SafeAsset {
id: string;
user_id: string | null;
project_id: string | null;
asset_type: string;
file_path: string;
mime_type: string | null;
width: number | null;
height: number | null;
duration: string | null;
size: string | null;
hash: string | null;
visibility: string;
status: string;
created_at: string;
}
export function toSafeAsset(asset: Asset): SafeAsset {
return {
id: asset.id.toString(),
user_id: asset.user_id?.toString() ?? null,
project_id: asset.project_id?.toString() ?? null,
asset_type: asset.asset_type,
file_path: asset.file_path,
mime_type: asset.mime_type,
width: asset.width,
height: asset.height,
duration: asset.duration?.toString() ?? null,
size: asset.size?.toString() ?? null,
hash: asset.hash,
visibility: asset.visibility,
status: asset.status,
created_at: asset.created_at.toISOString()
};
}
+163
View File
@@ -0,0 +1,163 @@
import {
BadRequestException,
Body,
Controller,
Get,
Inject,
Param,
Post,
Req,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import type { RequestWithApiCrypto } from '../common/api-crypto.service';
import { AssetsService } from './assets.service';
import { UploadAssetDto } from './upload.dto';
const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES);
function parseUploadLimitBytes(value: string | undefined, fallback: number) {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/.exec(normalized);
if (!match) return fallback;
const numberValue = Number(match[1]);
const unit = match[2] || 'b';
const multiplier =
unit === 'gb' ? 1024 * 1024 * 1024 :
unit === 'mb' ? 1024 * 1024 :
unit === 'kb' ? 1024 :
1;
return Number.isFinite(numberValue) && numberValue > 0
? Math.floor(numberValue * multiplier)
: fallback;
}
@Controller()
@UseGuards(JwtAuthGuard)
export class AssetsController {
constructor(@Inject(AssetsService) private readonly assetsService: AssetsService) {}
@Post('assets/upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES
}
})
)
uploadAsset(
@CurrentUser() user: AuthRequestUser,
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadAssetDto & Record<string, unknown>
) {
const uploadFile = file ?? this.fileFromEncryptedBody(dto);
return this.assetsService.uploadAsset(
user,
uploadFile,
dto.asset_type || 'document',
dto.project_id
);
}
@Post('projects/:projectId/novel/upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES
}
})
)
uploadNovel(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@UploadedFile() file: Express.Multer.File,
@Body() body: Record<string, unknown>
) {
return this.assetsService.uploadNovelFile(user, projectId, file ?? this.fileFromEncryptedBody(body));
}
@Get('assets/:assetId')
getAsset(@CurrentUser() user: AuthRequestUser, @Param('assetId') assetId: string) {
return this.assetsService.getAssetForUser(user, assetId);
}
@Get('assets/:assetId/download')
async downloadAsset(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Req() request: RequestWithApiCrypto,
@Res({ passthrough: true }) response: Response
) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
if (request.apiCrypto) {
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return {
filename: result.filename,
mime_type: result.asset.mime_type || 'application/octet-stream',
size: result.buffer.length,
content_base64: result.buffer.toString('base64')
};
}
response.setHeader('Content-Type', result.asset.mime_type || 'application/octet-stream');
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader(
'Content-Disposition',
`attachment; filename="${result.filename.replace(/"/g, '')}"`
);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return new StreamableFile(result.buffer);
}
private fileFromEncryptedBody(body: Record<string, unknown> | undefined) {
const filePayload = body?.file;
if (typeof filePayload !== 'object' || filePayload === null) {
throw new BadRequestException('Uploaded file is required');
}
const fileRecord = filePayload as Record<string, unknown>;
const originalName = String(fileRecord.original_name || fileRecord.name || 'upload.bin');
const mimeType = String(fileRecord.mime_type || 'application/octet-stream');
const contentBase64 = fileRecord.content_base64;
if (typeof contentBase64 !== 'string') {
throw new BadRequestException('Encrypted uploaded file content is required');
}
const buffer = Buffer.from(contentBase64, 'base64');
if (buffer.length > MAX_UPLOAD_BYTES) {
throw new BadRequestException('Uploaded file is too large');
}
return {
fieldname: 'file',
originalname: originalName,
encoding: '7bit',
mimetype: mimeType,
size: buffer.length,
buffer
} as Express.Multer.File;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProjectsModule } from '../projects/projects.module';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
import { PublicTempAssetsController } from './public-temp-assets.controller';
import { StorageService } from './storage.service';
@Module({
imports: [AuthModule, ProjectsModule],
controllers: [AssetsController, PublicTempAssetsController],
providers: [AssetsService, StorageService],
exports: [AssetsService, StorageService]
})
export class AssetsModule {}
+151
View File
@@ -0,0 +1,151 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AssetsService } from './assets.service';
import type { PrismaService } from '../prisma/prisma.service';
import type { ProjectsService } from '../projects/projects.service';
import type { StorageService } from './storage.service';
import type { AuthRequestUser } from '../auth/auth.types';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
function createFile(overrides: Partial<Express.Multer.File> = {}): Express.Multer.File {
return {
fieldname: 'file',
originalname: 'novel.txt',
encoding: '7bit',
mimetype: 'text/plain',
size: 12,
buffer: Buffer.from('hello novel'),
destination: '',
filename: '',
path: '',
stream: undefined as never,
...overrides
};
}
describe('AssetsService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn> };
asset: { create: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> };
};
let storage: Pick<StorageService, 'storePrivateFile' | 'readPrivateFile'>;
let projectsService: Pick<ProjectsService, 'assertProjectOwner'>;
let service: AssetsService;
beforeEach(() => {
prisma = {
project: {
findUnique: vi.fn()
},
asset: {
create: vi.fn(),
findUnique: vi.fn()
}
};
storage = {
storePrivateFile: vi.fn().mockResolvedValue({
file_path: 'local://novels/test.txt',
size: 12n,
hash: 'hash',
backend: 'local'
}),
readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('video bytes'))
};
projectsService = {
assertProjectOwner: vi.fn().mockResolvedValue(100n)
};
service = new AssetsService(
prisma as unknown as PrismaService,
storage as StorageService,
projectsService as ProjectsService
);
});
it('stores novel uploads as private assets', async () => {
prisma.asset.create.mockResolvedValue({
id: 200n,
user_id: 1n,
project_id: 100n,
asset_type: 'novel_text',
file_path: 'local://novels/test.txt',
file_url: null,
mime_type: 'text/plain',
width: null,
height: null,
duration: null,
size: 12n,
hash: 'hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.uploadNovelFile(user, '100', createFile());
expect(storage.storePrivateFile).toHaveBeenCalledWith(
expect.objectContaining({ originalname: 'novel.txt' }),
'novels'
);
expect(prisma.asset.create).toHaveBeenCalledWith({
data: expect.objectContaining({
asset_type: 'novel_text',
visibility: 'private',
file_url: null
})
});
expect(result.asset.visibility).toBe('private');
expect(result.next_step).toBe('copyright_confirm');
});
it('rejects unsupported novel file types', async () => {
await expect(
service.uploadNovelFile(
user,
'100',
createFile({ originalname: 'novel.exe', mimetype: 'application/octet-stream' })
)
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects uploads to projects owned by others', async () => {
vi.mocked(projectsService.assertProjectOwner).mockRejectedValue(
new ForbiddenException('Project is private')
);
await expect(service.uploadNovelFile(user, '100', createFile())).rejects.toBeInstanceOf(
ForbiddenException
);
});
it('returns a private file buffer for owned assets', async () => {
prisma.asset.findUnique.mockResolvedValue({
id: 300n,
user_id: 1n,
project_id: 100n,
asset_type: 'video',
file_path: 'local://videos/final.mp4',
file_url: null,
mime_type: 'video/mp4',
width: 1080,
height: 1920,
duration: 4,
size: 11n,
hash: 'video-hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.downloadAssetForUser(user, '300');
expect(storage.readPrivateFile).toHaveBeenCalledWith('local://videos/final.mp4');
expect(result.asset.id).toBe('300');
expect(result.filename).toBe('video-300.mp4');
expect(result.buffer.toString()).toBe('video bytes');
});
});
+192
View File
@@ -0,0 +1,192 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { AuthRequestUser } from '../auth/auth.types';
import { ProjectsService } from '../projects/projects.service';
import { toSafeAsset, type AssetType } from './asset.types';
import { StorageService } from './storage.service';
const ALLOWED_NOVEL_MIME_TYPES = new Set([
'text/plain',
'text/markdown',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/octet-stream'
]);
@Injectable()
export class AssetsService {
constructor(
@Inject(PrismaService)
private readonly prisma: PrismaService,
@Inject(StorageService)
private readonly storage: StorageService,
@Inject(ProjectsService)
private readonly projectsService: ProjectsService
) {}
async uploadAsset(
user: AuthRequestUser,
file: Express.Multer.File,
assetType: AssetType = 'document',
projectId?: string
) {
const projectBigInt = projectId
? await this.projectsService.assertProjectOwner(projectId, user)
: null;
const stored = await this.storage.storePrivateFile(file, assetType);
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(user.id),
project_id: projectBigInt,
asset_type: assetType,
file_path: stored.file_path,
file_url: null,
mime_type: file.mimetype || null,
size: stored.size,
hash: stored.hash,
visibility: 'private',
status: 'active'
}
});
return {
asset: toSafeAsset(asset),
storage_backend: stored.backend
};
}
async uploadNovelFile(
user: AuthRequestUser,
projectId: string,
file: Express.Multer.File
) {
this.validateNovelFile(file);
const projectBigInt = await this.projectsService.assertProjectOwner(projectId, user);
const stored = await this.storage.storePrivateFile(file, 'novels');
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(user.id),
project_id: projectBigInt,
asset_type: 'novel_text',
file_path: stored.file_path,
file_url: null,
mime_type: file.mimetype || 'text/plain',
size: stored.size,
hash: stored.hash,
visibility: 'private',
status: 'active'
}
});
return {
asset: toSafeAsset(asset),
storage_backend: stored.backend,
next_step: 'copyright_confirm'
};
}
async getAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
return toSafeAsset(asset);
}
async downloadAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const buffer = await this.storage.readPrivateFile(asset.file_path);
return {
asset: toSafeAsset(asset),
buffer,
filename: this.buildDownloadFilename(asset)
};
}
private async findAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
throw new NotFoundException('Asset not found');
}
return asset;
}
private buildDownloadFilename(asset: Asset) {
const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path);
const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset';
return `${safeType}-${asset.id.toString()}${extension}`;
}
private extensionFromPath(filePath: string) {
const match = /\.([a-z0-9]+)$/i.exec(filePath);
return match ? `.${match[1].toLowerCase()}` : '';
}
private validateNovelFile(file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('Novel file is required');
}
const lowerName = file.originalname.toLowerCase();
const hasAllowedExtension =
lowerName.endsWith('.txt') ||
lowerName.endsWith('.md') ||
lowerName.endsWith('.docx') ||
lowerName.endsWith('.pdf');
if (!hasAllowedExtension || !ALLOWED_NOVEL_MIME_TYPES.has(file.mimetype)) {
throw new BadRequestException('Only txt, md, docx, and text pdf novel files are supported now');
}
}
private parseId(id: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException('Invalid id');
}
}
private extensionFromMime(mimeType: string | null | undefined) {
switch (mimeType) {
case 'video/mp4':
return '.mp4';
case 'audio/wav':
case 'audio/x-wav':
return '.wav';
case 'audio/mpeg':
return '.mp3';
case 'application/x-subrip':
return '.srt';
case 'image/svg+xml':
return '.svg';
case 'image/png':
return '.png';
case 'image/jpeg':
return '.jpg';
case 'text/plain':
return '.txt';
case 'text/markdown':
return '.md';
case 'application/pdf':
return '.pdf';
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return '.docx';
default:
return '';
}
}
}
@@ -0,0 +1,24 @@
import { Controller, Get, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import type { Response } from 'express';
import { StorageService } from './storage.service';
@Controller('public-temp-assets')
export class PublicTempAssetsController {
constructor(@Inject(StorageService) private readonly storage: StorageService) {}
@Get(':token')
async downloadTemporaryAsset(
@Param('token') token: string,
@Res({ passthrough: true }) response: Response
) {
const result = await this.storage.readTemporaryPublicFile(token);
response.setHeader('Content-Type', result.mimeType);
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader('Content-Disposition', 'inline');
response.setHeader('Cache-Control', 'private, max-age=0, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
}
}
@@ -0,0 +1,43 @@
import { BadRequestException } from '@nestjs/common';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StorageService } from './storage.service';
describe('StorageService temporary public URLs', () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.PUBLIC_ASSET_BASE_URL;
delete process.env.PUBLIC_ASSET_SIGNING_SECRET;
});
it('creates a signed temporary URL and reads the private object through the token', async () => {
process.env.PUBLIC_ASSET_BASE_URL = 'https://api.example.com';
process.env.PUBLIC_ASSET_SIGNING_SECRET = 'test-public-asset-secret';
const service = new StorageService();
const readSpy = vi.spyOn(service, 'readPrivateFile').mockResolvedValue(Buffer.from('video-bytes'));
const url = service.createTemporaryPublicUrl({
filePath: 'local://live-action-video-clips/source.mp4',
mimeType: 'video/mp4',
expiresInSeconds: 600
});
const token = new URL(url).pathname.split('/').pop() || '';
const result = await service.readTemporaryPublicFile(decodeURIComponent(token));
expect(url).toMatch(/^https:\/\/api\.example\.com\/api\/public-temp-assets\//);
expect(readSpy).toHaveBeenCalledWith('local://live-action-video-clips/source.mp4');
expect(result.mimeType).toBe('video/mp4');
expect(result.buffer.toString()).toBe('video-bytes');
});
it('requires a public base URL before minting temporary links', () => {
process.env.PUBLIC_ASSET_SIGNING_SECRET = 'test-public-asset-secret';
const service = new StorageService();
expect(() =>
service.createTemporaryPublicUrl({
filePath: 'local://live-action-video-clips/source.mp4',
mimeType: 'video/mp4'
})
).toThrow(BadRequestException);
});
});
+284
View File
@@ -0,0 +1,284 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
import type { Readable } from 'node:stream';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { Client } from 'minio';
import type { StoredObject } from './asset.types';
type TemporaryPublicFilePayload = {
file_path: string;
mime_type: string;
expires_at: number;
nonce: string;
};
@Injectable()
export class StorageService {
private readonly root = this.resolveLocalRoot(process.env.LOCAL_STORAGE_ROOT || '../storage');
private readonly privateBucket =
process.env.MINIO_BUCKET_PRIVATE || 'ai-manga-private';
async storePrivateFile(file: Express.Multer.File, prefix: string): Promise<StoredObject> {
if (!file?.buffer?.length) {
throw new BadRequestException('Uploaded file is empty');
}
if (this.shouldUseMinio()) {
return this.storeWithMinio(file, prefix);
}
return this.storeLocally(file, prefix);
}
async readPrivateFile(filePath: string): Promise<Buffer> {
if (filePath.startsWith('local://')) {
return this.readLocalObject(filePath);
}
if (filePath.startsWith('minio://')) {
return this.readMinioObject(filePath);
}
throw new BadRequestException('Unsupported storage path');
}
createTemporaryPublicUrl(input: {
filePath: string;
mimeType?: string | null;
expiresInSeconds?: number | null;
}) {
const baseUrl = this.resolvePublicAssetBaseUrl();
const expiresInSeconds = this.normalizeTemporaryUrlExpires(input.expiresInSeconds);
const payload: TemporaryPublicFilePayload = {
file_path: input.filePath,
mime_type: input.mimeType || 'application/octet-stream',
expires_at: Math.floor(Date.now() / 1000) + expiresInSeconds,
nonce: randomUUID()
};
const payloadPart = this.base64UrlEncode(Buffer.from(JSON.stringify(payload), 'utf8'));
const signature = this.signTemporaryPublicPayload(payloadPart);
const token = `${payloadPart}.${signature}`;
return `${baseUrl}/public-temp-assets/${encodeURIComponent(token)}`;
}
async readTemporaryPublicFile(token: string) {
const payload = this.verifyTemporaryPublicToken(token);
return {
buffer: await this.readPrivateFile(payload.file_path),
mimeType: payload.mime_type || 'application/octet-stream',
filePath: payload.file_path,
expiresAt: payload.expires_at
};
}
private async storeLocally(
file: Express.Multer.File,
prefix: string
): Promise<StoredObject> {
const safePrefix = prefix.replace(/[^a-z0-9/_-]/gi, '_');
const extension = extname(file.originalname || '') || this.extensionFromMime(file.mimetype);
const hash = createHash('sha256').update(file.buffer).digest('hex');
const objectName = `${safePrefix}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${extension}`;
const fullPath = join(this.root, 'private', objectName);
await mkdir(join(this.root, 'private', safePrefix), { recursive: true });
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, file.buffer);
return {
file_path: `local://${objectName}`,
size: BigInt(file.size),
hash,
backend: 'local'
};
}
private async storeWithMinio(
file: Express.Multer.File,
prefix: string
): Promise<StoredObject> {
const client = new Client({
endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1',
port: Number(process.env.MINIO_PORT || 9000),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const exists = await client.bucketExists(this.privateBucket).catch(() => false);
if (!exists) {
await client.makeBucket(this.privateBucket);
}
const extension = extname(file.originalname || '') || this.extensionFromMime(file.mimetype);
const hash = createHash('sha256').update(file.buffer).digest('hex');
const objectName = `${prefix}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${extension}`;
await client.putObject(this.privateBucket, objectName, file.buffer, file.size, {
'Content-Type': file.mimetype
});
return {
file_path: `minio://${this.privateBucket}/${objectName}`,
size: BigInt(file.size),
hash,
backend: 'minio'
};
}
private async readLocalObject(filePath: string) {
const objectName = filePath.replace(/^local:\/\//, '');
if (!objectName || objectName.includes('..')) {
throw new BadRequestException('Invalid local storage path');
}
return readFile(join(this.root, 'private', objectName));
}
private async readMinioObject(filePath: string) {
const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath);
if (!match) {
throw new BadRequestException('Invalid MinIO storage path');
}
const [, bucket, objectName] = match;
const client = new Client({
endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1',
port: Number(process.env.MINIO_PORT || 9000),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
}
private async streamToBuffer(stream: Readable) {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
private shouldUseMinio() {
return process.env.STORAGE_DRIVER === 'minio';
}
private resolvePublicAssetBaseUrl() {
const configured =
process.env.PUBLIC_ASSET_BASE_URL ||
process.env.PUBLIC_API_BASE_URL ||
process.env.API_PUBLIC_BASE_URL ||
process.env.APP_PUBLIC_URL ||
process.env.PUBLIC_BASE_URL ||
'';
const normalized = configured.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(normalized)) {
throw new BadRequestException('PUBLIC_ASSET_BASE_URL_REQUIRED');
}
return normalized.endsWith('/api') ? normalized : `${normalized}/api`;
}
private normalizeTemporaryUrlExpires(value: number | null | undefined) {
const numeric = Number(value ?? process.env.PUBLIC_ASSET_URL_EXPIRES_SECONDS ?? 3600);
if (!Number.isFinite(numeric)) return 3600;
return Math.min(Math.max(Math.round(numeric), 60), 24 * 60 * 60);
}
private verifyTemporaryPublicToken(token: string): TemporaryPublicFilePayload {
const [payloadPart, signature] = String(token || '').split('.');
if (!payloadPart || !signature) {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_TOKEN');
}
const expected = this.signTemporaryPublicPayload(payloadPart);
if (!this.safeEqualBase64Url(signature, expected)) {
throw new ForbiddenException('INVALID_TEMP_PUBLIC_ASSET_SIGNATURE');
}
let payload: TemporaryPublicFilePayload;
try {
payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')) as TemporaryPublicFilePayload;
} catch {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_PAYLOAD');
}
if (!payload.file_path || typeof payload.file_path !== 'string') {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_PATH');
}
if (!Number.isFinite(payload.expires_at) || payload.expires_at < Math.floor(Date.now() / 1000)) {
throw new ForbiddenException('TEMP_PUBLIC_ASSET_EXPIRED');
}
return payload;
}
private signTemporaryPublicPayload(payloadPart: string) {
return this.base64UrlEncode(
createHmac('sha256', this.resolveTemporaryPublicAssetSecret())
.update(payloadPart)
.digest()
);
}
private resolveTemporaryPublicAssetSecret() {
const secret =
process.env.PUBLIC_ASSET_SIGNING_SECRET ||
process.env.TEMP_PUBLIC_ASSET_SECRET ||
process.env.JWT_SECRET ||
'';
if (!secret || secret.length < 16) {
throw new BadRequestException('PUBLIC_ASSET_SIGNING_SECRET_REQUIRED');
}
return secret;
}
private safeEqualBase64Url(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
private base64UrlEncode(buffer: Buffer) {
return buffer.toString('base64url');
}
private resolveLocalRoot(root: string) {
if (isAbsolute(root)) return root;
// Resolve relative storage roots from the backend package directory so
// starting the server from repo root or backend/ cannot split local files.
return resolve(__dirname, '../..', root);
}
private extensionFromMime(mimeType: string | undefined) {
switch (mimeType) {
case 'text/plain':
return '.txt';
case 'text/markdown':
return '.md';
case 'application/pdf':
return '.pdf';
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return '.docx';
default:
return '';
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { AssetType } from './asset.types';
export class UploadAssetDto {
asset_type?: AssetType;
project_id?: string;
}
+44
View File
@@ -0,0 +1,44 @@
import { Body, Controller, Get, Inject, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CurrentUser } from './current-user.decorator';
import { LoginDto, RegisterDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import type { AuthRequestUser } from './auth.types';
@Controller('auth')
export class AuthController {
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
logout() {
return { logged_out: true };
}
@Get('profile')
@UseGuards(JwtAuthGuard)
profile(@CurrentUser() user: AuthRequestUser) {
return this.authService.getProfile(user);
}
}
@Controller()
export class ProfileController {
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Get('profile')
@UseGuards(JwtAuthGuard)
profile(@CurrentUser() user: AuthRequestUser) {
return this.authService.getProfile(user);
}
}
+10
View File
@@ -0,0 +1,10 @@
export class RegisterDto {
email?: string;
password?: string;
nickname?: string;
}
export class LoginDto {
email?: string;
password?: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
import { UsersModule } from '../users/users.module';
import { AuthController, ProfileController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
const jwtExpiresIn = (process.env.JWT_EXPIRES_IN ?? '7d') as JwtSignOptions['expiresIn'];
@Module({
imports: [
UsersModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? 'dev_only_change_me',
signOptions: {
expiresIn: jwtExpiresIn
}
})
],
controllers: [AuthController, ProfileController],
providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard, JwtModule]
})
export class AuthModule {}
+129
View File
@@ -0,0 +1,129 @@
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from './auth.service';
import type { SafeUser } from '../users/user.types';
import type { UsersService } from '../users/users.service';
const safeUser: SafeUser = {
id: '1',
email: 'user@example.com',
phone: null,
nickname: 'User',
avatar_url: null,
role: 'user',
status: 'active',
wechat_openid: null,
created_at: '2026-05-31T00:00:00.000Z'
};
function createPrismaUser(passwordHash: string) {
return {
id: 1n,
email: 'user@example.com',
phone: null,
password_hash: passwordHash,
nickname: 'User',
avatar_url: null,
role: 'user',
status: 'active',
wechat_openid: null,
last_login_at: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
};
}
describe('AuthService', () => {
let usersService: Pick<
UsersService,
'findByEmail' | 'findById' | 'createUser' | 'toSafeUser'
>;
let jwtService: Pick<JwtService, 'sign'>;
let service: AuthService;
beforeEach(() => {
usersService = {
findByEmail: vi.fn(),
findById: vi.fn(),
createUser: vi.fn(),
toSafeUser: vi.fn()
};
jwtService = {
sign: vi.fn(() => 'signed.jwt.token')
};
service = new AuthService(usersService as UsersService, jwtService as JwtService);
});
it('registers an active user and returns a token', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
vi.mocked(usersService.createUser).mockResolvedValue(safeUser);
const result = await service.register({
email: ' USER@example.com ',
password: 'password123',
nickname: 'User'
});
expect(usersService.findByEmail).toHaveBeenCalledWith('user@example.com');
expect(usersService.createUser).toHaveBeenCalledWith(
expect.objectContaining({
email: 'user@example.com',
nickname: 'User'
})
);
expect(result).toMatchObject({
access_token: 'signed.jwt.token',
token_type: 'Bearer',
user: safeUser
});
});
it('rejects duplicate email registration', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(createPrismaUser('hash'));
await expect(
service.register({
email: 'user@example.com',
password: 'password123'
})
).rejects.toBeInstanceOf(ConflictException);
});
it('logs in with a valid password', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
vi.mocked(usersService.createUser).mockResolvedValue(safeUser);
const registered = await service.register({
email: 'user@example.com',
password: 'password123'
});
const passwordHash = vi.mocked(usersService.createUser).mock.calls[0]?.[0]
.password_hash;
expect(registered.access_token).toBe('signed.jwt.token');
vi.mocked(usersService.findByEmail).mockResolvedValue(
createPrismaUser(passwordHash)
);
vi.mocked(usersService.toSafeUser).mockReturnValue(safeUser);
const result = await service.login({
email: 'user@example.com',
password: 'password123'
});
expect(result.user).toEqual(safeUser);
});
it('rejects invalid login credentials', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
await expect(
service.login({
email: 'user@example.com',
password: 'password123'
})
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
+110
View File
@@ -0,0 +1,110 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
UnauthorizedException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { compare, hash } from 'bcryptjs';
import { UsersService } from '../users/users.service';
import type { LoginDto, RegisterDto } from './auth.dto';
import type { AuthResult, AuthRequestUser, JwtPayload } from './auth.types';
const PASSWORD_MIN_LENGTH = 8;
@Injectable()
export class AuthService {
constructor(
@Inject(UsersService)
private readonly usersService: UsersService,
@Inject(JwtService)
private readonly jwtService: JwtService
) {}
async register(dto: RegisterDto): Promise<AuthResult> {
const email = this.normalizeEmail(dto.email);
const password = this.validatePassword(dto.password);
const existing = await this.usersService.findByEmail(email);
if (existing) {
throw new ConflictException('Email already registered');
}
const passwordHash = await hash(password, 12);
const user = await this.usersService.createUser({
email,
password_hash: passwordHash,
nickname: this.normalizeOptionalText(dto.nickname)
});
return this.createAuthResult(user);
}
async login(dto: LoginDto): Promise<AuthResult> {
const email = this.normalizeEmail(dto.email);
const password = this.validatePassword(dto.password);
const user = await this.usersService.findByEmail(email);
if (!user || user.status !== 'active') {
throw new UnauthorizedException('Invalid email or password');
}
const passwordMatches = await compare(password, user.password_hash);
if (!passwordMatches) {
throw new UnauthorizedException('Invalid email or password');
}
return this.createAuthResult(this.usersService.toSafeUser(user));
}
async getProfile(currentUser: AuthRequestUser) {
const user = await this.usersService.findById(currentUser.id);
if (!user || user.status !== 'active') {
throw new UnauthorizedException('User is unavailable');
}
return this.usersService.toSafeUser(user);
}
private createAuthResult(user: AuthResult['user']): AuthResult {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role
};
return {
access_token: this.jwtService.sign(payload),
token_type: 'Bearer',
expires_in: process.env.JWT_EXPIRES_IN ?? '7d',
user
};
}
private normalizeEmail(email: string | undefined) {
const value = email?.trim().toLowerCase();
if (!value || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new BadRequestException('Valid email is required');
}
return value;
}
private validatePassword(password: string | undefined) {
if (!password || password.length < PASSWORD_MIN_LENGTH) {
throw new BadRequestException(
`Password must be at least ${PASSWORD_MIN_LENGTH} characters`
);
}
return password;
}
private normalizeOptionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || undefined;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { SafeUser } from '../users/user.types';
export interface JwtPayload {
sub: string;
email: string | null;
role: string;
}
export interface AuthRequestUser {
id: string;
email: string | null;
role: string;
}
export interface AuthResult {
access_token: string;
token_type: 'Bearer';
expires_in: string;
user: SafeUser;
}
@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthRequestUser } from './auth.types';
interface RequestWithUser {
user?: AuthRequestUser;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<RequestWithUser>();
return request.user;
}
);
+51
View File
@@ -0,0 +1,51 @@
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { describe, expect, it, vi } from 'vitest';
import { JwtAuthGuard } from './jwt-auth.guard';
function createContext(headers: Record<string, string | undefined>) {
const request = { headers };
return {
request,
context: {
switchToHttp: () => ({
getRequest: () => request
})
}
};
}
describe('JwtAuthGuard', () => {
it('attaches user payload for valid bearer tokens', async () => {
const jwtService = {
verifyAsync: vi.fn().mockResolvedValue({
sub: '1',
email: 'user@example.com',
role: 'user'
})
};
const guard = new JwtAuthGuard(jwtService as unknown as JwtService);
const { context, request } = createContext({
authorization: 'Bearer valid-token'
});
await expect(guard.canActivate(context as never)).resolves.toBe(true);
expect(request).toMatchObject({
user: {
id: '1',
email: 'user@example.com',
role: 'user'
}
});
});
it('rejects missing bearer tokens', async () => {
const guard = new JwtAuthGuard({ verifyAsync: vi.fn() } as unknown as JwtService);
const { context } = createContext({});
await expect(guard.canActivate(context as never)).rejects.toBeInstanceOf(
UnauthorizedException
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { AuthRequestUser, JwtPayload } from './auth.types';
interface AuthenticatedRequest {
headers: Record<string, string | string[] | undefined>;
user?: AuthRequestUser;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(@Inject(JwtService) private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.extractToken(request.headers.authorization);
if (!token) {
throw new UnauthorizedException('Missing bearer token');
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
request.user = {
id: payload.sub,
email: payload.email,
role: payload.role
};
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(authorization: string | string[] | undefined) {
const header = Array.isArray(authorization) ? authorization[0] : authorization;
if (!header) {
return null;
}
const [type, token] = header.split(' ');
return type?.toLowerCase() === 'bearer' && token ? token : null;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ForbiddenException } from '@nestjs/common';
import type { AuthRequestUser } from './auth.types';
export const ADMIN_PERMISSIONS = [
'admin:read',
'projects:write',
'users:read',
'users:write',
'billing:read',
'billing:write',
'reviews:read',
'reviews:write',
'tasks:read',
'tasks:write',
'providers:read',
'providers:write',
'costs:read',
'settings:read',
'settings:write',
'audit:read',
'audit:export'
] as const;
export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number];
const ROLE_PERMISSIONS: Record<string, readonly AdminPermission[] | '*'> = {
admin: '*',
operator: [
'admin:read',
'projects:write',
'users:read',
'billing:read',
'reviews:read',
'reviews:write',
'tasks:read',
'tasks:write',
'providers:read',
'costs:read',
'audit:read'
],
finance: [
'admin:read',
'users:read',
'billing:read',
'billing:write',
'tasks:read',
'costs:read',
'audit:read',
'audit:export'
],
auditor: [
'admin:read',
'users:read',
'billing:read',
'reviews:read',
'tasks:read',
'providers:read',
'costs:read',
'settings:read',
'audit:read',
'audit:export'
]
};
export function permissionsForRole(role: string) {
const permissions = ROLE_PERMISSIONS[role];
return permissions === '*' ? [...ADMIN_PERMISSIONS] : [...(permissions ?? [])];
}
export function hasPermission(user: AuthRequestUser, permission: AdminPermission) {
return permissionsForRole(user.role).includes(permission);
}
export function assertPermission(user: AuthRequestUser, permission: AdminPermission) {
if (!hasPermission(user, permission)) {
throw new ForbiddenException(`Permission required: ${permission}`);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
import type { AuthRequestUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
AdminAdjustQuotaDto,
AdminGrantQuotaDto,
AdminListQuotaAccountsQueryDto,
CreateOrderDto,
FreezeProjectQuotaDto,
ListOrdersQueryDto,
ListQuotaLogsQueryDto,
ReleaseProjectQuotaDto
} from './billing.dto';
import { BillingService } from './billing.service';
@Controller()
export class BillingController {
constructor(@Inject(BillingService) private readonly billingService: BillingService) {}
@Get('billing/packages')
listPackages() {
return this.billingService.listPackages();
}
@Get('billing/quota')
@UseGuards(JwtAuthGuard)
getQuota(@CurrentUser() user: AuthRequestUser) {
return this.billingService.getQuotaAccount(user);
}
@Get('billing/quota/logs')
@UseGuards(JwtAuthGuard)
listQuotaLogs(
@CurrentUser() user: AuthRequestUser,
@Query() query: ListQuotaLogsQueryDto
) {
return this.billingService.listQuotaLogs(user, query);
}
@Get('billing/orders')
@UseGuards(JwtAuthGuard)
listOrders(@CurrentUser() user: AuthRequestUser, @Query() query: ListOrdersQueryDto) {
return this.billingService.listMyOrders(user, query);
}
@Post('billing/orders')
@UseGuards(JwtAuthGuard)
createOrder(@CurrentUser() user: AuthRequestUser, @Body() dto: CreateOrderDto) {
return this.billingService.createOrder(user, dto);
}
@Post('billing/orders/:orderId/mock-pay')
@UseGuards(JwtAuthGuard)
mockPayOrder(@CurrentUser() user: AuthRequestUser, @Param('orderId') orderId: string) {
return this.billingService.mockPayOrder(user, orderId);
}
@Get('projects/:projectId/quota/estimate')
@UseGuards(JwtAuthGuard)
estimateProjectQuota(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.billingService.estimateProjectQuota(user, projectId);
}
@Post('projects/:projectId/quota/freeze')
@UseGuards(JwtAuthGuard)
freezeProjectQuota(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: FreezeProjectQuotaDto
) {
return this.billingService.freezeProjectQuota(user, projectId, dto);
}
@Post('projects/:projectId/quota/release')
@UseGuards(JwtAuthGuard)
releaseProjectQuota(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ReleaseProjectQuotaDto
) {
return this.billingService.releaseProjectQuota(user, projectId, dto);
}
@Get('admin/orders')
@UseGuards(JwtAuthGuard)
listAdminOrders(@CurrentUser() user: AuthRequestUser, @Query() query: ListOrdersQueryDto) {
return this.billingService.listAdminOrders(user, query);
}
@Get('admin/quota-accounts')
@UseGuards(JwtAuthGuard)
listAdminQuotaAccounts(
@CurrentUser() user: AuthRequestUser,
@Query() query: AdminListQuotaAccountsQueryDto
) {
return this.billingService.listAdminQuotaAccounts(user, query);
}
@Post('admin/users/:userId/quota/grant')
@UseGuards(JwtAuthGuard)
adminGrantQuota(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminGrantQuotaDto
) {
return this.billingService.adminGrantQuota(user, userId, dto);
}
@Post('admin/users/:userId/quota/adjust')
@UseGuards(JwtAuthGuard)
adminAdjustQuota(
@CurrentUser() user: AuthRequestUser,
@Param('userId') userId: string,
@Body() dto: AdminAdjustQuotaDto
) {
return this.billingService.adminAdjustQuota(user, userId, dto);
}
}
+41
View File
@@ -0,0 +1,41 @@
export class CreateOrderDto {
package_code?: string;
project_id?: string;
payment_method?: string;
}
export class ListOrdersQueryDto {
payment_status?: string;
limit?: string;
}
export class ListQuotaLogsQueryDto {
project_id?: string;
change_type?: string;
limit?: string;
}
export class FreezeProjectQuotaDto {
amount?: number;
reason?: string;
}
export class ReleaseProjectQuotaDto {
reason?: string;
}
export class AdminGrantQuotaDto {
amount?: number;
reason?: string;
}
export class AdminAdjustQuotaDto {
delta?: number;
reason?: string;
}
export class AdminListQuotaAccountsQueryDto {
user_id?: string;
status?: string;
limit?: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService]
})
export class BillingModule {}
+278
View File
@@ -0,0 +1,278 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import { BillingService } from './billing.service';
const now = new Date('2026-05-31T00:00:00.000Z');
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const admin: AuthRequestUser = {
id: '9',
email: 'admin@example.com',
role: 'admin'
};
function createProject(overrides: Record<string, unknown> = {}) {
return {
id: 10n,
user_id: 1n,
title: '额度项目',
input_mode: 'ai_original',
genre: 'urban',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 1,
episode_duration: 60,
status: 'storyboard_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createAccount(overrides: Record<string, unknown> = {}) {
return {
id: 20n,
user_id: 1n,
total_quota: new Prisma.Decimal(120),
available_quota: new Prisma.Decimal(120),
frozen_quota: new Prisma.Decimal(0),
used_quota: new Prisma.Decimal(0),
status: 'active',
created_at: now,
updated_at: now,
...overrides
};
}
function createOrder(overrides: Record<string, unknown> = {}) {
return {
id: 30n,
user_id: 1n,
project_id: null,
order_no: 'ORDTEST',
package_code: 'standard_3ep',
amount: new Prisma.Decimal(199),
currency: 'CNY',
payment_method: 'mock_pay',
payment_status: 'pending',
paid_at: null,
created_at: now,
updated_at: now,
...overrides
};
}
function createLog(overrides: Record<string, unknown> = {}) {
return {
id: 40n,
user_id: 1n,
project_id: 10n,
task_id: null,
change_type: 'freeze',
amount: new Prisma.Decimal(71),
balance_after: new Prisma.Decimal(49),
reason: 'project_generation_freeze',
metadata_json: {},
created_at: now,
...overrides
};
}
describe('BillingService', () => {
let prisma: any;
let service: BillingService;
beforeEach(() => {
prisma = {
$transaction: vi.fn((handler) => handler(prisma)),
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
createProject(data)
)
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: 1n })
},
order: {
create: vi.fn().mockResolvedValue(createOrder()),
findUnique: vi.fn().mockResolvedValue(createOrder()),
findMany: vi.fn().mockResolvedValue([createOrder()]),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
createOrder(data)
)
},
quotaAccount: {
upsert: vi.fn().mockResolvedValue(createAccount()),
findMany: vi.fn().mockResolvedValue([createAccount()]),
update: vi.fn(async ({ data }: { data: Record<string, Prisma.Decimal> }) =>
createAccount(data)
)
},
quotaLog: {
create: vi.fn().mockResolvedValue(createLog()),
findMany: vi.fn().mockResolvedValue([createLog()])
},
operationLog: {
create: vi.fn().mockResolvedValue({
id: 50n,
user_id: 9n,
operator_role: 'admin',
action: 'admin_adjust_quota',
target_type: 'user',
target_id: 1n,
ip: null,
user_agent: null,
metadata_json: {},
created_at: now
})
}
};
service = new BillingService(prisma as PrismaService);
});
it('lists available billing packages', () => {
const result = service.listPackages();
expect(result.packages.some((pkg) => pkg.code === 'standard_3ep')).toBe(true);
});
it('creates a pending order for a package', async () => {
const result = await service.createOrder(user, { package_code: 'standard_3ep' });
expect(prisma.order.create).toHaveBeenCalledWith({
data: expect.objectContaining({
user_id: 1n,
package_code: 'standard_3ep',
payment_status: 'pending'
})
});
expect(result.order.payment_status).toBe('pending');
});
it('mock pays an order and recharges quota', async () => {
const result = await service.mockPayOrder(user, '30');
expect(prisma.quotaAccount.update).toHaveBeenCalledWith({
where: { user_id: 1n },
data: expect.objectContaining({
total_quota: expect.any(Prisma.Decimal),
available_quota: expect.any(Prisma.Decimal)
})
});
expect(prisma.quotaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
change_type: 'recharge'
})
});
expect(result.order.payment_status).toBe('paid');
});
it('freezes project quota and marks project payment as frozen', async () => {
const result = await service.freezeProjectQuota(user, '10');
expect(prisma.quotaAccount.update).toHaveBeenCalledWith({
where: { user_id: 1n },
data: expect.objectContaining({
available_quota: expect.any(Prisma.Decimal),
frozen_quota: expect.any(Prisma.Decimal)
})
});
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { payment_status: 'quota_frozen' }
});
expect(result.project_payment_status).toBe('quota_frozen');
});
it('rejects freezing when available quota is insufficient', async () => {
prisma.quotaAccount.upsert.mockResolvedValue(
createAccount({ available_quota: new Prisma.Decimal(1) })
);
await expect(service.freezeProjectQuota(user, '10')).rejects.toBeInstanceOf(
BadRequestException
);
});
it('rejects formal render when project quota is not frozen', async () => {
await expect(service.ensureProjectQuotaReserved(createProject())).rejects.toBeInstanceOf(
BadRequestException
);
});
it('admin grants quota and writes an operation log', async () => {
const result = await service.adminGrantQuota(admin, '1', {
amount: 20,
reason: 'internal test'
});
expect(result.account.available_quota).toBe(140);
expect(prisma.quotaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
change_type: 'admin_grant',
amount: expect.any(Prisma.Decimal),
reason: 'internal test'
})
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'admin_grant_quota',
target_type: 'user',
target_id: 1n
})
});
});
it('admin deducts quota through correction and preserves audit logs', async () => {
const result = await service.adminAdjustQuota(admin, '1', {
delta: -10,
reason: 'wrong manual grant'
});
const updateData = prisma.quotaAccount.update.mock.calls[0][0].data;
expect(updateData.total_quota.toString()).toBe('110');
expect(updateData.available_quota.toString()).toBe('110');
expect(result.account.available_quota).toBe(110);
expect(prisma.quotaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
change_type: 'admin_correction_deduct',
amount: expect.any(Prisma.Decimal),
reason: 'wrong manual grant'
})
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'admin_adjust_quota',
metadata_json: expect.objectContaining({
delta: -10,
reason: 'wrong manual grant'
})
})
});
});
it('rejects admin quota deduction when available quota is insufficient', async () => {
prisma.quotaAccount.upsert.mockResolvedValue(
createAccount({ available_quota: new Prisma.Decimal(1) })
);
await expect(
service.adminAdjustQuota(admin, '1', { delta: -10, reason: 'correction' })
).rejects.toBeInstanceOf(BadRequestException);
});
});
+749
View File
@@ -0,0 +1,749 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import { Prisma, type Project } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import type { AuthRequestUser } from '../auth/auth.types';
import { assertPermission } from '../auth/rbac';
import { PrismaService } from '../prisma/prisma.service';
import {
AdminAdjustQuotaDto,
AdminGrantQuotaDto,
AdminListQuotaAccountsQueryDto,
CreateOrderDto,
FreezeProjectQuotaDto,
ListOrdersQueryDto,
ListQuotaLogsQueryDto,
ReleaseProjectQuotaDto
} from './billing.dto';
import {
BILLING_PACKAGES,
toSafeOrder,
toSafeQuotaAccount,
toSafeQuotaLog
} from './billing.types';
const DEFAULT_SHOTS_PER_EPISODE = 6;
const QUOTA_COSTS = {
source: 8,
story_bible: 6,
characters: 8,
character_images: 8,
memory: 4,
episode_plan_per_episode: 3,
script_per_episode: 3,
storyboard_per_episode: 4,
shot_image: 2,
audio_per_episode: 2,
subtitle_per_episode: 1,
video_per_episode: 6
} as const;
@Injectable()
export class BillingService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
listPackages() {
return { packages: BILLING_PACKAGES };
}
async getQuotaAccount(user: AuthRequestUser) {
const account = await this.ensureQuotaAccount(BigInt(user.id));
return toSafeQuotaAccount(account);
}
async listMyOrders(user: AuthRequestUser, query: ListOrdersQueryDto) {
const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50);
const orders = await this.prisma.order.findMany({
where: {
user_id: BigInt(user.id),
...(query.payment_status ? { payment_status: this.normalizeText(query.payment_status, 50) } : {})
},
orderBy: { created_at: 'desc' },
take: limit
});
return { orders: orders.map(toSafeOrder), total: orders.length, limit };
}
async listQuotaLogs(user: AuthRequestUser, query: ListQuotaLogsQueryDto) {
const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50);
const where: Prisma.QuotaLogWhereInput = { user_id: BigInt(user.id) };
if (query.project_id) {
const project = await this.findProjectForUser(query.project_id, user);
where.project_id = project.id;
}
if (query.change_type) {
where.change_type = this.normalizeText(query.change_type, 50);
}
const logs = await this.prisma.quotaLog.findMany({
where,
orderBy: { created_at: 'desc' },
take: limit
});
return { logs: logs.map(toSafeQuotaLog), total: logs.length, limit };
}
async createOrder(user: AuthRequestUser, dto: CreateOrderDto) {
const pkg = this.findPackage(dto.package_code);
const projectId = dto.project_id ? (await this.findProjectForUser(dto.project_id, user)).id : null;
const order = await this.prisma.order.create({
data: {
user_id: BigInt(user.id),
project_id: projectId,
order_no: this.createOrderNo(),
package_code: pkg.code,
amount: pkg.amount,
currency: pkg.currency,
payment_method: this.normalizeText(dto.payment_method ?? 'mock_pay', 50),
payment_status: 'pending'
}
});
return {
order: toSafeOrder(order),
package: pkg,
next_step: 'mock_pay'
};
}
async mockPayOrder(user: AuthRequestUser, orderId: string) {
const order = await this.prisma.order.findUnique({
where: { id: this.parseId(orderId, 'Invalid order id') }
});
if (!order || order.user_id.toString() !== user.id) {
throw new NotFoundException('Order not found');
}
const pkg = this.findPackage(order.package_code ?? undefined);
if (order.payment_status === 'paid') {
const account = await this.ensureQuotaAccount(order.user_id);
return {
order: toSafeOrder(order),
account: toSafeQuotaAccount(account),
package: pkg,
reused: true
};
}
if (order.payment_status !== 'pending') {
throw new BadRequestException('Only pending orders can be paid in mock mode');
}
const paidAt = new Date();
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, order.user_id);
const quota = new Prisma.Decimal(pkg.quota_amount);
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: order.user_id },
data: {
total_quota: account.total_quota.plus(quota),
available_quota: account.available_quota.plus(quota)
}
});
const updatedOrder = await tx.order.update({
where: { id: order.id },
data: {
payment_status: 'paid',
paid_at: paidAt
}
});
const log = await tx.quotaLog.create({
data: {
user_id: order.user_id,
project_id: order.project_id,
change_type: 'recharge',
amount: quota,
balance_after: updatedAccount.available_quota,
reason: `mock_pay:${pkg.code}`,
metadata_json: {
order_id: order.id.toString(),
order_no: order.order_no,
package_code: pkg.code
}
}
});
return { account: updatedAccount, order: updatedOrder, log };
});
return {
order: toSafeOrder(result.order),
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log),
package: pkg
};
}
async estimateProjectQuota(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
return this.createProjectEstimate(project);
}
async freezeProjectQuota(
user: AuthRequestUser,
projectId: string,
dto: FreezeProjectQuotaDto = {}
) {
const project = await this.findProjectForUser(projectId, user);
const estimate = this.createProjectEstimate(project);
const requested = dto.amount ? this.normalizeQuotaAmount(dto.amount, 'amount') : estimate.total_quota;
if (project.payment_status === 'paid') {
const account = await this.ensureQuotaAccount(project.user_id);
return {
account: toSafeQuotaAccount(account),
estimate,
amount: 0,
project_payment_status: 'paid',
reused: true
};
}
if (project.payment_status === 'quota_frozen') {
const account = await this.ensureQuotaAccount(project.user_id);
return {
account: toSafeQuotaAccount(account),
estimate,
amount: requested,
project_payment_status: 'quota_frozen',
reused: true
};
}
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, project.user_id);
const amount = new Prisma.Decimal(requested);
if (account.available_quota.lessThan(amount)) {
throw new BadRequestException('Insufficient quota');
}
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: project.user_id },
data: {
available_quota: account.available_quota.minus(amount),
frozen_quota: account.frozen_quota.plus(amount)
}
});
const updatedProject = await tx.project.update({
where: { id: project.id },
data: { payment_status: 'quota_frozen' }
});
const log = await tx.quotaLog.create({
data: {
user_id: project.user_id,
project_id: project.id,
change_type: 'freeze',
amount,
balance_after: updatedAccount.available_quota,
reason: this.normalizeText(dto.reason ?? 'project_generation_freeze', 255),
metadata_json: {
project_id: project.id.toString(),
estimate
}
}
});
return { account: updatedAccount, project: updatedProject, log, amount };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log),
estimate,
amount: Number(result.amount.toString()),
project_payment_status: result.project.payment_status
};
}
async releaseProjectQuota(
user: AuthRequestUser,
projectId: string,
dto: ReleaseProjectQuotaDto = {}
) {
const project = await this.findProjectForUser(projectId, user);
if (project.payment_status !== 'quota_frozen') {
const account = await this.ensureQuotaAccount(project.user_id);
return {
account: toSafeQuotaAccount(account),
amount: 0,
project_payment_status: project.payment_status,
reused: true
};
}
const estimate = this.createProjectEstimate(project);
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, project.user_id);
const estimateAmount = new Prisma.Decimal(estimate.total_quota);
const amount = account.frozen_quota.lessThan(estimateAmount)
? account.frozen_quota
: estimateAmount;
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: project.user_id },
data: {
available_quota: account.available_quota.plus(amount),
frozen_quota: account.frozen_quota.minus(amount)
}
});
const updatedProject = await tx.project.update({
where: { id: project.id },
data: { payment_status: 'unpaid' }
});
const log = await tx.quotaLog.create({
data: {
user_id: project.user_id,
project_id: project.id,
change_type: 'release',
amount,
balance_after: updatedAccount.available_quota,
reason: this.normalizeText(dto.reason ?? 'project_generation_release', 255),
metadata_json: {
project_id: project.id.toString(),
estimate
}
}
});
return { account: updatedAccount, project: updatedProject, log, amount };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log),
amount: Number(result.amount.toString()),
project_payment_status: result.project.payment_status
};
}
async ensureProjectQuotaReserved(project: Project) {
if (project.payment_status === 'paid') {
return;
}
if (project.payment_status !== 'quota_frozen') {
throw new BadRequestException('Project quota must be frozen before formal video render');
}
const account = await this.ensureQuotaAccount(project.user_id);
const estimate = this.createProjectEstimate(project);
if (account.frozen_quota.lessThan(new Prisma.Decimal(estimate.total_quota))) {
throw new BadRequestException('Frozen quota is insufficient for this project');
}
}
async deductReservedProjectQuota(project: Project, taskId: bigint, reason = 'video_render_success') {
if (project.payment_status === 'paid') {
return null;
}
if (project.payment_status !== 'quota_frozen') {
throw new BadRequestException('Project quota is not frozen');
}
const estimate = this.createProjectEstimate(project);
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, project.user_id);
const amount = new Prisma.Decimal(estimate.total_quota);
if (account.frozen_quota.lessThan(amount)) {
throw new BadRequestException('Frozen quota is insufficient for deduction');
}
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: project.user_id },
data: {
frozen_quota: account.frozen_quota.minus(amount),
used_quota: account.used_quota.plus(amount)
}
});
const log = await tx.quotaLog.create({
data: {
user_id: project.user_id,
project_id: project.id,
task_id: taskId,
change_type: 'deduct',
amount,
balance_after: updatedAccount.available_quota,
reason,
metadata_json: {
project_id: project.id.toString(),
estimate
}
}
});
const updatedProject = await tx.project.update({
where: { id: project.id },
data: { payment_status: 'paid' }
});
return { account: updatedAccount, log, project: updatedProject };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log),
project_payment_status: result.project.payment_status
};
}
async listAdminOrders(user: AuthRequestUser, query: ListOrdersQueryDto) {
assertPermission(user, 'billing:read');
const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50);
const orders = await this.prisma.order.findMany({
where: query.payment_status
? { payment_status: this.normalizeText(query.payment_status, 50) }
: {},
orderBy: { created_at: 'desc' },
take: limit
});
return { orders: orders.map(toSafeOrder), total: orders.length, limit };
}
async listAdminQuotaAccounts(user: AuthRequestUser, query: AdminListQuotaAccountsQueryDto) {
assertPermission(user, 'billing:read');
const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50);
const where: Prisma.QuotaAccountWhereInput = {};
if (query.user_id) {
where.user_id = this.parseId(query.user_id, 'Invalid user_id');
}
if (query.status) {
where.status = this.normalizeText(query.status, 50);
}
const accounts = await this.prisma.quotaAccount.findMany({
where,
orderBy: { updated_at: 'desc' },
take: limit
});
return { accounts: accounts.map(toSafeQuotaAccount), total: accounts.length, limit };
}
async adminGrantQuota(user: AuthRequestUser, targetUserId: string, dto: AdminGrantQuotaDto) {
assertPermission(user, 'billing:write');
const userId = this.parseId(targetUserId, 'Invalid user id');
const target = await this.prisma.user.findUnique({ where: { id: userId } });
if (!target) {
throw new NotFoundException('User not found');
}
const amount = this.normalizeQuotaAmount(dto.amount, 'amount');
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, userId);
const quota = new Prisma.Decimal(amount);
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: userId },
data: {
total_quota: account.total_quota.plus(quota),
available_quota: account.available_quota.plus(quota)
}
});
const log = await tx.quotaLog.create({
data: {
user_id: userId,
change_type: 'admin_grant',
amount: quota,
balance_after: updatedAccount.available_quota,
reason: this.normalizeText(dto.reason ?? 'admin_quota_grant', 255),
metadata_json: {
operator_id: user.id
}
}
});
await tx.operationLog.create({
data: {
user_id: this.parseId(user.id, 'Invalid user id'),
operator_role: user.role,
action: 'admin_grant_quota',
target_type: 'user',
target_id: userId,
metadata_json: {
amount,
reason: this.normalizeText(dto.reason ?? 'admin_quota_grant', 255)
}
}
});
return { account: updatedAccount, log };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log)
};
}
async adminAdjustQuota(user: AuthRequestUser, targetUserId: string, dto: AdminAdjustQuotaDto) {
assertPermission(user, 'billing:write');
const userId = this.parseId(targetUserId, 'Invalid user id');
const target = await this.prisma.user.findUnique({ where: { id: userId } });
if (!target) {
throw new NotFoundException('User not found');
}
const delta = this.normalizeQuotaDelta(dto.delta, 'delta');
const reason = this.normalizeText(dto.reason, 255);
const result = await this.prisma.$transaction(async (tx) => {
const account = await this.ensureQuotaAccountTx(tx, userId);
const amount = new Prisma.Decimal(Math.abs(delta));
const isAddition = delta > 0;
if (!isAddition && account.available_quota.lessThan(amount)) {
throw new BadRequestException('Available quota is insufficient for adjustment');
}
const updatedAccount = await tx.quotaAccount.update({
where: { user_id: userId },
data: isAddition
? {
total_quota: account.total_quota.plus(amount),
available_quota: account.available_quota.plus(amount)
}
: {
total_quota: account.total_quota.minus(amount),
available_quota: account.available_quota.minus(amount)
}
});
const log = await tx.quotaLog.create({
data: {
user_id: userId,
change_type: isAddition ? 'admin_correction_add' : 'admin_correction_deduct',
amount,
balance_after: updatedAccount.available_quota,
reason,
metadata_json: {
operator_id: user.id,
delta
}
}
});
await tx.operationLog.create({
data: {
user_id: this.parseId(user.id, 'Invalid user id'),
operator_role: user.role,
action: 'admin_adjust_quota',
target_type: 'user',
target_id: userId,
metadata_json: {
delta,
reason
}
}
});
return { account: updatedAccount, log };
});
return {
account: toSafeQuotaAccount(result.account),
log: toSafeQuotaLog(result.log)
};
}
private createProjectEstimate(project: Project) {
const episodeCount = this.normalizeEpisodeCount(project.target_episode_count ?? 1);
const shotCount = episodeCount * DEFAULT_SHOTS_PER_EPISODE;
const inputModeCost = project.input_mode === 'upload' ? QUOTA_COSTS.source : QUOTA_COSTS.source + 4;
const breakdown = [
{ key: 'source', label: project.input_mode === 'upload' ? '上传小说解析' : 'AI 原创小说', quota: inputModeCost },
{ key: 'story_bible', label: '故事圣经', quota: QUOTA_COSTS.story_bible },
{ key: 'characters', label: '角色圣经', quota: QUOTA_COSTS.characters },
{ key: 'character_images', label: '角色锚点图', quota: QUOTA_COSTS.character_images },
{ key: 'memory', label: '长篇记忆', quota: QUOTA_COSTS.memory },
{
key: 'episodes',
label: `分集计划 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.episode_plan_per_episode
},
{
key: 'scripts',
label: `单集脚本 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.script_per_episode
},
{
key: 'storyboards',
label: `分镜 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.storyboard_per_episode
},
{
key: 'shot_images',
label: `正式分镜图约 ${shotCount}`,
quota: shotCount * QUOTA_COSTS.shot_image
},
{
key: 'audio',
label: `TTS ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.audio_per_episode
},
{
key: 'subtitle',
label: `字幕 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.subtitle_per_episode
},
{
key: 'video',
label: `视频合成 ${episodeCount}`,
quota: episodeCount * QUOTA_COSTS.video_per_episode
}
];
const total = breakdown.reduce((sum, item) => sum + item.quota, 0);
return {
project_id: project.id.toString(),
input_mode: project.input_mode,
target_episode_count: episodeCount,
estimated_shot_count: shotCount,
total_quota: total,
breakdown
};
}
private findPackage(packageCode?: string) {
const pkg = BILLING_PACKAGES.find((item) => item.code === packageCode);
if (!pkg) {
throw new BadRequestException('Invalid package_code');
}
return pkg;
}
private async ensureQuotaAccount(userId: bigint) {
return this.prisma.quotaAccount.upsert({
where: { user_id: userId },
update: {},
create: {
user_id: userId,
total_quota: 0,
available_quota: 0,
frozen_quota: 0,
used_quota: 0,
status: 'active'
}
});
}
private async ensureQuotaAccountTx(tx: Prisma.TransactionClient, userId: bigint) {
return tx.quotaAccount.upsert({
where: { user_id: userId },
update: {},
create: {
user_id: userId,
total_quota: 0,
available_quota: 0,
frozen_quota: 0,
used_quota: 0,
status: 'active'
}
});
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private createOrderNo() {
const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
return `ORD${timestamp}${randomUUID().replace(/-/g, '').slice(0, 10).toUpperCase()}`;
}
private normalizeEpisodeCount(value: number) {
if (!Number.isInteger(value) || value < 1) return 1;
return Math.min(value, 100);
}
private normalizePositiveInt(
value: unknown,
field: string,
min: number,
max: number,
fallback: number
) {
if (value === undefined || value === null || value === '') return fallback;
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) {
throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`);
}
return numberValue;
}
private normalizeQuotaAmount(value: unknown, field: string) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue <= 0) {
throw new BadRequestException(`${field} must be a positive number`);
}
return Number(numberValue.toFixed(2));
}
private normalizeQuotaDelta(value: unknown, field: string) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue === 0) {
throw new BadRequestException(`${field} must be a non-zero number`);
}
if (Math.abs(numberValue) > 1000000) {
throw new BadRequestException(`${field} must not exceed 1000000`);
}
return Number(numberValue.toFixed(2));
}
private normalizeText(value: string | undefined, maxLength: number) {
const normalized = value?.trim();
if (!normalized) {
throw new BadRequestException('Text value is required');
}
if (normalized.length > maxLength) {
throw new BadRequestException(`Text value must be at most ${maxLength} characters`);
}
return normalized;
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
private assertAdmin(user: AuthRequestUser) {
if (user.role !== 'admin') {
throw new ForbiddenException('Admin role required');
}
}
}
+143
View File
@@ -0,0 +1,143 @@
import type { Order, QuotaAccount, QuotaLog } from '@prisma/client';
export interface BillingPackage {
code: string;
name: string;
description: string;
amount: number;
currency: 'CNY';
quota_amount: number;
included_episodes: number;
features: string[];
recommended?: boolean;
}
export const BILLING_PACKAGES: BillingPackage[] = [
{
code: 'trial_1ep',
name: '试用版',
description: '适合验证 1 集基础漫剧流程。',
amount: 0,
currency: 'CNY',
quota_amount: 35,
included_episodes: 1,
features: ['1 集', '低清预览', '内部测试授权']
},
{
code: 'standard_3ep',
name: '标准短剧版',
description: '适合 3 集 MVP 短剧闭环。',
amount: 199,
currency: 'CNY',
quota_amount: 120,
included_episodes: 3,
recommended: true,
features: ['3 集', '正式 MP4', '1 次小改额度']
},
{
code: 'serial_10ep',
name: '连载测试版',
description: '适合 10 集以内连载测试。',
amount: 599,
currency: 'CNY',
quota_amount: 420,
included_episodes: 10,
features: ['10 集', '批量生成', '人工审核入口']
},
{
code: 'custom_20ep',
name: '高端定制版',
description: '适合 20 集以上定制项目。',
amount: 1999,
currency: 'CNY',
quota_amount: 1200,
included_episodes: 20,
features: ['20 集以上', '角色精修', '关键镜头动态预留']
}
];
export interface SafeQuotaAccount {
id: string;
user_id: string;
total_quota: number;
available_quota: number;
frozen_quota: number;
used_quota: number;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeQuotaLog {
id: string;
user_id: string;
project_id: string | null;
task_id: string | null;
change_type: string;
amount: number;
balance_after: number | null;
reason: string | null;
metadata_json: unknown;
created_at: string;
}
export interface SafeOrder {
id: string;
user_id: string;
project_id: string | null;
order_no: string;
package_code: string | null;
amount: number;
currency: string;
payment_method: string | null;
payment_status: string;
paid_at: string | null;
created_at: string;
updated_at: string;
}
export function toSafeQuotaAccount(account: QuotaAccount): SafeQuotaAccount {
return {
id: account.id.toString(),
user_id: account.user_id.toString(),
total_quota: Number(account.total_quota.toString()),
available_quota: Number(account.available_quota.toString()),
frozen_quota: Number(account.frozen_quota.toString()),
used_quota: Number(account.used_quota.toString()),
status: account.status,
created_at: account.created_at.toISOString(),
updated_at: account.updated_at.toISOString()
};
}
export function toSafeQuotaLog(log: QuotaLog): SafeQuotaLog {
return {
id: log.id.toString(),
user_id: log.user_id.toString(),
project_id: log.project_id?.toString() ?? null,
task_id: log.task_id?.toString() ?? null,
change_type: log.change_type,
amount: Number(log.amount.toString()),
balance_after: log.balance_after ? Number(log.balance_after.toString()) : null,
reason: log.reason,
metadata_json: log.metadata_json,
created_at: log.created_at.toISOString()
};
}
export function toSafeOrder(order: Order): SafeOrder {
return {
id: order.id.toString(),
user_id: order.user_id.toString(),
project_id: order.project_id?.toString() ?? null,
order_no: order.order_no,
package_code: order.package_code,
amount: Number(order.amount.toString()),
currency: order.currency,
payment_method: order.payment_method,
payment_status: order.payment_status,
paid_at: order.paid_at?.toISOString() ?? null,
created_at: order.created_at.toISOString(),
updated_at: order.updated_at.toISOString()
};
}
+38
View File
@@ -0,0 +1,38 @@
import type { CharacterRoleType, CharacterStatus } from './character.types';
export class ExtractCharactersDto {
story_bible_id?: string;
}
export class CreateCharacterDto {
global_character_id?: string;
name?: string;
alias_names?: string[];
role_type?: CharacterRoleType;
gender_label?: string;
age_group?: string;
identity_desc?: string;
appearance_desc?: string;
face_desc?: string;
hair_desc?: string;
eye_desc?: string;
body_desc?: string;
costume_rules?: string;
special_props?: string;
personality_desc?: string;
speech_style?: string;
relationship_desc?: string;
character_arc?: string;
negative_rules?: string;
wardrobe_variant?: string;
voice_provider_code?: string;
voice_model?: string;
voice_id?: string;
voice_style?: string;
performance_style?: string;
importance_level?: number;
}
export class UpdateCharacterDto extends CreateCharacterDto {
status?: CharacterStatus;
}
+162
View File
@@ -0,0 +1,162 @@
import type { Character, GlobalCharacter, Prisma } from '@prisma/client';
export const CHARACTER_ROLE_TYPES = [
'protagonist',
'lead',
'supporting',
'antagonist',
'minor'
] as const;
export const CHARACTER_STATUSES = [
'draft',
'generated',
'edited',
'locked',
'deleted'
] as const;
export type CharacterRoleType = (typeof CHARACTER_ROLE_TYPES)[number];
export type CharacterStatus = (typeof CHARACTER_STATUSES)[number];
export interface SafeCharacter {
id: string;
project_id: string;
global_character_id: string | null;
name: string;
alias_names: Prisma.JsonValue | null;
role_type: string;
gender_label: string | null;
age_group: string | null;
identity_desc: string | null;
appearance_desc: string | null;
face_desc: string | null;
hair_desc: string | null;
eye_desc: string | null;
body_desc: string | null;
costume_rules: string | null;
special_props: string | null;
personality_desc: string | null;
speech_style: string | null;
relationship_desc: string | null;
character_arc: string | null;
negative_rules: string | null;
anchor_asset_id: string | null;
wardrobe_variant: string | null;
voice_provider_code: string | null;
voice_model: string | null;
voice_id: string | null;
voice_style: string | null;
performance_style: string | null;
importance_level: number;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeGlobalCharacter {
id: string;
name: string;
display_name: string | null;
role_archetype: string;
gender_label: string | null;
age_group: string | null;
identity_desc: string | null;
appearance_desc: string | null;
face_desc: string | null;
hair_desc: string | null;
eye_desc: string | null;
body_desc: string | null;
default_costume_rules: string | null;
wardrobe_json: Prisma.JsonValue | null;
special_props: string | null;
personality_desc: string | null;
speech_style: string | null;
voice_provider_code: string | null;
voice_model: string | null;
voice_id: string | null;
voice_style: string | null;
performance_style: string | null;
negative_rules: string | null;
anchor_asset_id: string | null;
voice_sample_asset_id: string | null;
commercial_status: string;
usage_scope: string;
status: string;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
}
export function toSafeCharacter(character: Character): SafeCharacter {
return {
id: character.id.toString(),
project_id: character.project_id.toString(),
global_character_id: character.global_character_id?.toString() ?? null,
name: character.name,
alias_names: character.alias_names,
role_type: character.role_type,
gender_label: character.gender_label,
age_group: character.age_group,
identity_desc: character.identity_desc,
appearance_desc: character.appearance_desc,
face_desc: character.face_desc,
hair_desc: character.hair_desc,
eye_desc: character.eye_desc,
body_desc: character.body_desc,
costume_rules: character.costume_rules,
special_props: character.special_props,
personality_desc: character.personality_desc,
speech_style: character.speech_style,
relationship_desc: character.relationship_desc,
character_arc: character.character_arc,
negative_rules: character.negative_rules,
anchor_asset_id: character.anchor_asset_id?.toString() ?? null,
wardrobe_variant: character.wardrobe_variant,
voice_provider_code: character.voice_provider_code,
voice_model: character.voice_model,
voice_id: character.voice_id,
voice_style: character.voice_style,
performance_style: character.performance_style,
importance_level: character.importance_level,
status: character.status,
created_at: character.created_at.toISOString(),
updated_at: character.updated_at.toISOString()
};
}
export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCharacter {
return {
id: character.id.toString(),
name: character.name,
display_name: character.display_name,
role_archetype: character.role_archetype,
gender_label: character.gender_label,
age_group: character.age_group,
identity_desc: character.identity_desc,
appearance_desc: character.appearance_desc,
face_desc: character.face_desc,
hair_desc: character.hair_desc,
eye_desc: character.eye_desc,
body_desc: character.body_desc,
default_costume_rules: character.default_costume_rules,
wardrobe_json: character.wardrobe_json,
special_props: character.special_props,
personality_desc: character.personality_desc,
speech_style: character.speech_style,
voice_provider_code: character.voice_provider_code,
voice_model: character.voice_model,
voice_id: character.voice_id,
voice_style: character.voice_style,
performance_style: character.performance_style,
negative_rules: character.negative_rules,
anchor_asset_id: character.anchor_asset_id?.toString() ?? null,
voice_sample_asset_id: character.voice_sample_asset_id?.toString() ?? null,
commercial_status: character.commercial_status,
usage_scope: character.usage_scope,
status: character.status,
created_by_user_id: character.created_by_user_id?.toString() ?? null,
created_at: character.created_at.toISOString(),
updated_at: character.updated_at.toISOString()
};
}
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
Patch,
Post,
Query,
UseGuards
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto';
import { CharactersService } from './characters.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class CharactersController {
constructor(@Inject(CharactersService) private readonly charactersService: CharactersService) {}
@Post('projects/:projectId/characters/extract')
extractCharacters(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ExtractCharactersDto
) {
return this.charactersService.extractCharacters(user, projectId, dto);
}
@Get('projects/:projectId/characters')
listCharacters(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('include_deleted') includeDeleted?: string
) {
return this.charactersService.listCharacters(user, projectId, includeDeleted === 'true');
}
@Post('projects/:projectId/characters')
createCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateCharacterDto
) {
return this.charactersService.createCharacter(user, projectId, dto);
}
@Post('projects/:projectId/characters/confirm')
confirmCharacters(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.confirmCharacters(user, projectId);
}
@Patch('characters/:characterId')
updateCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: UpdateCharacterDto
) {
return this.charactersService.updateCharacter(user, characterId, dto);
}
@Delete('characters/:characterId')
deleteCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.deleteCharacter(user, characterId);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
@Module({
imports: [AuthModule],
controllers: [CharactersController],
providers: [CharactersService],
exports: [CharactersService]
})
export class CharactersModule {}
@@ -0,0 +1,294 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Character, NovelChapter, Project, StoryBible } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import { CharactersService } from './characters.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '重生归来,我只搞事业',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'story_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z'),
completed_at: null,
...overrides
};
}
function createStoryBible(overrides: Partial<StoryBible> = {}): StoryBible {
return {
id: 40n,
project_id: 10n,
title: '重生归来,我只搞事业',
logline: '林晚重回命运转折点,用证据夺回项目。',
main_plot: '主要人物:林晚;其对手、旧友、合作者将在后续角色圣经中细化。',
core_conflict: '林晚必须在资本压力中守住原创项目。',
selling_points: '重生归来\n证据反杀',
tone: '克制、锋利、连续反转',
world_summary: '现代都市内容公司',
ending_direction: '幕后真相继续推进。',
taboo_rules: '不得改变主角姓名。',
version: 1,
status: 'confirmed',
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
return {
id: 30n,
project_id: 10n,
novel_source_id: 20n,
chapter_no: 1,
title: '第1章 暴雨重启',
content: '林晚站在暴雨夜里醒来,决定重新夺回项目。',
summary: '林晚确认重生并整理证据。',
visual_summary: '暴雨夜,林晚醒来,手机录音亮起。',
word_count: 22,
status: 'generated',
created_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 50n,
project_id: 10n,
global_character_id: null,
name: '林晚',
alias_names: [],
role_type: 'protagonist',
gender_label: '女',
age_group: '青年',
identity_desc: '故事主角',
appearance_desc: '眼神坚定',
face_desc: '精致脸型',
hair_desc: '深色中长发',
eye_desc: '深色眼睛',
body_desc: '身形修长',
costume_rules: '现代都市通勤装',
special_props: '手机、合同',
personality_desc: '冷静克制',
speech_style: '短句明确',
relationship_desc: '与对手冲突',
character_arc: '从被动到主动',
negative_rules: '不得改名',
anchor_asset_id: null,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: null,
performance_style: null,
importance_level: 100,
status: 'generated',
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z'),
...overrides
};
}
describe('CharactersService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
storyBible: {
findUnique: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
novelChapter: { findMany: ReturnType<typeof vi.fn> };
character: {
create: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
characterMemory: { create: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
};
let tx: {
project: { update: ReturnType<typeof vi.fn> };
character: {
createMany: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
};
};
let service: CharactersService;
beforeEach(() => {
tx = {
project: {
update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_character_confirm' }))
},
character: {
createMany: vi.fn().mockResolvedValue({ count: 3 }),
findMany: vi.fn().mockResolvedValue([
createCharacter(),
createCharacter({ id: 51n, name: '周启', role_type: 'antagonist', importance_level: 80 }),
createCharacter({ id: 52n, name: '沈知夏', role_type: 'supporting', importance_level: 60 })
]),
updateMany: vi.fn().mockResolvedValue({ count: 0 })
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_character_confirm' }))
},
storyBible: {
findUnique: vi.fn().mockResolvedValue(createStoryBible()),
findFirst: vi.fn().mockResolvedValue(createStoryBible())
},
novelChapter: {
findMany: vi.fn().mockResolvedValue([createChapter()])
},
character: {
create: vi.fn().mockResolvedValue(createCharacter({ status: 'edited' })),
findMany: vi.fn().mockResolvedValue([createCharacter()]),
findUnique: vi.fn().mockResolvedValue(createCharacter()),
update: vi.fn().mockResolvedValue(createCharacter({ status: 'edited' })),
updateMany: vi.fn(),
createMany: vi.fn()
},
characterMemory: {
create: vi.fn().mockResolvedValue({})
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
service = new CharactersService(prisma as unknown as PrismaService);
});
it('extracts characters from a confirmed story bible', async () => {
const result = await service.extractCharacters(user, '10', { story_bible_id: '40' });
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'character_extracting' }
});
expect(tx.character.createMany).toHaveBeenCalled();
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'waiting_character_confirm' }
});
expect(result.characters).toHaveLength(3);
expect(result.next_step).toBe('character_confirm');
});
it('requires a confirmed story bible before extraction', async () => {
prisma.storyBible.findFirst.mockResolvedValue(null);
await expect(service.extractCharacters(user, '10', {})).rejects.toBeInstanceOf(
BadRequestException
);
});
it('creates a manual character', async () => {
const result = await service.createCharacter(user, '10', {
name: '顾南',
role_type: 'supporting',
importance_level: 50
});
expect(prisma.character.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
name: '顾南',
role_type: 'supporting',
status: 'edited'
})
});
expect(result.status).toBe('edited');
});
it('blocks core field changes after a character is locked', async () => {
prisma.character.findUnique.mockResolvedValue(createCharacter({ status: 'locked' }));
await expect(
service.updateCharacter(user, '50', { name: '新的名字' })
).rejects.toBeInstanceOf(BadRequestException);
});
it('allows non-core patching after a character is locked', async () => {
prisma.character.findUnique.mockResolvedValue(createCharacter({ status: 'locked' }));
prisma.character.update.mockResolvedValue(
createCharacter({ status: 'locked', costume_rules: '新增雨夜外套变体。' })
);
const result = await service.updateCharacter(user, '50', {
costume_rules: '新增雨夜外套变体。'
});
expect(prisma.character.update).toHaveBeenCalledWith({
where: { id: 50n },
data: expect.objectContaining({
costume_rules: '新增雨夜外套变体。'
})
});
expect(prisma.character.update.mock.calls[0][0].data.status).toBeUndefined();
expect(prisma.characterMemory.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
character_id: 50n,
memory_type: 'profile_adjustment'
})
});
expect(result.costume_rules).toBe('新增雨夜外套变体。');
});
it('confirms characters and locks the library', async () => {
prisma.character.findMany.mockResolvedValue([
createCharacter(),
createCharacter({ id: 51n, name: '周启', role_type: 'antagonist' })
]);
const result = await service.confirmCharacters(user, '10');
expect(tx.character.updateMany).toHaveBeenCalledWith({
where: {
project_id: 10n,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'locked' }
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'character_confirmed' }
});
expect(result.next_step).toBe('episode_plan_generate');
});
it('rejects access to another user project', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
await expect(service.listCharacters(user, '10')).rejects.toBeInstanceOf(
ForbiddenException
);
});
});
@@ -0,0 +1,708 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Character, GlobalCharacter, NovelChapter, Prisma, Project, StoryBible } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto';
import {
CHARACTER_ROLE_TYPES,
CHARACTER_STATUSES,
toSafeCharacter,
type CharacterRoleType
} from './character.types';
interface CharacterDraft {
global_character_id: bigint | null;
name: string;
alias_names: Prisma.InputJsonValue;
role_type: CharacterRoleType;
gender_label: string | null;
age_group: string | null;
identity_desc: string | null;
appearance_desc: string | null;
face_desc: string | null;
hair_desc: string | null;
eye_desc: string | null;
body_desc: string | null;
costume_rules: string | null;
special_props: string | null;
personality_desc: string | null;
speech_style: string | null;
relationship_desc: string | null;
character_arc: string | null;
negative_rules: string | null;
anchor_asset_id: bigint | null;
importance_level: number;
wardrobe_variant: string | null;
voice_provider_code: string | null;
voice_model: string | null;
voice_id: string | null;
voice_style: string | null;
performance_style: string | null;
}
const LOCKED_CORE_FIELDS = new Set<keyof UpdateCharacterDto>([
'name',
'role_type',
'gender_label',
'age_group',
'identity_desc',
'appearance_desc',
'face_desc',
'hair_desc',
'eye_desc',
'body_desc'
]);
@Injectable()
export class CharactersService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async extractCharacters(user: AuthRequestUser, projectId: string, dto: ExtractCharactersDto) {
const project = await this.findProjectForUser(projectId, user);
const storyBible = dto.story_bible_id
? await this.findStoryBibleById(project.id, dto.story_bible_id)
: await this.findConfirmedStoryBible(project.id);
if (!storyBible) {
throw new BadRequestException('Confirmed story bible is required before character extraction');
}
const chapters = await this.prisma.novelChapter.findMany({
where: { project_id: project.id },
orderBy: { chapter_no: 'asc' }
});
const drafts = this.buildCharacterDrafts(storyBible, chapters);
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'character_extracting' }
});
const characters = await this.prisma.$transaction(async (tx) => {
await tx.character.updateMany({
where: {
project_id: project.id,
status: { not: 'deleted' }
},
data: { status: 'deleted' }
});
await tx.character.createMany({
data: drafts.map((draft) => ({
project_id: project.id,
...draft,
status: 'generated'
}))
});
const saved = await tx.character.findMany({
where: {
project_id: project.id,
status: { not: 'deleted' }
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
await tx.project.update({
where: { id: project.id },
data: { status: 'waiting_character_confirm' }
});
return saved;
});
return {
characters: characters.map(toSafeCharacter),
story_bible: {
id: storyBible.id.toString(),
version: storyBible.version,
status: storyBible.status
},
next_step: 'character_confirm'
};
}
async listCharacters(user: AuthRequestUser, projectId: string, includeDeleted = false) {
const project = await this.findProjectForUser(projectId, user);
const characters = await this.prisma.character.findMany({
where: {
project_id: project.id,
...(includeDeleted ? {} : { status: { not: 'deleted' } })
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
return characters.map(toSafeCharacter);
}
async createCharacter(user: AuthRequestUser, projectId: string, dto: CreateCharacterDto) {
const project = await this.findProjectForUser(projectId, user);
const globalCharacter = await this.findActiveGlobalCharacter(dto.global_character_id);
const draft = this.createDraftFromDto(dto, globalCharacter);
const character = await this.prisma.character.create({
data: {
project_id: project.id,
...draft,
status: 'edited'
}
});
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'waiting_character_confirm' }
});
return toSafeCharacter(character);
}
async updateCharacter(user: AuthRequestUser, characterId: string, dto: UpdateCharacterDto) {
const character = await this.findCharacterForUser(characterId, user);
this.assertLockedPatchAllowed(character, dto);
const globalCharacter = await this.findActiveGlobalCharacter(dto.global_character_id);
const data = this.createUpdateData(dto, character.status !== 'locked', globalCharacter, character);
if (Object.keys(data).length === 0) {
throw new BadRequestException('No character fields to update');
}
const updated = await this.prisma.character.update({
where: { id: character.id },
data
});
if (character.status === 'locked') {
await this.prisma.characterMemory.create({
data: {
project_id: character.project_id,
character_id: character.id,
episode_id: null,
memory_type: 'profile_adjustment',
content: this.describeLockedCharacterPatch(dto)
}
});
}
if (updated.status !== 'locked') {
await this.prisma.project.update({
where: { id: updated.project_id },
data: { status: 'waiting_character_confirm' }
});
}
return toSafeCharacter(updated);
}
async deleteCharacter(user: AuthRequestUser, characterId: string) {
const character = await this.findCharacterForUser(characterId, user);
if (character.status === 'locked') {
throw new BadRequestException('Locked characters cannot be deleted');
}
const deleted = await this.prisma.character.update({
where: { id: character.id },
data: { status: 'deleted' }
});
await this.prisma.project.update({
where: { id: character.project_id },
data: { status: 'waiting_character_confirm' }
});
return toSafeCharacter(deleted);
}
async confirmCharacters(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const characters = await this.prisma.character.findMany({
where: {
project_id: project.id,
status: { not: 'deleted' }
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
if (characters.length === 0) {
throw new BadRequestException('At least one character is required before confirmation');
}
if (!characters.some((character) => ['protagonist', 'lead'].includes(character.role_type))) {
throw new BadRequestException('A protagonist or lead character is required before confirmation');
}
const locked = await this.prisma.$transaction(async (tx) => {
await tx.character.updateMany({
where: {
project_id: project.id,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'locked' }
});
const saved = await tx.character.findMany({
where: {
project_id: project.id,
status: { not: 'deleted' }
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
await tx.project.update({
where: { id: project.id },
data: { status: 'character_confirmed' }
});
return saved;
});
return {
characters: locked.map(toSafeCharacter),
next_step: 'episode_plan_generate'
};
}
private buildCharacterDrafts(storyBible: StoryBible, chapters: NovelChapter[]): CharacterDraft[] {
const protagonist = this.guessProtagonist(storyBible, chapters);
const antagonist = this.guessAntagonist(storyBible, chapters, protagonist);
const supporter = this.guessSupporter(storyBible, chapters, protagonist, antagonist);
return [
this.buildProtagonist(protagonist, storyBible),
this.buildAntagonist(antagonist, storyBible),
this.buildSupporter(supporter, protagonist, storyBible)
].filter((draft, index, list) =>
list.findIndex((item) => item.name === draft.name) === index
);
}
private buildProtagonist(name: string, storyBible: StoryBible): CharacterDraft {
return {
global_character_id: null,
name,
alias_names: [],
role_type: 'protagonist',
gender_label: this.inferGender(name),
age_group: '青年',
identity_desc: this.extractAfter(storyBible.main_plot, '主要人物') ?? '故事主角,核心目标推动者。',
appearance_desc: `${name}五官清晰,眼神坚定,整体气质克制锋利,适合韩漫短剧主角。`,
face_desc: '精致鹅蛋脸或小方脸,轮廓干净,表情有压迫感。',
hair_desc: '深色中长发或利落短发,发型稳定,不随剧情随意改变。',
eye_desc: '深色眼睛,眼神坚定,关键反击场景有锐利高光。',
body_desc: '身形修长,站姿稳定,动作干练。',
costume_rules: '默认现代都市通勤装,深色外套、干净衬衫,重要场合可换正式套装。',
special_props: '手机、合同、录音或关键证据文件。',
personality_desc: '冷静、克制、目标感强,遇到压力先观察再反击。',
speech_style: '短句明确,不解释过多,关键台词有压迫感。',
relationship_desc: storyBible.main_plot ?? '与对手存在利益冲突,与潜在合作者存在信任考验。',
character_arc: storyBible.ending_direction ?? '从被动防守转向主动掌控局面。',
negative_rules: '不得改名,不得年龄漂移,不得突然软弱或无因放弃核心目标。',
anchor_asset_id: null,
importance_level: 100,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: '冷静克制的年轻女性声线,语速中等,反击台词更有压迫感。',
performance_style: '微表情克制,关键反击时眼神压迫增强。'
};
}
private buildAntagonist(name: string, storyBible: StoryBible): CharacterDraft {
return {
global_character_id: null,
name,
alias_names: [],
role_type: 'antagonist',
gender_label: this.inferGender(name),
age_group: '青年到中年',
identity_desc: '与主角核心目标冲突的主要阻碍者。',
appearance_desc: `${name}外表精致但带距离感,表情常带审视或压迫。`,
face_desc: '脸部线条偏锋利,笑容克制,眼神有算计感。',
hair_desc: '发型整齐,商务感强。',
eye_desc: '眼神冷静,常避开正面情绪。',
body_desc: '姿态控制感强,动作少但压迫明显。',
costume_rules: '商务深色系,避免与主角服装完全相同。',
special_props: '平板、合同、会议资料或控制权文件。',
personality_desc: '擅长隐藏真实动机,习惯利用规则和舆论施压。',
speech_style: '语气礼貌但带威胁,常用反问和条件交换。',
relationship_desc: storyBible.core_conflict ?? '与主角围绕核心目标持续对抗。',
character_arc: '前期占据优势,中期逐步暴露破绽,后期成为主线真相入口。',
negative_rules: '不得与主角混脸,不得突然洗白,不得无因放弃利益目标。',
anchor_asset_id: null,
importance_level: 80,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: '低沉或冷硬声线,语速偏慢,礼貌但带压迫。',
performance_style: '动作少但控制感强,表情审视、笑容克制。'
};
}
private buildSupporter(name: string, protagonist: string, storyBible: StoryBible): CharacterDraft {
return {
global_character_id: null,
name,
alias_names: [],
role_type: 'supporting',
gender_label: this.inferGender(name),
age_group: '青年',
identity_desc: '主角阶段性合作者或见证者。',
appearance_desc: `${name}亲和但有专业感,视觉上与${protagonist}形成区分。`,
face_desc: '脸部线条柔和,表情更外放。',
hair_desc: '自然深色发型,轮廓清楚。',
eye_desc: '眼神明亮,情绪反应明显。',
body_desc: '行动灵活,适合辅助调查和转场。',
costume_rules: '浅色或中性色日常装,避免抢主角视觉中心。',
special_props: '笔记本、工作证或资料袋。',
personality_desc: '敏锐、讲义气,但在压力下会犹豫。',
speech_style: '语速较快,常提醒风险,也会补充信息。',
relationship_desc: `${name}${protagonist}存在信任考验,后续可发展为稳定协作关系。`,
character_arc: storyBible.main_plot?.slice(0, 120) ?? '从旁观者成长为主角的重要支撑。',
negative_rules: '不得替代主角决策,不得在未铺垫时掌握关键真相。',
anchor_asset_id: null,
importance_level: 60,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: '亲和、反应快的年轻声线,信息补充时语速略快。',
performance_style: '情绪外放,适合惊讶、提醒和辅助调查。'
};
}
private createDraftFromDto(dto: CreateCharacterDto, globalCharacter: GlobalCharacter | null): CharacterDraft {
const name = this.optionalText(dto.name) ?? globalCharacter?.display_name ?? globalCharacter?.name;
if (!name) {
throw new BadRequestException('name is required');
}
const roleType = this.validateRoleType(dto.role_type ?? globalCharacter?.role_archetype ?? 'supporting');
return {
global_character_id: globalCharacter?.id ?? null,
name,
alias_names: this.normalizeAliases(dto.alias_names),
role_type: roleType,
gender_label: this.optionalText(dto.gender_label) ?? globalCharacter?.gender_label ?? null,
age_group: this.optionalText(dto.age_group) ?? globalCharacter?.age_group ?? null,
identity_desc: this.optionalText(dto.identity_desc) ?? globalCharacter?.identity_desc ?? null,
appearance_desc: this.optionalText(dto.appearance_desc) ?? globalCharacter?.appearance_desc ?? null,
face_desc: this.optionalText(dto.face_desc) ?? globalCharacter?.face_desc ?? null,
hair_desc: this.optionalText(dto.hair_desc) ?? globalCharacter?.hair_desc ?? null,
eye_desc: this.optionalText(dto.eye_desc) ?? globalCharacter?.eye_desc ?? null,
body_desc: this.optionalText(dto.body_desc) ?? globalCharacter?.body_desc ?? null,
costume_rules: this.optionalText(dto.costume_rules) ?? globalCharacter?.default_costume_rules ?? null,
special_props: this.optionalText(dto.special_props) ?? globalCharacter?.special_props ?? null,
personality_desc: this.optionalText(dto.personality_desc) ?? globalCharacter?.personality_desc ?? null,
speech_style: this.optionalText(dto.speech_style) ?? globalCharacter?.speech_style ?? null,
relationship_desc: this.optionalText(dto.relationship_desc),
character_arc: this.optionalText(dto.character_arc),
negative_rules: this.optionalText(dto.negative_rules) ?? globalCharacter?.negative_rules ?? null,
anchor_asset_id: globalCharacter?.anchor_asset_id ?? null,
importance_level: this.validateImportance(dto.importance_level ?? 10),
wardrobe_variant: this.optionalText(dto.wardrobe_variant),
voice_provider_code: this.optionalText(dto.voice_provider_code) ?? globalCharacter?.voice_provider_code ?? null,
voice_model: this.optionalText(dto.voice_model) ?? globalCharacter?.voice_model ?? null,
voice_id: this.optionalText(dto.voice_id) ?? globalCharacter?.voice_id ?? null,
voice_style: this.optionalText(dto.voice_style) ?? globalCharacter?.voice_style ?? null,
performance_style: this.optionalText(dto.performance_style) ?? globalCharacter?.performance_style ?? null
};
}
private createUpdateData(
dto: UpdateCharacterDto,
markEdited = true,
globalCharacter: GlobalCharacter | null,
currentCharacter: Character
): Prisma.CharacterUncheckedUpdateInput {
const data: Prisma.CharacterUncheckedUpdateInput = {};
if ('global_character_id' in dto) {
data.global_character_id = globalCharacter?.id ?? null;
if (globalCharacter) {
if (!currentCharacter.anchor_asset_id && globalCharacter.anchor_asset_id) {
data.anchor_asset_id = globalCharacter.anchor_asset_id;
}
if (!currentCharacter.voice_provider_code && globalCharacter.voice_provider_code) {
data.voice_provider_code = globalCharacter.voice_provider_code;
}
if (!currentCharacter.voice_model && globalCharacter.voice_model) {
data.voice_model = globalCharacter.voice_model;
}
if (!currentCharacter.voice_id && globalCharacter.voice_id) {
data.voice_id = globalCharacter.voice_id;
}
if (!currentCharacter.voice_style && globalCharacter.voice_style) {
data.voice_style = globalCharacter.voice_style;
}
if (!currentCharacter.performance_style && globalCharacter.performance_style) {
data.performance_style = globalCharacter.performance_style;
}
if (!currentCharacter.costume_rules && globalCharacter.default_costume_rules) {
data.costume_rules = globalCharacter.default_costume_rules;
}
}
}
if ('name' in dto) data.name = this.requiredText(dto.name, 'name is required');
if ('alias_names' in dto) data.alias_names = this.normalizeAliases(dto.alias_names);
if ('role_type' in dto) data.role_type = this.validateRoleType(dto.role_type);
if ('gender_label' in dto) data.gender_label = this.optionalText(dto.gender_label);
if ('age_group' in dto) data.age_group = this.optionalText(dto.age_group);
if ('identity_desc' in dto) data.identity_desc = this.optionalText(dto.identity_desc);
if ('appearance_desc' in dto) data.appearance_desc = this.optionalText(dto.appearance_desc);
if ('face_desc' in dto) data.face_desc = this.optionalText(dto.face_desc);
if ('hair_desc' in dto) data.hair_desc = this.optionalText(dto.hair_desc);
if ('eye_desc' in dto) data.eye_desc = this.optionalText(dto.eye_desc);
if ('body_desc' in dto) data.body_desc = this.optionalText(dto.body_desc);
if ('costume_rules' in dto) data.costume_rules = this.optionalText(dto.costume_rules);
if ('special_props' in dto) data.special_props = this.optionalText(dto.special_props);
if ('personality_desc' in dto) data.personality_desc = this.optionalText(dto.personality_desc);
if ('speech_style' in dto) data.speech_style = this.optionalText(dto.speech_style);
if ('relationship_desc' in dto) data.relationship_desc = this.optionalText(dto.relationship_desc);
if ('character_arc' in dto) data.character_arc = this.optionalText(dto.character_arc);
if ('negative_rules' in dto) data.negative_rules = this.optionalText(dto.negative_rules);
if ('wardrobe_variant' in dto) data.wardrobe_variant = this.optionalText(dto.wardrobe_variant);
if ('voice_provider_code' in dto) data.voice_provider_code = this.optionalText(dto.voice_provider_code);
if ('voice_model' in dto) data.voice_model = this.optionalText(dto.voice_model);
if ('voice_id' in dto) data.voice_id = this.optionalText(dto.voice_id);
if ('voice_style' in dto) data.voice_style = this.optionalText(dto.voice_style);
if ('performance_style' in dto) data.performance_style = this.optionalText(dto.performance_style);
if ('importance_level' in dto) {
data.importance_level = this.validateImportance(dto.importance_level);
}
if ('status' in dto) data.status = this.validateStatus(dto.status);
if (markEdited && Object.keys(data).length > 0 && data.status !== 'locked') {
data.status = data.status ?? 'edited';
}
return data;
}
private assertLockedPatchAllowed(character: Character, dto: UpdateCharacterDto) {
if (character.status !== 'locked') {
return;
}
for (const field of LOCKED_CORE_FIELDS) {
if (field in dto) {
throw new BadRequestException('Locked character core fields cannot be changed');
}
}
if (dto.status && dto.status !== 'locked') {
throw new BadRequestException('Locked character status cannot be changed here');
}
}
private describeLockedCharacterPatch(dto: UpdateCharacterDto) {
const labels: string[] = [];
if ('global_character_id' in dto) labels.push('全局角色绑定');
if ('alias_names' in dto) labels.push('别名');
if ('costume_rules' in dto) labels.push('服装规则');
if ('special_props' in dto) labels.push('特殊道具');
if ('personality_desc' in dto) labels.push('性格补充');
if ('speech_style' in dto) labels.push('说话方式');
if ('wardrobe_variant' in dto) labels.push('服装变体');
if ('voice_provider_code' in dto || 'voice_model' in dto || 'voice_id' in dto || 'voice_style' in dto) {
labels.push('角色声音');
}
if ('performance_style' in dto) labels.push('表演风格');
if ('relationship_desc' in dto) labels.push('人物关系');
if ('character_arc' in dto) labels.push('成长线');
if ('negative_rules' in dto) labels.push('禁用规则');
if ('importance_level' in dto) labels.push('重要级别');
return `锁定角色资料补充:${labels.join('、') || '非核心描述'}`;
}
private async findActiveGlobalCharacter(globalCharacterId: string | undefined) {
const normalized = globalCharacterId?.trim();
if (!normalized) {
return null;
}
const globalCharacter = await this.prisma.globalCharacter.findUnique({
where: { id: this.parseId(normalized, 'Invalid global_character_id') }
});
if (!globalCharacter || globalCharacter.status !== 'active') {
throw new NotFoundException('Active global character not found');
}
return globalCharacter;
}
private async findCharacterForUser(characterId: string, user: AuthRequestUser) {
const character = await this.prisma.character.findUnique({
where: { id: this.parseId(characterId, 'Invalid character id') }
});
if (!character || character.status === 'deleted') {
throw new NotFoundException('Character not found');
}
await this.findProjectForUser(character.project_id.toString(), user);
return character;
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private async findConfirmedStoryBible(projectId: bigint) {
return this.prisma.storyBible.findFirst({
where: {
project_id: projectId,
status: 'confirmed'
},
orderBy: { version: 'desc' }
});
}
private async findStoryBibleById(projectId: bigint, storyBibleId: string) {
const storyBible = await this.prisma.storyBible.findUnique({
where: { id: this.parseId(storyBibleId, 'Invalid story bible id') }
});
if (!storyBible || storyBible.project_id !== projectId || storyBible.status !== 'confirmed') {
throw new NotFoundException('Confirmed story bible not found');
}
return storyBible;
}
private guessProtagonist(storyBible: StoryBible, chapters: NovelChapter[]) {
const text = [storyBible.logline, storyBible.main_plot, ...chapters.map((chapter) => chapter.content)]
.filter(Boolean)
.join('\n');
return this.matchName(text, ['林晚', '沈知夏', '顾南', '陆沉']) ?? '林晚';
}
private guessAntagonist(storyBible: StoryBible, chapters: NovelChapter[], protagonist: string) {
const text = [storyBible.core_conflict, storyBible.main_plot, ...chapters.map((chapter) => chapter.content)]
.filter(Boolean)
.join('\n');
const matched = this.matchName(text, ['旧团队', '对手', '投资人', '周启', '苏曼', '陆沉']);
if (!matched || matched === protagonist || matched.length > 4) {
return '周启';
}
return matched;
}
private guessSupporter(
storyBible: StoryBible,
chapters: NovelChapter[],
protagonist: string,
antagonist: string
) {
const text = [storyBible.main_plot, ...chapters.map((chapter) => chapter.content)]
.filter(Boolean)
.join('\n');
const matched = this.matchName(text, ['合作者', '旧友', '助理', '沈知夏', '顾南']);
if (!matched || matched === protagonist || matched === antagonist || matched.length > 4) {
return '沈知夏';
}
return matched;
}
private matchName(text: string, candidates: string[]) {
const known = candidates.find((name) => text.includes(name) && name.length <= 4);
if (known) {
return known;
}
return /[\u4e00-\u9fa5]{2,4}(?=站在|醒来|必须|决定|知道|拿出|重回)/.exec(text)?.[0];
}
private extractAfter(value: string | null, label: string) {
if (!value) return null;
const line = value.split('\n').find((item) => item.includes(label));
return line?.replace(`${label}`, '').trim() || null;
}
private inferGender(name: string) {
if (/[晚夏曼雪月柔]/.test(name)) {
return '女';
}
if (/[沉南启川宇]/.test(name)) {
return '男';
}
return '未指定';
}
private validateRoleType(value: string | undefined): CharacterRoleType {
if (!value || !CHARACTER_ROLE_TYPES.includes(value as never)) {
throw new BadRequestException('role_type is invalid');
}
return value as CharacterRoleType;
}
private validateStatus(value: string | undefined) {
if (!value || !CHARACTER_STATUSES.includes(value as never)) {
throw new BadRequestException('status is invalid');
}
return value;
}
private validateImportance(value: unknown) {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < 0 || numberValue > 100) {
throw new BadRequestException('importance_level must be an integer between 0 and 100');
}
return numberValue;
}
private normalizeAliases(value: string[] | undefined): Prisma.InputJsonValue {
return Array.isArray(value)
? value.map((item) => item.trim()).filter(Boolean)
: [];
}
private requiredText(value: string | undefined, message: string) {
const normalized = value?.trim();
if (!normalized) {
throw new BadRequestException(message);
}
return normalized;
}
private optionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || null;
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
}
@@ -0,0 +1,59 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service';
import type { RequestWithRequestId } from './request-id.middleware';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly apiCrypto: ApiCryptoService) {}
catch(exception: unknown, host: ArgumentsHost) {
const context = host.switchToHttp();
const response = context.getResponse<Response>();
const request = context.getRequest<RequestWithRequestId & RequestWithApiCrypto>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const payload = {
code: status,
message: this.getMessage(exception),
data: null,
request_id: request.requestId || 'req_unknown'
};
if (request.apiCrypto) {
response.setHeader('x-api-encrypted', 'v1');
}
response.status(status).json(this.apiCrypto.encryptForRequest(request, payload));
}
private getMessage(exception: unknown) {
if (exception instanceof HttpException) {
const body = exception.getResponse();
if (typeof body === 'string') {
return body;
}
if (typeof body === 'object' && body !== null && 'message' in body) {
const message = body.message;
return Array.isArray(message) ? message.join('; ') : String(message);
}
}
if (exception instanceof Error) {
return exception.message || 'Internal server error';
}
return 'Internal server error';
}
}
@@ -0,0 +1,24 @@
import { Controller, Get, Header, Inject } from '@nestjs/common';
import { ApiCryptoService } from './api-crypto.service';
@Controller('crypto')
export class ApiCryptoController {
constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {}
@Get('handshake')
@Header('Cache-Control', 'no-store')
handshake() {
return this.apiCrypto.createHandshake();
}
}
@Controller('client-config')
export class ClientConfigController {
constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {}
@Get()
@Header('Cache-Control', 'no-store')
getClientConfig() {
return this.apiCrypto.getClientConfig();
}
}
@@ -0,0 +1,123 @@
import {
createCipheriv,
createDecipheriv,
createECDH,
hkdfSync,
randomBytes
} from 'node:crypto';
import { describe, expect, it } from 'vitest';
import {
ApiCryptoService,
type ApiCryptoEnvelope,
type ApiCryptoPublicJwk,
type RequestWithApiCrypto
} from './api-crypto.service';
function base64UrlEncode(input: Buffer) {
return input
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function base64UrlDecode(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
return Buffer.from(padded, 'base64');
}
function publicKeyToJwk(publicKey: Buffer): ApiCryptoPublicJwk {
return {
kty: 'EC',
crv: 'P-256',
x: base64UrlEncode(publicKey.subarray(1, 33)),
y: base64UrlEncode(publicKey.subarray(33, 65)),
ext: true
};
}
function jwkToPublicKey(jwk: ApiCryptoPublicJwk) {
return Buffer.concat([
Buffer.from([4]),
base64UrlDecode(jwk.x),
base64UrlDecode(jwk.y)
]);
}
function encryptPayload(payload: unknown, key: Buffer, sessionId: string, clientPublicKey: ApiCryptoPublicJwk): ApiCryptoEnvelope {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([
cipher.update(Buffer.from(JSON.stringify(payload), 'utf8')),
cipher.final()
]);
return {
version: 1,
session_id: sessionId,
client_public_key: clientPublicKey,
iv: base64UrlEncode(iv),
ciphertext: base64UrlEncode(Buffer.concat([encrypted, cipher.getAuthTag()]))
};
}
function decryptPayload(envelope: ApiCryptoEnvelope, key: Buffer) {
const iv = base64UrlDecode(envelope.iv);
const encryptedWithTag = base64UrlDecode(envelope.ciphertext);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(encryptedWithTag.subarray(-16));
const plaintext = Buffer.concat([
decipher.update(encryptedWithTag.subarray(0, -16)),
decipher.final()
]);
return JSON.parse(plaintext.toString('utf8')) as unknown;
}
describe('ApiCryptoService', () => {
it('decrypts client envelopes and encrypts API responses with the derived session key', async () => {
const service = new ApiCryptoService({
systemConfig: {
findUnique: async () => null
}
} as never);
const handshake = service.createHandshake();
const client = createECDH('prime256v1');
client.generateKeys();
const clientPublicKey = publicKeyToJwk(client.getPublicKey());
const sharedSecret = client.computeSecret(jwkToPublicKey(handshake.server_public_key));
const key = Buffer.from(
hkdfSync(
'sha256',
sharedSecret,
base64UrlDecode(handshake.salt),
Buffer.from(`ai-manga-api-v1:${handshake.session_id}`, 'utf8'),
32
)
);
const requestBody = { title: '加密测试', count: 3 };
const envelope = encryptPayload(requestBody, key, handshake.session_id, clientPublicKey);
const req = {
headers: {},
body: envelope
} as RequestWithApiCrypto;
const attachedEnvelope = await service.attachRequestContext(req);
const decryptedBody = service.decryptRequestBody(attachedEnvelope!, req.apiCrypto!);
expect(decryptedBody).toEqual(requestBody);
const encryptedResponse = service.encryptForRequest(req, {
code: 0,
message: 'success',
data: { ok: true },
request_id: 'req_test'
}) as ApiCryptoEnvelope;
expect(decryptPayload(encryptedResponse, key)).toMatchObject({
code: 0,
data: { ok: true }
});
});
});
+353
View File
@@ -0,0 +1,353 @@
import {
BadRequestException,
Injectable,
UnauthorizedException
} from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createECDH,
hkdfSync,
randomBytes,
randomUUID
} from 'node:crypto';
import type { Request } from 'express';
import { PrismaService } from '../prisma/prisma.service';
const API_CRYPTO_VERSION = 1;
const AES_KEY_BYTES = 32;
const AES_GCM_AUTH_TAG_BYTES = 16;
const AES_GCM_IV_BYTES = 12;
const DEFAULT_SESSION_TTL_SECONDS = 15 * 60;
const HKDF_INFO_PREFIX = 'ai-manga-api-v1';
const API_CRYPTO_CONFIG_KEY = 'security.api_crypto_enabled';
const CONFIG_CACHE_MS = 5000;
export interface ApiCryptoPublicJwk {
kty: 'EC';
crv: 'P-256';
x: string;
y: string;
ext?: boolean;
key_ops?: string[];
}
export interface ApiCryptoEnvelope {
version: number;
session_id: string;
client_public_key?: ApiCryptoPublicJwk;
iv: string;
ciphertext: string;
}
export interface ApiCryptoContext {
sessionId: string;
clientPublicKey: ApiCryptoPublicJwk;
key: Buffer;
}
export interface RequestWithApiCrypto extends Request {
apiCrypto?: ApiCryptoContext;
requestId?: string;
}
interface ApiCryptoSession {
privateKey: Buffer;
salt: Buffer;
expiresAt: number;
}
@Injectable()
export class ApiCryptoService {
private readonly sessions = new Map<string, ApiCryptoSession>();
private cachedEnabled: { value: boolean; expiresAt: number } | null = null;
constructor(private readonly prisma: PrismaService) {}
async isEnabled() {
const envOverride = this.readBooleanEnv(process.env.API_CRYPTO_ENABLED);
if (envOverride !== null) {
return envOverride;
}
const now = Date.now();
if (this.cachedEnabled && this.cachedEnabled.expiresAt > now) {
return this.cachedEnabled.value;
}
let value = false;
try {
const config = await this.prisma.systemConfig.findUnique({
where: { config_key: API_CRYPTO_CONFIG_KEY }
});
value = this.readEnabledFromConfig(config?.config_value);
} catch {
value = false;
}
this.cachedEnabled = {
value,
expiresAt: now + CONFIG_CACHE_MS
};
return value;
}
clearConfigCache() {
this.cachedEnabled = null;
}
async getClientConfig() {
return {
api_crypto_enabled: await this.isEnabled(),
api_crypto_mode: process.env.API_CRYPTO_ENABLED?.trim() || 'auto',
api_crypto_session_ttl_seconds: this.sessionTtlSeconds()
};
}
createHandshake() {
this.pruneExpiredSessions();
const ecdh = createECDH('prime256v1');
ecdh.generateKeys();
const sessionId = randomUUID();
const expiresAt = Date.now() + this.sessionTtlSeconds() * 1000;
const salt = randomBytes(16);
this.sessions.set(sessionId, {
privateKey: ecdh.getPrivateKey(),
salt,
expiresAt
});
return {
version: API_CRYPTO_VERSION,
algorithm: 'ECDH-P256-HKDF-SHA256-AES-256-GCM',
session_id: sessionId,
server_public_key: this.publicKeyToJwk(ecdh.getPublicKey()),
salt: this.base64UrlEncode(salt),
expires_at: new Date(expiresAt).toISOString()
};
}
shouldUseEncryptedApi(req: Request) {
return this.isEncryptedHeader(req.headers['x-api-encrypted']) || this.isEnvelope(req.body);
}
async attachRequestContext(req: RequestWithApiCrypto) {
const bodyEnvelope = this.isEnvelope(req.body) ? req.body : null;
const sessionId = this.readHeader(req.headers['x-api-session-id']) || bodyEnvelope?.session_id;
const clientPublicKey =
bodyEnvelope?.client_public_key ||
this.decodePublicKeyHeader(req.headers['x-api-client-public-key']);
if (!sessionId || !clientPublicKey) {
throw new BadRequestException('Encrypted API session headers are required');
}
req.apiCrypto = {
sessionId,
clientPublicKey,
key: this.deriveKey(sessionId, clientPublicKey)
};
return bodyEnvelope;
}
decryptRequestBody(envelope: ApiCryptoEnvelope, context: ApiCryptoContext) {
const plaintext = this.decryptEnvelope(envelope, context);
return plaintext === null ? {} : plaintext;
}
encryptForRequest(req: RequestWithApiCrypto, payload: unknown) {
if (!req.apiCrypto) {
return payload;
}
return this.encryptPayload(payload, req.apiCrypto);
}
encryptPayload(payload: unknown, context: ApiCryptoContext) {
const iv = randomBytes(AES_GCM_IV_BYTES);
const cipher = createCipheriv('aes-256-gcm', context.key, iv);
const plaintext = Buffer.from(JSON.stringify(payload ?? null), 'utf8');
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return {
encrypted: true,
version: API_CRYPTO_VERSION,
session_id: context.sessionId,
iv: this.base64UrlEncode(iv),
ciphertext: this.base64UrlEncode(Buffer.concat([encrypted, tag]))
};
}
isEnvelope(value: unknown): value is ApiCryptoEnvelope {
if (typeof value !== 'object' || value === null) {
return false;
}
const record = value as Record<string, unknown>;
return (
record.version === API_CRYPTO_VERSION &&
typeof record.session_id === 'string' &&
typeof record.iv === 'string' &&
typeof record.ciphertext === 'string'
);
}
private decryptEnvelope(envelope: ApiCryptoEnvelope, context: ApiCryptoContext) {
if (envelope.session_id !== context.sessionId) {
throw new BadRequestException('Encrypted API session mismatch');
}
const iv = this.base64UrlDecode(envelope.iv);
const encryptedWithTag = this.base64UrlDecode(envelope.ciphertext);
if (iv.length !== AES_GCM_IV_BYTES || encryptedWithTag.length <= AES_GCM_AUTH_TAG_BYTES) {
throw new BadRequestException('Invalid encrypted API payload');
}
const ciphertext = encryptedWithTag.subarray(0, -AES_GCM_AUTH_TAG_BYTES);
const tag = encryptedWithTag.subarray(-AES_GCM_AUTH_TAG_BYTES);
const decipher = createDecipheriv('aes-256-gcm', context.key, iv);
decipher.setAuthTag(tag);
try {
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(plaintext.toString('utf8')) as unknown;
} catch {
throw new BadRequestException('Cannot decrypt API payload');
}
}
private deriveKey(sessionId: string, clientPublicKey: ApiCryptoPublicJwk) {
const session = this.sessions.get(sessionId);
if (!session || session.expiresAt <= Date.now()) {
this.sessions.delete(sessionId);
throw new UnauthorizedException('Encrypted API session expired');
}
const ecdh = createECDH('prime256v1');
ecdh.setPrivateKey(session.privateKey);
const sharedSecret = ecdh.computeSecret(this.jwkToPublicKey(clientPublicKey));
const key = hkdfSync(
'sha256',
sharedSecret,
session.salt,
Buffer.from(`${HKDF_INFO_PREFIX}:${sessionId}`, 'utf8'),
AES_KEY_BYTES
);
return Buffer.from(key);
}
private publicKeyToJwk(publicKey: Buffer): ApiCryptoPublicJwk {
if (publicKey.length !== 65 || publicKey[0] !== 4) {
throw new Error('Invalid P-256 public key');
}
return {
kty: 'EC',
crv: 'P-256',
x: this.base64UrlEncode(publicKey.subarray(1, 33)),
y: this.base64UrlEncode(publicKey.subarray(33, 65)),
ext: true
};
}
private jwkToPublicKey(jwk: ApiCryptoPublicJwk) {
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') {
throw new BadRequestException('Invalid encrypted API public key');
}
const x = this.base64UrlDecode(jwk.x);
const y = this.base64UrlDecode(jwk.y);
if (x.length !== 32 || y.length !== 32) {
throw new BadRequestException('Invalid encrypted API public key');
}
return Buffer.concat([Buffer.from([4]), x, y]);
}
private decodePublicKeyHeader(value: string | string[] | undefined) {
const encoded = this.readHeader(value);
if (!encoded) return null;
try {
return JSON.parse(this.base64UrlDecode(encoded).toString('utf8')) as ApiCryptoPublicJwk;
} catch {
throw new BadRequestException('Invalid encrypted API public key header');
}
}
private readHeader(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
private isEncryptedHeader(value: string | string[] | undefined) {
const header = this.readHeader(value)?.trim().toLowerCase();
return header === 'v1' || header === '1' || header === 'true';
}
private sessionTtlSeconds() {
const configured = Number(process.env.API_CRYPTO_SESSION_TTL_SECONDS);
return Number.isFinite(configured) && configured > 0
? configured
: DEFAULT_SESSION_TTL_SECONDS;
}
private readBooleanEnv(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
if (!normalized || normalized === 'auto') return null;
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return null;
}
private readEnabledFromConfig(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'object' && value !== null && 'enabled' in value) {
return Boolean((value as { enabled?: unknown }).enabled);
}
return false;
}
private pruneExpiredSessions() {
const now = Date.now();
for (const [sessionId, session] of this.sessions.entries()) {
if (session.expiresAt <= now) {
this.sessions.delete(sessionId);
}
}
}
private base64UrlEncode(input: Buffer) {
return input
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
private base64UrlDecode(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
return Buffer.from(padded, 'base64');
}
}
@@ -0,0 +1,70 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
StreamableFile
} from '@nestjs/common';
import type { Response } from 'express';
import { Observable, from, mergeMap } from 'rxjs';
import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service';
import type { RequestWithRequestId } from './request-id.middleware';
interface ApiEnvelope {
code: number;
message: string;
data: unknown;
request_id: string;
}
function isApiEnvelope(value: unknown): value is ApiEnvelope {
return (
typeof value === 'object' &&
value !== null &&
'code' in value &&
'message' in value &&
'data' in value &&
'request_id' in value
);
}
@Injectable()
export class ApiResponseInterceptor implements NestInterceptor {
constructor(private readonly apiCrypto: ApiCryptoService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<RequestWithRequestId & RequestWithApiCrypto>();
const response = http.getResponse<Response>();
const requestId = request.requestId || 'req_unknown';
return next.handle().pipe(
mergeMap((data) => {
if (isApiEnvelope(data)) {
this.markEncryptedResponse(request, response);
return from(Promise.resolve(this.apiCrypto.encryptForRequest(request, data)));
}
if (data instanceof StreamableFile) {
return from(Promise.resolve(data));
}
const envelope = {
code: 0,
message: 'success',
data: data ?? null,
request_id: requestId
};
this.markEncryptedResponse(request, response);
return from(Promise.resolve(this.apiCrypto.encryptForRequest(request, envelope)));
})
);
}
private markEncryptedResponse(request: RequestWithApiCrypto, response: Response) {
if (request.apiCrypto) {
response.setHeader('x-api-encrypted', 'v1');
}
}
}
@@ -0,0 +1,83 @@
import { HttpException, HttpStatus, Inject, Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Response } from 'express';
import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service';
@Injectable()
export class EncryptedRequestMiddleware implements NestMiddleware {
constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {}
use(req: RequestWithApiCrypto, res: Response, next: NextFunction) {
void this.handle(req, res, next);
}
private async handle(req: RequestWithApiCrypto, res: Response, next: NextFunction) {
if (this.isConfigRoute(req)) {
next();
return;
}
const isEncryptedRequest = this.apiCrypto.shouldUseEncryptedApi(req);
const isCryptoEnabled = await this.apiCrypto.isEnabled();
if (!isEncryptedRequest && !isCryptoEnabled) {
next();
return;
}
try {
if (!isEncryptedRequest) {
throw new HttpException('Encrypted API is enabled, please encrypt this request', HttpStatus.BAD_REQUEST);
}
const envelope = await this.apiCrypto.attachRequestContext(req);
if (envelope) {
req.body = this.apiCrypto.decryptRequestBody(envelope, req.apiCrypto!);
} else if (this.requiresEncryptedBody(req)) {
throw new HttpException('Encrypted API request body is required', HttpStatus.BAD_REQUEST);
}
next();
} catch (error) {
const status = error instanceof HttpException ? error.getStatus() : HttpStatus.BAD_REQUEST;
const message = error instanceof Error ? error.message : 'Cannot decrypt API payload';
const payload = {
code: status,
message,
data: null,
request_id: req.requestId || 'req_unknown'
};
const body = this.apiCrypto.encryptForRequest(req, payload);
if (req.apiCrypto) {
res.setHeader('x-api-encrypted', 'v1');
}
res.status(status).json(body);
}
}
private isConfigRoute(req: RequestWithApiCrypto) {
const requestWithUrl = req as RequestWithApiCrypto & { originalUrl?: string };
const path = requestWithUrl.originalUrl || req.path || req.url || '';
return (
path === '/api/crypto/handshake' ||
path === '/api/client-config' ||
path === '/crypto/handshake' ||
path === '/client-config'
);
}
private requiresEncryptedBody(req: RequestWithApiCrypto) {
const method = req.method.toUpperCase();
const contentType = req.headers['content-type'];
const normalizedContentType = Array.isArray(contentType) ? contentType[0] : contentType;
return (
method !== 'GET' &&
method !== 'HEAD' &&
Boolean(normalizedContentType?.includes('application/json'))
);
}
}
@@ -0,0 +1,19 @@
import { randomUUID } from 'node:crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
export interface RequestWithRequestId extends Request {
requestId?: string;
}
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: RequestWithRequestId, res: Response, next: NextFunction) {
const incoming = req.headers['x-request-id'];
const requestId = Array.isArray(incoming) ? incoming[0] : incoming;
req.requestId = requestId || `req_${randomUUID()}`;
res.setHeader('x-request-id', req.requestId);
next();
}
}
+5
View File
@@ -0,0 +1,5 @@
export interface RequestWithId {
requestId?: string;
headers?: Record<string, string | string[] | undefined>;
user?: unknown;
}
@@ -0,0 +1,90 @@
import type { NextFunction, Request, Response } from 'express';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SecureTransportMiddleware } from './secure-transport.middleware';
const originalEnv = { ...process.env };
function createRequest(overrides: Partial<Request> = {}) {
return {
headers: {},
hostname: 'api.example.test',
ip: '203.0.113.10',
protocol: 'http',
secure: false,
requestId: 'req_test',
...overrides
} as Request & { requestId: string };
}
function createResponse() {
const headers: Record<string, string> = {};
const response = {
setHeader: vi.fn((name: string, value: string) => {
headers[name.toLowerCase()] = value;
}),
status: vi.fn().mockReturnThis(),
json: vi.fn()
};
return { response: response as unknown as Response, headers, raw: response };
}
describe('SecureTransportMiddleware', () => {
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
it('allows local HTTP when local development fallback is enabled', () => {
process.env.HTTPS_REQUIRED = 'true';
const middleware = new SecureTransportMiddleware();
const req = createRequest({ hostname: '127.0.0.1', ip: '127.0.0.1' });
const { response, headers, raw } = createResponse();
const next = vi.fn();
middleware.use(req, response, next as unknown as NextFunction);
expect(next).toHaveBeenCalledOnce();
expect(raw.status).not.toHaveBeenCalled();
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['strict-transport-security']).toContain('max-age=31536000');
});
it('rejects non-local HTTP requests when HTTPS is required', () => {
process.env.HTTPS_REQUIRED = 'true';
const middleware = new SecureTransportMiddleware();
const req = createRequest();
const { response, raw } = createResponse();
const next = vi.fn();
middleware.use(req, response, next as unknown as NextFunction);
expect(next).not.toHaveBeenCalled();
expect(raw.status).toHaveBeenCalledWith(426);
expect(raw.json).toHaveBeenCalledWith({
code: 426,
message: 'HTTPS is required for API requests',
data: null,
request_id: 'req_test'
});
});
it('accepts HTTPS forwarded by the reverse proxy', () => {
process.env.HTTPS_REQUIRED = 'true';
process.env.HTTPS_ALLOW_LOCAL_HTTP = 'false';
const middleware = new SecureTransportMiddleware();
const req = createRequest({
headers: { 'x-forwarded-proto': 'https' },
hostname: 'api.example.test',
ip: '203.0.113.10'
});
const { response, headers, raw } = createResponse();
const next = vi.fn();
middleware.use(req, response, next as unknown as NextFunction);
expect(next).toHaveBeenCalledOnce();
expect(raw.status).not.toHaveBeenCalled();
expect(headers['strict-transport-security']).toBe('max-age=31536000; includeSubDomains');
});
});
@@ -0,0 +1,66 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import type { RequestWithRequestId } from './request-id.middleware';
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
@Injectable()
export class SecureTransportMiddleware implements NestMiddleware {
use(req: RequestWithRequestId, res: Response, next: NextFunction) {
this.setSecurityHeaders(req, res);
if (this.isHttpsRequired() && !this.isSecureRequest(req) && !this.isLocalRequest(req)) {
res.status(426).json({
code: 426,
message: 'HTTPS is required for API requests',
data: null,
request_id: req.requestId || 'req_unknown'
});
return;
}
next();
}
private setSecurityHeaders(req: Request, res: Response) {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('x-frame-options', 'DENY');
res.setHeader('referrer-policy', 'no-referrer');
res.setHeader('permissions-policy', 'camera=(), microphone=(), geolocation=()');
res.setHeader('cross-origin-resource-policy', 'same-origin');
if (this.isHttpsRequired() || this.isSecureRequest(req)) {
res.setHeader(
'strict-transport-security',
'max-age=31536000; includeSubDomains'
);
}
}
private isHttpsRequired() {
const configured = process.env.HTTPS_REQUIRED?.trim().toLowerCase();
if (configured === 'true') return true;
if (configured === 'false') return false;
return process.env.NODE_ENV === 'production';
}
private isSecureRequest(req: Request) {
const forwardedProto = req.headers['x-forwarded-proto'];
const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
const firstProto = proto?.split(',')[0]?.trim().toLowerCase();
return req.secure || firstProto === 'https' || req.protocol === 'https';
}
private isLocalRequest(req: Request) {
if (process.env.HTTPS_ALLOW_LOCAL_HTTP === 'false') {
return false;
}
const host = req.hostname || req.ip || '';
return LOCAL_HOSTS.has(host) || req.ip === '::ffff:127.0.0.1';
}
}
+56
View File
@@ -0,0 +1,56 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function parseEnvValue(rawValue: string) {
let value = rawValue.trim();
if (!value) return '';
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return value
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t');
}
function parseEnvFile(content: string) {
const result: Record<string, string> = {};
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
if (!match) continue;
result[match[1]] = parseEnvValue(match[2]);
}
return result;
}
export function loadEnvFiles() {
const initialKeys = new Set(Object.keys(process.env));
const backendRoot = resolve(__dirname, '..', '..');
const repoRoot = resolve(backendRoot, '..');
const files = [...new Set([resolve(repoRoot, '.env'), resolve(backendRoot, '.env')])];
for (const file of files) {
if (!existsSync(file)) continue;
const parsed = parseEnvFile(readFileSync(file, 'utf8'));
for (const [key, value] of Object.entries(parsed)) {
if (initialKeys.has(key)) continue;
process.env[key] = value;
}
}
}
loadEnvFiles();
+17
View File
@@ -0,0 +1,17 @@
import type { EpisodeStatus } from './episode.types';
export class GenerateEpisodePlanDto {
target_episode_count?: number;
}
export class UpdateEpisodeDto {
episode_no?: number;
source_chapter_ids?: string[];
title?: string;
summary?: string;
opening_hook?: string;
middle_conflict?: string;
ending_hook?: string;
target_duration?: number;
status?: EpisodeStatus;
}
+39
View File
@@ -0,0 +1,39 @@
import type { Episode, Prisma } from '@prisma/client';
export const EPISODE_STATUSES = ['draft', 'generated', 'edited', 'confirmed'] as const;
export type EpisodeStatus = (typeof EPISODE_STATUSES)[number];
export interface SafeEpisode {
id: string;
project_id: string;
episode_no: number;
source_chapter_ids: Prisma.JsonValue | null;
title: string | null;
summary: string | null;
opening_hook: string | null;
middle_conflict: string | null;
ending_hook: string | null;
target_duration: number | null;
status: string;
created_at: string;
updated_at: string;
}
export function toSafeEpisode(episode: Episode): SafeEpisode {
return {
id: episode.id.toString(),
project_id: episode.project_id.toString(),
episode_no: episode.episode_no,
source_chapter_ids: episode.source_chapter_ids,
title: episode.title,
summary: episode.summary,
opening_hook: episode.opening_hook,
middle_conflict: episode.middle_conflict,
ending_hook: episode.ending_hook,
target_duration: episode.target_duration,
status: episode.status,
created_at: episode.created_at.toISOString(),
updated_at: episode.updated_at.toISOString()
};
}
@@ -0,0 +1,46 @@
import { Body, Controller, Get, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { GenerateEpisodePlanDto, UpdateEpisodeDto } from './episode.dto';
import { EpisodesService } from './episodes.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class EpisodesController {
constructor(@Inject(EpisodesService) private readonly episodesService: EpisodesService) {}
@Post('projects/:projectId/episodes/generate-plan')
generatePlan(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: GenerateEpisodePlanDto
) {
return this.episodesService.generatePlan(user, projectId, dto);
}
@Get('projects/:projectId/episodes')
listEpisodes(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.episodesService.listEpisodes(user, projectId);
}
@Patch('episodes/:episodeId')
updateEpisode(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: UpdateEpisodeDto
) {
return this.episodesService.updateEpisode(user, episodeId, dto);
}
@Post('projects/:projectId/episodes/confirm')
confirmEpisodes(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.episodesService.confirmEpisodes(user, projectId);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { EpisodesController } from './episodes.controller';
import { EpisodesService } from './episodes.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [EpisodesController],
providers: [EpisodesService],
exports: [EpisodesService]
})
export class EpisodesModule {}
@@ -0,0 +1,353 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
Character,
Episode,
NovelChapter,
PlotMemory,
PlotThread,
Project,
StoryBible
} from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import { EpisodesService } from './episodes.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const now = new Date('2026-05-31T00:00:00.000Z');
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '重生归来,我只搞事业',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'character_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createStoryBible(overrides: Partial<StoryBible> = {}): StoryBible {
return {
id: 40n,
project_id: 10n,
title: '重生归来,我只搞事业',
logline: '林晚重回命运转折点,用证据夺回项目。',
main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。',
core_conflict: '林晚必须在资本压力中守住原创项目。',
selling_points: '重生归来\n证据反杀',
tone: '克制、锋利、连续反转',
world_summary: '现代都市内容公司',
ending_direction: '幕后真相继续推进。',
taboo_rules: '不得改变主角姓名。',
version: 1,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 50n,
project_id: 10n,
global_character_id: null,
name: '林晚',
alias_names: [],
role_type: 'protagonist',
gender_label: '女',
age_group: '青年',
identity_desc: '故事主角',
appearance_desc: '眼神坚定',
face_desc: '精致脸型',
hair_desc: '深色中长发',
eye_desc: '深色眼睛',
body_desc: '身形修长',
costume_rules: '现代都市通勤装',
special_props: '手机、录音证据',
personality_desc: '冷静克制',
speech_style: '短句明确',
relationship_desc: '与周启围绕项目控制权对抗',
character_arc: '从被动到主动',
negative_rules: '不得改名',
anchor_asset_id: null,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: null,
performance_style: null,
importance_level: 100,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
return {
id: 30n,
project_id: 10n,
novel_source_id: 20n,
chapter_no: 1,
title: '第1章 暴雨重启',
content: '林晚站在暴雨夜里醒来,决定重新夺回项目。',
summary: '林晚确认重生并整理证据。',
visual_summary: '暴雨夜,林晚醒来,手机录音亮起。',
word_count: 22,
status: 'generated',
created_at: now,
...overrides
};
}
function createPlotMemory(overrides: Partial<PlotMemory> = {}): PlotMemory {
return {
id: 60n,
project_id: 10n,
episode_id: null,
chapter_id: 30n,
memory_type: 'foreshadowing',
content: '录音证据会在后续揭开幕后真相。',
importance_level: 90,
status: 'active',
created_at: now,
...overrides
};
}
function createPlotThread(overrides: Partial<PlotThread> = {}): PlotThread {
return {
id: 70n,
project_id: 10n,
thread_name: '主线目标',
thread_type: 'main_plot',
description: '林晚夺回原创项目控制权。',
start_episode_no: 1,
expected_resolve_episode_no: 3,
resolved_episode_no: null,
status: 'open',
created_at: now,
updated_at: now,
...overrides
};
}
function createEpisode(overrides: Partial<Episode> = {}): Episode {
return {
id: 80n,
project_id: 10n,
episode_no: 1,
source_chapter_ids: ['30'],
title: '第1集 暴雨重启',
summary: '林晚确认重生并整理证据。',
opening_hook: '林晚在暴雨夜发现关键转机。',
middle_conflict: '周启试图转移责任。',
ending_hook: '录音证据指向幕后真相。',
target_duration: 60,
status: 'generated',
created_at: now,
updated_at: now,
...overrides
};
}
describe('EpisodesService', () => {
let prisma: any;
let tx: any;
let service: EpisodesService;
beforeEach(() => {
tx = {
episode: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 3 }),
findMany: vi.fn().mockResolvedValue([
createEpisode(),
createEpisode({ id: 81n, episode_no: 2, title: '第2集 会议反击' }),
createEpisode({ id: 82n, episode_no: 3, title: '第3集 真相逼近' })
]),
updateMany: vi.fn().mockResolvedValue({ count: 3 })
},
project: {
update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_episode_confirm' }))
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject({ status: 'episode_planning' }))
},
storyBible: {
findFirst: vi.fn().mockResolvedValue(createStoryBible())
},
character: {
findMany: vi.fn().mockResolvedValue([
createCharacter(),
createCharacter({
id: 51n,
name: '周启',
role_type: 'antagonist',
importance_level: 80
})
])
},
novelChapter: {
findMany: vi.fn().mockResolvedValue([
createChapter(),
createChapter({
id: 31n,
chapter_no: 2,
title: '第2章 会议反击',
summary: '林晚在会议上用证据反击周启。'
}),
createChapter({
id: 32n,
chapter_no: 3,
title: '第3章 真相逼近',
summary: '幕后投资人的名字第一次出现。'
})
]),
count: vi.fn().mockResolvedValue(1)
},
plotMemory: {
findMany: vi.fn().mockResolvedValue([
createPlotMemory(),
createPlotMemory({
id: 61n,
memory_type: 'unresolved_conflict',
content: '林晚必须在资本压力中守住原创项目。'
})
])
},
plotThread: {
findMany: vi.fn().mockResolvedValue([createPlotThread()])
},
episode: {
count: vi.fn().mockResolvedValue(0),
findMany: vi.fn().mockResolvedValue([createEpisode()]),
findUnique: vi.fn().mockResolvedValue(createEpisode()),
findFirst: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(createEpisode({ status: 'edited', title: '第1集 新标题' }))
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
service = new EpisodesService(prisma as PrismaService);
});
it('generates an episode plan from story, character, and memory context', async () => {
const result = await service.generatePlan(user, '10', {});
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'episode_planning' }
});
expect(tx.episode.deleteMany).toHaveBeenCalledWith({ where: { project_id: 10n } });
expect(tx.episode.createMany.mock.calls[0][0].data).toHaveLength(3);
expect(tx.episode.createMany.mock.calls[0][0].data[0]).toEqual(
expect.objectContaining({
project_id: 10n,
episode_no: 1,
status: 'generated'
})
);
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'waiting_episode_confirm' }
});
expect(result.episodes).toHaveLength(3);
expect(result.next_step).toBe('episode_confirm');
});
it('requires long-form memories before planning episodes', async () => {
prisma.plotMemory.findMany.mockResolvedValue([]);
await expect(service.generatePlan(user, '10', {})).rejects.toBeInstanceOf(
BadRequestException
);
});
it('updates an editable episode and marks it edited', async () => {
const result = await service.updateEpisode(user, '80', {
title: '第1集 新标题',
opening_hook: '新开头钩子'
});
expect(prisma.episode.update).toHaveBeenCalledWith({
where: { id: 80n },
data: expect.objectContaining({
title: '第1集 新标题',
opening_hook: '新开头钩子',
status: 'edited'
})
});
expect(result.status).toBe('edited');
});
it('blocks editing confirmed episodes', async () => {
prisma.episode.findUnique.mockResolvedValue(createEpisode({ status: 'confirmed' }));
await expect(service.updateEpisode(user, '80', { title: '不可编辑' })).rejects.toBeInstanceOf(
BadRequestException
);
});
it('confirms a complete episode plan', async () => {
prisma.episode.findMany.mockResolvedValue([
createEpisode(),
createEpisode({ id: 81n, episode_no: 2 }),
createEpisode({ id: 82n, episode_no: 3 })
]);
const result = await service.confirmEpisodes(user, '10');
expect(tx.episode.updateMany).toHaveBeenCalledWith({
where: {
project_id: 10n,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'confirmed' }
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'episode_confirmed' }
});
expect(result.next_step).toBe('script_generate');
});
it('rejects incomplete episode confirmation', async () => {
prisma.episode.findMany.mockResolvedValue([createEpisode({ ending_hook: null })]);
await expect(service.confirmEpisodes(user, '10')).rejects.toBeInstanceOf(
BadRequestException
);
});
it('rejects access to another user project', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
await expect(service.listEpisodes(user, '10')).rejects.toBeInstanceOf(ForbiddenException);
});
});
+501
View File
@@ -0,0 +1,501 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type {
Character,
Episode,
NovelChapter,
PlotMemory,
PlotThread,
Prisma,
Project,
StoryBible
} from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { GenerateEpisodePlanDto, UpdateEpisodeDto } from './episode.dto';
import { EPISODE_STATUSES, toSafeEpisode, type EpisodeStatus } from './episode.types';
const MIN_EPISODES = 1;
const MAX_EPISODES = 100;
const MIN_DURATION = 15;
const MAX_DURATION = 600;
interface EpisodeDraft {
episode_no: number;
source_chapter_ids: Prisma.InputJsonValue;
title: string;
summary: string;
opening_hook: string;
middle_conflict: string;
ending_hook: string;
target_duration: number;
status: EpisodeStatus;
}
interface EpisodePlanContext {
storyBible: StoryBible;
characters: Character[];
chapters: NovelChapter[];
plotMemories: PlotMemory[];
plotThreads: PlotThread[];
}
@Injectable()
export class EpisodesService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async generatePlan(user: AuthRequestUser, projectId: string, dto: GenerateEpisodePlanDto) {
const project = await this.findProjectForUser(projectId, user);
const count = this.resolveEpisodeCount(project, dto.target_episode_count);
const context = await this.loadPlanContext(project.id);
const existingConfirmed = await this.prisma.episode.count({
where: {
project_id: project.id,
status: 'confirmed'
}
});
if (existingConfirmed > 0) {
throw new BadRequestException('Confirmed episodes cannot be regenerated');
}
const drafts = this.buildEpisodeDrafts(project, count, context);
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'episode_planning' }
});
const episodes = await this.prisma.$transaction(async (tx) => {
await tx.episode.deleteMany({
where: { project_id: project.id }
});
await tx.episode.createMany({
data: drafts.map((draft) => ({
project_id: project.id,
...draft
}))
});
const saved = await tx.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'waiting_episode_confirm' }
});
return saved;
});
return {
episodes: episodes.map(toSafeEpisode),
memory_context: {
story_bible_id: context.storyBible.id.toString(),
locked_character_count: context.characters.length,
active_plot_memory_count: context.plotMemories.length,
open_thread_count: context.plotThreads.length
},
next_step: 'episode_confirm'
};
}
async listEpisodes(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const episodes = await this.prisma.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
return episodes.map(toSafeEpisode);
}
async updateEpisode(user: AuthRequestUser, episodeId: string, dto: UpdateEpisodeDto) {
const episode = await this.findEpisodeForUser(episodeId, user);
if (episode.status === 'confirmed') {
throw new BadRequestException('Confirmed episodes cannot be edited');
}
const data = await this.createUpdateData(episode, dto);
if (Object.keys(data).length === 0) {
throw new BadRequestException('No episode fields to update');
}
const updated = await this.prisma.episode.update({
where: { id: episode.id },
data
});
await this.prisma.project.update({
where: { id: episode.project_id },
data: { status: 'waiting_episode_confirm' }
});
return toSafeEpisode(updated);
}
async confirmEpisodes(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const episodes = await this.prisma.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
this.assertEpisodesReadyForConfirmation(episodes);
const confirmed = await this.prisma.$transaction(async (tx) => {
await tx.episode.updateMany({
where: {
project_id: project.id,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'confirmed' }
});
const saved = await tx.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'episode_confirmed' }
});
return saved;
});
return {
episodes: confirmed.map(toSafeEpisode),
next_step: 'script_generate'
};
}
private async loadPlanContext(projectId: bigint): Promise<EpisodePlanContext> {
const [storyBible, characters, chapters, plotMemories, plotThreads] = await Promise.all([
this.prisma.storyBible.findFirst({
where: {
project_id: projectId,
status: 'confirmed'
},
orderBy: { version: 'desc' }
}),
this.prisma.character.findMany({
where: {
project_id: projectId,
status: 'locked'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
}),
this.prisma.novelChapter.findMany({
where: { project_id: projectId },
orderBy: { chapter_no: 'asc' }
}),
this.prisma.plotMemory.findMany({
where: {
project_id: projectId,
status: 'active'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
}),
this.prisma.plotThread.findMany({
where: {
project_id: projectId,
status: { in: ['open', 'progressing', 'paused'] }
},
orderBy: [{ status: 'asc' }, { id: 'asc' }]
})
]);
if (!storyBible) {
throw new BadRequestException('Confirmed story bible is required before episode planning');
}
if (characters.length === 0) {
throw new BadRequestException('Locked characters are required before episode planning');
}
if (chapters.length === 0) {
throw new BadRequestException('Novel chapters are required before episode planning');
}
if (plotMemories.length === 0) {
throw new BadRequestException('Long-form plot memories are required before episode planning');
}
return {
storyBible,
characters,
chapters,
plotMemories,
plotThreads
};
}
private buildEpisodeDrafts(
project: Project,
count: number,
context: EpisodePlanContext
): EpisodeDraft[] {
const protagonist =
context.characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ??
context.characters[0];
const antagonist = context.characters.find((character) => character.role_type === 'antagonist');
const importantForeshadowing = context.plotMemories.find(
(memory) => memory.memory_type === 'foreshadowing'
);
const unresolvedConflict = context.plotMemories.find(
(memory) => memory.memory_type === 'unresolved_conflict'
);
const mainThread =
context.plotThreads.find((thread) => thread.thread_type === 'main_plot') ??
context.plotThreads[0];
const duration = this.validateDuration(project.episode_duration ?? 60);
return Array.from({ length: count }, (_, index) => {
const episodeNo = index + 1;
const chapterGroup = this.pickChapterGroup(context.chapters, index, count);
const firstChapter = chapterGroup[0] ?? context.chapters[0];
const lastChapter = chapterGroup.at(-1) ?? firstChapter;
const chapterSummary = chapterGroup
.map((chapter) => chapter.summary || this.compact(chapter.content).slice(0, 70))
.join('');
const sourceChapterIds = chapterGroup.map((chapter) => chapter.id.toString());
const threadText = mainThread?.description || context.storyBible.main_plot || '主线目标持续推进';
return {
episode_no: episodeNo,
source_chapter_ids: sourceChapterIds,
title: this.buildEpisodeTitle(episodeNo, firstChapter, count),
summary: [
`${protagonist.name}围绕${this.compact(threadText).slice(0, 80)}推进第${episodeNo}集。`,
chapterSummary,
episodeNo === count
? context.storyBible.ending_direction || '阶段性回收关键伏笔,并保留下一阶段入口。'
: '本集保留短视频节奏,结尾留下可承接悬念。'
].filter(Boolean).join(' '),
opening_hook:
episodeNo === 1
? `${protagonist.name}在高压场景中发现关键转机,观众第一秒进入冲突。`
: `承接上一集悬念,${protagonist.name}立刻面对新的选择和压力。`,
middle_conflict:
unresolvedConflict?.content ||
`${antagonist?.name ?? '主要对手'}围绕核心利益继续施压,${protagonist.name}必须用证据或行动反击。`,
ending_hook: this.buildEndingHook(
episodeNo,
count,
protagonist.name,
lastChapter,
importantForeshadowing,
context.storyBible
),
target_duration: duration,
status: 'generated'
};
});
}
private buildEpisodeTitle(episodeNo: number, chapter: NovelChapter, count: number) {
const cleaned = chapter.title
?.replace(/^?[0-9]+[.\s-]*/u, '')
.trim();
const fallback = episodeNo === count ? '真相逼近' : episodeNo === 1 ? '开局反击' : '冲突升级';
return `${episodeNo}${cleaned || fallback}`;
}
private buildEndingHook(
episodeNo: number,
count: number,
protagonistName: string,
chapter: NovelChapter,
foreshadowing: PlotMemory | undefined,
storyBible: StoryBible
) {
if (episodeNo === count) {
return storyBible.ending_direction || `${protagonistName}阶段性赢下对抗,但幕后真相仍未完全揭开。`;
}
const source = foreshadowing?.content || chapter.summary || chapter.title || '关键线索';
return `${protagonistName}发现${this.compact(source).slice(0, 42)},下一集必须继续追查。`;
}
private pickChapterGroup(chapters: NovelChapter[], index: number, count: number) {
const start = Math.floor((index * chapters.length) / count);
const end = Math.max(start + 1, Math.floor(((index + 1) * chapters.length) / count));
return chapters.slice(start, Math.min(end, chapters.length));
}
private async createUpdateData(
episode: Episode,
dto: UpdateEpisodeDto
): Promise<Prisma.EpisodeUncheckedUpdateInput> {
const data: Prisma.EpisodeUncheckedUpdateInput = {};
if ('episode_no' in dto) {
data.episode_no = await this.validateEpisodeNoForUpdate(episode, dto.episode_no);
}
if ('source_chapter_ids' in dto) {
data.source_chapter_ids = await this.validateSourceChapterIds(
episode.project_id,
dto.source_chapter_ids
);
}
if ('title' in dto) data.title = this.optionalText(dto.title);
if ('summary' in dto) data.summary = this.optionalText(dto.summary);
if ('opening_hook' in dto) data.opening_hook = this.optionalText(dto.opening_hook);
if ('middle_conflict' in dto) data.middle_conflict = this.optionalText(dto.middle_conflict);
if ('ending_hook' in dto) data.ending_hook = this.optionalText(dto.ending_hook);
if ('target_duration' in dto) {
data.target_duration = this.validateDuration(dto.target_duration);
}
if ('status' in dto) data.status = this.validateStatus(dto.status);
if (Object.keys(data).length > 0 && data.status !== 'confirmed') {
data.status = data.status ?? 'edited';
}
return data;
}
private async validateEpisodeNoForUpdate(episode: Episode, value: number | undefined) {
const episodeNo = this.validatePositiveInt(value, 'episode_no', MIN_EPISODES, MAX_EPISODES);
if (episodeNo === episode.episode_no) {
return episodeNo;
}
const existing = await this.prisma.episode.findFirst({
where: {
project_id: episode.project_id,
episode_no: episodeNo,
id: { not: episode.id }
}
});
if (existing) {
throw new BadRequestException('episode_no already exists in this project');
}
return episodeNo;
}
private async validateSourceChapterIds(projectId: bigint, value: string[] | undefined) {
if (!Array.isArray(value) || value.length === 0) {
throw new BadRequestException('source_chapter_ids must be a non-empty array');
}
const ids = value.map((item) => this.parseId(String(item), 'Invalid source chapter id'));
const count = await this.prisma.novelChapter.count({
where: {
project_id: projectId,
id: { in: ids }
}
});
if (count !== ids.length) {
throw new BadRequestException('source_chapter_ids contain chapters outside this project');
}
return ids.map((id) => id.toString());
}
private assertEpisodesReadyForConfirmation(episodes: Episode[]) {
if (episodes.length === 0) {
throw new BadRequestException('Episode plan is required before confirmation');
}
for (const [index, episode] of episodes.entries()) {
if (episode.episode_no !== index + 1) {
throw new BadRequestException('Episode numbers must be continuous from 1');
}
if (
!episode.title ||
!episode.summary ||
!episode.opening_hook ||
!episode.middle_conflict ||
!episode.ending_hook ||
!episode.target_duration
) {
throw new BadRequestException('All episodes must include title, hooks, conflict, summary, and duration');
}
}
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private async findEpisodeForUser(episodeId: string, user: AuthRequestUser) {
const episode = await this.prisma.episode.findUnique({
where: { id: this.parseId(episodeId, 'Invalid episode id') }
});
if (!episode) {
throw new NotFoundException('Episode not found');
}
await this.findProjectForUser(episode.project_id.toString(), user);
return episode;
}
private resolveEpisodeCount(project: Project, value: number | undefined) {
return this.validatePositiveInt(
value ?? project.target_episode_count ?? (project.input_mode === 'ai_original' ? 3 : 1),
'target_episode_count',
MIN_EPISODES,
MAX_EPISODES
);
}
private validateDuration(value: number | undefined) {
return this.validatePositiveInt(value, 'target_duration', MIN_DURATION, MAX_DURATION);
}
private validateStatus(value: string | undefined): EpisodeStatus {
if (!value || !EPISODE_STATUSES.includes(value as never)) {
throw new BadRequestException('episode status is invalid');
}
return value as EpisodeStatus;
}
private validatePositiveInt(value: unknown, field: string, min: number, max: number) {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) {
throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`);
}
return numberValue;
}
private optionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || null;
}
private compact(value: string) {
return value.replace(/\s+/g, ' ').trim();
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
export class GenerateCharacterImagesDto {
image_types?: string[];
count_per_type?: number;
force?: boolean;
set_first_as_anchor?: boolean;
}
export class SetCharacterAnchorDto {
character_image_id?: string;
asset_id?: string;
}
export class GenerateShotImageDto {
image_type?: string;
force?: boolean;
}
export class GenerateEpisodeShotImagesDto {
image_type?: string;
only_missing?: boolean;
limit?: number;
}
+83
View File
@@ -0,0 +1,83 @@
import type { Asset, CharacterImage, ShotImage } from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
export const CHARACTER_IMAGE_TYPES = [
'front_reference',
'side_reference',
'expression_pack',
'costume_default',
'costume_special',
'anchor',
'scene_variant'
] as const;
export const SHOT_IMAGE_TYPES = ['preview', 'final'] as const;
export type CharacterImageType = (typeof CHARACTER_IMAGE_TYPES)[number];
export type ShotImageType = (typeof SHOT_IMAGE_TYPES)[number];
export interface SafeCharacterImage {
id: string;
project_id: string;
character_id: string;
asset_id: string | null;
image_type: string;
prompt_text: string | null;
negative_prompt: string | null;
is_anchor: boolean;
quality_score: number | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
}
export interface SafeShotImage {
id: string;
project_id: string;
episode_id: string | null;
shot_id: string;
asset_id: string | null;
image_type: string;
prompt_text: string | null;
negative_prompt: string | null;
quality_score: number | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
}
export function toSafeCharacterImage(
image: CharacterImage & { asset?: Asset | null }
): SafeCharacterImage {
return {
id: image.id.toString(),
project_id: image.project_id.toString(),
character_id: image.character_id.toString(),
asset_id: image.asset_id?.toString() ?? null,
image_type: image.image_type,
prompt_text: image.prompt_text,
negative_prompt: image.negative_prompt,
is_anchor: image.is_anchor,
quality_score: image.quality_score ? Number(image.quality_score.toString()) : null,
status: image.status,
created_at: image.created_at.toISOString(),
asset: image.asset ? toSafeAsset(image.asset) : undefined
};
}
export function toSafeShotImage(image: ShotImage & { asset?: Asset | null }): SafeShotImage {
return {
id: image.id.toString(),
project_id: image.project_id.toString(),
episode_id: image.episode_id?.toString() ?? null,
shot_id: image.shot_id.toString(),
asset_id: image.asset_id?.toString() ?? null,
image_type: image.image_type,
prompt_text: image.prompt_text,
negative_prompt: image.negative_prompt,
quality_score: image.quality_score ? Number(image.quality_score.toString()) : null,
status: image.status,
created_at: image.created_at.toISOString(),
asset: image.asset ? toSafeAsset(image.asset) : undefined
};
}
+74
View File
@@ -0,0 +1,74 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
UseGuards
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import {
GenerateCharacterImagesDto,
GenerateEpisodeShotImagesDto,
GenerateShotImageDto,
SetCharacterAnchorDto
} from './image.dto';
import { ImagesService } from './images.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class ImagesController {
constructor(@Inject(ImagesService) private readonly imagesService: ImagesService) {}
@Post('characters/:characterId/generate-images')
generateCharacterImages(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: GenerateCharacterImagesDto
) {
return this.imagesService.generateCharacterImages(user, characterId, dto);
}
@Get('characters/:characterId/images')
listCharacterImages(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.imagesService.listCharacterImages(user, characterId);
}
@Post('characters/:characterId/set-anchor')
setCharacterAnchor(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: SetCharacterAnchorDto
) {
return this.imagesService.setCharacterAnchor(user, characterId, dto);
}
@Post('storyboard-shots/:shotId/images/generate')
generateShotImage(
@CurrentUser() user: AuthRequestUser,
@Param('shotId') shotId: string,
@Body() dto: GenerateShotImageDto
) {
return this.imagesService.generateShotImage(user, shotId, dto);
}
@Get('storyboard-shots/:shotId/images')
listShotImages(@CurrentUser() user: AuthRequestUser, @Param('shotId') shotId: string) {
return this.imagesService.listShotImages(user, shotId);
}
@Post('episodes/:episodeId/shot-images/generate')
generateEpisodeShotImages(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: GenerateEpisodeShotImagesDto
) {
return this.imagesService.generateEpisodeShotImages(user, episodeId, dto);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { ImagesController } from './images.controller';
import { ImagesService } from './images.service';
@Module({
imports: [AuthModule, AssetsModule, PrismaModule, ProvidersModule],
controllers: [ImagesController],
providers: [ImagesService],
exports: [ImagesService]
})
export class ImagesModule {}
+485
View File
@@ -0,0 +1,485 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
Asset,
Character,
CharacterImage,
Episode,
Project,
RenderTask,
ShotImage,
StoryboardShot
} from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthRequestUser } from '../auth/auth.types';
import type { StorageService } from '../assets/storage.service';
import type { PrismaService } from '../prisma/prisma.service';
import type { ProvidersService } from '../providers/providers.service';
import { ImagesService } from './images.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const now = new Date('2026-05-31T00:00:00.000Z');
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '阶段15 图片项目',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'storyboard_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 20n,
project_id: 10n,
global_character_id: null,
name: '林晚',
alias_names: [],
role_type: 'protagonist',
gender_label: '女',
age_group: '青年',
identity_desc: '短剧主角',
appearance_desc: '眼神坚定,气质冷静',
face_desc: '精致鹅蛋脸',
hair_desc: '深色中长发',
eye_desc: '深色眼睛',
body_desc: '身形修长',
costume_rules: '现代都市通勤装',
special_props: '手机、录音证据',
personality_desc: '克制果断',
speech_style: '短句明确',
relationship_desc: '与周启对抗',
character_arc: '从被动到主动',
negative_rules: '不得改名,不得改发色',
anchor_asset_id: null,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: null,
performance_style: null,
importance_level: 100,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createEpisode(overrides: Partial<Episode> = {}): Episode {
return {
id: 30n,
project_id: 10n,
episode_no: 1,
source_chapter_ids: ['1'],
title: '第1集',
summary: '林晚反击。',
opening_hook: '会议室大屏播放录音。',
middle_conflict: '周启试图压制。',
ending_hook: '幕后车辆出现。',
target_duration: 60,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createShot(overrides: Partial<StoryboardShot> = {}): StoryboardShot {
return {
id: 40n,
project_id: 10n,
episode_id: 30n,
shot_no: 1,
scene_name: '雨夜反击',
location_desc: '会议室',
characters_json: [{ id: '20', name: '林晚' }],
visual_desc: '林晚站在会议桌前,冷静抬眼。',
action_desc: '林晚播放录音证据。',
dialogue_text: '这一回,我不会再退。',
narration_text: '局势开始反转。',
camera_motion: 'zoom_in',
effect_type: 'flash',
duration: new Prisma.Decimal(4),
scene_type: null,
importance_score: null,
emotion_score: null,
action_score: null,
route_tier: null,
prompt_text: '高质量韩漫风,会议室反击。',
negative_prompt: '低清晰度,多余人物。',
live_action_desc: null,
actor_action: null,
camera_instruction: null,
performance_instruction: null,
video_prompt: null,
keyframe_asset_id: null,
video_clip_asset_id: null,
video_status: null,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 50n,
user_id: 1n,
project_id: 10n,
asset_type: 'image',
file_path: 'local://generated-images/mock.svg',
file_url: 'mock://image/mock.png',
mime_type: 'image/svg+xml',
width: 1080,
height: 1920,
duration: null,
size: 1024n,
hash: 'hash-a',
visibility: 'private',
status: 'active',
created_at: now,
...overrides
};
}
function createCharacterImage(overrides: Partial<CharacterImage> = {}): CharacterImage {
return {
id: 60n,
project_id: 10n,
character_id: 20n,
asset_id: 50n,
image_type: 'front_reference',
prompt_text: 'prompt',
negative_prompt: 'negative',
is_anchor: false,
quality_score: new Prisma.Decimal(92),
status: 'generated',
created_at: now,
...overrides
};
}
function createShotImage(overrides: Partial<ShotImage> = {}): ShotImage {
return {
id: 70n,
project_id: 10n,
episode_id: 30n,
shot_id: 40n,
asset_id: 50n,
image_type: 'preview',
prompt_text: 'prompt',
negative_prompt: 'negative',
quality_score: new Prisma.Decimal(92),
status: 'generated',
created_at: now,
...overrides
};
}
function createTask(overrides: Partial<RenderTask> = {}): RenderTask {
return {
id: 80n,
project_id: 10n,
episode_id: null,
shot_id: null,
task_type: 'character_image_generate',
provider_id: null,
status: 'pending',
input_json: {},
input_hash: 'hash-task',
idempotency_key: 'idem-task',
output_asset_id: null,
provider_request_id: null,
retry_count: 0,
max_retry: 3,
cost_estimate: null,
cost_actual: null,
error_code: null,
error_message: null,
created_at: now,
started_at: null,
finished_at: null,
...overrides
};
}
describe('ImagesService', () => {
let prisma: any;
let storage: any;
let providers: any;
let tx: any;
let service: ImagesService;
beforeEach(() => {
tx = {
characterImage: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' }))
},
character: {
update: vi.fn().mockResolvedValue(createCharacter({ anchor_asset_id: 50n }))
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject())
},
character: {
findUnique: vi.fn().mockResolvedValue(createCharacter()),
findMany: vi.fn().mockResolvedValue([createCharacter({ anchor_asset_id: 50n })])
},
characterImage: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createCharacterImage()),
findMany: vi.fn().mockResolvedValue([createCharacterImage()]),
create: vi.fn().mockResolvedValue(createCharacterImage())
},
storyboardShot: {
findUnique: vi.fn().mockResolvedValue(createShot()),
findMany: vi.fn().mockResolvedValue([createShot()])
},
episode: {
findUnique: vi.fn().mockResolvedValue(createEpisode())
},
shotImage: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createShotImage()),
findMany: vi.fn().mockResolvedValue([createShotImage()]),
create: vi.fn().mockResolvedValue(createShotImage())
},
renderTask: {
create: vi.fn().mockResolvedValue(createTask()),
update: vi.fn().mockResolvedValue(createTask({ status: 'success', output_asset_id: 50n }))
},
asset: {
create: vi.fn().mockResolvedValue(createAsset()),
findUnique: vi.fn().mockResolvedValue(createAsset())
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
storage = {
storePrivateFile: vi.fn().mockResolvedValue({
file_path: 'local://generated-images/mock.svg',
size: 1024n,
hash: 'hash-a',
backend: 'local'
})
};
providers = {
executeProvider: vi.fn().mockResolvedValue({
provider: {
mode: 'mock'
},
result: {
provider_request_id: 'mock-mock-image-a',
asset_url: 'mock://image/a.png'
},
provider_log: {
cost_estimate: 0,
cost_actual: 0
}
})
};
service = new ImagesService(
prisma as PrismaService,
storage as StorageService,
providers as ProvidersService
);
});
it('generates locked character reference images through ImageProvider', async () => {
const result = await service.generateCharacterImages(user, '20', {
image_types: ['front_reference'],
set_first_as_anchor: false
});
expect(providers.executeProvider).toHaveBeenCalledWith(
expect.objectContaining({
provider_type: 'ImageProvider',
task_id: '80',
allow_fallback: false,
input_json: expect.objectContaining({
width: 1080,
height: 1920
})
})
);
expect(prisma.characterImage.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
character_id: 20n,
asset_id: 50n,
image_type: 'front_reference',
status: 'generated'
})
});
expect(prisma.renderTask.update).toHaveBeenCalledWith({
where: { id: 80n },
data: expect.objectContaining({
status: 'success',
output_asset_id: 50n
})
});
expect(result.images).toHaveLength(1);
expect(result.next_step).toBe('shot_image_generate');
});
it('sets a character anchor image and updates the character anchor asset', async () => {
const result = await service.setCharacterAnchor(user, '20', {
character_image_id: '60'
});
expect(tx.characterImage.updateMany).toHaveBeenCalledWith({
where: {
character_id: 20n,
is_anchor: true,
id: { not: 60n }
},
data: {
is_anchor: false,
status: 'generated'
}
});
expect(tx.character.update).toHaveBeenCalledWith({
where: { id: 20n },
data: { anchor_asset_id: 50n }
});
expect(result.anchor_asset_id).toBe('50');
});
it('generates a preview image for a confirmed storyboard shot', async () => {
const result = await service.generateShotImage(user, '40', {
image_type: 'preview'
});
expect(prisma.shotImage.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
episode_id: 30n,
shot_id: 40n,
asset_id: 50n,
image_type: 'preview',
status: 'generated'
})
});
expect(providers.executeProvider.mock.calls[0][0].input_json.prompt).toContain(
'anchor_asset_id=50'
);
expect(providers.executeProvider.mock.calls[0][0].allow_fallback).toBe(false);
expect(result.reused).toBe(false);
expect(result.next_step).toBe('final_image_generate');
});
it('stores real provider image bytes instead of the SVG fallback', async () => {
const png = Buffer.from('real-image-bytes');
providers.executeProvider.mockResolvedValueOnce({
provider: {
mode: 'real'
},
result: {
provider_request_id: 'real-image-1',
asset_url: 'openai://image/real-image-1.png',
content_base64: png.toString('base64'),
mime_type: 'image/png'
},
provider_log: {
cost_estimate: 0.02,
cost_actual: 0.02
}
});
storage.storePrivateFile.mockResolvedValueOnce({
file_path: 'local://generated-images/real.png',
size: BigInt(png.length),
hash: 'real-hash',
backend: 'local'
});
await service.generateShotImage(user, '40', { image_type: 'preview' });
expect(storage.storePrivateFile).toHaveBeenCalledWith(
expect.objectContaining({
originalname: 'shot-40-preview.png',
mimetype: 'image/png',
size: png.length,
buffer: png
}),
'generated-images'
);
expect(prisma.asset.create).toHaveBeenCalledWith({
data: expect.objectContaining({
file_path: 'local://generated-images/real.png',
file_url: 'openai://image/real-image-1.png',
mime_type: 'image/png',
status: 'active'
})
});
});
it('does not create mock images when a real provider returns no image content', async () => {
providers.executeProvider.mockResolvedValueOnce({
provider: {
mode: 'real'
},
result: {
provider_request_id: 'real-image-empty',
asset_url: 'openai://image/empty.png'
},
provider_log: {
cost_estimate: 0,
cost_actual: 0
}
});
await expect(service.generateShotImage(user, '40', { image_type: 'preview' })).rejects.toBeInstanceOf(
BadRequestException
);
expect(prisma.asset.create).not.toHaveBeenCalled();
expect(prisma.renderTask.update).toHaveBeenCalledWith({
where: { id: 80n },
data: expect.objectContaining({
status: 'failed',
error_code: 'IMAGE_ASSET_STORE_FAILED'
})
});
});
it('rejects image generation for unconfirmed storyboard shots', async () => {
prisma.storyboardShot.findUnique.mockResolvedValue(createShot({ status: 'generated' }));
await expect(service.generateShotImage(user, '40', {})).rejects.toBeInstanceOf(
BadRequestException
);
});
it('rejects access to another user project', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
await expect(service.listShotImages(user, '40')).rejects.toBeInstanceOf(ForbiddenException);
});
});
+985
View File
@@ -0,0 +1,985 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset, Character, Prisma, Project, StoryboardShot } from '@prisma/client';
import { Prisma as PrismaNamespace } from '@prisma/client';
import { createHash } from 'node:crypto';
import type { AuthRequestUser } from '../auth/auth.types';
import { StorageService } from '../assets/storage.service';
import { PrismaService } from '../prisma/prisma.service';
import { ProvidersService } from '../providers/providers.service';
import {
GenerateCharacterImagesDto,
GenerateEpisodeShotImagesDto,
GenerateShotImageDto,
SetCharacterAnchorDto
} from './image.dto';
import {
CHARACTER_IMAGE_TYPES,
SHOT_IMAGE_TYPES,
toSafeCharacterImage,
toSafeShotImage,
type CharacterImageType,
type ShotImageType
} from './image.types';
const DEFAULT_CHARACTER_IMAGE_TYPES: CharacterImageType[] = [
'front_reference',
'anchor',
'expression_pack'
];
const DEFAULT_QUALITY_SCORE = new PrismaNamespace.Decimal(92);
interface StoredGeneratedImage {
asset: Asset;
provider_request_id: string | null;
cost_estimate: number | null;
cost_actual: number | null;
}
@Injectable()
export class ImagesService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(StorageService) private readonly storage: StorageService,
@Inject(ProvidersService) private readonly providersService: ProvidersService
) {}
async generateCharacterImages(
user: AuthRequestUser,
characterId: string,
dto: GenerateCharacterImagesDto
) {
const { character, project } = await this.loadCharacterForUser(characterId, user);
if (character.status !== 'locked') {
throw new BadRequestException('Locked character is required before image generation');
}
const imageTypes = this.resolveCharacterImageTypes(dto.image_types);
const countPerType = this.normalizePositiveInt(dto.count_per_type, 'count_per_type', 1, 3, 1);
const images = [];
for (const imageType of imageTypes) {
for (let index = 0; index < countPerType; index += 1) {
const existing = dto.force
? null
: await this.prisma.characterImage.findFirst({
where: {
character_id: character.id,
image_type: imageType,
status: { in: ['generated', 'selected'] }
},
orderBy: { created_at: 'asc' }
});
if (existing) {
images.push(await this.loadCharacterImage(existing.id));
continue;
}
images.push(await this.generateSingleCharacterImage(project, character, imageType, index));
}
}
let anchor = null;
if (dto.set_first_as_anchor !== false) {
const anchorCandidate =
images.find((image) => image.image_type === 'anchor') ?? images[0] ?? null;
if (anchorCandidate?.id) {
anchor = await this.setCharacterAnchor(user, character.id.toString(), {
character_image_id: anchorCandidate.id
});
}
}
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'character_image_generated' }
});
return {
images,
anchor,
next_step: 'shot_image_generate'
};
}
async listCharacterImages(user: AuthRequestUser, characterId: string) {
const { character } = await this.loadCharacterForUser(characterId, user);
const images = await this.prisma.characterImage.findMany({
where: { character_id: character.id },
orderBy: [{ is_anchor: 'desc' }, { created_at: 'asc' }]
});
return Promise.all(images.map((image) => this.withCharacterAsset(image)));
}
async setCharacterAnchor(
user: AuthRequestUser,
characterId: string,
dto: SetCharacterAnchorDto
) {
const { character } = await this.loadCharacterForUser(characterId, user);
const image = await this.resolveCharacterAnchorImage(character, dto);
if (!image.asset_id) {
throw new BadRequestException('Character image has no asset');
}
const [updated] = await this.prisma.$transaction(async (tx) => {
await tx.characterImage.updateMany({
where: {
character_id: character.id,
is_anchor: true,
id: { not: image.id }
},
data: {
is_anchor: false,
status: 'generated'
}
});
const selected = await tx.characterImage.update({
where: { id: image.id },
data: {
is_anchor: true,
status: 'selected'
}
});
await tx.character.update({
where: { id: character.id },
data: { anchor_asset_id: image.asset_id }
});
return [selected];
});
return {
character_id: character.id.toString(),
anchor_asset_id: image.asset_id.toString(),
image: await this.withCharacterAsset(updated),
next_step: 'storyboard_image_generate'
};
}
async generateShotImage(user: AuthRequestUser, shotId: string, dto: GenerateShotImageDto) {
const { shot, project } = await this.loadShotForUser(shotId, user);
const imageType = this.validateShotImageType(dto.image_type ?? 'preview');
if (shot.status !== 'confirmed') {
throw new BadRequestException('Confirmed storyboard shot is required before image generation');
}
const existing = dto.force
? null
: await this.prisma.shotImage.findFirst({
where: {
shot_id: shot.id,
image_type: imageType,
status: 'generated'
},
orderBy: { created_at: 'asc' }
});
if (existing) {
return {
image: await this.loadShotImage(existing.id),
reused: true
};
}
const image = await this.generateSingleShotImage(project, shot, imageType);
await this.prisma.project.update({
where: { id: project.id },
data: { status: imageType === 'final' ? 'final_images_generated' : 'preview_images_generated' }
});
return {
image,
reused: false,
next_step: imageType === 'final' ? 'image_qc' : 'final_image_generate'
};
}
async listShotImages(user: AuthRequestUser, shotId: string) {
const { shot } = await this.loadShotForUser(shotId, user);
const images = await this.prisma.shotImage.findMany({
where: { shot_id: shot.id },
orderBy: [{ image_type: 'asc' }, { created_at: 'asc' }]
});
return Promise.all(images.map((image) => this.withShotAsset(image)));
}
async generateEpisodeShotImages(
user: AuthRequestUser,
episodeId: string,
dto: GenerateEpisodeShotImagesDto
) {
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
const imageType = this.validateShotImageType(dto.image_type ?? 'preview');
const onlyMissing = dto.only_missing !== false;
const limit = this.normalizePositiveInt(dto.limit, 'limit', 1, 50, 20);
const shots = await this.prisma.storyboardShot.findMany({
where: {
episode_id: episode.id,
status: 'confirmed'
},
orderBy: { shot_no: 'asc' },
take: limit
});
if (shots.length === 0) {
throw new BadRequestException('Confirmed storyboard shots are required before image generation');
}
const images = [];
for (const shot of shots) {
const existing = onlyMissing
? await this.prisma.shotImage.findFirst({
where: {
shot_id: shot.id,
image_type: imageType,
status: 'generated'
},
orderBy: { created_at: 'asc' }
})
: null;
if (existing) {
images.push(await this.loadShotImage(existing.id));
} else {
images.push(await this.generateSingleShotImage(project, shot, imageType));
}
}
await this.prisma.project.update({
where: { id: project.id },
data: { status: imageType === 'final' ? 'final_images_generated' : 'preview_images_generated' }
});
return {
episode_id: episode.id.toString(),
image_type: imageType,
images,
generated_count: images.length,
next_step: imageType === 'final' ? 'image_qc' : 'final_image_generate'
};
}
private async generateSingleCharacterImage(
project: Project,
character: Character,
imageType: CharacterImageType,
index: number
) {
const prompt = this.buildCharacterPrompt(project, character, imageType, index);
const negativePrompt = this.buildCharacterNegativePrompt(character);
const task = await this.createRenderTask(project.id, null, null, 'character_image_generate', {
target_type: 'character',
character_id: character.id.toString(),
image_type: imageType,
index,
prompt,
negative_prompt: negativePrompt,
width: 1080,
height: 1920
});
const stored = await this.executeAndStoreGeneratedImage({
project,
taskId: task.id,
prompt,
negativePrompt,
width: 1080,
height: 1920,
imageKind: `character-${character.id.toString()}-${imageType}-${index}`
});
const created = await this.prisma.characterImage.create({
data: {
project_id: project.id,
character_id: character.id,
asset_id: stored.asset.id,
image_type: imageType,
prompt_text: prompt,
negative_prompt: negativePrompt,
is_anchor: imageType === 'anchor',
quality_score: DEFAULT_QUALITY_SCORE,
status: 'generated'
}
});
return this.withCharacterAsset(created);
}
private async generateSingleShotImage(
project: Project,
shot: StoryboardShot,
imageType: ShotImageType
) {
const characterRefs = await this.loadShotCharacterRefs(shot);
const prompt = this.buildShotPrompt(project, shot, characterRefs, imageType);
const negativePrompt = this.buildShotNegativePrompt(shot, characterRefs);
const task = await this.createRenderTask(project.id, shot.episode_id, shot.id, 'shot_image_generate', {
target_type: 'storyboard_shot',
shot_id: shot.id.toString(),
image_type: imageType,
prompt,
negative_prompt: negativePrompt,
width: 1080,
height: 1920,
anchor_asset_ids: characterRefs
.map((character) => character.anchor_asset_id?.toString())
.filter((assetId): assetId is string => Boolean(assetId))
});
const stored = await this.executeAndStoreGeneratedImage({
project,
taskId: task.id,
prompt,
negativePrompt,
width: 1080,
height: 1920,
imageKind: `shot-${shot.id.toString()}-${imageType}`
});
const created = await this.prisma.shotImage.create({
data: {
project_id: project.id,
episode_id: shot.episode_id,
shot_id: shot.id,
asset_id: stored.asset.id,
image_type: imageType,
prompt_text: prompt,
negative_prompt: negativePrompt,
quality_score: DEFAULT_QUALITY_SCORE,
status: 'generated'
}
});
return this.withShotAsset(created);
}
private async executeAndStoreGeneratedImage(input: {
project: Project;
taskId: bigint;
prompt: string;
negativePrompt: string;
width: number;
height: number;
imageKind: string;
}): Promise<StoredGeneratedImage> {
const providerResult = await this.providersService.executeProvider({
provider_type: 'ImageProvider',
purpose: input.imageKind,
project_id: input.project.id.toString(),
task_id: input.taskId.toString(),
allow_fallback: false,
return_binary: true,
input_json: {
prompt: input.prompt,
negative_prompt: input.negativePrompt,
width: input.width,
height: input.height
}
});
try {
const result = this.jsonObject(providerResult.result);
const providerRequestId = this.stringifyText(result.provider_request_id);
const generatedFile = await this.createGeneratedImageFile(
result,
input,
providerResult.provider.mode === 'mock'
);
const stored = await this.storage.storePrivateFile(generatedFile as unknown as Express.Multer.File, 'generated-images');
const asset = await this.prisma.asset.create({
data: {
user_id: input.project.user_id,
project_id: input.project.id,
asset_type: 'image',
file_path: stored.file_path,
file_url: this.stringifyText(result.asset_url) || null,
mime_type: generatedFile.mimetype,
width: input.width,
height: input.height,
size: stored.size,
hash: stored.hash,
visibility: 'private',
status: generatedFile.isMock ? 'mock' : 'active'
}
});
await this.prisma.renderTask.update({
where: { id: input.taskId },
data: {
status: 'success',
output_asset_id: asset.id,
finished_at: new Date()
}
});
return {
asset,
provider_request_id: providerRequestId || null,
cost_estimate: providerResult.provider_log.cost_estimate,
cost_actual: providerResult.provider_log.cost_actual
};
} catch (error) {
const normalized = this.toError(error);
await this.prisma.renderTask.update({
where: { id: input.taskId },
data: {
status: 'failed',
error_code: 'IMAGE_ASSET_STORE_FAILED',
error_message: normalized.message,
finished_at: new Date()
}
});
throw error;
}
}
private async createGeneratedImageFile(
result: Record<string, unknown>,
input: {
width: number;
height: number;
imageKind: string;
prompt: string;
},
allowMockOutput: boolean
) {
const contentBase64 = this.stringifyText(result.content_base64);
const mimeType = this.normalizeImageMimeType(this.stringifyText(result.mime_type));
if (contentBase64) {
const buffer = this.decodeBase64(contentBase64, 'ImageProvider content_base64');
return {
originalname: `${input.imageKind}${this.extensionFromMime(mimeType)}`,
mimetype: mimeType,
size: buffer.length,
buffer,
isMock: false
};
}
const assetUrl = this.stringifyText(result.asset_url);
if (/^https?:\/\//i.test(assetUrl)) {
const downloaded = await this.downloadProviderAsset(assetUrl, 'image');
const downloadedMime = this.normalizeImageMimeType(downloaded.mimeType);
return {
originalname: `${input.imageKind}${this.extensionFromMime(downloadedMime)}`,
mimetype: downloadedMime,
size: downloaded.buffer.length,
buffer: downloaded.buffer,
isMock: false
};
}
if (!allowMockOutput) {
throw new BadRequestException('ImageProvider did not return image content or downloadable URL');
}
const svg = this.createMockSvg(input.width, input.height, input.imageKind, input.prompt);
const buffer = Buffer.from(svg);
return {
originalname: `${input.imageKind}.svg`,
mimetype: 'image/svg+xml',
size: buffer.length,
buffer,
isMock: true
};
}
private async downloadProviderAsset(url: string, expectedType: 'image') {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new BadRequestException(`Provider ${expectedType} download failed: HTTP ${response.status}`);
}
const mimeType = response.headers.get('content-type') || '';
const buffer = Buffer.from(await response.arrayBuffer());
if (!buffer.length) {
throw new BadRequestException(`Provider ${expectedType} download returned empty content`);
}
return { buffer, mimeType };
} catch (error) {
const normalized = this.toError(error);
throw new BadRequestException(`Provider ${expectedType} download failed: ${normalized.message}`);
} finally {
clearTimeout(timeout);
}
}
private decodeBase64(value: string, label: string) {
const buffer = Buffer.from(value, 'base64');
if (!buffer.length) {
throw new BadRequestException(`${label} is empty`);
}
return buffer;
}
private normalizeImageMimeType(value: string | undefined) {
const normalized = value?.split(';')[0]?.trim().toLowerCase();
if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'image/jpeg';
if (normalized === 'image/webp') return 'image/webp';
if (normalized === 'image/svg+xml') return 'image/svg+xml';
return 'image/png';
}
private extensionFromMime(mimeType: string) {
switch (mimeType) {
case 'image/jpeg':
return '.jpg';
case 'image/webp':
return '.webp';
case 'image/svg+xml':
return '.svg';
case 'image/png':
default:
return '.png';
}
}
private async createRenderTask(
projectId: bigint,
episodeId: bigint | null,
shotId: bigint | null,
taskType: 'character_image_generate' | 'shot_image_generate',
inputJson: Prisma.InputJsonObject
) {
const inputHash = this.hashJson(inputJson);
return this.prisma.renderTask.create({
data: {
project_id: projectId,
episode_id: episodeId,
shot_id: shotId,
task_type: taskType,
status: 'pending',
input_json: inputJson,
input_hash: inputHash,
idempotency_key: `${taskType}:${projectId.toString()}:${episodeId?.toString() ?? 'none'}:${shotId?.toString() ?? 'none'}:${inputHash}:${Date.now()}`,
retry_count: 0,
max_retry: 3
}
});
}
private buildCharacterPrompt(
project: Project,
character: Character,
imageType: CharacterImageType,
index: number
) {
return [
'high quality Korean webtoon style, vertical 9:16 character reference',
'clean illustration only, no visible text, no labels, no speech bubbles',
`project=${project.title ?? 'untitled'}`,
`image_type=${imageType}`,
`variant=${index + 1}`,
`name=${character.name}`,
character.global_character_id ? `global_character_id=${character.global_character_id.toString()}` : null,
`role=${character.role_type}`,
`gender=${character.gender_label ?? 'unspecified'}`,
`age=${character.age_group ?? 'adult'}`,
`identity=${character.identity_desc ?? 'main cast'}`,
`appearance=${character.appearance_desc ?? ''}`,
`face=${character.face_desc ?? ''}`,
`hair=${character.hair_desc ?? ''}`,
`eyes=${character.eye_desc ?? ''}`,
`body=${character.body_desc ?? ''}`,
`costume=${character.costume_rules ?? 'clean modern outfit'}`,
character.wardrobe_variant ? `wardrobe_variant=${character.wardrobe_variant}` : null,
character.performance_style ? `performance=${character.performance_style}` : null,
`props=${character.special_props ?? 'none'}`
].filter(Boolean).join('\n');
}
private buildCharacterNegativePrompt(character: Character) {
return [
'low quality, blurry, extra fingers, bad hands, text artifacts, watermark',
'visible text, Chinese characters, letters, subtitles, captions, speech bubbles, dialogue balloons, text boxes, unreadable glyphs, square glyph artifacts',
'face drift, age drift, hair color drift, duplicate person, mixed identity',
character.negative_rules
].filter(Boolean).join(', ');
}
private buildShotPrompt(
project: Project,
shot: StoryboardShot,
characters: Character[],
imageType: ShotImageType
) {
const characterLines = characters.map((character) =>
[
character.name,
character.age_group,
character.face_desc,
character.hair_desc,
character.costume_rules,
character.wardrobe_variant,
character.performance_style,
character.global_character_id ? `global_character_id=${character.global_character_id.toString()}` : null,
character.anchor_asset_id ? `anchor_asset_id=${character.anchor_asset_id.toString()}` : null
].filter(Boolean).join(' | ')
);
return [
'high quality Korean webtoon style, vertical 9:16 storyboard image',
'clean cinematic frame only, no visible text, no captions, no speech bubbles, no dialogue balloons, no comic text boxes',
'express dialogue through facial expression, pose, camera and lighting only',
`project=${project.title ?? 'untitled'}`,
`image_type=${imageType}`,
`shot_no=${shot.shot_no}`,
`scene=${shot.scene_name ?? ''}`,
`location=${shot.location_desc ?? ''}`,
`visual=${shot.visual_desc ?? ''}`,
`action=${shot.action_desc ?? ''}`,
`camera=${shot.camera_motion ?? 'subtle zoom'}`,
`effect=${shot.effect_type ?? 'none'}`,
`dialogue=${shot.dialogue_text ?? ''}`,
`narration=${shot.narration_text ?? ''}`,
`characters=${characterLines.join(' || ') || 'no named character'}`
].filter(Boolean).join('\n');
}
private buildShotNegativePrompt(shot: StoryboardShot, characters: Character[]) {
return [
shot.negative_prompt,
'low quality, blurry, extra fingers, bad hands, text artifacts, watermark',
'visible text, Chinese characters, letters, subtitles, captions, speech bubbles, dialogue balloons, comic panels with text, text boxes, unreadable glyphs, square glyph artifacts',
'wrong face, age drift, hair color drift, extra people, missing character',
characters.length > 2 ? 'avoid crowded composition, separate character faces clearly' : null
].filter(Boolean).join(', ');
}
private createMockSvg(width: number, height: number, label: string, prompt: string) {
const color = `#${this.hashJson({ label }).slice(0, 6)}`;
const promptHash = this.hashJson({ prompt }).slice(0, 12);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
`<rect width="100%" height="100%" fill="${color}"/>`,
'<rect x="72" y="72" width="936" height="1776" rx="36" fill="rgba(255,255,255,0.18)" stroke="rgba(255,255,255,0.55)" stroke-width="4"/>',
'<circle cx="540" cy="520" r="210" fill="rgba(255,255,255,0.26)"/>',
'<rect x="250" y="820" width="580" height="620" rx="120" fill="rgba(255,255,255,0.22)"/>',
`<text x="540" y="1540" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" fill="white">MOCK IMAGE</text>`,
`<text x="540" y="1600" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="white">${this.escapeXml(label.slice(0, 48))}</text>`,
`<text x="540" y="1650" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" fill="white">prompt:${promptHash}</text>`,
'</svg>'
].join('');
}
private async loadShotCharacterRefs(shot: StoryboardShot) {
const ids = this.extractCharacterIds(shot.characters_json);
if (ids.length === 0) {
return [];
}
return this.prisma.character.findMany({
where: {
id: { in: ids },
project_id: shot.project_id,
status: 'locked'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
}
private extractCharacterIds(value: Prisma.JsonValue | null) {
if (!Array.isArray(value)) {
return [];
}
const ids = value
.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null;
}
const raw = (item as Record<string, Prisma.JsonValue>).id;
return typeof raw === 'string' || typeof raw === 'number' ? this.tryParseId(raw) : null;
})
.filter((id): id is bigint => id !== null);
return [...new Set(ids)];
}
private async resolveCharacterAnchorImage(character: Character, dto: SetCharacterAnchorDto) {
if (dto.character_image_id) {
const image = await this.prisma.characterImage.findUnique({
where: { id: this.parseId(dto.character_image_id, 'Invalid character_image_id') }
});
if (!image || image.character_id !== character.id) {
throw new NotFoundException('Character image not found');
}
return image;
}
if (dto.asset_id) {
const assetId = this.parseId(dto.asset_id, 'Invalid asset_id');
const image = await this.prisma.characterImage.findFirst({
where: {
character_id: character.id,
asset_id: assetId
},
orderBy: { created_at: 'asc' }
});
if (!image) {
throw new NotFoundException('Character image not found for asset');
}
return image;
}
const image = await this.prisma.characterImage.findFirst({
where: {
character_id: character.id,
status: { in: ['generated', 'selected'] }
},
orderBy: [{ is_anchor: 'desc' }, { created_at: 'asc' }]
});
if (!image) {
throw new NotFoundException('Character image not found');
}
return image;
}
private async loadCharacterImage(id: bigint) {
const image = await this.prisma.characterImage.findUnique({
where: { id }
});
if (!image) {
throw new NotFoundException('Character image not found');
}
return this.withCharacterAsset(image);
}
private async loadShotImage(id: bigint) {
const image = await this.prisma.shotImage.findUnique({
where: { id }
});
if (!image) {
throw new NotFoundException('Shot image not found');
}
return this.withShotAsset(image);
}
private async withCharacterAsset(image: Awaited<ReturnType<typeof this.prisma.characterImage.findFirst>>) {
if (!image) {
throw new NotFoundException('Character image not found');
}
const asset = image.asset_id
? await this.prisma.asset.findUnique({ where: { id: image.asset_id } })
: null;
return toSafeCharacterImage({ ...image, asset });
}
private async withShotAsset(image: Awaited<ReturnType<typeof this.prisma.shotImage.findFirst>>) {
if (!image) {
throw new NotFoundException('Shot image not found');
}
const asset = image.asset_id
? await this.prisma.asset.findUnique({ where: { id: image.asset_id } })
: null;
return toSafeShotImage({ ...image, asset });
}
private async loadCharacterForUser(characterId: string, user: AuthRequestUser) {
const character = await this.prisma.character.findUnique({
where: { id: this.parseId(characterId, 'Invalid character id') }
});
if (!character || character.status === 'deleted') {
throw new NotFoundException('Character not found');
}
const project = await this.findProjectForUser(character.project_id, user);
return { character, project };
}
private async loadShotForUser(shotId: string, user: AuthRequestUser) {
const shot = await this.prisma.storyboardShot.findUnique({
where: { id: this.parseId(shotId, 'Invalid shot id') }
});
if (!shot) {
throw new NotFoundException('Storyboard shot not found');
}
const project = await this.findProjectForUser(shot.project_id, user);
return { shot, project };
}
private async loadEpisodeForUser(episodeId: string, user: AuthRequestUser) {
const episode = await this.prisma.episode.findUnique({
where: { id: this.parseId(episodeId, 'Invalid episode id') }
});
if (!episode) {
throw new NotFoundException('Episode not found');
}
const project = await this.findProjectForUser(episode.project_id, user);
return { episode, project };
}
private async findProjectForUser(projectId: bigint, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: projectId }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private resolveCharacterImageTypes(value: string[] | undefined) {
if (!value?.length) {
return DEFAULT_CHARACTER_IMAGE_TYPES;
}
return value.map((item) => this.validateCharacterImageType(item));
}
private validateCharacterImageType(value: unknown): CharacterImageType {
if (typeof value !== 'string' || !(CHARACTER_IMAGE_TYPES as readonly string[]).includes(value)) {
throw new BadRequestException('image_type is not supported');
}
return value as CharacterImageType;
}
private validateShotImageType(value: unknown): ShotImageType {
if (typeof value !== 'string' || !(SHOT_IMAGE_TYPES as readonly string[]).includes(value)) {
throw new BadRequestException('image_type must be preview or final');
}
return value as ShotImageType;
}
private normalizePositiveInt(
value: unknown,
field: string,
min: number,
max: number,
fallback: number
) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) {
throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`);
}
return numberValue;
}
private parseId(value: string | bigint | number, message: string) {
try {
const id = BigInt(value);
if (id <= 0n) {
throw new Error('ID must be positive');
}
return id;
} catch {
throw new BadRequestException(message);
}
}
private tryParseId(value: string | number) {
try {
const id = BigInt(value);
return id > 0n ? id : null;
} catch {
return null;
}
}
private hashJson(value: Prisma.InputJsonValue | Prisma.JsonValue | null) {
return createHash('sha256').update(this.stableStringify(value)).digest('hex');
}
private stableStringify(value: Prisma.InputJsonValue | Prisma.JsonValue | null): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => this.stableStringify(item)).join(',')}]`;
}
const entries = Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, child]) => `${JSON.stringify(key)}:${this.stableStringify(child)}`);
return `{${entries.join(',')}}`;
}
private jsonObject(value: unknown) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private stringifyText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
private toError(error: unknown) {
return error instanceof Error ? error : new Error(String(error));
}
private escapeXml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}
@@ -0,0 +1,556 @@
import 'reflect-metadata';
import '../config/load-env';
import { Prisma, PrismaClient } from '@prisma/client';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
type TestcaseCharacter = {
character_key: string;
name: string;
role_type: string;
gender_label?: string;
age_group?: string;
identity_desc?: string;
appearance_desc?: string;
face_desc?: string;
hair_desc?: string;
eye_desc?: string;
body_desc?: string;
costume_rules?: string;
special_props?: string;
personality_desc?: string;
speech_style?: string;
relationship_desc?: string;
character_arc?: string;
negative_rules?: string;
wardrobe_variant?: string;
voice_provider_code?: string;
voice_model?: string;
voice_id?: string;
voice_style?: string;
performance_style?: string;
importance_level?: number;
};
type TestcaseShotCharacter = {
character_key?: string;
name?: string;
};
type TestcaseShot = {
shot_no: number;
scene_name: string;
location_desc: string;
characters_json: TestcaseShotCharacter[];
visual_desc: string;
action_desc: string;
dialogue_text?: string | null;
narration_text?: string | null;
camera_motion?: string | null;
effect_type?: string | null;
duration: number;
scene_type?: string | null;
importance_score?: number | null;
emotion_score?: number | null;
action_score?: number | null;
route_tier?: string | null;
status?: string;
};
type LiveActionTestcase = {
testcase_id: string;
project: {
title?: string;
input_mode?: string;
genre?: string;
style_code?: string;
output_type?: string;
output_mode?: string;
visual_mode?: string;
video_generation_level?: string;
target_episode_count?: number;
episode_duration?: number;
quality_level?: string;
is_long_series?: boolean;
};
story_bible: {
premise?: string;
world_setting?: string;
tone?: string;
forbidden_setting?: string;
continuity_rules?: string[];
};
characters: TestcaseCharacter[];
scenes: Array<{
scene_key: string;
name: string;
location_desc: string;
visual_rules?: string;
}>;
episode: {
episode_no: number;
title?: string;
summary?: string;
opening_hook?: string;
middle_conflict?: string;
ending_hook?: string;
target_duration?: number;
script_text?: string;
narration_text?: string;
dialogue_json?: unknown;
};
storyboard_shots: TestcaseShot[];
output_requirements?: unknown;
acceptance_criteria?: unknown;
};
type RuntimeConfig = {
filePath: string;
ownerEmail: string | null;
replace: boolean;
};
const DEFAULT_TESTCASE_PATH = resolve(
__dirname,
'..',
'..',
'..',
'storage',
'private',
'live-action-testcases',
'takeaway-heir-episode-001.json'
);
async function main() {
const config = parseArgs(process.argv.slice(2));
const prisma = new PrismaClient();
try {
const testcase = await loadTestcase(config.filePath);
assertTestcase(testcase);
const owner = await resolveOwner(prisma, config.ownerEmail);
if (config.replace) {
await deleteExistingImportedProjects(prisma, testcase);
}
const result = await prisma.$transaction(async (tx) => {
const project = await tx.project.create({
data: {
user_id: owner.id,
title: testcase.project.title ?? '真人短剧压测项目',
input_mode: testcase.project.input_mode ?? 'ai_original',
genre: testcase.project.genre ?? 'urban_counterattack',
style_code: testcase.project.style_code ?? 'live_action',
output_type: testcase.project.output_type ?? 'short_video',
output_mode: testcase.project.output_mode ?? 'live_action_ai',
visual_mode: testcase.project.visual_mode ?? 'live_action',
video_generation_level: testcase.project.video_generation_level ?? 'standard',
target_episode_count: testcase.project.target_episode_count ?? 1,
episode_duration: testcase.project.episode_duration ?? testcase.episode.target_duration ?? 60,
status: 'storyboard_confirmed',
copyright_status: 'confirmed',
payment_status: 'paid',
quality_level: testcase.project.quality_level ?? 'provider_acceptance',
is_long_series: testcase.project.is_long_series ?? false
}
});
await tx.copyrightRecord.create({
data: {
project_id: project.id,
user_id: owner.id,
authorization_type: 'ai_original_testcase',
statement_text: `测试用例 ${testcase.testcase_id}:AI 原创短剧压测素材,仅用于内部流水线验收。`
}
});
await tx.storyBible.create({
data: {
project_id: project.id,
title: testcase.project.title ?? '真人短剧压测故事圣经',
logline: testcase.story_bible.premise ?? testcase.episode.summary ?? null,
main_plot: testcase.episode.script_text ?? testcase.story_bible.premise ?? null,
core_conflict: testcase.episode.middle_conflict ?? null,
selling_points: [
testcase.episode.opening_hook,
testcase.episode.ending_hook
].filter(Boolean).join('\n') || null,
tone: testcase.story_bible.tone ?? '都市逆袭',
world_summary: testcase.story_bible.world_setting ?? null,
ending_direction: testcase.episode.ending_hook ?? null,
taboo_rules: [
testcase.story_bible.forbidden_setting,
...(testcase.story_bible.continuity_rules ?? [])
].filter(Boolean).join('\n'),
version: 1,
status: 'confirmed'
}
});
await tx.worldBible.create({
data: {
project_id: project.id,
world_type: 'modern_urban',
setting_text: testcase.story_bible.world_setting ?? null,
rules_text: testcase.story_bible.premise ?? null,
social_structure: '现代都市,林氏集团为隐秘顶级财团。',
time_period: '现代',
visual_rules: testcase.scenes.map((scene) => `${scene.name}${scene.location_desc}${scene.visual_rules ? `${scene.visual_rules}` : ''}`).join('\n'),
forbidden_rules: testcase.story_bible.forbidden_setting ?? null,
status: 'confirmed'
}
});
const characterByKey = new Map<string, { id: bigint; name: string }>();
for (const character of testcase.characters) {
const saved = await tx.character.create({
data: {
project_id: project.id,
name: character.name,
alias_names: [],
role_type: character.role_type,
gender_label: character.gender_label ?? null,
age_group: character.age_group ?? null,
identity_desc: character.identity_desc ?? null,
appearance_desc: character.appearance_desc ?? null,
face_desc: character.face_desc ?? null,
hair_desc: character.hair_desc ?? null,
eye_desc: character.eye_desc ?? null,
body_desc: character.body_desc ?? null,
costume_rules: character.costume_rules ?? null,
special_props: character.special_props ?? null,
personality_desc: character.personality_desc ?? null,
speech_style: character.speech_style ?? null,
relationship_desc: character.relationship_desc ?? null,
character_arc: character.character_arc ?? null,
negative_rules: character.negative_rules ?? null,
wardrobe_variant: character.wardrobe_variant ?? null,
voice_provider_code: character.voice_provider_code ?? null,
voice_model: character.voice_model ?? null,
voice_id: character.voice_id ?? null,
voice_style: character.voice_style ?? null,
performance_style: character.performance_style ?? null,
importance_level: character.importance_level ?? 0,
status: 'locked'
}
});
characterByKey.set(character.character_key, {
id: saved.id,
name: saved.name
});
await tx.actorProfile.create({
data: {
project_id: project.id,
character_id: saved.id,
actor_desc: [
character.name,
character.age_group,
character.gender_label,
character.identity_desc,
character.appearance_desc
].filter(Boolean).join(''),
appearance_rules: [
character.face_desc,
character.hair_desc,
character.body_desc,
character.negative_rules
].filter(Boolean).join(''),
wardrobe_rules: [
character.costume_rules,
character.special_props,
character.wardrobe_variant
].filter(Boolean).join(''),
performance_style: character.performance_style ?? character.personality_desc ?? null,
voice_style: character.voice_style ?? character.speech_style ?? null,
reference_asset_ids: [],
status: 'generated'
}
});
}
const episode = await tx.episode.create({
data: {
project_id: project.id,
episode_no: testcase.episode.episode_no,
source_chapter_ids: [],
title: testcase.episode.title ?? null,
summary: testcase.episode.summary ?? null,
opening_hook: testcase.episode.opening_hook ?? null,
middle_conflict: testcase.episode.middle_conflict ?? null,
ending_hook: testcase.episode.ending_hook ?? null,
target_duration: testcase.episode.target_duration ?? null,
status: 'confirmed'
}
});
await tx.episodeScript.create({
data: {
project_id: project.id,
episode_id: episode.id,
script_text: testcase.episode.script_text ?? null,
narration_text: testcase.episode.narration_text ?? null,
dialogue_json: toPrismaJson(testcase.episode.dialogue_json ?? []),
version: 1,
status: 'confirmed'
}
});
const shotIds: Array<{ id: string; shot_no: number; route_tier: string | null }> = [];
for (const shot of testcase.storyboard_shots) {
const saved = await tx.storyboardShot.create({
data: {
project_id: project.id,
episode_id: episode.id,
shot_no: shot.shot_no,
scene_name: shot.scene_name,
location_desc: shot.location_desc,
characters_json: toPrismaJson(resolveShotCharacters(shot.characters_json, characterByKey)),
visual_desc: shot.visual_desc,
action_desc: shot.action_desc,
dialogue_text: shot.dialogue_text ?? null,
narration_text: shot.narration_text ?? null,
camera_motion: shot.camera_motion ?? null,
effect_type: shot.effect_type ?? null,
duration: new Prisma.Decimal(shot.duration),
scene_type: shot.scene_type ?? null,
importance_score: shot.importance_score ?? null,
emotion_score: shot.emotion_score ?? null,
action_score: shot.action_score ?? null,
route_tier: shot.route_tier ?? null,
prompt_text: buildShotPromptText(shot),
negative_prompt: buildShotNegativePrompt(),
live_action_desc: null,
actor_action: null,
camera_instruction: null,
performance_instruction: null,
video_prompt: null,
video_status: null,
status: 'confirmed'
}
});
shotIds.push({
id: saved.id.toString(),
shot_no: saved.shot_no,
route_tier: saved.route_tier
});
}
await tx.operationLog.create({
data: {
user_id: owner.id,
operator_role: owner.role,
action: 'live_action_testcase_import',
target_type: 'project',
target_id: project.id,
metadata_json: toPrismaJson({
testcase_id: testcase.testcase_id,
file_path: config.filePath,
episode_id: episode.id.toString(),
shot_count: testcase.storyboard_shots.length,
total_duration: totalDuration(testcase),
output_requirements: testcase.output_requirements ?? {},
acceptance_criteria: testcase.acceptance_criteria ?? {}
})
}
});
return {
project_id: project.id.toString(),
episode_id: episode.id.toString(),
owner_user_id: owner.id.toString(),
shot_ids: shotIds,
total_duration: totalDuration(testcase)
};
});
console.log(JSON.stringify({
status: 'imported',
testcase_id: testcase.testcase_id,
...result,
next_step: 'prepare_live_action_shots'
}, null, 2));
} finally {
await prisma.$disconnect();
}
}
function parseArgs(args: string[]): RuntimeConfig {
const map = new Map<string, string>();
for (const arg of args) {
if (!arg.startsWith('--')) continue;
const [key, ...rest] = arg.slice(2).split('=');
map.set(key, rest.length > 0 ? rest.join('=') : 'true');
}
return {
filePath: resolve(map.get('file') ?? process.env.LIVE_ACTION_TESTCASE_FILE ?? DEFAULT_TESTCASE_PATH),
ownerEmail: map.get('owner-email') ?? process.env.LIVE_ACTION_TESTCASE_OWNER_EMAIL ?? null,
replace: booleanArg(map.get('replace') ?? process.env.LIVE_ACTION_TESTCASE_REPLACE)
};
}
function booleanArg(value: string | undefined) {
if (!value) return false;
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
async function loadTestcase(filePath: string): Promise<LiveActionTestcase> {
const raw = await readFile(filePath, 'utf8');
return JSON.parse(raw) as LiveActionTestcase;
}
function assertTestcase(testcase: LiveActionTestcase) {
if (!testcase.testcase_id) throw new Error('testcase_id is required');
if (!testcase.project?.title) throw new Error('project.title is required');
if (!Array.isArray(testcase.characters) || testcase.characters.length === 0) {
throw new Error('characters are required');
}
if (!testcase.episode?.episode_no) throw new Error('episode.episode_no is required');
if (!Array.isArray(testcase.storyboard_shots) || testcase.storyboard_shots.length === 0) {
throw new Error('storyboard_shots are required');
}
}
async function resolveOwner(prisma: PrismaClient, ownerEmail: string | null) {
const explicit = ownerEmail
? await prisma.user.findFirst({ where: { email: ownerEmail, status: 'active' } })
: null;
const owner = explicit ??
await prisma.user.findFirst({ where: { role: 'admin', status: 'active' }, orderBy: { id: 'asc' } }) ??
await prisma.user.findFirst({ where: { status: 'active' }, orderBy: { id: 'asc' } });
if (!owner) {
throw new Error('No active user found. Seed an admin user before importing the testcase.');
}
return owner;
}
async function deleteExistingImportedProjects(prisma: PrismaClient, testcase: LiveActionTestcase) {
const logs = await prisma.operationLog.findMany({
where: {
action: 'live_action_testcase_import',
target_type: 'project',
metadata_json: {
path: '$.testcase_id',
equals: testcase.testcase_id
}
},
select: {
target_id: true
}
});
const ids = logs
.map((log) => log.target_id)
.filter((id): id is bigint => Boolean(id));
if (ids.length === 0) return;
await prisma.$transaction(async (tx) => {
await tx.videoClip.deleteMany({ where: { project_id: { in: ids } } });
await tx.renderTask.deleteMany({ where: { project_id: { in: ids } } });
await tx.shotImage.deleteMany({ where: { project_id: { in: ids } } });
await tx.storyboardShot.deleteMany({ where: { project_id: { in: ids } } });
await tx.episodeScript.deleteMany({ where: { project_id: { in: ids } } });
await tx.episode.deleteMany({ where: { project_id: { in: ids } } });
await tx.actorProfile.deleteMany({ where: { project_id: { in: ids } } });
await tx.characterMemory.deleteMany({ where: { project_id: { in: ids } } });
await tx.characterImage.deleteMany({ where: { project_id: { in: ids } } });
await tx.character.deleteMany({ where: { project_id: { in: ids } } });
await tx.worldBible.deleteMany({ where: { project_id: { in: ids } } });
await tx.storyBible.deleteMany({ where: { project_id: { in: ids } } });
await tx.plotMemory.deleteMany({ where: { project_id: { in: ids } } });
await tx.plotThread.deleteMany({ where: { project_id: { in: ids } } });
await tx.continuityCheck.deleteMany({ where: { project_id: { in: ids } } });
await tx.contentReview.deleteMany({ where: { project_id: { in: ids } } });
await tx.copyrightRecord.deleteMany({ where: { project_id: { in: ids } } });
await tx.operationLog.deleteMany({
where: {
OR: [
{ target_type: 'project', target_id: { in: ids } },
{ metadata_json: { path: '$.testcase_id', equals: testcase.testcase_id } }
]
}
});
await tx.project.deleteMany({ where: { id: { in: ids } } });
});
}
function resolveShotCharacters(
characters: TestcaseShotCharacter[],
characterByKey: Map<string, { id: bigint; name: string }>
) {
return characters.map((character) => {
const saved = character.character_key ? characterByKey.get(character.character_key) : null;
return {
id: saved?.id.toString() ?? null,
character_key: character.character_key ?? null,
name: saved?.name ?? character.name ?? ''
};
});
}
function buildShotPromptText(shot: TestcaseShot) {
return [
'真人短剧分镜图参考',
`场景:${shot.location_desc}`,
`人物:${shot.characters_json.map((character) => character.name).filter(Boolean).join('、') || '主要角色'}`,
`画面:${shot.visual_desc}`,
`动作:${shot.action_desc}`,
shot.camera_motion ? `镜头:${shot.camera_motion}` : null,
shot.effect_type ? `效果:${shot.effect_type}` : null,
`时长:${shot.duration}`
].filter(Boolean).join('\n');
}
function buildShotNegativePrompt() {
return [
'低清晰度',
'人物变脸',
'服装突变',
'多余手指',
'字幕乱码',
'水印',
'动漫风',
'夸张玄幻特效'
].join('');
}
function totalDuration(testcase: LiveActionTestcase) {
return Number(testcase.storyboard_shots.reduce((sum, shot) => sum + Number(shot.duration || 0), 0).toFixed(2));
}
function toPrismaJson(value: unknown): Prisma.InputJsonValue {
if (value === null || value === undefined) return {};
if (typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
if (Array.isArray(value)) return value.map((item) => toPrismaJson(item));
if (typeof value === 'object') {
const output: Record<string, Prisma.InputJsonValue> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (child !== undefined) {
output[key] = toPrismaJson(child);
}
}
return output;
}
return String(value);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});

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