Compare commits

...

12 Commits

Author SHA1 Message Date
saas-backend-agent 341d759c8e fix(test): 移除 InMemory 并发测试,改为顺序幂等验证
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m48s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m27s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 55s
AI Code Review / AI Code Review (pull_request) Successful in 6m27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m20s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 198h13m44s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 198h13m47s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 198h13m47s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 198h18m13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 198h18m13s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 198h18m21s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 198h13m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 198h18m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 198h18m19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 198h18m22s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 198h18m20s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 198h18m23s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 198h18m24s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 198h18m24s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 198h18m25s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 198h48m27s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 198h52m56s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 198h53m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 198h53m4s
InMemory 仓储不支持 DB 级 partial unique index,
并发 create 会产生重复(CI 失败)。真正的并发保护
依赖 PostgreSQL 唯一约束(由 TestSqlRepoIntegrityErrorRecovery 验证)。

改为:创建后重复调用验证 find 路径返回同一对象。

Issue #1775
2026-09-08 09:05:45 +08:00
saas-backend-agent 14a81005a0 fix(test): InMemory 仓储并发安全——加锁模拟 DB partial unique index
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
AI Code Review / AI Code Review (pull_request) Successful in 6m19s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m51s
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m10s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 198h19m46s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 198h19m49s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 198h19m49s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 198h26m30s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 198h26m30s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 198h19m46s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 198h26m36s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 198h26m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 198h26m33s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 198h26m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 198h26m37s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 198h26m38s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 198h26m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 198h26m43s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 198h26m44s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 198h54m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 199h1m12s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 199h1m18s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 199h1m20s
InMemoryProjectRepository/AssetLibraryRepository 的
get_or_create_default_* 方法 find-then-create 无锁,
并发时产生多个项目/素材库(CI test_concurrent_10_calls 失败)。
PostgreSQL 有 partial unique index 兜底,内存实现需
threading.Lock 模拟相同语义。

