Compare commits

...

12 Commits

Author SHA1 Message Date
agent 48ca560e05 fix(edit-flow): 批量生成时 plan.config 只回写一次,避免 generation_task_id 被循环覆盖
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 55s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m47s
AI Code Review / AI Code Review (pull_request) Successful in 2m12s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m43s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m8s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 53s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m20s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 577h9m4s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 577h9m5s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 577h9m6s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 577h9m7s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 577h13m18s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 577h13m21s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 577h14m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 577h14m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 577h14m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 577h14m16s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 577h14m16s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 577h14m17s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 577h42m53s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 577h42m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 577h47m9s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 577h48m1s
PR #1464 AI Review 发现的阻塞问题:count > 1 批量生成时,循环内
对同一 source_edit_plan_id 多次调用 _writeback_edit_plan_config,
每次都会覆盖 config.generation_task_id,最终只保留最后一个 task id。

修复:仅在首个成功任务(len(created_tasks)==1)时回写。
2026-08-23 14:11:42 +08:00
xiaoxia ab381b2e74 feat(generate): 编辑流程自动保存草稿+创建任务参数修复+轮询改新接口+清理幽灵API (#1465)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m39s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m45s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m39s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m35s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m18s
AI Code Review / AI Code Review (pull_request) Failing after 2m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m21s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m6s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m44s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m36s
CI/CD Pipeline / Integration Tests (push) Successful in 3m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m6s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m10s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m25s
CI/CD Pipeline / Unit Tests (push) Successful in 13m14s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m47s
CI/CD Pipeline / CI Gate (pull_request) Successful in 46s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 577h14m2s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 577h14m4s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 577h14m6s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 577h14m8s
CI/CD Pipeline / Production Browser E2E (push) Failing after 577h14m37s
CI/CD Pipeline / Deploy Production (push) Failing after 577h14m39s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 577h14m39s
CI/CD Pipeline / Build Production Web Image (push) Failing after 577h14m39s
CI/CD Pipeline / Build Production API Image (push) Failing after 577h14m40s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 577h23m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 577h23m47s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 577h23m45s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 577h24m12s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 577h26m42s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 577h26m46s
CI/CD Pipeline / PR Build API Image (push) Failing after 577h26m50s
CI/CD Pipeline / Frontend Lint (push) Failing after 577h27m52s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 577h27m57s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 577h28m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 577h29m38s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 577h28m4s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 577h28m6s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 577h47m53s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 577h47m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 577h48m26s
CI/CD Pipeline / CI Gate (push) Failing after 577h48m27s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 577h57m33s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 578h0m28s
CI/CD Pipeline / PR Build Web Image (push) Failing after 578h0m36s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-23 13:56:32 +08:00
xiaoxia 94caa63436 Merge pull request 'fix(edit-flow): 补全 title_config 数据链路,修复编辑流程断裂' (#1464) from fix/edit-flow-data-chain into develop
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 577h30m21s
2026-08-23 13:55:42 +08:00
xiaoxia fe49fef1ad fix: auto-save draft + API cleanup for editing flow (PR #1465)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m12s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Failing after 577h31m12s
CI/CD Pipeline / Frontend Lint (push) Failing after 577h31m33s
CI/CD Pipeline / PR Build Web Image (push) Failing after 577h31m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 577h32m24s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 577h32m28s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 577h32m30s
CI/CD Pipeline / PR Build API Image (push) Failing after 578h5m4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 578h6m14s
- Step2/Step4 auto-save via useDraftAutoSave (GET-merge-PUT, serial queue)
- useDraftAutoSave: failed saves preserve patch with exponential backoff retry
- Step6 cover requests include full title_config
- Step7 create task: source_edit_plan_id, title_config, cover_url priority
- Polling switched to GET /generation/tasks/{task_id} with proper error handling
- getGenerationTaskResults retries before treating as failure
- Removed 7 ghost API functions (non-existent backend routes)
- BGM presets path updated to /templates/{id}/editor/bgm/presets
- Type alignment for BatchGenerationTaskResponse
2026-08-23 13:53:36 +08:00
CI Bot 0b000f96a6 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 41s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m17s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 10m6s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
AI Code Review / AI Code Review (pull_request) Waiting for AI Code Review to complete
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m13s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 577h35m46s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 577h35m46s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 577h35m48s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 577h35m47s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 577h35m49s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 577h45m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 577h45m55s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 577h46m36s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 577h45m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 577h46m39s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 577h46m34s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 577h46m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 577h46m41s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 578h9m36s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 578h20m23s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 578h20m28s
2026-08-23 05:39:27 +00:00
CI Bot 817c6fa6a3 test: 补全 _writeback_edit_plan_config 单测,修复增量覆盖率不足
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m39s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m20s
AI Code Review / AI Code Review (pull_request) Failing after 2m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 4m18s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 577h48m27s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 577h48m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 577h48m33s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 577h48m38s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 577h48m40s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 577h50m3s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 577h50m5s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 578h22m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 578h22m24s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 578h23m49s
新增 9 个单元测试覆盖 generation_tasks.py 增量代码:
- 空 plan_id 直接返回
- plan 不存在 warning + 不 commit
- 正常写入(有/无 title_config)
- config 非 dict 兜底
- DB 异常 rollback + rollback 失败静默
- 空 title_config 不写入
2026-08-23 13:34:57 +08:00
CI Bot 410f672195 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 37s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m50s
AI Code Review / AI Code Review (pull_request) Successful in 4m53s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m40s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 12m4s
CI/CD Pipeline / CI Gate (pull_request) Failing after 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 577h57m44s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 577h57m45s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 577h57m46s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 577h57m46s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 577h57m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 578h9m1s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 578h10m22s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 578h9m51s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 578h10m24s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 578h10m29s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 578h10m26s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 578h10m30s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 578h10m30s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 578h31m32s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 578h43m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 578h44m16s
2026-08-23 05:15:40 +00:00
agent 06f68230af fix(edit-flow): 补全 title_config 数据链路,修复编辑流程断裂
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m1s
AI Code Review / AI Code Review (pull_request) Failing after 2m15s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m1s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 578h12m39s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 578h12m41s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 578h13m7s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 578h13m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 578h13m11s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 578h13m13s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 578h13m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 578h46m28s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 578h46m57s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 578h47m1s
1. Schema: CreateGenerationTaskRequest 增加 title_config(dict|None=None)
2. DB: generation_tasks 新增 title_config JSON 列 (alembic 057)
3. Domain/ORM/Repo/UseCase 全链路透传 title_config
4. API 创建任务成功后,merge 回写 EditPlan.config
   (generation_task_id + title_config),失败不影响主流程
5. Worker 优先使用 title_config 构建标题图层,custom_title 兜底
6. GenerationTaskResponse 返回 title_config
7. 旧路由 /generate 和 /generation-status 标记 DEPRECATED

所有新字段默认 None/{},不传时行为与现在完全一致。
2026-08-23 13:12:40 +08:00
xiaoxia 5a5c653d2c feat(cleanup): pending 任务自动清理,防止队列占满 (#1463)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m5s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m8s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m17s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m35s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m13s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m38s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m46s
CI/CD Pipeline / Integration Tests (push) Successful in 2m51s
AI Code Review / AI Code Review (pull_request) Successful in 8m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m9s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m12s
CI/CD Pipeline / Unit Tests (push) Successful in 13m12s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m19s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 578h15m49s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 578h15m50s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 578h15m51s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 578h15m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 578h16m9s
CI/CD Pipeline / CI Gate (push) Failing after 578h16m10s
CI/CD Pipeline / Deploy Production (push) Failing after 578h16m10s
CI/CD Pipeline / Build Production Web Image (push) Failing after 578h16m11s
CI/CD Pipeline / Build Production API Image (push) Failing after 578h16m11s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 578h24m49s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 578h24m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 578h24m55s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 578h27m54s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 578h27m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 578h28m23s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 578h29m10s
CI/CD Pipeline / PR Build API Image (push) Failing after 578h29m13s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 578h29m15s
CI/CD Pipeline / Frontend Lint (push) Failing after 578h29m15s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 578h29m17s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 578h30m32s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 578h29m19s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 578h49m38s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 578h49m39s
CI/CD Pipeline / Canary Release to Production (push) Failing after 578h49m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 578h49m59s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 578h58m38s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 578h59m4s
CI/CD Pipeline / PR Build Web Image (push) Failing after 579h2m58s
2026-08-23 12:55:39 +08:00
CI Bot 2148e2bc48 chore: trigger CI rebuild after api-base image update (Pillow + CJK fonts)
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m3s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m30s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m54s
AI Code Review / AI Code Review (pull_request) Successful in 8m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m6s
CI/CD Pipeline / Unit Tests (push) Successful in 14m38s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m14s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Canary Release to Production (push) Failing after 579h51m34s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 579h57m42s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 579h57m42s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 579h57m43s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 579h57m44s
CI/CD Pipeline / Production Browser E2E (push) Failing after 579h57m48s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 579h57m49s
CI/CD Pipeline / Build Production Web Image (push) Failing after 579h57m50s
CI/CD Pipeline / CI Gate (push) Failing after 579h57m50s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 580h7m5s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 580h7m6s
CI/CD Pipeline / Build Production API Image (push) Failing after 579h57m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 580h7m26s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 580h7m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 580h9m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 580h10m1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 580h10m3s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 580h9m6s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 580h10m5s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 580h12m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 580h12m27s
CI/CD Pipeline / Frontend Lint (push) Failing after 580h12m29s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 580h12m30s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 580h31m29s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 580h31m30s
CI/CD Pipeline / Deploy Production (push) Failing after 580h31m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 580h40m58s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 580h43m50s
CI/CD Pipeline / PR Build Web Image (push) Failing after 580h46m12s
2026-08-23 11:13:41 +08:00
xiaoxia 9ac21d37f0 Merge pull request 'fix(cover): Worker本地ffmpeg抽帧 + E2源素材封面标题叠加' (#1462) from fix/cover-ffmpeg-title-overlay into develop
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 48s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m12s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m48s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m24s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m48s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m33s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m18s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m1s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m13s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m56s
AI Code Review / AI Code Review (pull_request) Successful in 8m30s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / Integration Tests (push) Successful in 5m8s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Unit Tests (push) Successful in 14m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m57s
CI/CD Pipeline / CI Gate (pull_request) Successful in 19s
API Base Image Build / Build API Base Image (push) Successful in 29m19s
Worker Base Image Build / Build Worker Base Image (push) Successful in 33m30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 580h25m39s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 580h25m43s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 580h25m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 580h25m43s
CI/CD Pipeline / Production Browser E2E (push) Failing after 580h26m55s
CI/CD Pipeline / Deploy Production (push) Failing after 580h26m57s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 580h26m59s
CI/CD Pipeline / Build Production API Image (push) Failing after 580h26m59s
CI/CD Pipeline / Build Production Web Image (push) Failing after 580h26m59s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 580h35m9s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 580h35m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 580h35m55s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 580h38m32s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 580h35m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 580h39m6s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 580h38m34s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 580h40m58s
CI/CD Pipeline / PR Build API Image (push) Failing after 580h41m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 580h41m12s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 580h41m14s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 580h41m18s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 580h42m32s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 580h59m28s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 580h59m30s
CI/CD Pipeline / Canary Release to Production (push) Failing after 581h0m44s
CI/CD Pipeline / CI Gate (push) Failing after 581h0m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 581h9m17s
CI/CD Pipeline / PR Build Web Image (push) Failing after 581h14m48s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 581h15m4s
2026-08-23 10:43:40 +08:00
xiaoxia 2837f11123 fix: 预览完成后保存rendered_storage_key + createPreview传title_config
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m9s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m26s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m18s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m9s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 51s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Failing after 4m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m24s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m40s
CI/CD Pipeline / Integration Tests (push) Successful in 4m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m49s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m32s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m24s
CI/CD Pipeline / Unit Tests (push) Successful in 16m33s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m37s
CI/CD Pipeline / CI Gate (pull_request) Successful in 13s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 580h48m27s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 580h48m28s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 580h48m28s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 580h48m30s
CI/CD Pipeline / Production Browser E2E (push) Failing after 580h48m56s
CI/CD Pipeline / Deploy Production (push) Failing after 580h48m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 580h49m0s
CI/CD Pipeline / CI Gate (push) Failing after 580h49m1s
CI/CD Pipeline / Build Production Web Image (push) Failing after 580h49m0s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 581h2m45s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 581h2m53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 581h3m46s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 581h4m17s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 581h2m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 581h4m20s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 581h2m49s
CI/CD Pipeline / Frontend Lint (push) Failing after 581h5m11s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 581h3m48s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 581h5m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 581h5m29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 581h5m38s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 581h6m39s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 581h22m16s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 581h22m17s
CI/CD Pipeline / Canary Release to Production (push) Failing after 581h22m46s
CI/CD Pipeline / Build Production API Image (push) Failing after 581h22m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 581h36m39s
CI/CD Pipeline / PR Build Web Image (push) Failing after 581h38m6s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 581h39m15s
2026-08-23 10:19:28 +08:00
43 changed files with 1001 additions and 265 deletions
@@ -0,0 +1,26 @@
"""Add title_config to generation_tasks
Revision ID: 057_title_config
Revises: 056_fix_cover_templates_config
Create Date: 2026-08-23
"""
import sqlalchemy as sa
from alembic import op
revision = "057_title_config"
down_revision = "056_fix_cover_templates_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "title_config")
@@ -16,6 +16,7 @@ from app.core.task_enqueue import (
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
get_project_repository,
@@ -32,6 +33,7 @@ from app.schemas.generation_task import (
ListGenerationTasksResponse,
)
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from packages.application import (
CreateGenerationTaskCommand,
@@ -69,6 +71,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
output_height=getattr(task, "output_height", 720),
cover_url=getattr(task, "cover_url", ""),
custom_title=getattr(task, "custom_title", ""),
title_config=getattr(task, "title_config", {}) or {},
logs=getattr(task, "logs", "[]"),
status=task.status,
progress=task.progress,
@@ -142,6 +145,54 @@ def _select_assets_from_library(
return [a.id for a in ready_video_assets]
def _writeback_edit_plan_config(
plan_id: str,
task_id: str,
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
if title_config:
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def _resolve_project_and_library(
request: CreateGenerationTaskRequest,
project_repository: Any,
@@ -187,6 +238,7 @@ def create_generation_task(
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse:
logger.info(
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
@@ -304,6 +356,7 @@ def create_generation_task(
output_height=request.output_height,
cover_url=request.cover_url,
custom_title=request.custom_title,
title_config=request.title_config or {},
)
)
try:
@@ -315,6 +368,15 @@ def create_generation_task(
log_task_status=True,
):
created_tasks.append(task)
# 只在首个成功任务时回写一次 plan.config,
# 避免批量生成时循环覆盖 generation_task_id
if request.source_edit_plan_id and len(created_tasks) == 1:
_writeback_edit_plan_config(
plan_id=request.source_edit_plan_id,
task_id=task.id,
title_config=request.title_config,
db=db,
)
else:
failed_tasks.append(task)
except UserPendingLimitExceeded as _e:
@@ -56,6 +56,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
@router.post("/generate", response_model=EditPlanGenerateResponse)
def generate_editor_draft(
template_id: str,
@@ -285,6 +286,7 @@ def _get_task_output_url(task, gen_task_repo, db) -> str:
return ""
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
def get_editor_generation_status(
template_id: str,
+6
View File
@@ -33,6 +33,11 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── 标题配置(结构化,优先于 custom_title 纯文本)──
title_config: dict | None = Field(
default=None,
description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。",
)
# ── 视频标题 ──
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
# ── 批量生成 ──
@@ -109,6 +114,7 @@ class GenerationTaskResponse(BaseModel):
output_height: int = 720
cover_url: str = ""
custom_title: str = ""
title_config: dict = Field(default_factory=dict)
status: str
progress: float
result_count: int
+12 -3
View File
@@ -4,11 +4,20 @@
import apiClient from "../client"
import type { BgmPreset, BgmPresetsQuery } from "./types"
/** 获取 BGM 预设列表 */
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
/**
* 获取 BGM 预设列表
* @param templateId 模板/草稿 ID
* @param params 分类/关键词筛选
*/
export const getBgmPresets = async (
templateId: string,
params?: BgmPresetsQuery,
): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {}
if (params?.category) searchParams.category = params.category
if (params?.keyword) searchParams.keyword = params.keyword
const res = await apiClient.get("/bgm/presets", { params: searchParams })
const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, {
params: searchParams,
})
return res.data?.data ?? res.data ?? []
}
+13
View File
@@ -1,9 +1,22 @@
import apiClient from "../client"
export interface GenerateCoverTitleConfig {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
/** 标题样式,用于在封面上叠加标题文字 */
title_config?: GenerateCoverTitleConfig
}
export interface GenerateCoverResponse {
+7
View File
@@ -6,6 +6,7 @@ import apiClient from "../client"
import type {
CreateGenerationTaskRequest,
CreateGenerationTaskResponse,
GenerationTaskDetail,
TaskItem,
TaskListParams,
TaskListResponse,
@@ -19,6 +20,12 @@ export const createGenerationTask = async (
return data
}
/** 获取单个生成任务详情(轮询用) */
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskDetail> => {
const { data } = await apiClient.get<GenerationTaskDetail>(`/generation/tasks/${taskId}`)
return data
}
/** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
+14 -2
View File
@@ -82,10 +82,12 @@ export interface CreateGenerationTaskRequest {
stroke?: boolean
shadow?: boolean
}
/** 关联的草稿 ID(编辑流程数据链路用) */
source_edit_plan_id?: string
}
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
export interface CreateGenerationTaskResponse {
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
export interface GenerationTaskDetail {
id: string
project_id: string
asset_library_id: string
@@ -95,8 +97,18 @@ export interface CreateGenerationTaskResponse {
asset_ids: string[]
title_ids: string[]
voice_ids: string[]
source_edit_plan_id?: string
status: string
progress: number
result_count: number
error_message: string
error_info?: TaskErrorInfo
created_at?: string | null
updated_at?: string | null
}
/** 创建生成任务响应(后端返回批量结构 {items, total}) */
export interface CreateGenerationTaskResponse {
items: GenerationTaskDetail[]
total: number
}
+3 -42
View File
@@ -4,51 +4,29 @@
import apiClient from "../client"
import type {
EditPlan,
EditPlanListParams,
EditPlanListResponse,
CreateEditPlanRequest,
UpdateEditPlanRequest,
GenerateResponse,
GenerationStatusResponse,
EditPlanGeneration,
GeneratedVideo,
CopyEditPlanRequest,
} from "./types"
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
signal?: AbortSignal,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
return response.data
}
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
@@ -72,20 +50,3 @@ export async function getGenerationTaskResults(taskId: string): Promise<Generate
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
@@ -15,10 +15,7 @@ export type {
EditPlanSegment,
EditPlanConfig,
EditPlan,
CreateEditPlanRequest,
UpdateEditPlanRequest,
EditPlanListParams,
EditPlanListResponse,
GenerateResponse,
EditPlanGeneration,
ClipStatusItem,
@@ -37,7 +34,6 @@ export type {
ClipReorderResponse,
ClipBatchDeleteResponse,
ClipsFromAssetsResponse,
CopyEditPlanRequest,
TransitionEffect,
MediaAsset,
} from "./types"
@@ -53,17 +49,12 @@ export {
// 模板草稿 CRUD + 生成
export {
getEditPlans,
getEditPlan,
createEditPlan,
updateEditPlan,
deleteEditPlan,
generateEditPlan,
getGenerationStatus,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
copyEditPlan,
} from "./editPlans"
// 片段 CRUD + 批量操作
+11
View File
@@ -118,6 +118,17 @@ export interface EditPlanConfig {
generate_count?: number
/** 素材模式 */
material_mode?: string
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
title?: {
text?: string
font?: string
font_size?: number
color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
/** 预览视频 URL(封面生成用) */
rendered_storage_key?: string
/** 生成任务 ID */
-3
View File
@@ -9,8 +9,6 @@ export type {
TemplateSegment,
TemplateListParams,
TemplateListResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
CopyTemplateResponse,
} from "./types"
@@ -24,5 +22,4 @@ export {
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
} from "./templates"
-14
View File
@@ -5,8 +5,6 @@
import apiClient from "../client"
import type {
CopyTemplateResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
TemplateItem,
TemplateListParams,
TemplateListResponse,
@@ -45,15 +43,3 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data
}
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
@@ -16,9 +16,17 @@ interface BgmSelectorProps {
onClose: () => void
config: BgmMixConfig
onChange: (config: BgmMixConfig) => void
/** 模板/草稿 ID,用于请求 BGM 预设 */
templateId?: string
}
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
const BgmSelector: React.FC<BgmSelectorProps> = ({
open,
onClose,
config,
onChange,
templateId,
}) => {
const {
presets,
loading,
@@ -30,7 +38,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChan
loadPresets,
handlePreview,
stopPreview,
} = useBgmSelector(open)
} = useBgmSelector(open, templateId)
/* ── 选中 BGM ── */
const handleSelect = useCallback(
@@ -19,7 +19,7 @@ export const CATEGORY_LIST: {
* BGM 选择器数据与交互 Hook
* 封装列表加载、搜索、分类筛选、试听播放逻辑
*/
export function useBgmSelector(open: boolean) {
export function useBgmSelector(open: boolean, templateId?: string) {
const [presets, setPresets] = useState<BgmPreset[]>([])
const [loading, setLoading] = useState(false)
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
@@ -30,19 +30,23 @@ export function useBgmSelector(open: boolean) {
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
if (!templateId) {
setPresets([])
return
}
setLoading(true)
try {
const params: { category?: string; keyword?: string } = {}
if (activeCategory !== "all") params.category = activeCategory
if (keyword.trim()) params.keyword = keyword.trim()
const data = await getBgmPresets(params)
const data = await getBgmPresets(templateId, params)
setPresets(data)
} catch {
message.error("加载 BGM 列表失败")
} finally {
setLoading(false)
}
}, [activeCategory, keyword])
}, [activeCategory, keyword, templateId])
useEffect(() => {
if (open) loadPresets()
@@ -171,6 +171,7 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
generateCount,
sourceEditPlanId: editPlanId,
})
/* ================================================================
@@ -141,6 +141,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onSelectedMaterialsChange={onSelectedMaterialsChange}
smartSelectedIds={smartSelectedIds}
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
selectedTemplate={selectedTemplate}
/>
)
case 3:
@@ -156,6 +157,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
selectedTemplate={selectedTemplate}
/>
)
case 5:
@@ -182,6 +184,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
duration={duration}
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
selectedTemplate={selectedTemplate}
titleSettings={titleSettings}
/>
)
case 7:
@@ -15,6 +15,8 @@ interface Step2MaterialSelectProps {
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
@@ -12,6 +12,8 @@ import AiTitleGenerator from "./title/AiTitleGenerator"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -14,6 +14,8 @@ interface Step6CoverSettingsProps {
assetIds?: string[]
/** 当前选中的模板 ID */
selectedTemplate?: string
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
titleSettings?: import("../types").TitleSettings
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
@@ -40,6 +42,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
duration: props.duration,
assetIds: props.assetIds,
selectedTemplate: props.selectedTemplate,
titleSettings: props.titleSettings,
})
const handleAutoGenerate = () => {
@@ -19,6 +19,8 @@ export interface UseGenerateVideoProps {
autoSubtitles: boolean
bgm: boolean
generateCount: number
/** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */
sourceEditPlanId?: string | null
}
/** 生成阶段 */
@@ -1,87 +1,146 @@
import { useRef, useCallback } from "react"
import { message } from "antd"
import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor"
import axios from "axios"
import { getGenerationTask } from "@/api/tasks/tasks"
import { getGenerationTaskResults } from "@/api/template-editor"
import { safeExtractError } from "./errorUtils"
interface UseGenerationPollingOptions {
templateId: string
onProgress: (progress: number) => void
onComplete: (videos: unknown[]) => void
onFailed: (errorMsg: string) => void
}
/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */
const MAX_RETRYABLE_ERRORS = 10
/** 获取结果的最大重试次数 */
const MAX_RESULTS_RETRIES = 3
/**
* 生成状态轮询 Hook
* 轮询生成状态,更新进度,处理完成/失败
* 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id})
*
* 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态,
* 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks
* 返回的 task_id 轮询任务详情,不再依赖 plan。
*
* 错误处理:
* - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试
* - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次
* - 任务完成后获取结果失败会重试 MAX_RESULTS_RETRIES 次,仍失败则 onFailed
*/
export const useGenerationPolling = ({
templateId,
onProgress,
onComplete,
onFailed,
}: UseGenerationPollingOptions) => {
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
const cancelledRef = useRef(false)
const clearTimer = useCallback(() => {
cancelledRef.current = true
if (progressTimer.current) {
clearTimeout(progressTimer.current)
progressTimer.current = undefined
}
}, [])
const startPolling = useCallback(() => {
const poll = async () => {
/** 任务完成后拉取结果列表,带重试 */
const fetchResultsWithRetry = useCallback(
async (taskId: string, attempt = 0): Promise<unknown[] | null> => {
try {
const data = await getGenerationStatus(templateId)
if (data.plan_status === "completed") {
onProgress(100)
// 获取生成的视频结果
let videos: unknown[] = []
if (data.generation_task_id) {
try {
videos = await getGenerationTaskResults(data.generation_task_id)
} catch (err) {
console.error("[获取生成结果失败]", err)
}
}
onComplete(videos)
message.success("视频生成完成!")
return
return await getGenerationTaskResults(taskId)
} catch (err) {
if (cancelledRef.current) return null
console.error(`[获取生成结果失败] 第 ${attempt + 1} 次`, err)
if (attempt < MAX_RESULTS_RETRIES - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)))
return fetchResultsWithRetry(taskId, attempt + 1)
}
if (data.plan_status === "failed") {
const dataAny = data as unknown as Record<string, unknown>
const rawMsg =
dataAny.error_message ||
dataAny.error ||
dataAny.message ||
(Array.isArray(data.clips)
? (data.clips as { status: string; error_message?: string }[]).find(
(c) => c.status === "failed",
)?.error_message
: undefined) ||
"视频生成失败,请联系管理员或重试"
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] templateId:", templateId, "响应:", data)
onFailed(errorMsg)
message.error(errorMsg)
return
}
const clips = data.clips || []
const total = clips.length || 1
const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length
onProgress(Math.round((done / total) * 100))
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
console.error("[轮询出错] templateId:", templateId, pollErr)
progressTimer.current = setTimeout(poll, 3000)
return null
}
}
},
[],
)
progressTimer.current = setTimeout(poll, 2000)
}, [templateId, onProgress, onComplete, onFailed])
const startPolling = useCallback(
(taskId: string) => {
cancelledRef.current = false
let consecutiveErrors = 0
const poll = async () => {
if (cancelledRef.current) return
try {
const task = await getGenerationTask(taskId)
consecutiveErrors = 0
if (task.status === "completed") {
onProgress(100)
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
const errorMsg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
console.error("[生成结果获取失败] taskId:", taskId)
onFailed(errorMsg)
message.error(errorMsg)
return
}
onComplete(videos)
message.success("视频生成完成!")
return
}
if (task.status === "failed" || task.status === "cancelled") {
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] taskId:", taskId, "响应:", task)
onFailed(errorMsg)
message.error(errorMsg)
return
}
// pending / waiting / running — 继续轮询
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
onProgress(pct)
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
if (cancelledRef.current) return
console.error("[轮询出错] taskId:", taskId, pollErr)
// 4xx 不可恢复,立即失败
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
if (status && status >= 400 && status < 500) {
const msg =
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.detail) ||
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.message) ||
`查询任务失败 (${status})`
const errorMsg = safeExtractError(msg)
onFailed(errorMsg)
message.error(errorMsg)
return
}
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
const errorMsg = "任务状态查询连续失败,请稍后在任务列表查看结果"
onFailed(errorMsg)
message.error(errorMsg)
return
}
progressTimer.current = setTimeout(poll, 3000)
}
}
progressTimer.current = setTimeout(poll, 1500)
},
[onProgress, onComplete, onFailed, fetchResultsWithRetry],
)
return { startPolling, clearTimer }
}
@@ -0,0 +1,124 @@
/**
* 草稿自动保存工具 Hook
*
* 背景:后端 PUT /templates/{id}/editor 的 config 是「整体替换」语义,
* 直接发送 { config: { asset_ids } } 会把 title 等其他字段覆盖掉。
* 本 Hook 统一执行「GET 当前 config → 浅合并新字段 → PUT 回去」,
* 并用串行队列 + AbortController 保证:
* - 同一时刻只有一个保存请求在飞
* - 快速连续变化时只提交最后一次
* - 组件卸载时取消未完成请求
* - 保存失败时保留补丁,自动重试(指数退避,最多 5 次)
*
* 保存失败只 console.warn,不弹窗、不阻塞。
*/
import { useCallback, useEffect, useRef } from "react"
import { getEditPlan, updateEditPlan } from "@/api/template-editor"
type ConfigPatch = Record<string, unknown>
/** 最大自动重试次数 */
const MAX_RETRIES = 5
/** 初始重试延迟(ms),每次翻倍 */
const BASE_RETRY_DELAY = 1000
export function useDraftAutoSave(templateId?: string) {
const timerRef = useRef<ReturnType<typeof setTimeout>>()
const abortRef = useRef<AbortController | null>(null)
// 待合并的补丁队列(解决「保存进行中又来了新变化」)
const pendingPatchRef = useRef<ConfigPatch | null>(null)
const savingRef = useRef(false)
const templateIdRef = useRef(templateId)
templateIdRef.current = templateId
const flush = useCallback(async (retryCount = 0) => {
const tid = templateIdRef.current
if (!tid) return
// 已有保存在飞:把新补丁暂存,等当前请求结束后再合并一次
if (savingRef.current) return
// 快照当前补丁,但先不清空 —— 成功后才清除,失败时保留以便重试
const patchToSave = pendingPatchRef.current
if (!patchToSave) {
savingRef.current = false
return
}
savingRef.current = true
const controller = new AbortController()
abortRef.current = controller
try {
// 1. 读当前 config(拿最新,避免覆盖别人/别的步骤写入的字段)
const current = await getEditPlan(tid)
if (controller.signal.aborted) return
const merged = { ...(current.config || {}), ...patchToSave }
// 2. 写回完整合并后的 config
await updateEditPlan(tid, { config: merged }, controller.signal)
// 3. 保存成功才清除已保存的补丁
// (保存期间可能有新补丁进来,只清除我们已经保存的部分)
pendingPatchRef.current = null
} catch (err) {
const name = (err as { name?: string })?.name
if (name === "CanceledError" || name === "AbortError") {
// 组件卸载或新请求取消,不重试
return
}
console.warn("[useDraftAutoSave] 自动保存草稿失败:", err)
// 保存失败:把本次尝试保存的补丁合并回 pendingPatchRef
// (保存期间可能有新补丁,新补丁优先)
pendingPatchRef.current = {
...patchToSave,
...(pendingPatchRef.current || {}),
}
// 指数退避重试
if (retryCount < MAX_RETRIES && !controller.signal.aborted) {
const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount)
timerRef.current = setTimeout(() => {
void flush(retryCount + 1)
}, delay)
}
// 超过最大重试次数后,补丁仍保留在 pendingPatchRef 中,
// 下次 scheduleSave 触发时会一起带上
} finally {
savingRef.current = false
// 保存期间又积累了新变化(且不是在重试路径中),再触发一次
if (pendingPatchRef.current && !controller.signal.aborted && retryCount === 0) {
timerRef.current = setTimeout(() => {
void flush()
}, 50)
}
}
}, [])
/**
* 调度一次自动保存(防抖)
* @param patch 要合并进 config 的局部字段
* @param delay 防抖毫秒数
*/
const scheduleSave = useCallback(
(patch: ConfigPatch, delay = 500) => {
const tid = templateIdRef.current
if (!tid) return
if (timerRef.current) clearTimeout(timerRef.current)
// 累计补丁(同一周期内多次变化合并成一次写入)
pendingPatchRef.current = { ...(pendingPatchRef.current || {}), ...patch }
timerRef.current = setTimeout(() => {
void flush()
}, delay)
},
[flush],
)
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (abortRef.current) abortRef.current.abort()
}
}, [])
return { scheduleSave }
}
export default useDraftAutoSave
@@ -34,7 +34,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}, [])
const { startPolling, clearTimer } = useGenerationPolling({
templateId: selectedTemplate,
onProgress: handleProgress,
onComplete: handleComplete,
onFailed: handleFailed,
@@ -90,16 +89,20 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// 直接创建正式生成任务
await createGenerationTask({
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: props.coverSettings?.upload_url || "",
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
title_config: {
@@ -116,7 +119,12 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
: {}),
})
startPolling()
// 从创建响应直接拿 task_id,改用新接口轮询
const taskId = taskResp.items?.[0]?.id
if (!taskId) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
startPolling(taskId)
} catch (err: unknown) {
console.error("[handleGenerate] 生成失败:", err)
setGenerating(false)
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef } from "react"
import { formatDuration } from "../utils/formatDuration"
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
import { useDraftAutoSave } from "./useDraftAutoSave"
interface UseStep2MaterialsProps {
materialMode: "manual" | "auto"
@@ -14,6 +15,8 @@ interface UseStep2MaterialsProps {
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
}
export function useStep2Materials({
@@ -23,6 +26,7 @@ export function useStep2Materials({
onSelectedMaterialsChange,
smartSelectedIds,
onSmartSelectedIdsChange,
selectedTemplate,
}: UseStep2MaterialsProps) {
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
useMaterialLibrary()
@@ -53,6 +57,14 @@ export function useStep2Materials({
handleSmartMatch()
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
/* ── Step2 选择素材后自动保存草稿(防抖 500ms,失败静默) ── */
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
useEffect(() => {
if (!selectedTemplate) return
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
scheduleSave({ asset_ids: ids }, 500)
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
/* ── 手动选择素材 ── */
const handleToggleMaterial = useCallback(
(materialId: string) => {
@@ -4,17 +4,24 @@ import { getTitles } from "@/api/titles"
import type { TitleSettings } from "../../types"
import { useAiTitleGenerator } from "./useAiTitleGenerator"
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
import { useDraftAutoSave } from "../useDraftAutoSave"
interface UseStep4TitleProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
}
/**
* Step 4 标题设置 Hook
* 封装 AI 标题生成、标题样式设置等逻辑
*/
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
export function useStep4Title({
titleSettings,
onTitleSettingsChange,
selectedTemplate,
}: UseStep4TitleProps) {
// 标题库数据
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
@@ -42,6 +49,38 @@ export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
const isFirstMount = useRef(true)
/* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */
const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate)
useEffect(() => {
if (!selectedTemplate) return
scheduleTitleSave(
{
title: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
},
800,
)
}, [
selectedTemplate,
titleSettings.title,
titleSettings.font,
titleSettings.size,
titleSettings.color,
titleSettings.position,
titleSettings.bold,
titleSettings.stroke,
titleSettings.shadow,
scheduleTitleSave,
])
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
useEffect(() => {
@@ -7,6 +7,8 @@ import { message } from "antd"
import type { CoverConfig, CoverTemplate } from "../types/cover"
import { generateCover } from "@/api/generation"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import { updateEditPlan } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import {
fetchCoverTemplates,
createCoverTemplate,
@@ -22,6 +24,8 @@ interface UseStep6CoverProps {
assetIds?: string[]
/** 当前选中的模板 ID */
selectedTemplate?: string
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
titleSettings?: TitleSettings
}
export function useStep6Cover({
@@ -30,6 +34,7 @@ export function useStep6Cover({
duration,
assetIds = [],
selectedTemplate = "",
titleSettings,
}: UseStep6CoverProps) {
const [generating, setGenerating] = useState(false)
@@ -92,6 +97,20 @@ export function useStep6Cover({
const response = await generateCover(selectedTemplate, {
asset_ids: assetIds,
cover_type: "ai_frame",
...(titleSettings?.title
? {
title_config: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
}
: {}),
})
clearTimeout(timeoutId)
const thumbnailUrl = response.cover?.image_url || ""
@@ -136,6 +155,20 @@ export function useStep6Cover({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: duration || 30,
...(titleSettings?.title
? {
title_config: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
}
: {}),
})
// 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
await new Promise<void>((resolve, reject) => {
@@ -154,6 +187,21 @@ export function useStep6Cover({
try {
const status = await getPreviewStatus(previewResp.task_id)
if (status.status === "completed") {
// 保存预览视频地址到 plan.config.rendered_storage_key,
// 供封面 API 的 E1 兜底路径定位渲染后的视频(含标题烧录)。
// video_url 可能是完整 http(s) URL 或 OSS storage_key,两种格式后端都能处理。
if (status.video_url) {
try {
await updateEditPlan(selectedTemplate, {
config: { rendered_storage_key: status.video_url },
})
} catch (saveErr) {
console.warn(
"[Step6] 保存 rendered_storage_key 失败(不阻塞封面重试):",
saveErr,
)
}
}
done(() => resolve())
} else if (status.status === "failed") {
done(() => reject(new Error(status.error_message || "预览渲染失败")))
@@ -171,6 +219,20 @@ export function useStep6Cover({
const retryResp = await generateCover(selectedTemplate, {
asset_ids: assetIds,
cover_type: "ai_frame",
...(titleSettings?.title
? {
title_config: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
}
: {}),
})
const retryUrl = retryResp.cover?.image_url || ""
if (retryUrl) {
@@ -212,7 +274,15 @@ export function useStep6Cover({
clearTimeout(timeoutId)
setGenerating(false)
}
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating, duration])
}, [
selectedTemplate,
assetIds,
coverSettings,
onCoverSettingsChange,
generating,
duration,
titleSettings,
])
// ── 模板操作方法 ──
const handleSelectTemplate = useCallback((id: string) => {
+2 -2
View File
@@ -32,7 +32,7 @@ describe("bgm API", () => {
describe("getBgmPresets", () => {
it("should resolve successfully", async () => {
await expect(getBgmPresets("test-params?")).resolves.not.toThrow()
await expect(getBgmPresets("test-template", { category: "test" })).resolves.not.toThrow()
})
it("should reject on API error", async () => {
@@ -42,7 +42,7 @@ describe("bgm API", () => {
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(getBgmPresets("test-params?")).rejects.toThrow()
await expect(getBgmPresets("test-template", { category: "test" })).rejects.toThrow()
})
})
})
-85
View File
@@ -1,16 +1,12 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import {
getEditPlans,
getEditPlan,
createEditPlan,
updateEditPlan,
deleteEditPlan,
generateEditPlan,
getGenerationStatus,
aiRecommendClips,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
getEditPlanClips,
getEditPlanClip,
createEditPlanClip,
@@ -19,7 +15,6 @@ import {
reorderEditPlanClips,
batchDeleteEditPlanClips,
createClipsFromAssets,
copyEditPlan,
getMediaAssets,
getMediaAsset,
} from "@/api/template-editor"
@@ -53,22 +48,6 @@ describe("editPlans API", () => {
mockPatch.mockResolvedValue({ data: { success: true, items: [] } })
})
describe("getEditPlans", () => {
it("should resolve successfully", async () => {
await expect(getEditPlans("test-params?")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(getEditPlans("test-params?")).rejects.toThrow()
})
})
describe("getEditPlan", () => {
it("should resolve successfully", async () => {
await expect(getEditPlan("test-planId")).resolves.not.toThrow()
@@ -85,22 +64,6 @@ describe("editPlans API", () => {
})
})
describe("createEditPlan", () => {
it("should resolve successfully", async () => {
await expect(createEditPlan({ name: "test-item" })).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(createEditPlan({ name: "test-item" })).rejects.toThrow()
})
})
describe("updateEditPlan", () => {
it("should resolve successfully", async () => {
await expect(updateEditPlan("test-planId")).resolves.not.toThrow()
@@ -117,22 +80,6 @@ describe("editPlans API", () => {
})
})
describe("deleteEditPlan", () => {
it("should resolve successfully", async () => {
await expect(deleteEditPlan("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(deleteEditPlan("test-planId")).rejects.toThrow()
})
})
describe("generateEditPlan", () => {
it("should resolve successfully", async () => {
await expect(generateEditPlan("test-planId")).resolves.not.toThrow()
@@ -213,22 +160,6 @@ describe("editPlans API", () => {
})
})
describe("cancelGeneration", () => {
it("should resolve successfully", async () => {
await expect(cancelGeneration("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(cancelGeneration("test-planId")).rejects.toThrow()
})
})
describe("getEditPlanClips", () => {
it("should resolve successfully", async () => {
await expect(getEditPlanClips("test-planId")).resolves.not.toThrow()
@@ -357,22 +288,6 @@ describe("editPlans API", () => {
})
})
describe("copyEditPlan", () => {
it("should resolve successfully", async () => {
await expect(copyEditPlan("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(copyEditPlan("test-planId")).rejects.toThrow()
})
})
describe("getMediaAssets", () => {
it("should resolve successfully", async () => {
await expect(getMediaAssets("test-libraryId?")).resolves.not.toThrow()
-17
View File
@@ -5,7 +5,6 @@ import {
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
} from "@/api/templates"
const mockGet = vi.fn()
@@ -116,20 +115,4 @@ describe("templates API", () => {
await expect(copyTemplate("test-templateId")).rejects.toThrow()
})
})
describe("generateFromTemplate", () => {
it("should resolve successfully", async () => {
await expect(generateFromTemplate("test-templateId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(generateFromTemplate("test-templateId")).rejects.toThrow()
})
})
})
@@ -150,12 +150,10 @@ vi.mock("@/api/template-editor", () => ({
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
getEditPlan: vi.fn().mockResolvedValue({}),
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
updateEditPlan: vi.fn().mockResolvedValue({}),
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
cancelGeneration: vi.fn().mockResolvedValue({}),
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
createEditPlanClip: vi.fn().mockResolvedValue({}),
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}),
@@ -109,7 +109,6 @@ vi.mock("@/api/templates", () => ({
getTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
toggleFavoriteTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
copyTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
generateFromTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
}))
vi.mock("@/pages/templates/TemplateLibrary.css", () => ({}))
+10
View File
@@ -19,4 +19,14 @@ celery_app.conf.imports = (
"worker_app.tasks.batch_download",
"worker_app.tasks._startup",
"apps.worker.video_processing.dedup",
"worker_app.tasks.cleanup",
)
# Celery Beat 定时任务调度
celery_app.conf.beat_schedule = {
"cleanup-stale-pending-tasks": {
"task": "worker.cleanup_stale_pending_tasks",
"schedule": 600.0, # 每 10 分钟(秒)
"options": {"expires": 300}, # 5 分钟过期,避免堆积
},
}
+40 -3
View File
@@ -10,6 +10,9 @@ logger = logging.getLogger(__name__)
# 孤儿任务超时阈值:渲染任务超过此时间未更新则视为卡死
ORPHAN_TASK_TIMEOUT_MINUTES = 10
# Pending 任务超时阈值:pending 任务在队列中等待超过此时间则自动清理
PENDING_TASK_TIMEOUT_MINUTES = 30
def cleanup_orphan_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> int: # pragma: no cover
"""清理数据库中超时未更新的 running GenerationTask(孤儿任务)。
@@ -83,6 +86,38 @@ def cleanup_stale_jobs(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> in
return 0
def cleanup_stale_pending_tasks(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINUTES) -> int: # pragma: no cover
"""清理数据库中卡在 pending 状态超时的 GenerationTask。
全局任务队列有 pending 数量上限,长期卡在 pending 的任务会占满队列,
导致新用户无法创建任务。通过 created_at 超时判断并标记为 failed。
Args:
timeout_minutes: 超时时间(分钟),默认 PENDING_TASK_TIMEOUT_MINUTES
Returns:
清理的任务数量
"""
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
session = SessionLocal()
try:
repo = SQLAlchemyGenerationTaskRepository(session)
count = repo.cleanup_stale_pending(timeout_minutes)
if count > 0:
logger.warning("清理了 %d 个超时的 pending GenerationTask(超过 %d 分钟未处理)", count, timeout_minutes)
else:
logger.info("无超时 pending GenerationTask 需要清理")
return count
except Exception as e:
logger.error("清理超时 pending GenerationTask 失败: %s", e, exc_info=True)
return 0
finally:
session.close()
def cleanup_all_stale_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> dict: # pragma: no cover
"""统一清理所有超时的孤儿任务。
@@ -93,15 +128,17 @@ def cleanup_all_stale_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES)
"""
gen_count = cleanup_orphan_tasks(timeout_minutes)
job_count = cleanup_stale_jobs(timeout_minutes)
total = gen_count + job_count
pending_count = cleanup_stale_pending_tasks(PENDING_TASK_TIMEOUT_MINUTES)
total = gen_count + job_count + pending_count
if total > 0:
logger.warning(
"孤儿任务清理完成: GenerationTask=%d, Job=%d, 总计=%d",
"任务清理完成: 孤儿 GenerationTask=%d, 孤儿 Job=%d, 超时 pending=%d, 总计=%d",
gen_count,
job_count,
pending_count,
total,
)
return {"generation_tasks": gen_count, "jobs": job_count}
return {"generation_tasks": gen_count, "jobs": job_count, "pending": pending_count}
@worker_ready.connect
+35
View File
@@ -0,0 +1,35 @@
"""定期清理任务 — Celery Beat 调度。
包含:
- cleanup_stale_pending_tasks: 定期清理卡在 pending 超时的 generation_tasks
"""
import logging
from celery import shared_task
from worker_app.tasks._startup import (
PENDING_TASK_TIMEOUT_MINUTES,
cleanup_stale_pending_tasks,
)
logger = logging.getLogger(__name__)
@shared_task(name="worker.cleanup_stale_pending_tasks")
def scheduled_cleanup_stale_pending(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINUTES) -> dict:
"""Celery Beat 调度的定期任务:清理超时的 pending 任务。
每 10 分钟执行一次(由 celery_app.py 的 beat_schedule 配置),
查找所有 status='pending' 且 created_at < NOW() - timeout_minutes
的 generation_tasks,批量更新为 failed。
Args:
timeout_minutes: 超时时间(分钟),默认 30 分钟
Returns:
{"cleaned": int}
"""
count = cleanup_stale_pending_tasks(timeout_minutes)
if count > 0:
logger.info("[Beat] 清理了 %d 个超时 pending 任务(超时阈值 %d 分钟)", count, timeout_minutes)
return {"cleaned": count}
+27 -17
View File
@@ -887,6 +887,7 @@ def _load_task_info(task_id: str) -> dict | None:
"output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT,
"cover_url": getattr(gen_task, "cover_url", "") or "",
"custom_title": getattr(gen_task, "custom_title", "") or "",
"title_config": dict(getattr(gen_task, "title_config", {}) or {}),
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
}
finally:
@@ -967,6 +968,7 @@ def _render_video(
bgm_config: dict | None = None,
voice_ids: list[str] | None = None,
custom_title: str = "",
title_config: dict | None = None,
) -> tuple[Path, float, list[dict] | None]:
"""渲染视频(含配音混音)。
@@ -999,27 +1001,34 @@ def _render_video(
list(template_config.keys()),
)
# ── 用户自定义标题覆盖模板标题配置 ──────────────────────────────────
if custom_title:
# ── 用户自定义标题:title_config 优先,custom_title 兜底 ─────────────
effective_title_cfg: dict | None = None
if title_config and isinstance(title_config, dict) and title_config.get("text", "").strip():
effective_title_cfg = dict(title_config)
elif custom_title:
try:
user_title_cfg = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
if isinstance(user_title_cfg, dict) and user_title_cfg.get("text", "").strip():
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
if "font_size" in user_title_cfg and "size" not in user_title_cfg:
user_title_cfg["size"] = user_title_cfg["font_size"]
if "font_color" in user_title_cfg and "color" not in user_title_cfg:
user_title_cfg["color"] = user_title_cfg["font_color"]
plan_cfg = dict(virtual_plan.config or {})
plan_cfg["title"] = user_title_cfg
virtual_plan.config = plan_cfg
logger.info(
"[task_id=%s] [渲染] 用户自定义标题已注入: text=%s",
task_id,
user_title_cfg.get("text", "")[:30],
)
parsed = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
if isinstance(parsed, dict) and parsed.get("text", "").strip():
effective_title_cfg = parsed
except (json.JSONDecodeError, TypeError):
logger.warning("[task_id=%s] custom_title JSON解析失败: %s", task_id, custom_title[:100])
if effective_title_cfg:
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
if "font_size" in effective_title_cfg and "size" not in effective_title_cfg:
effective_title_cfg["size"] = effective_title_cfg["font_size"]
if "font_color" in effective_title_cfg and "color" not in effective_title_cfg:
effective_title_cfg["color"] = effective_title_cfg["font_color"]
plan_cfg = dict(virtual_plan.config or {})
plan_cfg["title"] = effective_title_cfg
virtual_plan.config = plan_cfg
logger.info(
"[task_id=%s] [渲染] 标题配置已注入(source=%s): text=%s",
task_id,
"title_config" if title_config else "custom_title",
effective_title_cfg.get("text", "")[:30],
)
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
if bgm_config:
plan_cfg = virtual_plan.config or {}
@@ -1401,6 +1410,7 @@ def generate_video(self, task_id: str) -> dict:
bgm_config=task_info.get("bgm_config", {}),
voice_ids=task_info.get("voice_ids", []),
custom_title=task_info.get("custom_title", ""),
title_config=task_info.get("title_config", {}),
)
if gen_task:
+5
View File
@@ -6,8 +6,13 @@ set -e
CONCURRENCY="${WORKER_CONCURRENCY:-2}"
# ⚠️ 部署约束:此 Worker 必须且只能运行单实例(replicas=1)
# -B 标志嵌入 celery beat,beat 负责定期触发 pending 超时清理等定时任务
# 多实例部署会导致每个 Worker 独立运行 Beat,造成定时任务重复执行
# 若需横向扩展 Worker,必须将 Beat 拆分为独立服务(celery beat -A worker_app.celery_app)
exec celery \
-A worker_app.celery_app \
worker \
--loglevel=info \
"-B" \
"--concurrency=${CONCURRENCY}"
@@ -42,6 +42,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
output_height=getattr(model, "output_height", 720) or 720,
cover_url=getattr(model, "cover_url", "") or "",
custom_title=getattr(model, "custom_title", "") or "",
title_config=dict(getattr(model, "title_config", {}) or {}),
logs=model.logs or "[]",
created_at=model.created_at,
updated_at=model.updated_at,
@@ -86,6 +87,7 @@ class SQLAlchemyGenerationTaskRepository:
output_height=task.output_height,
cover_url=task.cover_url or "",
custom_title=task.custom_title or "",
title_config=dict(task.title_config) if task.title_config else {},
logs=task.logs,
created_at=task.created_at,
updated_at=task.updated_at,
@@ -273,6 +275,7 @@ class SQLAlchemyGenerationTaskRepository:
model.output_height = task.output_height
model.cover_url = task.cover_url or ""
model.custom_title = task.custom_title or ""
model.title_config = dict(task.title_config) if task.title_config else {}
model.logs = task.logs
self.session.commit()
return task
@@ -310,3 +313,42 @@ class SQLAlchemyGenerationTaskRepository:
model.completed_at = datetime.now(timezone.utc)
self.session.commit()
return len(models)
def cleanup_stale_pending(self, timeout_minutes: int = 30) -> int:
"""清理超时的 pending 任务(未被 Worker 拉取的任务)。
全局任务队列有 pending 数量上限,长期卡在 pending 的任务会占满队列,
导致新用户无法创建任务。将超时的 pending 任务标记为 failed。
Args:
timeout_minutes: 超时时间(分钟),默认 30 分钟
Returns:
清理的任务数量
"""
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
error_info = {
"error_type": "PendingTimeout",
"message": f"任务在 pending 状态停留超过 {timeout_minutes} 分钟,自动清理",
"failed_at": datetime.now(timezone.utc).isoformat(),
}
count = (
self.session.query(GenerationTaskModel)
.filter(
GenerationTaskModel.status == GenerationTaskStatus.PENDING.value,
GenerationTaskModel.created_at < cutoff,
)
.update(
{
GenerationTaskModel.status: GenerationTaskStatus.FAILED.value,
GenerationTaskModel.error_message: "pending timeout: auto cleanup",
GenerationTaskModel.error_info: error_info,
GenerationTaskModel.completed_at: datetime.now(timezone.utc),
},
synchronize_session=False,
)
)
self.session.commit()
return count
@@ -298,6 +298,7 @@ class GenerationTaskModel(Base):
output_height = Column(Integer, nullable=False, default=720)
cover_url = Column(String(1000), nullable=False, default="")
custom_title = Column(String(500), nullable=False, default="")
title_config = Column(JSON, nullable=False, default=dict)
bgm_config = Column(JSON, nullable=False, default=dict)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
logs = Column(Text, nullable=False, default="[]", server_default="[]")
+1
View File
@@ -69,6 +69,7 @@ class CreateGenerationTaskUseCase:
output_height=command.output_height,
cover_url=command.cover_url,
custom_title=command.custom_title,
title_config=command.title_config,
)
return self.generation_task_repository.create(task)
+3
View File
@@ -121,6 +121,7 @@ class GenerationTask:
output_height: int = 720
cover_url: str = ""
custom_title: str = ""
title_config: dict = field(default_factory=dict)
extra_meta: dict = field(default_factory=dict)
logs: str = "[]"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -153,6 +154,7 @@ class GenerationTask:
output_height: int = 720,
cover_url: str = "",
custom_title: str = "",
title_config: dict | None = None,
extra_meta: dict | None = None,
) -> "GenerationTask":
if not project_id.strip() and not template_id.strip():
@@ -184,6 +186,7 @@ class GenerationTask:
output_height=output_height,
cover_url=cover_url,
custom_title=custom_title,
title_config=dict(title_config) if title_config else {},
extra_meta=dict(extra_meta) if extra_meta else {},
)
@@ -0,0 +1,151 @@
"""GenerationTaskRepository - cleanup_stale_pending 超时 pending 清理单元测试。"""
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.models import Base
from packages.domain import GenerationTask, GenerationTaskStatus
def _repository():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
return SQLAlchemyGenerationTaskRepository(session), session, engine
def _make_task(**kwargs) -> GenerationTask:
defaults = dict(
project_id="proj-1",
asset_library_id="lib-1",
created_by_user_id="user-1",
)
defaults.update(kwargs)
return GenerationTask.create(**defaults)
# ---------------------------------------------------------------------------
# cleanup_stale_pending 基本测试
# ---------------------------------------------------------------------------
def test_cleanup_stale_pending_no_tasks_returns_zero():
"""没有任务时返回 0。"""
repo, _, _ = _repository()
count = repo.cleanup_stale_pending(timeout_minutes=30)
assert count == 0
def test_cleanup_stale_pending_recent_pending_not_cleaned():
"""30 分钟内的 pending 任务不被清理。"""
repo, _, _ = _repository()
task = _make_task()
repo.create(task)
# 刚创建的 pending 任务不应被清理
count = repo.cleanup_stale_pending(timeout_minutes=30)
assert count == 0
assert repo.get(task.id).status == GenerationTaskStatus.PENDING
def test_cleanup_stale_pending_old_pending_marked_failed():
"""超过 30 分钟的 pending 任务被标记为 failed。"""
repo, _, engine = _repository()
task = _make_task()
repo.create(task)
# 手动把 created_at 改到 1 小时前
with engine.connect() as conn:
conn.execute(
text("UPDATE generation_tasks SET created_at = :ts WHERE id = :id"),
{"ts": datetime.now(timezone.utc) - timedelta(hours=1), "id": task.id},
)
conn.commit()
count = repo.cleanup_stale_pending(timeout_minutes=30)
assert count == 1
saved = repo.get(task.id)
assert saved.status == GenerationTaskStatus.FAILED
assert saved.error_message == "pending timeout: auto cleanup"
assert saved.error_info.get("error_type") == "PendingTimeout"
assert "30" in saved.error_info["message"]
assert "failed_at" in saved.error_info
assert saved.completed_at is not None
def test_cleanup_stale_pending_running_not_touched():
"""running 任务不受影响,只清理 pending。"""
repo, _, engine = _repository()
task = _make_task()
repo.create(task)
task.mark_processing()
repo.update(task)
# 回写 created_at 到 1 小时前
with engine.connect() as conn:
conn.execute(
text("UPDATE generation_tasks SET created_at = :ts WHERE id = :id"),
{"ts": datetime.now(timezone.utc) - timedelta(hours=1), "id": task.id},
)
conn.commit()
count = repo.cleanup_stale_pending(timeout_minutes=30)
assert count == 0
assert repo.get(task.id).status == GenerationTaskStatus.RUNNING
def test_cleanup_stale_pending_custom_timeout():
"""自定义超时时间生效。"""
repo, _, engine = _repository()
task = _make_task()
repo.create(task)
# 回写 created_at 到 20 分钟前
with engine.connect() as conn:
conn.execute(
text("UPDATE generation_tasks SET created_at = :ts WHERE id = :id"),
{"ts": datetime.now(timezone.utc) - timedelta(minutes=20), "id": task.id},
)
conn.commit()
# 30 分钟超时:不清理
count_30 = repo.cleanup_stale_pending(timeout_minutes=30)
assert count_30 == 0
# 15 分钟超时:清理
count_15 = repo.cleanup_stale_pending(timeout_minutes=15)
assert count_15 == 1
assert repo.get(task.id).status == GenerationTaskStatus.FAILED
def test_cleanup_stale_pending_multiple():
"""批量清理多个超时的 pending 任务。"""
repo, _, engine = _repository()
tasks = []
for i in range(5):
t = _make_task(project_id=f"proj-{i}")
repo.create(t)
tasks.append(t)
# 全部回写 created_at 到 2 小时前
with engine.connect() as conn:
for t in tasks:
conn.execute(
text("UPDATE generation_tasks SET created_at = :ts WHERE id = :id"),
{"ts": datetime.now(timezone.utc) - timedelta(hours=2), "id": t.id},
)
conn.commit()
count = repo.cleanup_stale_pending(timeout_minutes=30)
assert count == 5
for t in tasks:
assert repo.get(t.id).status == GenerationTaskStatus.FAILED
@@ -0,0 +1,126 @@
"""Tests for _writeback_edit_plan_config in generation_tasks route.
覆盖 CI 增量覆盖率不足的代码:
- generation_tasks.py 行 160-193 (_writeback_edit_plan_config 函数体)
- generation_tasks.py 行 371-372 (路由中调用该函数)
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.generation_tasks import _writeback_edit_plan_config
@pytest.fixture
def mock_db():
"""Mock SQLAlchemy Session."""
db = MagicMock()
db.query.return_value = db
db.filter.return_value = db
return db
@pytest.fixture
def mock_plan():
"""Mock EditPlanModel instance."""
plan = MagicMock()
plan.config = {"existing_key": "existing_value"}
return plan
class TestWritebackEditPlanConfig:
"""_writeback_edit_plan_config 全分支覆盖"""
# ---- 行 160-161: plan_id 为空直接返回 ----
def test_empty_plan_id_returns_immediately(self, mock_db):
_writeback_edit_plan_config(plan_id="", task_id="task_1", title_config={"text": "hi"}, db=mock_db)
mock_db.query.assert_not_called()
mock_db.commit.assert_not_called()
def test_none_plan_id_returns_immediately(self, mock_db):
_writeback_edit_plan_config(plan_id=None, task_id="task_1", title_config=None, db=mock_db)
mock_db.query.assert_not_called()
# ---- 行 165-168: plan 不存在 → warning + 不 commit ----
def test_plan_not_found_no_commit(self, mock_db):
mock_db.first.return_value = None
_writeback_edit_plan_config(plan_id="plan_999", task_id="task_1", title_config=None, db=mock_db)
mock_db.query.assert_called_once()
mock_db.commit.assert_not_called()
# ---- 行 170-182: 正常写入 + title_config ----
def test_success_with_title_config(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
_writeback_edit_plan_config(
plan_id="plan_123",
task_id="task_456",
title_config={"text": "标题", "font_size": 36},
db=mock_db,
)
assert mock_plan.config["generation_task_id"] == "task_456"
assert mock_plan.config["title_config"] == {"text": "标题", "font_size": 36}
assert mock_plan.config["existing_key"] == "existing_value"
mock_db.commit.assert_called_once()
# ---- 行 170-175: 正常写入、无 title_config ----
def test_success_without_title_config(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_789", title_config=None, db=mock_db)
assert mock_plan.config["generation_task_id"] == "task_789"
assert "title_config" not in mock_plan.config
mock_db.commit.assert_called_once()
# ---- 行 170: config 不是 dict → 兜底空 dict ----
def test_config_not_dict_uses_empty_dict(self, mock_db):
bad_plan = MagicMock()
bad_plan.config = "not_a_dict"
mock_db.first.return_value = bad_plan
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
assert isinstance(bad_plan.config, dict)
assert bad_plan.config["generation_task_id"] == "task_1"
mock_db.commit.assert_called_once()
# ---- 行 183-189: DB 异常 → warning + rollback ----
def test_db_exception_triggers_rollback(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
mock_db.commit.side_effect = RuntimeError("DB connection lost")
# 不应抛异常
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
mock_db.rollback.assert_called_once()
# ---- 行 190-193: rollback 也失败 → 静默 ----
def test_rollback_failure_silent(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
mock_db.commit.side_effect = RuntimeError("commit failed")
mock_db.rollback.side_effect = RuntimeError("rollback also failed")
# 两个异常都不应抛出
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
mock_db.rollback.assert_called_once()
# ---- 行 173: title_config 为空 dict → 不写入 title_config ----
def test_empty_title_config_not_written(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config={}, db=mock_db)
# 空 dict 为 falsy,不写入
assert "title_config" not in mock_plan.config
assert mock_plan.config["generation_task_id"] == "task_1"