Initial AI manga platform
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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 集长篇连载
|
||||
@@ -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/WebP;Mock 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,不能直接发给 MiniMax;Provider 层必须做 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
File diff suppressed because it is too large
Load Diff
@@ -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 小样。
|
||||
@@ -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`,再尝试入 BullMQ;Redis 或 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`。
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './styles.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>)
|
||||
: {};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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('*');
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AssetType } from './asset.types';
|
||||
|
||||
export class UploadAssetDto {
|
||||
asset_type?: AssetType;
|
||||
project_id?: string;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class RegisterDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user