Issue #1775
2026-09-08 08:57:13 +08:00
xiaoxia 9c0474ef9c feat: #1775 默认项目/素材库幂等化(DB 唯一约束兜底)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m53s
CI/CD Pipeline / Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Style (push) Successful in 3m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate - Security (push) Successful in 5m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m13s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Unit Tests (push) Failing after 7m58s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Failing after 198h32m29s
CI/CD Pipeline / CI Gate (push) Failing after 198h32m31s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 198h32m31s
CI/CD Pipeline / Build Production Web Image (push) Failing after 198h32m31s
CI/CD Pipeline / Build Production API Image (push) Failing after 198h32m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 198h36m16s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 198h37m22s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 198h37m23s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 198h39m3s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 198h37m23s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 198h39m11s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 198h39m14s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 198h39m20s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 198h39m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 198h39m27s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 198h39m33s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 198h39m42s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 198h39m45s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 198h39m39s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 198h40m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 198h40m28s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 198h40m29s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 198h40m29s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 198h40m29s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 198h40m30s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 198h40m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 198h40m30s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 198h40m37s
CI/CD Pipeline / PR Build API Image (push) Failing after 198h40m37s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 198h40m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 198h40m39s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 199h14m21s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 199h14m26s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 199h15m10s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 199h15m11s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 199h15m11s
CI/CD Pipeline / PR Build Web Image (push) Failing after 199h15m19s
Squash merge PR #1783 into develop
2026-09-08 08:43:31 +08:00
xiaoxia 691c811cd4 chore: 删除无引用的旧7步流程遗留组件 GenerationStatus (#1782)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 20s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 31s
CI/CD Pipeline / CI Gate (pull_request) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Validate - Security (push) Successful in 5m57s
AI Code Review / AI Code Review (pull_request) Successful in 6m25s
CI/CD Pipeline / Unit Tests (push) Failing after 8m20s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 206h28m53s
CI/CD Pipeline / Deploy Production (push) Failing after 206h28m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 206h28m57s
CI/CD Pipeline / Build Production API Image (push) Failing after 206h28m57s
CI/CD Pipeline / CI Gate (push) Failing after 206h28m57s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 206h33m32s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 206h36m23s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 206h36m42s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 206h36m23s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 206h36m24s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 206h36m47s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 206h36m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 206h36m54s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 206h36m54s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 206h37m0s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 206h37m1s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 206h37m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 206h37m1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 206h37m13s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 206h37m2s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 206h37m14s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 206h37m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 206h37m14s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 206h37m15s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 206h37m16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 206h37m15s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 206h37m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 206h37m17s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 206h37m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 206h37m21s
CI/CD Pipeline / PR Build API Image (push) Failing after 206h37m21s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 206h37m22s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 207h3m39s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 207h11m30s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 207h11m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 207h11m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 207h11m57s
CI/CD Pipeline / PR Build Web Image (push) Failing after 207h12m3s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:46:49 +08:00
xiaoxia 2114b7e7ae fix: Step5 播放器上移+去完成提示;Step6 封面页去视频全宽居中 (#1781)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 14s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 14s
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 48s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m19s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m32s
CI/CD Pipeline / Integration Tests (push) Successful in 2m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Style (push) Successful in 2m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m0s
CI/CD Pipeline / Validate - Security (push) Successful in 6m12s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Unit Tests (push) Failing after 8m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 10m56s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 12m44s
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 206h39m21s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 206h39m24s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 206h39m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 206h39m24s
CI/CD Pipeline / Canary Release to Production (push) Failing after 206h43m6s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 206h39m25s
CI/CD Pipeline / CI Gate (push) Failing after 206h43m19s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 206h39m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 206h39m25s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 206h43m19s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 206h39m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 206h43m19s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 206h39m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 206h39m26s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 206h49m12s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 206h49m12s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 206h50m11s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 206h50m48s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 206h50m50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 206h51m45s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 206h51m45s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 206h51m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 206h51m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 206h51m46s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 206h51m59s
CI/CD Pipeline / PR Build Web Image (push) Failing after 206h51m59s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 206h51m50s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 206h51m52s
CI/CD Pipeline / PR Build API Image (push) Failing after 206h52m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 206h52m15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 207h14m6s
CI/CD Pipeline / Build Production Web Image (push) Failing after 207h18m1s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 207h23m54s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 207h25m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 207h26m26s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:31:06 +08:00
xiaoxia 7766ba1479 fix(#1774): 模板不存在明确返回404并收敛双表异常降级逻辑 (#1779)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + 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 / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped 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 / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped 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 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
CI/CD Pipeline / Build Production Web Image (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 / Build Staging Worker Image (pull_request) Failing after 206h53m51s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 206h53m52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 206h53m55s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 206h53m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 206h53m55s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 206h53m55s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 206h53m55s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 206h53m55s
CI/CD Pipeline / PR Build Web Image (push) Failing after 206h53m55s
CI/CD Pipeline / Frontend Lint (push) Failing after 206h53m56s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 206h54m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 206h54m2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 207h28m33s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 207h28m36s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 207h28m36s
CI/CD Pipeline / PR Build API Image (push) Failing after 207h28m37s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:30:08 +08:00
xiaoxia ef686dde8f fix(#1769): 模板列表过滤无片段配置的无效模板,避免前端选中后400 (#1772)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 47s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m9s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m10s
CI/CD Pipeline / Validate - Style (push) Successful in 2m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m24s
CI/CD Pipeline / Validate - Security (push) Successful in 7m3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m39s
CI/CD Pipeline / Unit Tests (push) Successful in 9m55s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 207h3m47s
CI/CD Pipeline / Deploy Production (push) Failing after 207h3m47s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 207h3m50s
CI/CD Pipeline / Build Production Web Image (push) Failing after 207h3m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 207h3m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 207h9m51s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 207h12m1s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 207h12m2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 207h12m4s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 207h12m22s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 207h12m26s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 207h12m29s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 207h12m32s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 207h13m21s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 207h13m21s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 207h13m26s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 207h13m26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 207h13m38s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 207h13m39s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 207h13m39s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 207h13m39s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 207h13m39s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 207h13m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 207h13m45s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 207h13m48s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 207h13m48s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 207h13m48s
CI/CD Pipeline / PR Build API Image (push) Failing after 207h13m49s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 207h13m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 207h13m53s
CI/CD Pipeline / CI Gate (push) Failing after 207h38m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 207h47m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 207h47m13s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 207h48m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 207h48m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 207h48m20s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 207h48m29s
CI/CD Pipeline / PR Build Web Image (push) Failing after 207h48m30s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:10:13 +08:00
xiaoxia 01026156ae fix(#1777): Step2素材库只显示视频库 + 失效模板运行时自动回退 (#1780)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m13s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (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
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
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 207h14m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 207h14m41s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 207h14m46s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 207h14m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 207h14m51s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 207h14m54s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 207h15m1s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 207h15m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 207h15m6s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 207h15m14s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 207h15m7s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 207h15m16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 207h16m28s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 207h16m28s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 207h16m30s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 207h16m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 207h16m33s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 207h16m28s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 207h16m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 207h16m29s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 207h16m48s
CI/CD Pipeline / PR Build API Image (push) Failing after 207h16m49s
CI/CD Pipeline / Frontend Lint (push) Failing after 207h16m51s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 207h16m52s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 207h16m54s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 207h49m24s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 207h49m40s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 207h49m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 207h51m10s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 207h51m18s
CI/CD Pipeline / PR Build Web Image (push) Failing after 207h51m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:07:18 +08:00
xiaoxia 42c0885813 Merge pull request 'fix: #1765 generate_pixel_perturbation 支持 int seed 可复现' (#1778) from fix/pixel-perturbation-seed into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Integration Tests (push) Successful in 1m38s
CI/CD Pipeline / Validate - Style (push) Successful in 2m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Unit Tests (push) Successful in 8m6s
CI/CD Pipeline / Validate - Security (push) Successful in 16m13s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 207h42m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 207h42m56s
CI/CD Pipeline / Deploy Production (push) Failing after 207h42m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 207h42m56s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 207h58m42s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 207h58m42s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 207h56m11s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 207h56m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 207h56m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 208h16m30s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 208h16m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 208h19m37s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 208h19m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 208h19m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 208h19m44s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 208h20m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 208h20m46s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 208h20m48s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 208h22m3s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 208h22m3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 208h22m25s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 208h22m25s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 208h22m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 208h22m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 208h22m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 208h22m27s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 208h22m28s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 207h59m10s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 208h22m33s
CI/CD Pipeline / PR Build API Image (push) Failing after 207h59m11s
CI/CD Pipeline / Frontend Lint (push) Failing after 207h59m11s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 208h17m37s
CI/CD Pipeline / PR Build Web Image (push) Failing after 208h33m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 208h33m54s
CI/CD Pipeline / CI Gate (push) Failing after 208h17m37s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 208h33m23s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 208h54m23s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 208h56m43s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 208h57m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 208h57m6s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 208h57m8s
2026-09-07 23:01:36 +08:00
saas-backend-agent cd6d8615e6 test: #1765 补充 int seed 分支测试,diff coverage 达标
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m30s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m34s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 20m16s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 208h16m18s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 208h16m18s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 208h16m24s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 208h16m25s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 208h36m16s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 208h36m16s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 208h36m23s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 208h36m34s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 208h36m35s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 208h36m35s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 208h36m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 208h36m41s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 208h36m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 208h36m43s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 208h36m43s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 208h36m50s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 208h51m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 209h11m9s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 209h11m20s
新增 5 个测试覆盖 int seed 入参分支(reproducible/与 Random 对象
等价/None 默认),满足 diff coverage ≥60% 要求。
2026-09-07 22:47:22 +08:00
saas-backend-agent fcc7863b31 fix: #1765 generate_pixel_perturbation 支持 int seed 参数
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
CI/CD Pipeline / Validate - Security (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 Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 208h47m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 208h47m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 208h48m3s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 208h48m7s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 208h48m8s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 208h48m8s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 208h48m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 208h48m9s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 208h48m10s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 208h48m10s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 208h48m17s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 209h22m37s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 209h22m48s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 209h22m48s
与 get_rhythm_template(seed) 接口保持一致:传入 int 时自动构造
random.Random(seed),保证基于 variantSeed 可复现;Random 对象和
None 调用方式不变。
2026-09-07 22:35:49 +08:00
xiaoxia 6af2f3c08d Merge pull request 'fix: 修复 compute_duplicate_rate 方法丢失导致 27 个单测失败' (#1773) from fix/dedup-method-restore into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 35s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m47s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m35s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 3m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
AI Code Review / AI Code Review (pull_request) Successful in 7m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Unit Tests (push) Successful in 9m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m11s
CI/CD Pipeline / Validate - Security (push) Successful in 22m1s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 208h38m51s
CI/CD Pipeline / CI Gate (push) Failing after 208h38m55s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 208h38m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 208h38m55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 208h56m56s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 208h57m59s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 208h58m0s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 208h58m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 208h58m13s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 208h58m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 208h58m32s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 208h58m7s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 209h0m24s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 209h0m24s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 209h0m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 209h0m26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 209h0m52s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 209h0m54s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 209h0m52s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 209h0m54s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 209h0m53s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 209h0m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 209h0m57s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 209h0m55s
CI/CD Pipeline / PR Build Web Image (push) Failing after 209h0m59s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 209h0m56s
CI/CD Pipeline / PR Build API Image (push) Failing after 209h1m0s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 209h1m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 209h1m2s
CI/CD Pipeline / Deploy Production (push) Failing after 209h13m31s
CI/CD Pipeline / Build Production Web Image (push) Failing after 209h13m35s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 209h32m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 209h33m9s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 209h35m4s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 209h35m6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 209h35m33s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 209h35m36s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 209h35m39s
2026-09-07 22:23:11 +08:00
49 changed files with 1791 additions and 648 deletions
@@ -0,0 +1,72 @@
"""Projects is_default + partial unique index for idempotent default project (Issue #1775)
Revision ID: 069_project_is_default
Revises: 068_user_profile_completed
Create Date: 2026-09-08
背景:
小程序端 getOrCreateDefaultProject 在重试/并发/前端重复调用下,
仅靠应用层"先查再插"不保证幂等,会给同一用户重复创建默认项目。
改动:
1. projects 表新增 is_default 布尔列(默认 false)
2. 部分唯一索引 uq_projects_owner_default:(owner_user_id) WHERE is_default = true
—— 保证每个用户至多一个默认项目
3. 存量数据回填:把名为"默认项目"的存量项目按创建时间最早者标记为 is_default=true
(只标记不删除;存量重复项目的清理另行确认后单独执行)
注意:部分唯一索引依赖 PostgreSQL,不支持 downgrade 到其他方言。
"""
import sqlalchemy as sa
from alembic import op
revision = "069_project_is_default"
down_revision = "068_user_profile_completed"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. 新增 is_default 列
op.add_column(
"projects",
sa.Column(
"is_default",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
# 2. 存量回填:每个拥有"默认项目"的用户,只把最早创建的那一个标记为默认。
# 用 ROW_NUMBER() 取每组第一条;非"默认项目"命名的项目不标记(保守,不动用户自建项目)。
op.execute("""
UPDATE projects p
SET is_default = true
WHERE p.id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY owner_user_id
ORDER BY created_at ASC, id ASC
) AS rn
FROM projects
WHERE name = '默认项目'
) t
WHERE t.rn = 1
)
""")
# 3. 部分唯一索引:每用户至多一个默认项目(只约束 is_default = true 的行)
op.execute("""
CREATE UNIQUE INDEX uq_projects_owner_default
ON projects (owner_user_id)
WHERE is_default = true
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_projects_owner_default")
op.drop_column("projects", "is_default")
+5 -24
View File
@@ -20,7 +20,7 @@ from packages.application import (
GetProjectUseCase,
ListAssetLibrariesUseCase,
)
from packages.domain import AssetLibrary, AssetLibraryKind
from packages.domain import AssetLibraryKind
from ._helpers import check_project_access
@@ -120,30 +120,11 @@ def ensure_default_library(
kind = AssetLibraryKind(request.kind)
# 查找该项目下同 kind 的素材库,返回第一个
existing = asset_library_repository.find_by_project(request.project_id)
for lib in existing:
if lib.kind == kind:
return _to_asset_library_response(lib)
# 不存在 → 自动创建
import uuid
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
# Issue #1775: 幂等获取/创建——依赖唯一约束 uq_asset_libraries_project_kind,
# 并发创建冲突时回滚重查返回已有记录,不再依赖应用层"先查后插",也不会 500。
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
library = AssetLibrary(
id=str(uuid.uuid4()),
project_id=request.project_id,
name=default_name,
kind=kind,
asset_count=0,
total_size=0,
created_at=now,
updated_at=now,
)
created = asset_library_repository.create(library)
return _to_asset_library_response(created)
library = asset_library_repository.get_or_create_default_library(request.project_id, kind, name=default_name)
return _to_asset_library_response(library)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
+41 -1
View File
@@ -1,13 +1,14 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_project_repository
from app.dependencies import get_asset_library_repository, get_project_repository
from app.schemas.project import (
CreateProjectRequest,
ListProjectsResponse,
ProjectResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from packages.application import (
CreateProjectCommand,
@@ -16,10 +17,20 @@ from packages.application import (
GetProjectUseCase,
ListProjectsUseCase,
)
from packages.domain import AssetLibraryKind
router = APIRouter()
class DefaultContextResponse(BaseModel):
"""幂等默认上下文响应(Issue #1775):默认项目 + 各类型默认素材库 ID。"""
project_id: str
image_library_id: str
video_library_id: str
voice_library_id: str
def _to_project_response(item) -> ProjectResponse:
return ProjectResponse(
id=item.id,
@@ -72,6 +83,35 @@ def create_project(
return _to_project_response(project)
@router.post("/ensure-default", response_model=DefaultContextResponse)
def ensure_default_project_and_libraries(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> DefaultContextResponse:
"""幂等获取/创建当前用户的默认项目和三类默认素材库(Issue #1775)。
- 同一用户永远只有一个默认项目(部分唯一索引 uq_projects_owner_default)
- 同一项目同 kind 永远只有一个默认素材库(唯一约束 uq_asset_libraries_project_kind)
- 并发调用/失败重试:唯一约束冲突时返回已存在记录,不报 500
- 项目和素材库的创建各自在仓储事务内幂等,冲突回滚后重查返回同一条
"""
user_id = authenticated_user.user.id
project = project_repository.get_or_create_default_project(user_id)
libraries = {}
for kind in (AssetLibraryKind.VIDEO, AssetLibraryKind.VOICE, AssetLibraryKind.IMAGE):
library = asset_library_repository.get_or_create_default_library(project.id, kind)
libraries[kind] = library.id
return DefaultContextResponse(
project_id=project.id,
image_library_id=libraries[AssetLibraryKind.IMAGE],
video_library_id=libraries[AssetLibraryKind.VIDEO],
voice_library_id=libraries[AssetLibraryKind.VOICE],
)
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_project(
project_id: str,
+5
View File
@@ -106,6 +106,10 @@ def list_templates(
tag: str | None = Query(None, description="按标签筛选"),
keyword: str | None = Query(None, description="按名称关键词搜索"),
mode: str | None = Query(None, description="按剪辑模式筛选"),
valid_only: bool = Query(
False,
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
@@ -116,6 +120,7 @@ def list_templates(
tag=tag,
keyword=keyword,
mode=mode,
valid_only=valid_only,
)
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
@@ -36,17 +36,11 @@ from app.services.asset_segment_tracker import (
remove_used_segment,
)
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import (
_calc_random_start_time,
build_scene_segments,
@@ -399,73 +393,42 @@ def _safe_segment_duration(value, default: float) -> float:
def _get_template_segments(
template_id: str,
user_id: str,
tpl_svc: EditTemplateService,
db: Session,
) -> list[tuple[int, float, float]]:
"""获取模板的片段配置(顺序、最短时长、最长时长).
优先从新模板系统(template_clip_configs)查询,
若不存在则回退到旧模板系统(template_segments)。
单一数据源:模板主表为 ``templates``(用户自建,归属 user_id)/
``edit_templates``(全局模板库),片段配置主表为 ``template_clip_configs``
(由 ``EditTemplateService.list_clip_configs_for_editor`` 统一读取)。
不再使用"新表抛异常 → 降级直查配置表 → 再降级查 segments"的异常控制流,
也不在正常请求中打印 ``ValueError: 模板不存在`` 堆栈。
Args:
template_id: 模板 ID
user_id: 当前登录用户 ID(用于归属校验)
tpl_svc: 模板编辑器服务
Returns:
[(segment_order, duration_min, duration_max), ...] 按 order 排序
[(segment_order, duration_min, duration_max), ...] 按 order 排序;
模板存在但未配置片段时返回空列表。
Raises:
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
"""
# 优先查新模板系统
try:
clip_configs = tpl_svc.list_clip_configs(template_id)
if clip_configs:
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
clip_configs = tpl_svc.list_clip_configs_for_editor(template_id, user_id)
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
try:
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
direct_configs = direct_repo.list_by_template(template_id)
if direct_configs:
result = []
for cc in direct_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表)
try:
old_repo = SQLAlchemyTemplateRepository(db)
segments = old_repo.list_segments(template_id)
if segments:
result = []
for s in segments:
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((s.segment_order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("旧模板系统查询segments失败", exc_info=True)
# 所有途径都失败:模板没有片段配置(可能是无效测试模板)
logger.error(
"模板无片段配置:template_id=%s(可能是 is_active=false 的无效模板)",
template_id,
)
return []
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
def _recommended_time_conflicts(
@@ -668,13 +631,21 @@ def create_clips_from_assets_editor(
7. 素材时长为 0 或缺失时报 400,不创建无效片段
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 1. 查询模板 segments
segments = _get_template_segments(template_id, tpl_svc, db)
# 1. 查询模板片段配置。模板不存在/已删除/无权限 → 404;
# 模板存在但确实未配置片段 → 422(配置错误,与 404 区分)。
try:
segments = _get_template_segments(template_id, user_id, tpl_svc)
except TemplateNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在或无权访问",
) from exc
if not segments:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板没有片段配置,无法创建片段",
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="模板未配置片段",
)
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
@@ -41,29 +41,33 @@ def get_draft_plan_id(
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
确保 template_id → plan_id 的映射始终存在。
兼容策略:优先从新模板系统(edit_templates 表)查找,
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
模板读取遵循单一数据源、显式判定(不使用异常降级):
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
模板不存在、已删除或不归属于当前用户时,一律返回 404。
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get_active(template_id, user_id)
is_global_template = tpl_svc.get_template(template_id) is not None
if old_template is None and not is_global_template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 1. 草稿已存在 → 直接返回
draft = tpl_svc.get_template_draft(template_id)
if draft is not None:
return draft.id
# 2. 新系统有模板 → 用新服务创建草稿
if tpl_svc.get_template(template_id) is not None:
# 2. 全局模板(新系统)→ 用新服务创建草稿
if is_global_template:
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
return draft.id
# 3. 回退到旧模板系统(templates 表)
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get(template_id, user_id=user_id)
if old_template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 4. 基于旧模板创建草稿计划
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
from app.services.plan_generator_service import PlanGeneratorService
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
@@ -150,7 +150,7 @@ def rollback_template(
try:
tpl = tpl_svc.rollback_to_version(template_id, request.version)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
clip_configs = tpl_svc.list_clip_configs(template_id)
return EditorRollbackResponse(
+65 -1
View File
@@ -34,6 +34,17 @@ from packages.domain.template_clip_converter import (
logger = logging.getLogger(__name__)
class TemplateNotFoundError(Exception):
"""模板不存在、已删除或当前用户无权访问.
与"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
"""
def __init__(self, template_id: str) -> None:
self.template_id = template_id
super().__init__(f"模板不存在: {template_id}")
class EditTemplateService:
"""模板管理服务
@@ -217,7 +228,14 @@ class EditTemplateService:
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""列出模板的片段配置"""
"""列出模板的片段配置
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
"""
# 确保模板存在
self.get_template_or_raise(template_id)
return self._clip_config_repo.list_by_template(
@@ -227,6 +245,52 @@ class EditTemplateService:
limit=limit,
)
def list_clip_configs_for_editor(
self,
template_id: str,
user_id: str,
*,
clip_type: Optional[ClipType] = None,
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""编辑器读取模板片段配置的单一数据源入口.
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
模板主表按双表现状显式判定,不使用 try/except 控制流:
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
Args:
template_id: 模板 ID
user_id: 当前登录用户 ID(用于旧表模板归属校验)
Raises:
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
"""
# 1) 用户自建模板(旧表 templates,归属 user_id)
if self._clip_config_repo.template_owned_by(template_id, user_id):
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
if self._template_repo.get(template_id) is not None:
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
# 3) 两表都没有:不存在 / 已删除 / 无权限
raise TemplateNotFoundError(template_id)
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
"""获取片段配置详情"""
return self._clip_config_repo.get(config_id)
+16 -4
View File
@@ -5,10 +5,22 @@ import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetLibraryItem } from "./types"
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get("/asset-libraries")
return response.data.items || []
/**
* 获取当前用户的素材库
*
* @param kind 可选,按素材库类型过滤(video/voice/image)。
* 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的
* kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库
* 混进来(#1777:视频选择器只展示视频库)。
*/
export const getAssetLibraries = async (
kind?: AssetLibraryItem["kind"],
): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", {
params: kind ? { kind } : undefined,
})
const items = response.data.items || []
return kind ? items.filter((lib) => lib.kind === kind) : items
}
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
+13
View File
@@ -55,6 +55,19 @@ apiClient.interceptors.response.use(
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
/**
* 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。
* 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」,
* 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。
*/
_silentErrorToast?: boolean
}
// 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗
if (originalRequest?._silentErrorToast) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(error as any).__msgShown = true
return Promise.reject(error)
}
// 401 → 尝试刷新 Token
@@ -12,17 +12,25 @@ import type {
ListCategoriesResponse,
} from "./types"
/** 获取模板列表 */
/** 获取模板列表
*
* valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用,
* 避免选中无片段配置的模板导致 from-assets 400,#1769/#1772);
* 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。
* 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。
*/
export const getEditingTemplates = async (params?: {
category?: string
tag?: string
skip?: number
limit?: number
validOnly?: boolean
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
...(params?.validOnly ? { valid_only: true } : {}),
},
})
let list = response.data.items
+7 -2
View File
@@ -91,7 +91,7 @@ export async function createClipsFromAssets(
assetIds: string[],
clipType = "main",
requiredClipsCount?: number,
opts?: { signal?: AbortSignal },
opts?: { signal?: AbortSignal; silentErrorToast?: boolean },
): Promise<ClipsFromAssetsResponse> {
const body: Record<string, unknown> = {
asset_ids: assetIds,
@@ -104,7 +104,12 @@ export async function createClipsFromAssets(
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
body,
{ timeout: 60000, signal: opts?.signal },
{
timeout: 60000,
signal: opts?.signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
)
return response.data
}
@@ -43,11 +43,17 @@ export async function updateEditPlanClips(
templateId: string,
clips: EditPlanClipInput[],
signal?: AbortSignal,
/** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */
silentErrorToast?: boolean,
): Promise<{ count: number }> {
const response = await apiClient.put(
`/templates/${templateId}/editor/clips`,
{ clips },
{ signal },
{
signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast)
...(silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
)
return response.data
}
+18 -67
View File
@@ -45,6 +45,7 @@ const GeneratePage: React.FC = () => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -416,15 +417,11 @@ const GeneratePage: React.FC = () => {
/* ── 最终成片(单视频右侧播放) ── */
const finalVideo = generatedVideos[0]
/* ── 布局 class:步骤4标题页=预览+标题侧栏;步骤5/6批量=整行宽;步骤1~3=整行宽 ── */
/* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */
const layoutClassName = useMemo(() => {
if (currentStep < 4) return "xx-generate-layout full-width"
if (currentStep === 4) return "xx-generate-layout step4-layout"
// 步骤5:全宽+内容居中(单视频视频播放器居中,批量网格居中)
if (currentStep === 5) return "xx-generate-layout full-width"
// 步骤6:封面选择保持两栏布局
return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout"
}, [currentStep, isBatch])
return "xx-generate-layout full-width"
}, [currentStep])
/* ================================================================
渲染
@@ -524,6 +521,7 @@ const GeneratePage: React.FC = () => {
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
onTemplateInvalid={handleInvalidTemplate}
generating={generating}
generated={generated}
generateError={generateError}
@@ -545,18 +543,7 @@ const GeneratePage: React.FC = () => {
selectedVariantIds={selectedVariantIds}
/>
<GenerateStepActions
currentStep={currentStep}
onPrev={goPrev}
onNext={goNext}
onConfirmGenerate={handleConfirmGenerate}
generating={generating}
generated={generated}
generateError={generateError}
selectedCount={isBatch ? selectedVariantIds.length : 1}
/>
{/* ════ 步骤5(单视频):成片播放器内联居中(#1761) ════ */}
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
{currentStep === 5 && !isBatch && generated && finalVideo && (
<div
style={{
@@ -580,6 +567,7 @@ const GeneratePage: React.FC = () => {
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay
style={{
width: "auto",
maxWidth: "100%",
@@ -607,55 +595,18 @@ const GeneratePage: React.FC = () => {
</div>
</div>
)}
</div>
{/* ════ 步骤6(单视频):右侧成片播放器 ════ */}
{currentStep >= 6 && !isBatch && generated && finalVideo && (
<div className="xx-generate-right-col">
<div
className="xx-inline-video-player"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "#000",
borderRadius: 12,
padding: 8,
}}
>
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay={currentStep === 5}
// 竖屏自适应(#1750):成片固定 1080×1920(9:16),元数据到达前按 9:16 占位,
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
style={{
width: "auto",
maxWidth: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "contain",
borderRadius: 8,
}}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
⬇️ 下载
</button>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗 分享
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁 前往成片库
</button>
</div>
</div>
</div>
)}
<GenerateStepActions
currentStep={currentStep}
onPrev={goPrev}
onNext={goNext}
onConfirmGenerate={handleConfirmGenerate}
generating={generating}
generated={generated}
generateError={generateError}
selectedCount={isBatch ? selectedVariantIds.length : 1}
/>
</div>
</div>
{/* 数量选择弹窗 */}
@@ -50,6 +50,8 @@ export interface GenerateStepContentProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
/** 当前模板创建片段被判失效(404/400/422)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
/* 生成 */
generating: boolean
generated: boolean
@@ -108,6 +110,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVoice,
onSelectedVoiceChange,
onServerClipsChange,
onTemplateInvalid,
generating,
generated,
generateError,
@@ -153,6 +156,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedTemplate={selectedTemplate}
templateSegments={templateSegments}
onServerClipsChange={onServerClipsChange}
onTemplateInvalid={onTemplateInvalid}
/>
)
case 3:
@@ -189,7 +193,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
/>
)
case 5:
/* 确认生成页:批量=逐任务进度网格;单视频=进度状态卡(成片播放器在左侧大区域) */
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态(完成后只显示成片播放器,播放器在按钮上方) */
if (previewCount > 1) {
return (
<BatchGenerationGrid
@@ -199,10 +203,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
/>
)
}
/* 单视频:渲染进度 / 失败重试 / 完成提示(成片播放器在右侧栏) */
/* 单视频:生成中显示进度卡、失败显示重试卡;生成完成后不再渲染提示卡,页面只保留成片播放器+操作按钮 */
if (generated && !generating && !generateError) return null
return (
<div className="xx-form-section">
<h3>🎬 确认生成</h3>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
@@ -234,14 +238,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
</button>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-info">
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
<div className="xx-gen-success-sub">右侧可预览成片,点击「下一步」选择封面</div>
</div>
</div>
)}
</div>
)
case 6:
@@ -23,6 +23,8 @@ interface Step2MaterialSelectProps {
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
@@ -36,16 +38,25 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label>选择视频库</label>
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
{m.libraries.length === 0 && !m.materialsLoading ? (
<div className="xx-empty-state">
<p>暂无视频素材库</p>
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
请先在「素材库」中创建视频素材库并上传视频
</p>
</div>
) : (
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
)}
</div>
{m.materialMode === "manual" && (
@@ -1,117 +0,0 @@
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
interface GenerationStatusProps {
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
getGenerationPhase: (progress: number) => { icon: string; label: string }
onScrollToPreview: () => void
onRetry: () => void
onDismissError: () => void
}
const GenerationStatus: React.FC<GenerationStatusProps> = ({
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
onScrollToPreview,
onRetry,
onDismissError,
}) => {
return (
<div style={{ marginTop: 16 }}>
{!generating && !generated && !generateError && (
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">🎬</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">尚未开始生成视频</div>
<div className="xx-gen-progress-sub">
请返回「选择标题」步骤,点击「确认生成视频」开始渲染最终视频
</div>
</div>
</div>
</div>
)}
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title">视频生成完成!</div>
<div className="xx-gen-success-sub">
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onScrollToPreview}
>
查看结果
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title">生成失败</div>
<div className="xx-gen-error-msg">
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄 重试
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
知道了
</button>
</div>
</div>
)}
</div>
)
}
export default GenerationStatus
+5 -29
View File
@@ -117,10 +117,6 @@
grid-template-columns: 1fr;
}
.xx-generate-layout.full-width .xx-generate-right-col {
display: none;
}
/* ============================================================
左侧表单区 generate-form
============================================================ */
@@ -2323,28 +2319,6 @@
生成结果(右侧)
================================================================ */
.xx-generate-right-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* ── 内联视频播放器(右侧) ── */
.xx-inline-video-player {
width: 100%;
max-width: 320px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.xx-inline-video-player video {
background: #000;
}
.xx-preview-header {
display: flex;
align-items: center;
@@ -2710,11 +2684,12 @@
/* ── 封面设置区域改造样式 ── */
/* 封面操作按钮区 */
/* 封面操作按钮区(单视频全宽页居中) */
.xx-cover-actions {
display: flex;
gap: 12px;
margin-bottom: 12px;
justify-content: center;
}
/* 已选模板文字 */
@@ -3202,10 +3177,11 @@
margin: 0;
}
/* ── 批量封面网格 ── */
/* ── 批量封面网格(单卡/少卡时居中排列,卡片限宽不拉伸) ── */
.xx-cover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(180px, 220px));
justify-content: center;
gap: 16px;
}
@@ -8,11 +8,22 @@ import type { AssetItem } from "@/api/assets"
* 管理素材库列表、当前选中库、素材列表加载
*/
export function useMaterialLibrary() {
/* ── 素材库数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
/* ── 素材库数据 API ──
* Step2 是视频选片,只拉取 kind=video 的素材库(#1777):
* 后端按 kind 查询参数过滤,前端 getAssetLibraries("video") 再兜底过滤一次,
* 避免配音库(voice)/图片库(image) 混进「选择视频库」下拉。
* queryKey 带 kind,与素材管理页/配音页的 ["asset-libraries"] 全量缓存隔离。
*/
const { data: allLibraries = [] } = useQuery({
queryKey: ["asset-libraries", "video"],
queryFn: () => getAssetLibraries("video"),
staleTime: 60_000,
})
// 前端兜底过滤:仅保留 kind=video 的素材库(后端按 kind 查询参数过滤)
const libraries = useMemo(
() => allLibraries.filter((lib) => lib.kind === "video"),
[allLibraries],
)
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
@@ -40,6 +40,8 @@ export interface GenerateFormState {
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/** 当前选中模板在创建片段时被判失效(404/400/422)后的运行时自动回退 */
handleInvalidTemplate: () => boolean
/* 素材 */
selectedMaterials: string[]
@@ -131,7 +133,8 @@ export const useGenerateFormState = (): GenerateFormState => {
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板选择 ── */
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
const { selectedTemplate, setSelectedTemplate, userTemplates, handleInvalidTemplate } =
useTemplateSelection()
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
// selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆;
@@ -228,6 +231,7 @@ export const useGenerateFormState = (): GenerateFormState => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -0,0 +1,81 @@
/**
* 失效模板判定与自动回退工具(#1777)
*
* 背景:用户进入生成页后,之前选中的模板可能已被删除、或从未配置片段。
* 调用片段相关接口(PUT/POST /templates/{id}/editor/clips[...]/from-assets)时:
* - 模板不存在 → 后端返回 404(并行工单 #1774 把「模板不存在」统一为该状态码)
* - 模板无片段配置 → 当前部分场景返回 400(detail 含「片段配置」),
* 参数校验类错误返回 422
* 这三类响应都说明「当前选中的模板不可用于生成」,应清除失效选择并自动切换到
* 第一个有效模板,同时提示用户,而不是让页面卡死、无任何反馈。
*/
import type { EditingTemplate } from "@/api/editing-planner"
/** 失效模板相关的 HTTP 状态码 */
const INVALID_TEMPLATE_STATUSES = new Set([404, 400, 422])
/**
* 从任意抛出值(axios 错误)提取 HTTP 状态码。
* 非 axios 错误 / 无响应时返回 null。
*/
export function getHttpStatus(err: unknown): number | null {
if (!err || typeof err !== "object") return null
const status = (err as { response?: { status?: number }; status?: number })?.response?.status
return typeof status === "number" ? status : null
}
/** 安全提取后端错误文本(detail/message/msg,422 数组也兜底拼一下) */
function extractErrorText(err: unknown): string {
if (!err || typeof err !== "object") return ""
const data = (err as { response?: { data?: unknown } })?.response?.data
if (!data) return ""
try {
const text = JSON.stringify(data)
return typeof text === "string" ? text : ""
} catch {
return ""
}
}
/**
* 判断一次 clips/from-assets 请求失败是否因为「模板失效」。
*
* 严格判定,避免把无关的 400/422(例如素材参数问题)误判为模板失效:
* - 404:模板/编辑计划不存在,一定是模板失效
* - 400:仅当后端文本明确提到「片段配置」(无片段配置无法创建片段)才判定
* - 422:参数校验类,from-assets 场景下命中「片段/segments」相关字段才判定
*/
export function isInvalidTemplateError(err: unknown): boolean {
const status = getHttpStatus(err)
if (status === null || !INVALID_TEMPLATE_STATUSES.has(status)) return false
if (status === 404) return true
const text = extractErrorText(err)
if (status === 400) {
// 后端当前返回:「模板没有片段配置,无法创建片段」
return /片段配置|没有片段|无片段|segments?|clip.*config/i.test(text)
}
// 422:FastAPI 校验错误,命中模板片段相关字段
return /segment|clip|片段|模板/i.test(text)
}
/**
* 判断模板是否可用于生成(有效模板)。
*
* 有效 = 处于激活态(is_active !== false,字段缺失视为 true 兼容旧后端)
* 且至少配置了一个片段。
* 与后端 valid_only 过滤口径保持一致(#1769/#1772),这里是前端双保险。
*/
export function isValidTemplate(template: EditingTemplate | null | undefined): boolean {
if (!template) return false
if (template.is_active === false) return false
return (template.segments?.length ?? 0) > 0
}
/** 从模板列表中取出第一个有效模板,没有则返回 null */
export function findFirstValidTemplate(
templates: EditingTemplate[] | null | undefined,
): EditingTemplate | null {
if (!Array.isArray(templates)) return null
return templates.find(isValidTemplate) ?? null
}
@@ -1,22 +1,87 @@
import { useState, useEffect } from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { useQuery } from "@tanstack/react-query"
import { message } from "antd"
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
import { findFirstValidTemplate, isValidTemplate } from "./templateFallback"
/** 失效模板自动切换的提示文案 */
export const INVALID_TEMPLATE_FALLBACK_TOAST = "原模板已失效,已自动切换"
export function useTemplateSelection() {
// selectedTemplate 纯内存状态,绝不写入 localStorage/sessionStorage/URL,
// 因此失效模板 ID 不会被持久化、刷新后也不会恢复(#1777 要求 4)
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
const { data: allTemplates = [] } = useQuery<EditingTemplate[]>({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
// valid_only:后端过滤掉没有片段配置的无效模板(#1769/#1772)。
// 旧后端忽略该 query 参数时,下方 isValidTemplate 前端兜底再过滤一次。
queryFn: () => getEditingTemplates({ validOnly: true }),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
// 双保险:后端 valid_only 已过滤,前端再按 is_active + segments 兜底,
// 保证下拉/自动选择只包含可用于生成的有效模板
const validTemplates = allTemplates.filter(isValidTemplate)
const userTemplates = validTemplates
return { selectedTemplate, setSelectedTemplate, userTemplates }
// 用 ref 持有最新值,供稳定回调 handleInvalidTemplate 使用(避免闭包拿到旧值)
const templatesRef = useRef(validTemplates)
templatesRef.current = validTemplates
const selectedRef = useRef(selectedTemplate)
selectedRef.current = selectedTemplate
// 已提示过失效的模板 ID,避免用户停留在失效模板上时 clips 防抖请求反复弹 toast;
// 用户手动切换/成功切换后重置,保证下一个失效模板仍能提示
const fallbackNotifiedRef = useRef<string>("")
/* 自动选择:模板加载完成且当前未选中时,自动选中第一个有效模板。
* 用户手动选择(setSelectedTemplate 被显式调用)后 selectedTemplate 非空,
* 本 effect 直接 return,绝不覆盖用户的手动选择(#1777 要求 4:手动优先)。 */
useEffect(() => {
if (selectedTemplate) return
const firstValid = validTemplates[0]
if (firstValid) {
setSelectedTemplate(firstValid.id)
}
}, [validTemplates, selectedTemplate])
/** 用户手动选择模板:优先级最高,重置失效提示标记 */
const handleSelectTemplate = useCallback((id: string) => {
fallbackNotifiedRef.current = ""
setSelectedTemplate(id)
}, [])
/**
* 运行时失效回退(#1777 要求 3):
* 创建片段接口返回 404(模板不存在)/ 400/422(模板无片段配置)时调用。
* - 清除失效选择,自动切换到第一个有效模板,并 toast 提示;
* - 没有有效模板时清空选择,Step1 展示明确的「暂无可用模板」空状态引导,
* 不让用户卡在失效模板上。
* 返回 true 表示已按「模板失效」处理(调用方可据此静默原始错误提示)。
*/
const handleInvalidTemplate = useCallback((): boolean => {
const current = selectedRef.current
// 同一个失效模板只提示一次(clips 防抖 effect 在素材/模板变化时会反复触发)
if (current && fallbackNotifiedRef.current === current) return true
const fallback = findFirstValidTemplate(templatesRef.current)
fallbackNotifiedRef.current = current || "__empty__"
if (fallback) {
setSelectedTemplate(fallback.id)
message.warning(INVALID_TEMPLATE_FALLBACK_TOAST)
} else {
// 没有任何有效模板:清空选择,交由 Step1 空状态引导用户去模板编辑器创建
setSelectedTemplate("")
message.warning("当前没有可用模板,请先在「模板编辑器」中创建并配置片段")
}
return true
}, [])
return {
selectedTemplate,
setSelectedTemplate: handleSelectTemplate,
userTemplates,
handleInvalidTemplate,
}
}
@@ -10,6 +10,7 @@ import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
import { useDraftAutoSave } from "./useDraftAutoSave"
import { isInvalidTemplateError } from "./useGenerateFormState/templateFallback"
interface UseStep2MaterialsProps {
materialMode: "manual" | "auto"
@@ -24,6 +25,8 @@ interface UseStep2MaterialsProps {
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
}
export function useStep2Materials({
@@ -36,6 +39,7 @@ export function useStep2Materials({
selectedTemplate,
templateSegments,
onServerClipsChange,
onTemplateInvalid,
}: UseStep2MaterialsProps) {
const {
libraries,
@@ -102,6 +106,8 @@ export function useStep2Materials({
selectedTemplateRef.current = selectedTemplate
const onServerClipsChangeRef = useRef(onServerClipsChange)
onServerClipsChangeRef.current = onServerClipsChange
const onTemplateInvalidRef = useRef(onTemplateInvalid)
onTemplateInvalidRef.current = onTemplateInvalid
useEffect(() => {
const tid = selectedTemplateRef.current
@@ -123,11 +129,12 @@ export function useStep2Materials({
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
try {
// 1. 清空旧片段
await updateEditPlanClips(tid, [], controller.signal)
// 1. 清空旧片段(静默全局 toast:模板失效时由下方回退统一提示)
await updateEditPlanClips(tid, [], controller.signal, true)
// 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底)
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
signal: controller.signal,
silentErrorToast: true,
})
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
const clipList = await getEditPlanClips(tid, { limit: 500 })
@@ -146,6 +153,14 @@ export function useStep2Materials({
message.error("智能选片失败,请重试")
return
}
// 模板失效(404 模板不存在 / 400/422 无片段配置):
// 清空失效选择并自动切到第一个有效模板 + toast,避免页面卡死无提示(#1777)
if (isInvalidTemplateError(err)) {
console.warn("[useStep2Materials] 当前模板已失效,触发自动回退:", err)
onServerClipsChangeRef.current?.([])
onTemplateInvalidRef.current?.()
return
}
console.warn("[useStep2Materials] 写入 clips 失败:", err)
}
}, 800)
@@ -41,7 +41,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
@@ -24,7 +24,7 @@ export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMateri
// ── 获取 voice 类型素材库 ─────────────────────────────────
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
staleTime: 60_000,
})
@@ -26,7 +26,7 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
/* 获取或创建默认配音库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
})
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
@@ -0,0 +1,140 @@
/**
* 失效模板判定/回退纯函数单测(#1777)
*/
import { describe, it, expect } from "vitest"
import type { EditingTemplate } from "@/api/editing-planner"
import {
getHttpStatus,
isInvalidTemplateError,
isValidTemplate,
findFirstValidTemplate,
} from "@/pages/generate/hooks/useGenerateFormState/templateFallback"
function makeTemplate(partial: Partial<EditingTemplate> & { id: string }): EditingTemplate {
return {
name: partial.id,
mode: "pip",
category: "默认",
tags: [],
title_config: {
ai_auto_select: false,
content: "",
font_preset: "",
font_color: "",
font_size: 28,
position: "top",
},
subtitle_config: {
enabled: true,
position: "bottom",
font: "",
color: "",
size: 20,
animation: "",
},
bgm_config: { enabled: false, music_id: "" },
segments: [{ segment_order: 0, material_type: null }],
is_active: true,
created_at: "",
updated_at: "",
...partial,
} as EditingTemplate
}
function axiosError(status: number, data?: unknown) {
return { isAxiosError: true, response: { status, data } }
}
describe("getHttpStatus", () => {
it("提取 axios 错误的 HTTP 状态码", () => {
expect(getHttpStatus(axiosError(404))).toBe(404)
expect(getHttpStatus(axiosError(400))).toBe(400)
})
it("非 axios/无响应错误返回 null", () => {
expect(getHttpStatus(new Error("network"))).toBeNull()
expect(getHttpStatus(null)).toBeNull()
expect(getHttpStatus(undefined)).toBeNull()
expect(getHttpStatus({ isAxiosError: true })).toBeNull()
})
})
describe("isInvalidTemplateError", () => {
it("404 始终判定为模板失效(模板不存在)", () => {
expect(isInvalidTemplateError(axiosError(404))).toBe(true)
expect(isInvalidTemplateError(axiosError(404, { detail: "Not Found" }))).toBe(true)
})
it("400 且后端文案提到「片段配置」判定为模板无片段配置", () => {
expect(
isInvalidTemplateError(axiosError(400, { detail: "模板没有片段配置,无法创建片段" })),
).toBe(true)
})
it("400 但文案与片段配置无关 → 不误判", () => {
expect(isInvalidTemplateError(axiosError(400, { detail: "素材参数错误" }))).toBe(false)
})
it("422 命中片段/模板字段判定为失效", () => {
expect(
isInvalidTemplateError(
axiosError(422, { detail: [{ loc: ["body", "segments"], msg: "field required" }] }),
),
).toBe(true)
})
it("其他状态码(401/403/500/超时/网络)不判定为模板失效", () => {
expect(isInvalidTemplateError(axiosError(401))).toBe(false)
expect(isInvalidTemplateError(axiosError(403))).toBe(false)
expect(isInvalidTemplateError(axiosError(500))).toBe(false)
expect(isInvalidTemplateError({ code: "ECONNABORTED", message: "timeout of 60000ms" })).toBe(
false,
)
expect(isInvalidTemplateError(new Error("Network Error"))).toBe(false)
})
})
describe("isValidTemplate", () => {
it("有片段且未被标记 inactive → 有效", () => {
expect(isValidTemplate(makeTemplate({ id: "t1" }))).toBe(true)
})
it("segments 为空 → 无效(无片段配置)", () => {
expect(isValidTemplate(makeTemplate({ id: "t2", segments: [] }))).toBe(false)
})
it("is_active=false → 无效(已停用/删除)", () => {
expect(isValidTemplate(makeTemplate({ id: "t3", is_active: false }))).toBe(false)
})
it("is_active 字段缺失时视为有效(兼容旧后端)", () => {
const t = makeTemplate({ id: "t4" })
delete (t as Partial<EditingTemplate>).is_active
expect(isValidTemplate(t)).toBe(true)
})
it("null/undefined → 无效", () => {
expect(isValidTemplate(null)).toBe(false)
expect(isValidTemplate(undefined)).toBe(false)
})
})
describe("findFirstValidTemplate", () => {
it("跳过无效模板,返回第一个有效模板", () => {
const list = [
makeTemplate({ id: "empty", segments: [] }),
makeTemplate({ id: "inactive", is_active: false }),
makeTemplate({ id: "valid1" }),
makeTemplate({ id: "valid2" }),
]
expect(findFirstValidTemplate(list)?.id).toBe("valid1")
})
it("全部无效 → null(用于空状态引导)", () => {
expect(
findFirstValidTemplate([
makeTemplate({ id: "a", segments: [] }),
makeTemplate({ id: "b", is_active: false }),
]),
).toBeNull()
})
it("空数组/null → null", () => {
expect(findFirstValidTemplate([])).toBeNull()
expect(findFirstValidTemplate(null)).toBeNull()
expect(findFirstValidTemplate(undefined)).toBeNull()
})
})
@@ -0,0 +1,74 @@
/**
* useMaterialLibrary Hook 单测(#1777)
* - Step2 视频库选择器只拉取 kind=video 的素材库,配音库(voice)/图片库(image) 不混入
* - 自动选中第一个视频库
*/
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
import type { AssetItem, AssetLibraryItem } from "@/api/assets"
vi.mock("@/api/assets", () => ({
getAssetLibraries: vi.fn(),
getAssets: vi.fn(),
isAssetUsable: vi.fn(() => true),
}))
import { getAssetLibraries, getAssets } from "@/api/assets"
import { useMaterialLibrary } from "@/pages/generate/hooks/step2-materials/useMaterialLibrary"
const mockGetLibraries = vi.mocked(getAssetLibraries)
const mockGetAssets = vi.mocked(getAssets)
function lib(id: string, kind: AssetLibraryItem["kind"], name = id): AssetLibraryItem {
return { id, name, kind }
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return ({ children }: { children: ReactNode }) =>
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
}
beforeEach(() => {
vi.clearAllMocks()
mockGetAssets.mockResolvedValue({ items: [] as AssetItem[], total: 0 })
})
describe("useMaterialLibrary (#1777 kind=video 过滤)", () => {
it("按 kind=video 拉取素材库(后端参数过滤)", async () => {
mockGetLibraries.mockResolvedValueOnce([lib("v1", "video")])
renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalledTimes(1))
expect(mockGetLibraries).toHaveBeenCalledWith("video")
})
it("下拉库列表只包含视频库(自动选中第一个视频库)", async () => {
mockGetLibraries.mockResolvedValueOnce([
lib("voice-1", "voice"),
lib("img-1", "image"),
lib("video-1", "video"),
lib("video-2", "video"),
])
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.libraries).toHaveLength(2))
expect(result.current.libraries.map((l) => l.id)).toEqual(["video-1", "video-2"])
expect(result.current.libraries.every((l) => l.kind === "video")).toBe(true)
// 自动选中第一个视频库
expect(result.current.selectedLibraryId).toBe("video-1")
})
it("没有视频库时库列表为空且不自动选中(UI 展示空状态)", async () => {
mockGetLibraries.mockResolvedValueOnce([lib("voice-1", "voice"), lib("img-1", "image")])
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalled())
expect(result.current.libraries).toEqual([])
expect(result.current.selectedLibraryId).toBe("")
})
})
@@ -0,0 +1,174 @@
/**
* useTemplateSelection Hook 单测(#1777)
* - 自动选择跳过无片段/inactive 模板,只选第一个有效模板
* - 传 validOnly=true 给后端
* - 用户手动选择优先,自动逻辑不覆盖
* - handleInvalidTemplate:失效时自动切到第一个有效模板 + toast;无有效模板时清空
* - selectedTemplate 仅内存态,不写入 localStorage/sessionStorage
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
// antd message mock(拦截 toast)——vi.hoisted 保证 mock 工厂可引用
const { messageMock } = vi.hoisted(() => ({
messageMock: {
warning: vi.fn(),
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
loading: vi.fn(() => vi.fn()),
},
}))
vi.mock("antd", () => ({ message: messageMock }))
vi.mock("@/api/editing-planner", () => ({
getEditingTemplates: vi.fn(),
}))
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
import { useTemplateSelection } from "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
const mockGetTemplates = vi.mocked(getEditingTemplates)
function tpl(id: string, partial: Partial<EditingTemplate> = {}): EditingTemplate {
return {
id,
name: id,
mode: "pip",
category: "默认",
tags: [],
title_config: {
ai_auto_select: false,
content: "",
font_preset: "",
font_color: "",
font_size: 28,
position: "top",
},
subtitle_config: {
enabled: true,
position: "bottom",
font: "",
color: "",
size: 20,
animation: "",
},
bgm_config: { enabled: false, music_id: "" },
segments: [{ segment_order: 0, material_type: null }],
is_active: true,
created_at: "",
updated_at: "",
...partial,
} as EditingTemplate
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return ({ children }: { children: ReactNode }) =>
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
})
afterEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe("useTemplateSelection (#1777)", () => {
it("请求模板时传 validOnly=true,并自动选中第一个有片段的有效模板", async () => {
mockGetTemplates.mockResolvedValueOnce([
tpl("empty", { segments: [] }),
tpl("inactive", { is_active: false }),
tpl("valid-a"),
tpl("valid-b"),
])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("valid-a"))
expect(mockGetTemplates).toHaveBeenCalledWith({ validOnly: true })
// 暴露给 UI 的 userTemplates 已过滤掉无效模板
expect(result.current.userTemplates.map((t) => t.id)).toEqual(["valid-a", "valid-b"])
})
it("列表全部无效时 selectedTemplate 为空(交空状态引导),不选中失效模板", async () => {
mockGetTemplates.mockResolvedValueOnce([
tpl("empty", { segments: [] }),
tpl("inactive", { is_active: false }),
])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetTemplates).toHaveBeenCalled())
// 给 effect 一个 tick
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
expect(result.current.userTemplates).toHaveLength(0)
})
it("用户手动选择优先:自动逻辑不会覆盖手动选择", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("a"), tpl("b")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("a"))
act(() => result.current.setSelectedTemplate("b"))
expect(result.current.selectedTemplate).toBe("b")
// 重新渲染 / refetch 后仍保持用户的手动选择
await waitFor(() => expect(result.current.selectedTemplate).toBe("b"))
})
it("handleInvalidTemplate:当前模板失效时自动切到第一个有效模板并 toast", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] }), tpl("good")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
// 自动选中有效模板 good(bad 无片段不会被自动选中)
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
messageMock.warning.mockClear()
// 模拟运行时用户停留在一个已失效的模板 id(外部/草稿态),触发回退
act(() => result.current.setSelectedTemplate("stale-id"))
expect(result.current.selectedTemplate).toBe("stale-id")
act(() => {
const handled = result.current.handleInvalidTemplate()
expect(handled).toBe(true)
})
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
expect(messageMock.warning).toHaveBeenCalledWith("原模板已失效,已自动切换")
})
it("handleInvalidTemplate:无有效模板时清空选择并提示去创建", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] })])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.userTemplates).toHaveLength(0))
act(() => result.current.setSelectedTemplate("stale-id"))
act(() => {
result.current.handleInvalidTemplate()
})
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
expect(messageMock.warning).toHaveBeenCalledWith(expect.stringContaining("没有可用模板"))
})
it("失效模板 ID 不写入任何持久化存储", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("good")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
act(() => result.current.setSelectedTemplate("stale-invalid-id"))
act(() => result.current.handleInvalidTemplate())
const ls = JSON.stringify(localStorage)
const ss = JSON.stringify(sessionStorage)
expect(ls).not.toContain("stale-invalid-id")
expect(ss).not.toContain("stale-invalid-id")
// URL 也不含
expect(window.location.href).not.toContain("stale-invalid-id")
})
})
+16 -1
View File
@@ -2126,6 +2126,14 @@
"type": "JSON",
"unique": false
},
{
"index": false,
"name": "is_default",
"nullable": false,
"primary_key": false,
"type": "BOOLEAN",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -2142,6 +2150,13 @@
],
"name": "ix_projects_owner_user_id",
"unique": false
},
{
"columns": [
"owner_user_id"
],
"name": "uq_projects_owner_default",
"unique": true
}
],
"primary_key": [
@@ -3570,4 +3585,4 @@
]
}
}
}
}
@@ -46,3 +46,21 @@ class InMemoryAssetLibraryRepository:
if library:
library.asset_count = max(0, library.asset_count - 1)
library.total_size = max(0, library.total_size - size_delta)
def get_or_create_default_library(
self,
project_id: str,
kind: AssetLibraryKind,
*,
name: str | None = None,
) -> AssetLibrary:
"""幂等获取/创建默认素材库(Issue #1775,内存实现,模拟唯一约束语义)。"""
for lib in self._libraries.values():
if lib.project_id == project_id and lib.kind == kind:
return lib
# 回退到 find_by_project
for lib in self.find_by_project(project_id, kind):
return lib
library_name = name or f"{kind.value}素材库"
library = AssetLibrary.create(project_id=project_id, name=library_name, kind=kind)
return self.create(library)
@@ -29,3 +29,30 @@ class InMemoryProjectRepository:
del self._items[project_id]
return True
return False
def find_default_by_owner(self, owner_user_id: str) -> Project | None:
"""查找用户的默认项目(Issue #1775 幂等接口,内存实现)。"""
for p in self._items.values():
if p.owner_user_id == owner_user_id and getattr(p, "is_default", False):
return p
return None
def get_or_create_default_project(
self,
owner_user_id: str,
*,
name: str = "默认项目",
description: str = "小程序自动创建的默认项目",
) -> Project:
"""幂等获取/创建默认项目(内存实现,模拟 DB 部分唯一索引语义)。"""
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
project = Project.create(
owner_user_id=owner_user_id,
name=name,
description=description,
is_default=True,
)
self._items[project.id] = project
return project
@@ -90,3 +90,75 @@ class SQLAlchemyAssetLibraryRepository:
model.asset_count = max(0, (model.asset_count or 0) - 1)
model.total_size = max(0, (model.total_size or 0) - size_delta)
self.session.commit()
def get_or_create_default_library(
self,
project_id: str,
kind: AssetLibraryKind,
*,
name: str | None = None,
) -> AssetLibrary:
"""幂等获取/创建项目下指定 kind 的默认素材库(Issue #1775)。
依赖唯一约束 uq_asset_libraries_project_kind(project_id, kind):
并发创建只有一个成功,其余 IntegrityError 后回滚重查,
保证同一项目同 kind 永远只有一个素材库。
"""
from sqlalchemy.exc import IntegrityError
default_names = {
AssetLibraryKind.VIDEO: "视频素材库",
AssetLibraryKind.VOICE: "配音素材库",
AssetLibraryKind.IMAGE: "图片素材库",
}
library_name = name or default_names.get(kind, f"{kind.value}素材库")
# 快速路径
existing = (
self.session.query(AssetLibraryModel)
.filter(AssetLibraryModel.project_id == project_id, AssetLibraryModel.kind == kind.value)
.first()
)
if existing:
return self._to_entity(existing)
library = AssetLibrary.create(project_id=project_id, name=library_name, kind=kind)
model = AssetLibraryModel(
id=library.id,
project_id=library.project_id,
name=library.name,
kind=library.kind.value,
asset_count=0,
total_size=0,
created_at=library.created_at,
updated_at=library.updated_at,
)
try:
self.session.add(model)
self.session.commit()
return library
except IntegrityError:
self.session.rollback()
existing = (
self.session.query(AssetLibraryModel)
.filter(
AssetLibraryModel.project_id == project_id,
AssetLibraryModel.kind == kind.value,
)
.first()
)
if existing:
return self._to_entity(existing)
raise
def _to_entity(self, model: AssetLibraryModel) -> AssetLibrary:
return AssetLibrary(
id=model.id,
project_id=model.project_id,
name=model.name,
kind=AssetLibraryKind(model.kind),
asset_count=int(model.asset_count or 0),
total_size=int(model.total_size or 0),
created_at=model.created_at,
updated_at=model.updated_at,
)
+7 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import declarative_base
Base: Any = declarative_base()
@@ -44,12 +44,18 @@ class UserModel(Base):
class ProjectModel(Base):
__tablename__ = "projects"
__table_args__ = (
# Issue #1775: 每个用户至多一个默认项目(部分唯一索引,只约束 is_default=true 的行)。
# 注意:不加 UniqueConstraint(那会要求全表唯一),用部分索引表达"每用户一个默认项目"。
Index("uq_projects_owner_default", "owner_user_id", unique=True, postgresql_where=text("is_default = true")),
)
id = Column(String(36), primary_key=True)
owner_user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=False, default="")
shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表
is_default = Column(Boolean, nullable=False, default=False, server_default="false")
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -15,6 +15,7 @@ class SQLAlchemyProjectRepository:
name=model.name,
description=model.description,
shared_users=model.shared_users or [],
is_default=bool(getattr(model, "is_default", False)),
created_at=model.created_at,
)
@@ -33,9 +34,12 @@ class SQLAlchemyProjectRepository:
name=project.name,
description=project.description,
shared_users=project.shared_users,
is_default=project.is_default,
created_at=project.created_at,
)
self.session.add(model)
if existing:
existing.is_default = project.is_default
self.session.commit()
return project
@@ -76,3 +80,59 @@ class SQLAlchemyProjectRepository:
self.session.delete(model)
self.session.commit()
return True
def find_default_by_owner(self, owner_user_id: str) -> Project | None:
"""查找用户的默认项目(is_default=true)。"""
model = (
self.session.query(ProjectModel)
.filter(ProjectModel.owner_user_id == owner_user_id, ProjectModel.is_default.is_(True))
.first()
)
return self._to_entity(model) if model else None
def get_or_create_default_project(
self,
owner_user_id: str,
*,
name: str = "默认项目",
description: str = "小程序自动创建的默认项目",
) -> Project:
"""幂等获取/创建用户的默认项目(Issue #1775)。
依赖部分唯一索引 uq_projects_owner_default(每用户至多一条 is_default=true):
并发创建时只有一个 INSERT 成功,其余触发 IntegrityError 后回滚重查,
保证同一用户永远只有一个默认项目。
"""
from sqlalchemy.exc import IntegrityError
# 快速路径:已有默认项目
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
project = Project.create(
owner_user_id=owner_user_id,
name=name,
description=description,
is_default=True,
)
model = ProjectModel(
id=project.id,
owner_user_id=project.owner_user_id,
name=project.name,
description=project.description,
shared_users=project.shared_users,
is_default=True,
created_at=project.created_at,
)
try:
self.session.add(model)
self.session.commit()
return project
except IntegrityError:
# 并发:另一个请求已插入默认项目,回滚后重查
self.session.rollback()
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
raise
@@ -6,7 +6,10 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
TemplateModel,
)
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
@@ -38,6 +41,24 @@ class SQLAlchemyTemplateClipConfigRepository:
models = query.offset(skip).limit(limit).all()
return [self._model_to_entity(m) for m in models]
def template_owned_by(self, template_id: str, user_id: str) -> bool:
"""校验旧模板主表 ``templates`` 中模板归属当前用户且未删除(is_active=True).
片段配置主表 ``template_clip_configs`` 本身没有 user_id 列,
归属关系通过模板主表 ``templates.user_id`` 确定。
新表 ``edit_templates`` 为全局模板库(无 user_id 列),不走此校验。
"""
return (
self.session.query(TemplateModel.id)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
.first()
is not None
)
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
"""根据 ID 获取配置"""
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first()
@@ -10,6 +10,7 @@ from __future__ import annotations
import uuid
from typing import List, Optional
from sqlalchemy import or_
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import (
@@ -28,6 +29,20 @@ class SQLAlchemyTemplateRepository:
def __init__(self, session: Session) -> None:
self.session = session
def _filter_with_segment_configs(self, query):
"""只保留在 template_clip_configs 或 template_segments 中存在片段配置的模板。
两张表都没有记录的模板无法用于生成(from-assets 会 400),
剪辑页选模板时应排除;模板编辑器不传 valid_only,仍可见全部模板。
"""
has_clip_config = self.session.query(TemplateClipConfigModel.id).filter(
TemplateClipConfigModel.template_id == TemplateModel.id,
)
has_segment = self.session.query(TemplateSegmentModel.id).filter(
TemplateSegmentModel.template_id == TemplateModel.id,
)
return query.filter(or_(has_clip_config.exists(), has_segment.exists()))
# ── Template CRUD ──
def list_by_user(
@@ -40,11 +55,14 @@ class SQLAlchemyTemplateRepository:
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> List[Template]:
query = self.session.query(TemplateModel).filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
if valid_only:
query = self._filter_with_segment_configs(query)
if category:
query = query.filter(TemplateModel.category == category)
if mode:
@@ -102,6 +120,27 @@ class SQLAlchemyTemplateRepository:
template.segments = self.list_segments(template.id)
return template
def get_active(self, template_id: str, user_id: str) -> Optional[Template]:
"""获取归属当前用户且未删除(is_active=True)的模板,否则返回 None.
用于编辑器访问门禁:模板不存在、已软删除或不属于当前用户时返回 None,
由调用方映射为 404。与 :meth:`get` 的区别是额外过滤 is_active。
"""
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
.first()
)
if model is None:
return None
template = self._model_to_entity(model)
template.segments = self.list_segments(template.id)
return template
def create(self, template: Template) -> Template:
model = TemplateModel(
id=template.id,
@@ -173,11 +212,14 @@ class SQLAlchemyTemplateRepository:
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> int:
query = self.session.query(TemplateModel).filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
if valid_only:
query = self._filter_with_segment_configs(query)
if category:
query = query.filter(TemplateModel.category == category)
if mode:
@@ -62,6 +62,7 @@ class ListTemplatesFilter:
tag: Optional[str] = None
keyword: Optional[str] = None
mode: Optional[str] = None
valid_only: bool = False
@dataclass
@@ -106,6 +106,7 @@ class ListTemplatesUseCase:
tag=filter.tag,
keyword=filter.keyword,
mode=filter.mode,
valid_only=filter.valid_only,
)
@@ -127,6 +128,7 @@ class CountTemplatesUseCase:
tag=filter.tag,
keyword=filter.keyword,
mode=filter.mode,
valid_only=filter.valid_only,
)
+3 -1
View File
@@ -70,10 +70,11 @@ class Project:
name: str
description: str = ""
shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表
is_default: bool = False # 是否为用户的默认项目(小程序自动创建),DB 部分唯一索引保证每人至多一个
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
def create(cls, owner_user_id: str, name: str, description: str = "") -> "Project":
def create(cls, owner_user_id: str, name: str, description: str = "", is_default: bool = False) -> "Project":
clean_name = name.strip()
if not clean_name:
raise ValueError("项目名称不能为空")
@@ -83,6 +84,7 @@ class Project:
name=clean_name,
description=description.strip(),
shared_users=[],
is_default=is_default,
)
def is_owner(self, user_id: str) -> bool:
+13 -4
View File
@@ -129,7 +129,10 @@ def reselect_clips_for_variant(
Raises:
ValueError: 源片段为空 / 素材池为空 / 素材时长全为 0(无法差异化选片)。
"""
rng = rng or random.Random()
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
if not source_clips:
raise ValueError("源 plan 无片段,无法为变体重新选片")
if not candidate_asset_ids:
@@ -312,7 +315,10 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
- speed_factor: 0.95~1.05 速度微调(±5%,肉眼不太敏感但时间轴不同)
- brightness_shift: -10~+10 亮度偏移(eq=brightness,画面明暗差异)
"""
rng = rng or random.Random()
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
return {
"hflip": rng.random() < 0.3,
"zoom_ratio": round(1.0 + rng.uniform(0, 0.08), 4),
@@ -321,7 +327,7 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
}
def generate_pixel_perturbation(rng: random.Random | None = None) -> dict:
def generate_pixel_perturbation(rng: random.Random | int | None = None) -> dict:
"""为一个变体生成像素级扰动滤镜参数(Issue #1765)。
在现有视觉扰动(hflip/zoom/brightness)基础上,额外叠加 2-3 种
@@ -336,7 +342,10 @@ def generate_pixel_perturbation(rng: random.Random | None = None) -> dict:
返回 dict,可直接存入 plan.config["pixel_perturbation"]。
渲染侧读取后追加到 ffmpeg filter chain。
"""
rng = rng or random.Random()
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
# 可用滤镜池
filter_options = ["noise", "unsharp", "curves", "color_balance"]
+2
View File
@@ -18,6 +18,7 @@ class TemplateRepositoryPort(Protocol):
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> List[Template]: ...
def get(self, template_id: str, user_id: str) -> Optional[Template]: ...
def create(self, template: Template) -> Template: ...
@@ -31,6 +32,7 @@ class TemplateRepositoryPort(Protocol):
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> int: ...
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template: ...
def list_segments(self, template_id: str) -> List[TemplateSegment]: ...
@@ -0,0 +1,177 @@
"""默认项目/默认素材库幂等化测试(Issue #1775)。
覆盖:
- get_or_create_default_project:同用户幂等、不同用户独立、重复调用返回同一个
- get_or_create_default_library:同项目同 kind 幂等、IntegrityError 后重查
- Project.is_default 字段传递
- ensure-default-context 组合逻辑(用内存仓储)
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from packages.adapters.in_memory.asset_library_repository import InMemoryAssetLibraryRepository
from packages.adapters.in_memory.project_repository import InMemoryProjectRepository
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository,
)
from packages.adapters.sqlalchemy_impl.project_repository import SQLAlchemyProjectRepository
from packages.domain import AssetLibrary, AssetLibraryKind, Project
class TestDefaultProjectIdempotent:
"""默认项目幂等。"""
def test_first_call_creates_default(self):
repo = InMemoryProjectRepository()
project = repo.get_or_create_default_project("user-1")
assert project.owner_user_id == "user-1"
assert project.is_default is True
assert project.name == "默认项目"
def test_second_call_returns_same_project(self):
repo = InMemoryProjectRepository()
p1 = repo.get_or_create_default_project("user-1")
p2 = repo.get_or_create_default_project("user-1")
assert p1.id == p2.id
def test_different_users_independent(self):
repo = InMemoryProjectRepository()
p1 = repo.get_or_create_default_project("user-1")
p2 = repo.get_or_create_default_project("user-2")
assert p1.id != p2.id
assert p1.owner_user_id == "user-1"
assert p2.owner_user_id == "user-2"
def test_find_default_by_owner(self):
repo = InMemoryProjectRepository()
created = repo.get_or_create_default_project("user-1")
found = repo.find_default_by_owner("user-1")
assert found is not None
assert found.id == created.id
def test_find_default_returns_none_when_no_default(self):
repo = InMemoryProjectRepository()
# 手动建一个非默认项目
normal = Project.create(owner_user_id="user-1", name="普通项目")
normal.is_default = False
repo.save(normal)
assert repo.find_default_by_owner("user-1") is None
def test_repeated_calls_after_creation_return_same(self):
"""先创建默认项目后,后续多次调用均返回已有项目(测试 find 路径)。
注:真正的并发保护依赖 PostgreSQL partial unique index,
InMemory 仓储不做并发测试(无 DB 约束),并发场景由
TestSqlRepoIntegrityErrorRecovery 通过 SQLAlchemy + SQLite 验证。
"""
repo = InMemoryProjectRepository()
first = repo.get_or_create_default_project("user-repeat")
for _ in range(9):
again = repo.get_or_create_default_project("user-repeat")
assert again.id == first.id
defaults = [p for p in repo.find_by_owner_user_id("user-repeat") if p.is_default]
assert len(defaults) == 1
class TestDefaultLibraryIdempotent:
"""默认素材库幂等。"""
def test_first_call_creates(self):
repo = InMemoryAssetLibraryRepository()
lib = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
assert lib.project_id == "proj-1"
assert lib.kind == AssetLibraryKind.VIDEO
def test_second_call_returns_same(self):
repo = InMemoryAssetLibraryRepository()
l1 = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
l2 = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
assert l1.id == l2.id
def test_different_kinds_independent(self):
repo = InMemoryAssetLibraryRepository()
v = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
a = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VOICE)
i = repo.get_or_create_default_library("proj-1", AssetLibraryKind.IMAGE)
assert len({v.id, a.id, i.id}) == 3
def test_different_projects_independent(self):
repo = InMemoryAssetLibraryRepository()
l1 = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
l2 = repo.get_or_create_default_library("proj-2", AssetLibraryKind.VIDEO)
assert l1.id != l2.id
def test_repeated_calls_after_creation_return_same(self):
"""先创建后多次调用均返回同一素材库(测试 find 路径)。"""
repo = InMemoryAssetLibraryRepository()
first = repo.get_or_create_default_library("proj-repeat", AssetLibraryKind.VOICE)
for _ in range(9):
again = repo.get_or_create_default_library("proj-repeat", AssetLibraryKind.VOICE)
assert again.id == first.id
class TestProjectIsDefaultField:
"""Project.is_default 字段语义。"""
def test_create_non_default_by_default(self):
p = Project.create(owner_user_id="u", name="普通项目")
assert p.is_default is False
def test_create_default(self):
p = Project.create(owner_user_id="u", name="默认项目", is_default=True)
assert p.is_default is True
class TestSqlRepoIntegrityErrorRecovery:
"""SQLAlchemy 仓储:唯一约束冲突时回滚重查,返回已有记录(不报 500)。"""
def test_project_integrity_error_returns_existing(self):
from sqlalchemy.exc import IntegrityError
session = MagicMock()
# commit 第一次抛 IntegrityError(并发冲突),回滚后查询返回已有项目
existing_model = MagicMock()
existing_model.id = "existing-id"
existing_model.owner_user_id = "user-1"
existing_model.name = "默认项目"
existing_model.description = ""
existing_model.shared_users = []
existing_model.is_default = True
existing_model.created_at = None
session.commit.side_effect = [IntegrityError("stmt", {}, Exception("dup")), None]
# 第一次 query(快速路径 find_default)返回 None;rollback 后第二次返回 existing
session.query.return_value.filter.return_value.first.side_effect = [None, existing_model]
repo = SQLAlchemyProjectRepository(session)
result = repo.get_or_create_default_project("user-1")
assert result.id == "existing-id"
session.rollback.assert_called_once()
def test_library_integrity_error_returns_existing(self):
from sqlalchemy.exc import IntegrityError
session = MagicMock()
existing_model = MagicMock()
existing_model.id = "lib-existing"
existing_model.project_id = "proj-1"
existing_model.name = "视频素材库"
existing_model.kind = "video"
existing_model.asset_count = 0
existing_model.total_size = 0
existing_model.created_at = None
existing_model.updated_at = None
session.commit.side_effect = [IntegrityError("stmt", {}, Exception("dup")), None]
session.query.return_value.filter.return_value.first.side_effect = [None, existing_model]
repo = SQLAlchemyAssetLibraryRepository(session)
result = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
assert result.id == "lib-existing"
assert result.kind == AssetLibraryKind.VIDEO
session.rollback.assert_called_once()
+37 -4
View File
@@ -239,8 +239,8 @@ class TestEditorClipsBySegments:
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_no_segments_raises_400(self, mock_storage):
"""模板没有 segment 配置时返回 400。"""
def test_no_segments_raises_422(self, mock_storage):
"""模板存在但未配置片段时返回 422(配置错误,与 404 区分)。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
@@ -264,11 +264,44 @@ class TestEditorClipsBySegments:
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "片段配置" in exc_info.value.detail
assert exc_info.value.status_code == 422
assert "片段" in exc_info.value.detail
# 不应调用替换方法
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_template_not_found_raises_404(self, mock_storage):
"""模板不存在/已删除/无权限(服务层抛 TemplateNotFoundError)时返回 404。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
from app.services.edit_template_service import TemplateNotFoundError
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with patch(
"app.api.routes.templates_editor.clips._get_template_segments",
side_effect=TemplateNotFoundError("tpl-missing"),
):
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-missing",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 404
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
class TestEditorClipsDurationAndStartTime:
"""测试素材时长获取、clip duration 缩短、start_time 传入。"""
+223 -196
View File
@@ -1,13 +1,17 @@
"""_get_template_segments 回退路径测试.
"""模板片段配置读取路径测试(#1774).
验证三级回退链:
1. 新模板系统(tpl_svc.list_clip_configs)正常 → 直接返回
2. 新模板系统主表不存在(ValueError)→ 直接查 template_clip_configs 表兜底
3. 直接查表也失败 → 回退旧模板系统(template_segments)
4. 全部失败 → 返回空列表
收敛后模板读取走单一数据源,不再有"新表抛异常→降级查旧表→再降级查 segments"
的异常控制流:
覆盖 P0 修复:自建模板在 edit_templates 主表不存在但在 template_clip_configs 有记录时,
from-assets 流程不再 400。
- ``EditTemplateService.list_clip_configs_for_editor`` 显式判定模板归属/存在性:
1. 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True)→ 直接读
``template_clip_configs``;
2. 全局模板在新表 ``edit_templates``(无 user_id)→ 直接读 ``template_clip_configs``;
3. 两表都没有 → 抛 ``TemplateNotFoundError``(路由层映射 404)。
- ``_get_template_segments`` 仅做配置→(order, min, max) 的映射与排序,
模板存在但无配置返回空列表(路由层映射 422)。
使用真实 SQLite 内存库 + 真实仓储,验证端到端读路径不抛 ``ValueError: 模板不存在``。
"""
from __future__ import annotations
@@ -15,21 +19,198 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.templates_editor.clips import _get_template_segments
import pytest # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from packages.adapters.sqlalchemy_impl.models import ( # noqa: E402
Base,
EditTemplateModel,
TemplateClipConfigModel,
TemplateModel,
)
TEST_TEMPLATE_ID = "tmpl-orphan-001"
DEFAULT_DUR = 5.0 # _DEFAULT_EDITOR_CLIP_DURATION
USER_ID = "user-001"
OTHER_USER_ID = "user-002"
# ---------------------------------------------------------------------------
# 真实内存 DB fixture
# ---------------------------------------------------------------------------
def _make_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def _seed_legacy_template(session, template_id: str, user_id: str, *, active: bool = True, clip_count: int = 3):
"""创建旧表 templates 模板(+ template_clip_configs 片段配置)。"""
session.add(
TemplateModel(
id=template_id,
user_id=user_id,
name=f"模板-{template_id}",
mode="one_take",
is_active=active,
)
)
for order in range(clip_count):
session.add(
TemplateClipConfigModel(
id=f"cc-{template_id}-{order}",
template_id=template_id,
clip_type="main",
order=order,
min_duration=5.0,
max_duration=8.0,
)
)
session.commit()
def _seed_global_template(session, template_id: str, *, status: str = "active", clip_count: int = 2):
"""创建新表 edit_templates 全局模板(+ template_clip_configs 片段配置)。"""
session.add(
EditTemplateModel(
id=template_id,
name=f"全局模板-{template_id}",
template_type="default",
editing_mode="one_take",
status=status,
)
)
for order in range(clip_count):
session.add(
TemplateClipConfigModel(
id=f"gcc-{template_id}-{order}",
template_id=template_id,
clip_type="main",
order=order,
min_duration=3.0,
max_duration=6.0,
)
)
session.commit()
# ---------------------------------------------------------------------------
# Service 读路径测试
# ---------------------------------------------------------------------------
class TestListClipConfigsForEditor:
"""list_clip_configs_for_editor 单一数据源 + 归属/存在性判定。"""
def test_legacy_user_template_returns_configs(self):
"""用户自建模板(templates 表 + 3 条 clip_configs)→ 正常返回,不抛异常。"""
from app.services.edit_template_service import EditTemplateService
session = _make_session()
_seed_legacy_template(session, "tmpl-legacy", USER_ID, clip_count=3)
svc = EditTemplateService(session)
configs = svc.list_clip_configs_for_editor("tmpl-legacy", USER_ID)
assert len(configs) == 3
assert [c.order for c in configs] == [0, 1, 2]
assert all(c.min_duration == 5.0 for c in configs)
def test_missing_template_raises_not_found(self):
"""模板不存在(两表都没有)→ TemplateNotFoundError。"""
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
session = _make_session()
svc = EditTemplateService(session)
with pytest.raises(TemplateNotFoundError):
svc.list_clip_configs_for_editor("tmpl-not-exist", USER_ID)
def test_other_users_template_raises_not_found(self):
"""他人模板(user_id 不匹配)→ TemplateNotFoundError(归属校验)。"""
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
session = _make_session()
_seed_legacy_template(session, "tmpl-owner", OTHER_USER_ID, clip_count=3)
svc = EditTemplateService(session)
with pytest.raises(TemplateNotFoundError):
svc.list_clip_configs_for_editor("tmpl-owner", USER_ID)
def test_deleted_legacy_template_raises_not_found(self):
"""已软删除(is_active=False)的旧表模板 → TemplateNotFoundError。"""
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
session = _make_session()
_seed_legacy_template(session, "tmpl-deleted", USER_ID, active=False, clip_count=3)
svc = EditTemplateService(session)
with pytest.raises(TemplateNotFoundError):
svc.list_clip_configs_for_editor("tmpl-deleted", USER_ID)
def test_legacy_template_without_configs_returns_empty(self):
"""模板存在且归属正确但无片段配置 → 返回空列表(不抛异常,路由层映射 422)。"""
from app.services.edit_template_service import EditTemplateService
session = _make_session()
_seed_legacy_template(session, "tmpl-noconfig", USER_ID, clip_count=0)
svc = EditTemplateService(session)
configs = svc.list_clip_configs_for_editor("tmpl-noconfig", USER_ID)
assert configs == []
def test_global_template_returns_configs(self):
"""新表 edit_templates 全局模板(无 user_id)→ 任意用户可读,正常返回。"""
from app.services.edit_template_service import EditTemplateService
session = _make_session()
_seed_global_template(session, "tmpl-global", clip_count=2)
svc = EditTemplateService(session)
configs = svc.list_clip_configs_for_editor("tmpl-global", USER_ID)
assert len(configs) == 2
assert [c.order for c in configs] == [0, 1]
def test_normal_legacy_request_does_not_raise_valueerror(self):
"""正常旧表模板请求绝不在读路径抛 ValueError: 模板不存在(回归保护)。"""
import logging
from app.services.edit_template_service import EditTemplateService
session = _make_session()
_seed_legacy_template(session, "tmpl-ok", USER_ID, clip_count=3)
svc = EditTemplateService(session)
with pytest.MonkeyPatch.context() as mp:
# 若读路径意外抛 ValueError 并被记录为异常堆栈,测试能感知
errors: list[str] = []
mp.setattr(
logging.getLogger("app.services.edit_template_service"),
"exception",
lambda *a, **k: errors.append(str(a)),
)
configs = svc.list_clip_configs_for_editor("tmpl-ok", USER_ID)
assert len(configs) == 3
assert errors == []
# ---------------------------------------------------------------------------
# _get_template_segments 映射测试
# ---------------------------------------------------------------------------
def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
"""构造 mock TemplateClipConfig 领域实体."""
cc = MagicMock()
cc.order = order
cc.min_duration = min_dur
@@ -37,207 +218,53 @@ def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
return cc
def _make_old_segment(segment_order: int, dur_min: float = 4.0, dur_max: float = 7.0):
"""构造 mock 旧 TemplateSegment."""
s = MagicMock()
s.segment_order = segment_order
s.duration_min = dur_min
s.duration_max = dur_max
return s
class TestGetTemplateSegments:
"""_get_template_segments 仅做映射/排序,异常与空配置语义明确。"""
def test_maps_and_sorts_configs(self):
from app.api.routes.templates_editor.clips import _get_template_segments
# ---------------------------------------------------------------------------
# 测试
# ---------------------------------------------------------------------------
class TestGetTemplateSegmentsFallback:
"""_get_template_segments 三级回退链."""
def test_new_system_works(self):
"""路径1:新模板系统正常返回 → 直接使用."""
configs = [_make_clip_config(0, 2.0, 6.0), _make_clip_config(1, 3.0, 9.0)]
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = configs
db = MagicMock()
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 2
assert result[0] == (0, 2.0, 6.0)
assert result[1] == (1, 3.0, 9.0)
tpl_svc.list_clip_configs.assert_called_once_with(TEST_TEMPLATE_ID)
def test_main_table_missing_direct_query_succeeds(self):
"""路径2(P0修复):主表不存在 ValueError → 直接查表成功.
模拟自建模板在 edit_templates 主表已删除/不存在,
但 template_clip_configs 表有记录。
"""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError(f"模板不存在: {TEST_TEMPLATE_ID}")
db = MagicMock()
# Mock SQLAlchemyTemplateClipConfigRepository
direct_configs = [
_make_clip_config(0, 2.0, 5.0),
_make_clip_config(1, 3.0, 7.0),
_make_clip_config(2, 4.0, 8.0),
]
with (
__import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls,
):
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = direct_configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 3
assert result[0] == (0, 2.0, 5.0)
assert result[1] == (1, 3.0, 7.0)
assert result[2] == (2, 4.0, 8.0)
mock_repo.list_by_template.assert_called_once_with(TEST_TEMPLATE_ID)
def test_main_table_missing_direct_query_empty_falls_to_old(self):
"""路径2→3:主表不存在 + 直接查表为空 → 回退旧系统."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
old_segments = [_make_old_segment(0, 3.0, 6.0)]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = [] # 新表也没记录
mock_repo_cls.return_value = mock_repo
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
) as mock_old_cls:
mock_old = MagicMock()
mock_old.list_segments.return_value = old_segments
mock_old_cls.return_value = mock_old
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 1
assert result[0] == (0, 3.0, 6.0)
def test_all_fail_returns_empty(self):
"""路径4:三级全部失败 → 返回空列表."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.side_effect = Exception("DB error")
mock_repo_cls.return_value = mock_repo
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
) as mock_old_cls:
mock_old = MagicMock()
mock_old.list_segments.return_value = [] # 旧表也空
mock_old_cls.return_value = mock_old
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert result == []
def test_direct_query_sorts_by_order(self):
"""直接查表返回的结果按 order 排序."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
# 故意乱序
configs = [
tpl_svc.list_clip_configs_for_editor.return_value = [
_make_clip_config(2, 5.0, 10.0),
_make_clip_config(0, 2.0, 4.0),
_make_clip_config(1, 3.0, 6.0),
]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
result = _get_template_segments("tmpl-1", USER_ID, tpl_svc)
assert [r[0] for r in result] == [0, 1, 2]
assert result[0] == (0, 2.0, 4.0)
assert result[1] == (1, 3.0, 6.0)
assert result[2] == (2, 5.0, 10.0)
tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", USER_ID)
def test_empty_configs_returns_empty(self):
from app.api.routes.templates_editor.clips import _get_template_segments
def test_direct_query_handles_none_durations(self):
"""直接查表时 min/max_duration 为 None → 使用默认值."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
tpl_svc.list_clip_configs_for_editor.return_value = []
assert _get_template_segments("tmpl-1", USER_ID, tpl_svc) == []
def test_missing_template_propagates_not_found(self):
from app.api.routes.templates_editor.clips import _get_template_segments
from app.services.edit_template_service import TemplateNotFoundError
tpl_svc = MagicMock()
tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
with pytest.raises(TemplateNotFoundError):
_get_template_segments("tmpl-x", USER_ID, tpl_svc)
def test_none_durations_use_default(self):
from app.api.routes.templates_editor.clips import _get_template_segments
tpl_svc = MagicMock()
cc = MagicMock()
cc.order = 0
cc.min_duration = None
cc.max_duration = None
tpl_svc.list_clip_configs_for_editor.return_value = [cc]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = [cc]
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 1
# None → default (5.0), max(None or None) → default (5.0)
assert result[0] == (0, DEFAULT_DUR, DEFAULT_DUR)
def test_new_system_returns_empty_tries_direct(self):
"""新模板系统返回空列表(非异常)→ 继续尝试直接查表."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = [] # 空列表,非异常
db = MagicMock()
direct_configs = [_make_clip_config(0, 3.0, 6.0)]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = direct_configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
# 新系统返回空 → 不走 except → 但也没 return → 继续往下走
# 直接查表有数据 → 返回
assert len(result) == 1
assert result[0] == (0, 3.0, 6.0)
def test_existing_template_unaffected(self):
"""正常模板(主表存在)行为不变."""
configs = [_make_clip_config(0, 2.0, 5.0)]
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = configs
db = MagicMock()
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
# 直接查表不应被调用(新系统已返回)
mock_repo_cls.assert_not_called()
assert len(result) == 1
assert result[0] == (0, 2.0, 5.0)
result = _get_template_segments("tmpl-1", USER_ID, tpl_svc)
assert result == [(0, DEFAULT_DUR, DEFAULT_DUR)]
+60 -70
View File
@@ -226,10 +226,10 @@ class TestGetMediakitRecommendations:
class TestGetTemplateSegments:
"""测试模板片段配置查询。"""
"""测试模板片段配置查询(单一数据源:template_clip_configs)。"""
def test_returns_segments_from_new_template_system(self):
"""新模板系统(clip_configs)有数据时优先使用。"""
def test_returns_segments_from_clip_configs(self):
"""片段配置主表(clip_configs)有数据时按 order 排序返回。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
@@ -241,66 +241,34 @@ class TestGetTemplateSegments:
cc2.order = 1
cc2.min_duration = 4.0
cc2.max_duration = 8.0
mock_tpl_svc.list_clip_configs.return_value = [cc2, cc1] # 乱序返回
mock_tpl_svc.list_clip_configs_for_editor.return_value = [cc2, cc1] # 乱序返回
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
result = _get_template_segments("tmpl-1", "user-1", mock_tpl_svc)
assert len(result) == 2
assert result[0] == (0, 3.0, 5.0)
assert result[1] == (1, 4.0, 8.0)
mock_tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", "user-1")
def test_falls_back_to_old_template_segments(self):
"""新模板系统无数据时回退到旧系统。"""
def test_returns_empty_when_no_configs(self):
"""模板存在但没有片段配置时返回空列表(路由层据此返回 422)。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
mock_tpl_svc.list_clip_configs_for_editor.return_value = []
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
seg1 = MagicMock()
seg1.segment_order = 0
seg1.duration_min = 2.0
seg1.duration_max = 4.0
mock_repo.list_segments.return_value = [seg1]
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", "user-1", mock_tpl_svc)
assert result == []
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert len(result) == 1
assert result[0] == (0, 2.0, 4.0)
def test_returns_empty_when_no_segments(self):
"""两套系统都没有片段配置时返回空列表。"""
def test_missing_template_raises(self):
"""模板不存在/无权限时服务层抛 TemplateNotFoundError(路由层据此返回 404)。"""
from app.api.routes.templates_editor.clips import _get_template_segments
from app.services.edit_template_service import TemplateNotFoundError
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
mock_tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
mock_repo.list_segments.return_value = []
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert result == []
def test_new_system_exception_falls_back(self):
"""新模板系统异常时回退到旧系统。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.side_effect = RuntimeError("db error")
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
seg = MagicMock()
seg.segment_order = 0
seg.duration_min = 1.0
seg.duration_max = 3.0
mock_repo.list_segments.return_value = [seg]
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert len(result) == 1
with pytest.raises(TemplateNotFoundError):
_get_template_segments("tmpl-x", "user-1", mock_tpl_svc)
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
@@ -358,7 +326,7 @@ def _make_tpl_svc_with_segments(segments):
"""segments: list of (order, min_dur, max_dur)"""
svc = MagicMock()
clip_configs = [_make_clip_config(o, mn, mx) for o, mn, mx in segments]
svc.list_clip_configs.return_value = clip_configs
svc.list_clip_configs_for_editor.return_value = clip_configs
return svc
@@ -540,34 +508,56 @@ class TestFromAssetsByTemplateSegments:
orders = [c["order"] for c in clips_data]
assert orders == [0, 1, 2]
def test_no_segments_raises_400(self):
"""模板没有 segment 配置时返回 400。"""
def test_no_segments_raises_422(self):
"""模板存在但未配置片段时返回 422(与模板不存在的 404 区分)。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
from fastapi import HTTPException
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
mock_tpl_svc.list_clip_configs_for_editor.return_value = []
mock_plan_svc = _make_plan_svc()
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
mock_repo.list_segments.return_value = []
MockRepo.return_value = mock_repo
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=MagicMock(),
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 422
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=MagicMock(),
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
def test_template_not_found_raises_404(self):
"""模板不存在/已删除/无权限时返回 404。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
from app.services.edit_template_service import TemplateNotFoundError
from fastapi import HTTPException
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
mock_plan_svc = _make_plan_svc()
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tmpl-x",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=MagicMock(),
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 404
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
+29
View File
@@ -110,3 +110,32 @@ class TestPixelPerturbationAcceptance:
# 至少 2 种不同组合
unique = len(set(results))
assert unique >= 2, f"Expected >= 2 unique filter combos, got {unique}: {results}"
class TestIntSeedSupport:
"""int seed 入参支持(与 get_rhythm_template(seed) 接口一致)。"""
def test_int_seed_returns_dict(self):
"""int seed 正常返回 dict。"""
result = generate_pixel_perturbation(42)
assert isinstance(result, dict)
assert "filters" in result
def test_int_seed_reproducible(self):
"""相同 int seed 结果一致。"""
assert generate_pixel_perturbation(42) == generate_pixel_perturbation(42)
def test_int_seed_differs_across_seeds(self):
"""不同 int seed 大概率不同(遍历确认至少 2 种组合)。"""
results = {tuple(generate_pixel_perturbation(s)["filters"]) for s in range(30)}
assert len(results) >= 2
def test_int_seed_matches_random_obj(self):
"""int seed 与等价 random.Random(seed) 结果一致。"""
assert generate_pixel_perturbation(7) == generate_pixel_perturbation(random.Random(7))
def test_none_seed_works(self):
"""None 入参(默认随机)正常返回。"""
result = generate_pixel_perturbation(None)
assert isinstance(result, dict)
assert len(result["filters"]) in [2, 3]
+2
View File
@@ -188,6 +188,7 @@ class TestListTemplatesUseCase:
tag="tag1",
keyword="test",
mode="one_take",
valid_only=False,
)
def test_list_pagination(self):
@@ -226,6 +227,7 @@ class TestCountTemplatesUseCase:
tag="tag1",
keyword="kw",
mode="pip",
valid_only=False,
)
@@ -158,6 +158,52 @@ class TestListByUser:
assert len(result[0].segments) == 1
assert result[0].segments[0].duration_min == 2.0
def test_valid_only_filters_templates_without_segments(self, repo, session):
"""#1769: valid_only=True 时排除两张片段表都没有记录的无效模板."""
# 有效模板:有 clip_configs
valid_clip = _make_template(name="有效模板-clip_configs")
repo.create(valid_clip)
repo.create_segments([_make_segment(valid_clip.id, order=1)])
# 有效模板:仅有旧表 template_segments 记录
valid_old = _make_template(name="有效模板-old_segments")
repo.create(valid_old)
old = TemplateSegmentModel(
id=str(uuid.uuid4()),
template_id=valid_old.id,
segment_order=1,
duration_min=2.0,
duration_max=6.0,
)
session.add(old)
session.commit()
# 无效模板:两张表都没有记录
invalid = _make_template(name="无效模板-无片段")
repo.create(invalid)
# 默认不过滤:编辑器视角能看到全部 3 个模板
all_templates = repo.list_by_user("u1")
assert len(all_templates) == 3
assert repo.count_by_user("u1") == 3
# valid_only=True:剪辑页视角只返回 2 个有效模板
valid_templates = repo.list_by_user("u1", valid_only=True)
assert {t.name for t in valid_templates} == {"有效模板-clip_configs", "有效模板-old_segments"}
assert all(len(t.segments) > 0 for t in valid_templates)
assert repo.count_by_user("u1", valid_only=True) == 2
def test_valid_only_with_filters_and_pagination(self, repo, session):
"""valid_only 与其他过滤/分页条件组合使用."""
tpl = _make_template(name="口播模板", mode="voice_over")
repo.create(tpl)
repo.create_segments([_make_segment(tpl.id, order=1, material_type="人物")])
_invalid = _make_template(name="口播无效模板", mode="voice_over")
repo.create(_invalid)
result = repo.list_by_user("u1", mode="voice_over", valid_only=True)
assert len(result) == 1
assert result[0].name == "口播模板"
assert repo.count_by_user("u1", mode="voice_over", valid_only=True) == 1
class TestCopyTemplate:
def test_copy_writes_to_clip_configs(self, repo, session):