Compare commits

...

16 Commits

Author SHA1 Message Date
张宏杰 66eaacbf5f fix: 修复预览播放器三个问题 - 素材静音+自动连播+标题显示
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 33s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m18s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m45s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m41s
AI Code Review / AI Code Review (pull_request) Failing after 2m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m26s
CI/CD Pipeline / CI Gate (pull_request) Successful in 15s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m0s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 689h18m6s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 689h18m10s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 689h18m12s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 689h18m14s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 689h18m16s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 689h18m28s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 689h23m29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 689h23m35s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 689h23m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 689h23m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 689h25m16s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 689h25m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 689h25m44s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 689h51m51s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 689h57m16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 689h58m57s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 689h59m25s
1. 素材原声静音:video 标签添加 muted 属性,只播放配音音频
2. 片段自动连播:switchToSegment 后自动调用 video.play()
3. 标题显示:移除 display:grid 避免堆叠上下文冲突,添加 isolation:isolate
2026-08-18 22:00:20 +08:00
张宏杰 094d22cee7 fix: 修复预览播放器三个问题(标题不显示、无配音、片段切换黑屏)(#1429)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m44s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m1s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m21s
CI/CD Pipeline / Unit Tests (push) Successful in 13m48s
CI/CD Pipeline / Integration Tests (push) Successful in 4m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Canary Release to Production (push) Failing after 689h33m59s
CI/CD Pipeline / CI Gate (push) Failing after 689h43m57s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 689h45m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 689h45m30s
CI/CD Pipeline / Build Production Web Image (push) Failing after 689h45m32s
CI/CD Pipeline / Build Production API Image (push) Failing after 689h45m33s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 689h56m1s
CI/CD Pipeline / PR Build API Image (push) Failing after 689h56m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 689h58m40s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 689h59m25s
CI/CD Pipeline / Deploy Production (push) Failing after 690h19m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 690h29m46s
- CSS: 给 .xx-preview-video::before 添加 z-index: 0
- 音频: 添加 voiceAudioUrl 链路和 Audio 同步逻辑
- 预加载: 提前 2 秒预加载下一段减少黑屏
2026-08-18 21:26:53 +08:00
xiaoxia 122e9aaa86 fix(e2e): update Step 5 heading for frontend preview architecture (#1428)
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Failing after 689h59m39s
CI/CD Pipeline / PR Build API Image (push) Failing after 689h59m43s
CI/CD Pipeline / Frontend Lint (push) Failing after 690h0m25s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 690h0m26s
CI/CD Pipeline / PR Build Web Image (push) Failing after 690h33m23s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-18 21:26:00 +08:00
xiaoxia 8f1d6f20d9 fix(api): add /app/apps/api to PYTHONPATH in api.Dockerfile (#1427)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m34s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m24s
CI/CD Pipeline / Unit Tests (push) Successful in 13m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 13m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m41s
CI/CD Pipeline / Integration Tests (push) Successful in 10m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 49s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m1s
CI/CD Pipeline / CI Gate (push) Failing after 690h20m13s
CI/CD Pipeline / Canary Release to Production (push) Failing after 690h17m10s
CI/CD Pipeline / Deploy Production (push) Failing after 690h27m7s
CI/CD Pipeline / Production Browser E2E (push) Failing after 690h27m7s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 690h27m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 690h27m9s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 690h39m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 690h27m9s
CI/CD Pipeline / PR Build Web Image (push) Failing after 690h39m57s
CI/CD Pipeline / PR Build API Image (push) Failing after 690h39m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 690h40m25s
CI/CD Pipeline / Frontend Lint (push) Failing after 690h40m24s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-18 20:46:00 +08:00
张宏杰 1a00b23b09 ci: retry CI (Worker Docker build transient failure)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m1s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m26s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m44s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m46s
CI/CD Pipeline / Unit Tests (push) Successful in 13m39s
CI/CD Pipeline / Integration Tests (push) Successful in 6m1s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Failing after 690h48m4s
CI/CD Pipeline / Deploy Production (push) Failing after 690h50m8s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 690h50m10s
CI/CD Pipeline / Build Production API Image (push) Failing after 690h50m10s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 691h3m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 691h3m50s
CI/CD Pipeline / PR Build Web Image (push) Failing after 691h3m47s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 691h3m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 691h23m48s
CI/CD Pipeline / Build Production Web Image (push) Failing after 691h23m52s
CI/CD Pipeline / PR Build API Image (push) Failing after 691h37m31s
2026-08-18 20:22:33 +08:00
张宏杰 4a36eb8827 ci: retry deploy staging (SSH transient failure)
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m0s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m4s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m48s
CI/CD Pipeline / Integration Tests (push) Successful in 4m44s
CI/CD Pipeline / Unit Tests (push) Successful in 12m39s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 30m3s
CI/CD Pipeline / Canary Release to Production (push) Failing after 691h5m42s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 691h5m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 691h5m43s
CI/CD Pipeline / Production Browser E2E (push) Failing after 691h23m2s
CI/CD Pipeline / Deploy Production (push) Failing after 691h23m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 691h23m6s
CI/CD Pipeline / CI Gate (push) Failing after 691h23m7s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 691h35m42s
CI/CD Pipeline / PR Build Web Image (push) Failing after 691h35m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 691h35m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 691h35m47s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 691h35m50s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 691h39m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 691h39m26s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 691h56m47s
CI/CD Pipeline / Build Production API Image (push) Failing after 691h56m49s
2026-08-18 19:50:36 +08:00
xiaoxia c329608436 fix: 重写 worker-base-image.yml,使用 docker build 替代 buildx (#1426)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m16s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m5s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m39s
CI/CD Pipeline / Integration Tests (push) Successful in 2m6s
CI/CD Pipeline / Unit Tests (push) Successful in 10m23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22m34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32m23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 52m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 3m52s
CI/CD Pipeline / Canary Release to Production (push) Failing after 691h39m4s
CI/CD Pipeline / Production Browser E2E (push) Failing after 692h24m57s
CI/CD Pipeline / Deploy Production (push) Failing after 692h24m59s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 691h39m5s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 691h39m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 692h25m1s
CI/CD Pipeline / CI Gate (push) Failing after 692h25m2s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 692h25m1s
CI/CD Pipeline / PR Build Web Image (push) Failing after 692h35m21s
CI/CD Pipeline / PR Build API Image (push) Failing after 692h35m23s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 692h35m27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 692h12m48s
CI/CD Pipeline / Build Production Web Image (push) Failing after 692h58m43s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 693h9m1s
CI/CD Pipeline / Frontend Lint (push) Failing after 693h9m7s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-18 18:51:00 +08:00
张宏杰 8a1a8406a1 fix: 修复 FrontendPreviewPlayer TypeScript 编译错误(未使用变量)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m13s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m32s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m44s
CI/CD Pipeline / Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16m51s
CI/CD Pipeline / Unit Tests (push) Failing after 16m55s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 16m56s
CI/CD Pipeline / Build Staging API Image (push) Failing after 16m56s
CI/CD Pipeline / Frontend Unit 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 692h35m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 692h35m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 692h35m57s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 692h52m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 692h52m51s
CI/CD Pipeline / Frontend Lint (push) Failing after 692h52m53s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 692h52m55s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 693h9m37s
CI/CD Pipeline / PR Build Web Image (push) Failing after 693h26m31s
2026-08-18 18:33:31 +08:00
张宏杰 7f0efd9651 ci: retry staging build (transient Docker failure)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m8s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m10s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m44s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10m38s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 10m40s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API 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 Worker Image (push) Failing after 693h4m39s
CI/CD Pipeline / PR Build Web Image (push) Failing after 693h4m39s
CI/CD Pipeline / PR Build API Image (push) Failing after 693h4m39s
CI/CD Pipeline / Frontend Lint (push) Failing after 693h4m40s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 693h4m46s
2026-08-18 18:21:38 +08:00
xiaoxia 4bb1c4205d perf: 删除 psycopg2-binary 依赖 + 预构建 API 基础镜像 (#1422)
Worker Base Image Build / Build Worker Base Images (worker-base-runtime-cache, infra/docker/worker-base-runtime.Dockerfile, worker-base-runtime, runtime) (push) Failing after 1m19s
API Base Image Build / Build API Base Image (push) Successful in 1m40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m5s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m24s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m22s
CI/CD Pipeline / Integration Tests (push) Successful in 3m3s
Worker Base Image Build / Build Worker Base Images (worker-base-builder-cache, infra/docker/worker-base-builder.Dockerfile, worker-base-builder, builder) (push) Failing after 11m48s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 11m49s
CI/CD Pipeline / Unit Tests (push) Successful in 12m22s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 693h7m59s
CI/CD Pipeline / CI Gate (push) Failing after 693h8m0s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 693h8m0s
CI/CD Pipeline / Build Production Web Image (push) Failing after 693h8m0s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 693h19m9s
CI/CD Pipeline / PR Build Web Image (push) Failing after 693h19m11s
CI/CD Pipeline / PR Build API Image (push) Failing after 693h19m13s
CI/CD Pipeline / Frontend Lint (push) Failing after 693h19m21s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 693h20m26s
CI/CD Pipeline / Deploy Production (push) Failing after 693h41m41s
CI/CD Pipeline / Build Production API Image (push) Failing after 693h41m41s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-18 18:06:01 +08:00
张宏杰 cffb18c535 fix: 修复预览播放器视频无法播放和标题不显示(二次修复)(#1424)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m11s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m46s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 6m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m17s
CI/CD Pipeline / Integration Tests (push) Successful in 5m22s
CI/CD Pipeline / Unit Tests (push) Successful in 13m21s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 693h21m58s
CI/CD Pipeline / CI Gate (push) Failing after 693h21m59s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 693h21m59s
CI/CD Pipeline / Build Production Web Image (push) Failing after 693h21m59s
CI/CD Pipeline / Build Production API Image (push) Failing after 693h22m0s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 693h34m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 693h35m2s
CI/CD Pipeline / PR Build Web Image (push) Failing after 693h34m47s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 693h38m40s
CI/CD Pipeline / Deploy Production (push) Failing after 693h55m40s
CI/CD Pipeline / PR Build API Image (push) Failing after 694h8m30s
修复 PR #1423 未解决的3个根因:

1. useSegmentScheduler: 用 srcLoadedRef 替代 video.src 检查
   - video.src='' 在浏览器中被解析为页面URL,导致条件永远为false
   - switchToSegment 先绑事件再设src,加 readyState 检查和10s超时
   - 显式调用 video.load() 确保加载启动

2. FrontendPreviewPlayer: 移除嵌套的 .xx-preview-video 容器
   - 改为 Fragment + absolute 定位,避免双层 overflow:hidden 冲突
   - 视频/控制条/播放按钮直接填充父容器

3. PreviewVideoPanel: 简化为单一 .xx-preview-video 容器
   - TitleOverlay z-index:20 确保在视频(z:1)和控制条(z:10)之上
2026-08-18 17:45:00 +08:00
张宏杰 b430743f7a fix: 修复预览播放器视频无法播放和标题不显示 (#1423)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m33s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m8s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m35s
CI/CD Pipeline / Integration Tests (push) Successful in 6m7s
CI/CD Pipeline / Unit Tests (push) Successful in 15m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20m29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 42m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 695h29m18s
CI/CD Pipeline / Production Browser E2E (push) Failing after 696h0m53s
CI/CD Pipeline / CI Gate (push) Failing after 696h0m55s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 696h0m56s
CI/CD Pipeline / Build Production API Image (push) Failing after 696h0m56s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 696h15m59s
CI/CD Pipeline / PR Build API Image (push) Failing after 696h16m0s
CI/CD Pipeline / Frontend Lint (push) Failing after 696h16m1s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 696h16m2s
CI/CD Pipeline / Deploy Production (push) Failing after 696h34m36s
CI/CD Pipeline / Build Production Web Image (push) Failing after 696h34m38s
CI/CD Pipeline / PR Build Web Image (push) Failing after 696h49m42s
问题1:视频无法播放
- switchToSegment 改为返回 Promise,play() 等待视频加载完成再播放
- 添加 videoUrl 调试日志和错误监听

问题2:标题不显示
- TitleOverlay 标题为空时显示占位文本标题预览
- 始终渲染 TitleOverlay,让用户能看到样式效果
2026-08-18 15:10:21 +08:00
xiaoxia cd485a6370 fix: docker buildx build 加 25 分钟超时防止无限挂起 (#1420)
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m47s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m35s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m6s
CI/CD Pipeline / Integration Tests (push) Successful in 2m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 11m37s
CI/CD Pipeline / Unit Tests (push) Successful in 11m44s
CI/CD Pipeline / Build Staging API Image (push) Successful in 27m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m58s
CI/CD Pipeline / Canary Release to Production (push) Failing after 696h58m28s
CI/CD Pipeline / CI Gate (push) Failing after 697h18m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 697h18m41s
CI/CD Pipeline / Production Browser E2E (push) Failing after 697h18m40s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 697h30m25s
CI/CD Pipeline / Build Production Web Image (push) Failing after 697h18m41s
CI/CD Pipeline / PR Build Web Image (push) Failing after 697h30m26s
CI/CD Pipeline / PR Build API Image (push) Failing after 697h30m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 697h30m26s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 697h30m27s
CI/CD Pipeline / Deploy Production (push) Failing after 697h52m21s
CI/CD Pipeline / Build Production API Image (push) Failing after 697h52m22s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-18 13:56:01 +08:00
xiaoxia 8ef96e8569 feat: 新增 POST /assets/batch 批量获取素材详情接口 (#1418)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m41s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m2s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m20s
CI/CD Pipeline / Integration Tests (push) Successful in 2m39s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Failing after 697h42m43s
CI/CD Pipeline / PR Build API Image (push) Failing after 697h42m47s
CI/CD Pipeline / Frontend Lint (push) Failing after 697h43m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 697h43m32s
CI/CD Pipeline / PR Build Web Image (push) Failing after 698h16m26s
2026-08-18 13:42:54 +08:00
xiaoxia 82e05fcdbb fix: token刷新数据源统一+非空断言修复+并发竞态锁 (#1416)
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 697h43m56s
2026-08-18 13:42:32 +08:00
xiaoxia 766277142c fix: 标题叠加层始终渲染+移除不存在的batch接口调用 (#1419)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m59s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m11s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m32s
CI/CD Pipeline / Integration Tests (push) Successful in 2m45s
CI/CD Pipeline / Unit Tests (push) Successful in 12m3s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 697h48m17s
CI/CD Pipeline / Deploy Production (push) Failing after 697h48m17s
CI/CD Pipeline / CI Gate (push) Failing after 697h48m19s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 697h48m21s
CI/CD Pipeline / Build Production API Image (push) Failing after 697h48m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 698h0m21s
CI/CD Pipeline / PR Build Web Image (push) Failing after 698h0m23s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 698h0m26s
CI/CD Pipeline / PR Build API Image (push) Failing after 698h0m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 698h22m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 698h34m6s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-18 13:26:01 +08:00
21 changed files with 958 additions and 337 deletions
+84
View File
@@ -0,0 +1,84 @@
name: API Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements.txt'
- 'infra/docker/api-base.Dockerfile'
workflow_dispatch:
jobs:
build-api-base:
name: Build API Base Image
runs-on: runtime-builder
timeout-minutes: 45
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Registry
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
sleep 5
done
- name: Build and push API base image
shell: sh
run: |
set -eu
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest"
echo "=== Building API base image ==="
# 使用普通 docker build(单平台不需要 buildx
docker build \
-f infra/docker/api-base.Dockerfile \
-t "${ACR_IMAGE}" \
.
echo ""
echo "✅ Image built successfully"
# 推送到 ACR
echo "=== Pushing to ACR ==="
docker push "${ACR_IMAGE}"
echo "✅ Pushed to ACR"
# 打标签并推送到 Gitea Packages 作为备份
echo "=== Pushing to Gitea Packages ==="
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
echo "✅ Gitea backup push completed"
- name: Cleanup
if: always()
shell: sh
run: |
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
echo "Cleanup done"
+24 -32
View File
@@ -24,18 +24,18 @@ jobs:
- name: builder
dockerfile: infra/docker/worker-base-builder.Dockerfile
image_name: worker-base-builder
cache_name: worker-base-builder-cache
- name: runtime
dockerfile: infra/docker/worker-base-runtime.Dockerfile
image_name: worker-base-runtime
cache_name: worker-base-runtime-cache
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Registry
shell: sh
@@ -48,7 +48,8 @@ jobs:
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
@@ -56,48 +57,39 @@ jobs:
sleep 5
done
- name: Setup buildx builder
shell: sh
run: |
set -eu
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
echo "Created $BUILDER_NAME"
else
docker buildx use "$BUILDER_NAME"
echo "Using existing $BUILDER_NAME"
fi
docker buildx inspect --bootstrap
- name: Build and push base image
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
echo "=== Building ${{ matrix.name }} base image ==="
echo "Dockerfile: ${{ matrix.dockerfile }}"
echo "Image: ${IMAGE_TAG}"
echo "Cache: ${CACHE_REF}"
# 用通用构建脚本
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
# 同时推送到 Gitea Packages 作为备份(可选)
# 使用普通 docker build(单平台不需要 buildx
docker build \
-f "${{ matrix.dockerfile }}" \
-t "${IMAGE_TAG}" \
.
docker push "${IMAGE_TAG}"
# 同时推送到 Gitea Packages 作为备份
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
echo ""
echo "✅ ${{ matrix.name }} base image built and pushed"
- name: Cleanup buildx builder
- name: Cleanup local images
if: always()
shell: sh
run: |
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
docker rmi "${REGISTRY}/${{ matrix.image_name }}:latest" 2>/dev/null || true
docker rmi "git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest" 2>/dev/null || true
docker image prune -f 2>/dev/null || true
echo "Image cleanup done"
+14 -1
View File
@@ -1,5 +1,5 @@
import logging
from typing import Any, Optional
from typing import Any, List, Optional
from app.api.routes._helpers import check_project_access, format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user
@@ -14,6 +14,7 @@ from app.schemas.asset import (
AssetResponse,
BatchClassifyRequest,
BatchDeleteRequest,
BatchGetRequest,
BatchMarkRequest,
BatchOperationResponse,
BatchTagRequest,
@@ -370,6 +371,18 @@ def update_asset_review_status(
return _to_asset_response(updated)
@router.post("/batch", response_model=List[AssetResponse])
def batch_get_assets(
request: BatchGetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
) -> list[AssetResponse]:
"""批量获取素材详情(根据 ID 列表)。"""
items = asset_repository.find_by_ids(request.ids)
storage_service = get_storage_service()
return [_to_asset_response(item, storage_service) for item in items]
@router.post("/batch-delete", response_model=BatchOperationResponse)
def batch_delete_assets(
request: BatchDeleteRequest,
+3 -3
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
import psycopg2
import psycopg
import redis
from app.config import settings
from fastapi import APIRouter, status
@@ -49,7 +49,7 @@ async def _check_database() -> dict:
"message": "Using in-memory database",
}
try:
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
@@ -124,7 +124,7 @@ async def _check_migrations() -> dict:
"message": "Using in-memory database, no migrations needed",
}
try:
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur:
cur.execute("""
SELECT COUNT(*) FROM information_schema.tables
+6
View File
@@ -58,6 +58,12 @@ class AssetResponse(BaseModel):
MAX_BATCH_SIZE = 200
class BatchGetRequest(BaseModel):
"""批量获取素材详情请求。"""
ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
class BatchDeleteRequest(BaseModel):
"""批量删除请求(软删除)。"""
+2 -6
View File
@@ -214,12 +214,8 @@ test.describe("Core generation flow", () => {
await titleInput.fill(titleText)
await page.getByRole("button", { name: "下一步" }).click()
// Step 5: preview — 需要先生成预览视频,才能进入下一步
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
// 点击"生成预览"按钮触发预览生成
await page.locator(".xx-preview-generate-btn").click()
// 等待预览生成完成(后端渲染,可能需要较长时间)
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
// Step 5: preview — 前端实时预览架构改造,无需后端生成预览
await expect(page.getByRole("heading", { name: /预览设置/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
+48 -21
View File
@@ -9,6 +9,9 @@ import { refreshAccessToken } from "./login"
let refreshTimer: ReturnType<typeof setTimeout> | null = null
/** 正在执行刷新操作的 Promise,防止主动刷新和 401 被动刷新并发竞争 */
let activeRefreshPromise: Promise<void> | null = null
/** 提前刷新的缓冲时间(秒) */
const REFRESH_BUFFER_SECONDS = 60
@@ -39,14 +42,55 @@ export function cancelProactiveRefresh(): void {
}
}
/**
* 执行 token 刷新(带并发锁,供主动刷新和被动 401 共用)
* 返回当前刷新操作的 Promise;若已有刷新进行中则复用该 Promise。
*/
export function executeTokenRefresh(): Promise<void> | null {
// 已有刷新进行中 → 复用
if (activeRefreshPromise) {
return activeRefreshPromise
}
const { user, refreshToken: refreshTokenValue } = useAuthStore.getState()
// 安全检查:user 或 refreshToken 为空时跳过刷新
if (!user || !refreshTokenValue) {
return null
}
activeRefreshPromise = (async () => {
try {
const data = await refreshAccessToken(refreshTokenValue)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshTokenValue
// 更新 Zustand store + localStorage
useAuthStore.getState().setAuth(user, newAccessToken, newRefreshToken)
// 递归调度下一次刷新
scheduleProactiveRefresh()
} catch {
// 刷新失败 → 清除认证状态,跳转登录页
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/login"
} finally {
activeRefreshPromise = null
}
})()
return activeRefreshPromise
}
/**
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
*/
export function scheduleProactiveRefresh(): void {
cancelProactiveRefresh()
const accessToken = localStorage.getItem("access_token")
const refreshTokenValue = useAuthStore.getState().refreshToken
// 统一从 Zustand store 读取(与 setAuth 写入保持一致)
const { accessToken, refreshToken: refreshTokenValue } = useAuthStore.getState()
if (!accessToken || !refreshTokenValue) return
@@ -59,24 +103,7 @@ export function scheduleProactiveRefresh(): void {
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
refreshTimer = setTimeout(async () => {
try {
const data = await refreshAccessToken(refreshTokenValue)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshTokenValue
// 更新 Zustand store + localStorage
useAuthStore
.getState()
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
// 递归调度下一次刷新
scheduleProactiveRefresh()
} catch {
// 刷新失败 → 清除认证状态,跳转登录页
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/login"
}
refreshTimer = setTimeout(() => {
executeTokenRefresh()
}, delaySeconds * 1000)
}
+17 -12
View File
@@ -5,8 +5,8 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { message } from "antd"
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./auth"
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh"
// 创建 Axios 实例
const apiClient = axios.create({
@@ -98,21 +98,26 @@ apiClient.interceptors.response.use(
isRefreshing = true
try {
const data = await refreshAccessToken(refreshToken)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshToken
// 使用共享的刷新函数(带并发锁 + 安全检查)
const refreshPromise = executeTokenRefresh()
if (!refreshPromise) {
// user 或 refreshToken 为空,无法刷新
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/"
return Promise.reject(new Error("Unable to refresh: missing user or refresh token"))
}
await refreshPromise
// 更新 Zustand + localStorage
useAuthStore
.getState()
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
// 获取刷新后的新 token
const newAccessToken = useAuthStore.getState().accessToken
if (!newAccessToken) {
return Promise.reject(new Error("Token refresh failed: no new access token"))
}
// 处理排队的请求
processQueue(null, newAccessToken)
// 重新调度主动刷新(基于新 token 的过期时间)
scheduleProactiveRefresh()
// 重试原始请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
@@ -13,7 +13,9 @@ import React, { useMemo } from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
import { useQuery } from "@tanstack/react-query"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import { getAssetsByKind } from "@/api/assets"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
import GenerateStepsBar from "./components/GenerateStepsBar"
@@ -105,6 +107,18 @@ const GeneratePage: React.FC = () => {
[userTemplates, selectedTemplate],
)
/* ── 配音音频 URL ── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const voiceAudioUrl = useMemo(() => {
if (!selectedVoice) return undefined
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
return asset?.file_url || undefined
}, [selectedVoice, voiceMaterials])
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
currentStep,
@@ -236,6 +250,7 @@ const GeneratePage: React.FC = () => {
assetsReady={previewAssetsReady}
assetsLoading={previewAssetsLoading}
titleSettings={titleSettings}
voiceAudioUrl={voiceAudioUrl}
/>
)}
{currentStep >= 6 && (
@@ -2,6 +2,9 @@
* 前端预览播放器
* 用原生 <video> 标签按时间线播放素材片段
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
*
* 注意:本组件不创建 .xx-preview-video 容器(由父组件 PreviewVideoPanel 提供)
* 避免嵌套 .xx-preview-video 导致 CSS 冲突
*/
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
@@ -18,6 +21,8 @@ interface FrontendPreviewPlayerProps {
videoRatio: string
/** 是否准备好播放(素材已加载) */
ready: boolean
/** 配音音频 URL */
voiceAudioUrl?: string
}
/** 格式化时间 mm:ss */
@@ -41,9 +46,7 @@ function buildPlaybackSegments(
const segments: PlaybackSegment[] = []
assets.forEach((asset, i) => {
// 获取素材时长(从 metadata 或顶层字段)
const assetDuration = asset.duration || asset.metadata?.duration || 30
// 模板片段的时长约束
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
const segDuration = tplSeg
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
@@ -52,9 +55,15 @@ function buildPlaybackSegments(
const startTime = 0
const endTime = Math.min(startTime + segDuration, assetDuration)
const videoUrl = asset.file_url || asset.storage_key
console.log(
`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl?.substring(0, 80)}, file_url=${!!asset.file_url}`,
)
segments.push({
assetId: asset.id,
videoUrl: asset.file_url || asset.storage_key,
videoUrl,
startTime,
endTime,
order: i,
@@ -67,10 +76,10 @@ function buildPlaybackSegments(
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
assets,
template,
videoRatio,
videoRatio: _videoRatio,
ready,
voiceAudioUrl,
}) => {
// 构建播放片段
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
const {
@@ -78,13 +87,83 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
currentTime,
totalDuration,
currentSegmentIndex,
isEnded,
canPlay,
togglePlayPause,
seekTo,
videoRef,
} = useSegmentScheduler(segments)
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 创建/更新 Audio 元素
useEffect(() => {
if (!voiceAudioUrl) {
// 没有配音,清理已有 audio
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
audioRef.current = null
}
return
}
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.preload = "auto"
}
if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
}, [voiceAudioUrl])
// 同步播放状态
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying && !prevIsPlayingRef.current) {
// 刚进入播放
audio.currentTime = currentTime
audio.play().catch(() => {})
} else if (!isPlaying && prevIsPlayingRef.current) {
// 刚暂停
audio.pause()
}
prevIsPlayingRef.current = isPlaying
}, [isPlaying, currentTime])
// seek 时同步音频
const handleSeekTo = useCallback(
(time: number) => {
seekTo(time)
const audio = audioRef.current
if (audio && audio.src) {
audio.currentTime = time
}
},
[seekTo],
)
// 播放结束时暂停音频
useEffect(() => {
if (!isPlaying) {
const audio = audioRef.current
if (audio) audio.pause()
}
}, [isPlaying])
// 清理
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
}
}
}, [])
// 进度条拖拽
const [isDragging, setIsDragging] = useState(false)
const progressRef = useRef<HTMLDivElement>(null)
@@ -94,9 +173,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
seekTo(ratio * totalDuration)
handleSeekTo(ratio * totalDuration)
},
[totalDuration, seekTo],
[totalDuration, handleSeekTo],
)
const handleMouseDown = useCallback(
@@ -113,7 +192,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
seekTo(ratio * totalDuration)
handleSeekTo(ratio * totalDuration)
}
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
@@ -122,15 +201,25 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, totalDuration, seekTo])
}, [isDragging, totalDuration, handleSeekTo])
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// 未就绪状态
if (!ready || !assets.length) {
return (
<div className="xx-preview-empty">
<div
className="xx-preview-empty"
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}}
>
<SoundOutlined style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }} />
<p className="xx-preview-empty-title">...</p>
<p className="xx-preview-empty-desc"></p>
@@ -141,7 +230,18 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// 无播放片段
if (!canPlay) {
return (
<div className="xx-preview-empty">
<div
className="xx-preview-empty"
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}}
>
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
@@ -152,76 +252,85 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
return (
<div className="xx-frontend-preview-player">
{/* 视频区域 */}
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
<video
ref={videoRef}
preload="auto"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
}}
playsInline
/>
<>
{/* ✅ 不再创建 .xx-preview-video 容器,直接渲染 video + 覆盖层 */}
{/* video 元素 — 由父组件 .xx-preview-video 容器提供定位和尺寸 */}
<video
muted
ref={videoRef}
preload="auto"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
zIndex: 1,
}}
playsInline
/>
{/* 播放/暂停按钮覆盖 */}
{!isPlaying && (
<button
className="xx-preview-play-btn"
onClick={togglePlayPause}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
width: 56,
height: 56,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 28,
zIndex: 10,
transition: "opacity 0.2s",
}}
>
{isEnded ? <PlayCircleOutlined /> : <PlayCircleOutlined />}
</button>
)}
{/* 当前片段指示器 */}
<div
{/* 播放按钮覆盖 */}
{!isPlaying && (
<button
className="xx-preview-play-btn"
onClick={togglePlayPause}
style={{
position: "absolute",
top: 8,
left: 8,
background: "rgba(0,0,0,0.6)",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
width: 56,
height: 56,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
fontSize: 28,
zIndex: 10,
transition: "opacity 0.2s",
}}
>
{currentSegmentIndex + 1}/{segments.length}
</div>
<PlayCircleOutlined />
</button>
)}
{/* 片段指示器 */}
<div
style={{
position: "absolute",
top: 8,
left: 8,
background: "rgba(0,0,0,0.6)",
color: "#fff",
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
zIndex: 10,
}}
>
{currentSegmentIndex + 1}/{segments.length}
</div>
{/* 控制条 */}
{/* 控制条 — 绝对定位在底部 */}
<div
className="xx-preview-controls"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 0",
padding: "8px 12px",
background: "linear-gradient(transparent, rgba(0,0,0,0.6))",
zIndex: 10,
}}
>
{/* 播放/暂停 */}
@@ -230,7 +339,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
style={{
background: "none",
border: "none",
color: "var(--text-primary, #fff)",
color: "#fff",
fontSize: 18,
cursor: "pointer",
padding: 4,
@@ -245,7 +354,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<span
style={{
fontSize: 12,
color: "var(--text-secondary, #999)",
color: "rgba(255,255,255,0.8)",
minWidth: 80,
fontVariantNumeric: "tabular-nums",
}}
@@ -275,7 +384,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
{/* 进度指示点 */}
<div
style={{
position: "absolute",
@@ -293,7 +401,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
/>
</div>
</div>
</div>
</>
)
}
@@ -6,6 +6,10 @@
* 架构改造:完全去除后端 FFmpeg 预览依赖
* - 使用 FrontendPreviewPlayer 直接播放素材片段
* - TitleOverlay CSS 层实时响应标题样式变化
*
* 布局:本组件提供 .xx-preview-video 容器(position: relative + overflow: hidden
* FrontendPreviewPlayer 的内容通过 absolute 定位填充容器
* TitleOverlay 通过 absolute 定位 + z-index: 30 覆盖在最上层
*/
import React, { useMemo } from "react"
import { LoadingOutlined } from "@ant-design/icons"
@@ -27,6 +31,8 @@ interface PreviewVideoPanelProps {
assetsLoading: boolean
/** 标题设置 — 用于 CSS 实时预览层 */
titleSettings?: TitleSettings
/** 配音音频 URL */
voiceAudioUrl?: string
}
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
@@ -103,6 +109,8 @@ function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
/**
* CSS 标题预览覆盖层
* 始终渲染:有标题显示标题,无标题显示占位文本"标题预览"
* z-index: 20(在视频 z-index:1 和控制条 z-index:10 之上)
*/
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
const positionStyle = useMemo(
@@ -124,16 +132,17 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
],
)
if (!titleSettings.title?.trim()) return null
// 标题为空时显示占位文本
const displayTitle = titleSettings.title?.trim() || "标题预览"
return (
<div
className="xx-preview-title-overlay"
style={{
position: "absolute",
inset: 0,
zIndex: 20,
pointerEvents: "none",
overflow: "hidden",
}}
>
<div
@@ -143,7 +152,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
position: "absolute",
}}
>
{titleSettings.title}
{displayTitle}
</div>
</div>
)
@@ -158,6 +167,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
assetsReady,
assetsLoading,
titleSettings,
voiceAudioUrl,
}) => {
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
@@ -168,33 +178,42 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
{assetsReady && assets.length > 0 && <span className="xx-preview-badge"></span>}
</div>
{/* 加载中 */}
{assetsLoading && (
<div className="xx-preview-loading-panel">
<div className="xx-preview-video" style={videoAspectStyle}>
<div className="xx-preview-loading-center">
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
...
</p>
</div>
{/* ✅ 预览容器 — 唯一的 .xx-preview-video 容器
内部所有内容(视频、控制条、标题叠加层)通过 absolute 定位填充 */}
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
{/* 加载中状态 */}
{assetsLoading && (
<div
className="xx-preview-loading-center"
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 5,
}}
>
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
...
</p>
</div>
</div>
)}
)}
{/* 前端预览播放器 + CSS 标题叠加 */}
{!assetsLoading && (
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assetsReady}
/>
{/* CSS 标题实时预览层 — 与 FFmpeg ASS 渲染坐标对齐 */}
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
</div>
)}
{/* 前端播放器(视频 + 控制条 + 播放按钮)*/}
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assetsReady}
voiceAudioUrl={voiceAudioUrl}
/>
{/* CSS 标题实时预览层 — z-index: 20,始终渲染在内容层之上 */}
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
</div>
{/* 素材信息 */}
{assetsReady && assets.length > 0 && (
+2 -2
View File
@@ -895,13 +895,12 @@
max-height: 400px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
display: grid;
place-items: center;
color: var(--text-inverse);
font-size: 36px;
position: relative;
overflow: hidden;
margin-bottom: 16px;
isolation: isolate;
}
.xx-preview-video::before {
@@ -910,6 +909,7 @@
inset: 0;
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
pointer-events: none;
z-index: 0;
}
.xx-preview-video video {
@@ -1,33 +1,22 @@
/**
* 预览素材加载 Hook
* 根据选中的素材 ID 列表,批量获取素材详情(含 file_url、duration 等)
* 根据选中的素材 ID 列表,逐个获取素材详情(含 file_url、duration 等)
* 供前端预览播放器使用
*
* 注意:后端没有批量接口(/assets/batch 返回 405),
* 因此直接使用 Promise.allSettled 并发请求单个 GET /assets/{id}
*/
import { useState, useEffect, useCallback, useRef } from "react"
import type { AssetItem } from "@/api/assets"
import type { AxiosResponse } from "axios"
/** 批量获取素材详情的 API 路径 */
const ASSETS_BATCH_URL = "/assets/batch"
/**
* 通过 ID 列表批量获取素材
* 优先使用批量接口,失败则回退为逐个获取
* 通过 ID 列表逐个获取素材(并发)
* 使用 Promise.allSettled 确保单个失败不影响整体
*/
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
if (!ids.length) return []
try {
// 尝试批量接口
const { default: apiClient } = await import("@/api/client")
const response = await apiClient.post(ASSETS_BATCH_URL, { ids })
const items: AssetItem[] = response.data?.items || response.data || []
if (items.length > 0) return items
} catch {
// 批量接口不存在,回退为逐个获取
}
// 回退:逐个获取
try {
const { default: apiClient } = await import("@/api/client")
const results = await Promise.allSettled(
@@ -91,6 +91,10 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const rafRef = useRef<number>(0)
const isSeekingRef = useRef(false)
// ✅ 关键修复:用 ref 标记是否已加载过片段(替代检查 video.src)
// video.src = "" 在浏览器中会被解析为页面 URL,导致 "video.src === ''" 永远为 false
const srcLoadedRef = useRef(false)
// 预加载用的隐藏 video 元素
const preloadVideoRef = useRef<HTMLVideoElement | null>(null)
@@ -109,34 +113,115 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
: 0
/** 切换到指定片段 */
/** 切换到指定片段,返回 Promise 在视频可播放后 resolve */
const switchToSegment = useCallback(
(index: number, seekToLocalTime?: number) => {
const video = videoRef.current
if (!video || index >= segments.length) return
const seg = segments[index]
video.src = seg.videoUrl
const localTime = seekToLocalTime ?? seg.startTime
// 等待 src 设置后再设置 currentTime
const onLoaded = () => {
video.currentTime = localTime
video.removeEventListener("loadedmetadata", onLoaded)
}
video.addEventListener("loadedmetadata", onLoaded)
setCurrentSegmentIndex(index)
// 预加载下一段
if (index + 1 < segments.length) {
const nextSeg = segments[index + 1]
if (!preloadVideoRef.current) {
preloadVideoRef.current = document.createElement("video")
preloadVideoRef.current.preload = "auto"
(index: number, seekToLocalTime?: number): Promise<void> => {
return new Promise((resolve) => {
const video = videoRef.current
if (!video || index >= segments.length) {
resolve()
return
}
preloadVideoRef.current.src = nextSeg.videoUrl
}
const seg = segments[index]
const videoUrl = seg.videoUrl
if (!videoUrl || videoUrl === "") {
console.error(
`[useSegmentScheduler] 片段 ${index} 的 videoUrl 为空!assetId: ${seg.assetId}`,
)
resolve()
return
}
// 检查是否是完整的 HTTP URL
if (!videoUrl.startsWith("http://") && !videoUrl.startsWith("https://")) {
console.warn(`[useSegmentScheduler] 片段 ${index} 的 videoUrl 不是完整 URL: ${videoUrl}`)
}
const localTime = seekToLocalTime ?? seg.startTime
// ✅ 关键修复:清除旧事件监听,防止重复绑定
const cleanup = () => {
video.removeEventListener("loadedmetadata", onLoaded)
video.removeEventListener("error", onError)
video.removeEventListener("canplay", onCanPlay)
clearTimeout(timeoutId)
}
const onLoaded = () => {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} loadedmetadata, src=${videoUrl.substring(0, 80)}`,
)
resolve()
}
const onCanPlay = () => {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} canplay, src=${videoUrl.substring(0, 80)}`,
)
resolve()
}
const onError = () => {
console.error(
`[useSegmentScheduler] 视频加载失败: ${videoUrl}`,
video.error?.message || `code=${video.error?.code}`,
)
srcLoadedRef.current = true // 标记为已尝试,避免死循环
cleanup()
resolve()
}
// ✅ 关键修复:10秒超时防止 Promise 永远不 resolve
const timeoutId = setTimeout(() => {
console.warn(
`[useSegmentScheduler] 片段 ${index} 加载超时 (10s), readyState=${video.readyState}`,
)
cleanup()
resolve()
}, 10000)
// 先绑定事件,再设置 src(避免错过已触发的loadedmetadata
video.addEventListener("loadedmetadata", onLoaded)
video.addEventListener("canplay", onCanPlay)
video.addEventListener("error", onError)
// 设置 src 并显式调用 load()
video.src = videoUrl
video.load()
// ✅ 关键修复:如果 readyState >= 1(已有 metadata),说明加载已完成
// 直接设置 currentTime 并 resolve,不等待事件
if (video.readyState >= 1) {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} 已缓存(readyState=${video.readyState}), 直接播放`,
)
resolve()
return
}
setCurrentSegmentIndex(index)
// 预加载下一段
if (index + 1 < segments.length) {
const nextSeg = segments[index + 1]
if (!preloadVideoRef.current) {
preloadVideoRef.current = document.createElement("video")
preloadVideoRef.current.preload = "auto"
}
preloadVideoRef.current.src = nextSeg.videoUrl
}
})
},
[segments],
)
@@ -152,17 +237,35 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const seg = segments[currentSegmentIndex]
if (!seg) return
// 提前 2 秒预加载下一段
if (currentSegmentIndex + 1 < segments.length) {
const nextSeg = segments[currentSegmentIndex + 1]
if (!preloadVideoRef.current) {
preloadVideoRef.current = document.createElement("video")
preloadVideoRef.current.preload = "auto"
}
if (preloadVideoRef.current.src !== nextSeg.videoUrl) {
preloadVideoRef.current.src = nextSeg.videoUrl
preloadVideoRef.current.load()
}
}
// 检查是否到达出点(容差 0.15s)
if (video.currentTime >= seg.endTime - 0.15) {
const nextIndex = currentSegmentIndex + 1
if (nextIndex < segments.length) {
// 切到下一段
switchToSegment(nextIndex)
switchToSegment(nextIndex).then(() => {
const v = videoRef.current
if (v) {
v.play().catch((e) =>
console.warn("[useSegmentScheduler] auto-play next segment failed:", e),
)
}
})
const accumulatedTime =
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
setCurrentTime(accumulatedTime)
} else {
// 播放结束
video.pause()
setIsPlaying(false)
setIsEnded(true)
@@ -170,7 +273,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
return
}
} else {
// 更新全局时间
const globalTime =
(timelineStarts[currentSegmentIndex] || 0) + (video.currentTime - seg.startTime)
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
@@ -182,22 +284,41 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 播放 */
const play = useCallback(async () => {
const video = videoRef.current
if (!video || !canPlay) return
if (!video || !canPlay) {
console.log("[useSegmentScheduler] play: video=", !!video, "canPlay=", canPlay)
return
}
setIsEnded(false)
// 如果还没设置 src(首次播放),先加载第一段
if (!video.src || video.src === "") {
switchToSegment(0)
// ✅ 关键修复:用 ref 标记代替 video.src 检查
if (!srcLoadedRef.current) {
console.log("[useSegmentScheduler] 首次播放,加载片段 0...")
await switchToSegment(0)
}
try {
await video.play()
const playPromise = video.play()
if (playPromise !== undefined) {
await playPromise
}
console.log("[useSegmentScheduler] 播放开始, src=", video.src?.substring(0, 80))
setIsPlaying(true)
rafRef.current = requestAnimationFrame(tick)
} catch {
// 自动播放可能被浏览器阻止,忽略
console.warn("[useSegmentScheduler] auto-play blocked by browser")
} catch (err) {
console.warn("[useSegmentScheduler] 播放失败:", err, "src=", video.src?.substring(0, 80))
// 如果播放失败,尝试重新加载片段
if (!srcLoadedRef.current) {
srcLoadedRef.current = false
await switchToSegment(0)
try {
await video.play()
setIsPlaying(true)
rafRef.current = requestAnimationFrame(tick)
} catch (retryErr) {
console.error("[useSegmentScheduler] 重试播放仍然失败:", retryErr)
}
}
}
}, [canPlay, switchToSegment, tick])
@@ -217,18 +338,16 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (isEnded) {
// 播放结束后再次播放,从头开始
setIsEnded(false)
switchToSegment(0, segments[0]?.startTime)
const video = videoRef.current
if (video) {
const onSeeked = () => {
video.play().catch(() => {})
srcLoadedRef.current = false // ✅ 重置标记,强制重新加载
switchToSegment(0, segments[0]?.startTime).then(() => {
const video = videoRef.current
if (video) {
video.play().catch((e) => console.warn("[useSegmentScheduler] restart play failed:", e))
setIsPlaying(true)
setCurrentTime(0)
rafRef.current = requestAnimationFrame(tick)
video.removeEventListener("seeked", onSeeked)
}
video.addEventListener("seeked", onSeeked)
}
})
} else {
play()
}
@@ -237,7 +356,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 跳转到指定全局时间 */
const seekTo = useCallback(
(time: number) => {
async (time: number) => {
const video = videoRef.current
if (!video || !canPlay) return
@@ -246,11 +365,8 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
isSeekingRef.current = true
// 如果片段变了,需要切换 src
if (index !== currentSegmentIndex) {
switchToSegment(index, localTime)
// switchToSegment 会设置 src 并在 loadedmetadata 后设置 currentTime
// 所以这里不需要再设置
await switchToSegment(index, localTime)
} else {
video.currentTime = localTime
}
@@ -259,7 +375,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
setCurrentSegmentIndex(index)
setIsEnded(false)
// 延迟恢复 tick 检测
setTimeout(() => {
isSeekingRef.current = false
}, 200)
@@ -285,10 +400,12 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
setCurrentTime(0)
setCurrentSegmentIndex(0)
setIsEnded(false)
srcLoadedRef.current = false // ✅ 重置标记
const video = videoRef.current
if (video) {
video.pause()
video.src = ""
video.removeAttribute("src")
video.load()
}
}, [segments])
+46 -20
View File
@@ -20,9 +20,16 @@ vi.mock("@/api/auth", () => ({
refreshAccessToken: vi.fn(),
}))
vi.mock("@/api/auth/tokenRefresh", () => ({
scheduleProactiveRefresh: vi.fn(),
cancelProactiveRefresh: vi.fn(),
executeTokenRefresh: vi.fn(),
}))
import { message } from "antd"
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "@/api/auth"
import { executeTokenRefresh } from "@/api/auth/tokenRefresh"
import apiClient from "@/api/client"
// 从真实实例取出拦截器回调
@@ -264,23 +271,28 @@ describe("apiClient - 401 token refresh", () => {
expect(window.location.href).toBe("/")
})
it("refreshes token on 401 and calls setAuth", async () => {
it("refreshes token on 401 and calls executeTokenRefresh", async () => {
const mockSetAuth = vi.fn()
vi.mocked(useAuthStore.getState).mockReturnValue({
let currentAccessToken = "old-access"
vi.mocked(useAuthStore.getState).mockImplementation(() => ({
user: { id: "1", email: "test@test.com" },
accessToken: "old-access",
accessToken: currentAccessToken,
refreshToken: "old-refresh",
isAuthenticated: true,
clearAuth: vi.fn(),
setAuth: mockSetAuth,
} as any)
vi.mocked(refreshAccessToken).mockResolvedValue({
access_token: "new-access",
refresh_token: "new-refresh",
} as never)
setAuth: ((_user: any, newAccess: string, _newRefresh: string) => {
currentAccessToken = newAccess
mockSetAuth(_user, newAccess, _newRefresh)
}) as any,
}))
// Mock executeTokenRefresh to simulate successful refresh
vi.mocked(executeTokenRefresh).mockImplementation(() => {
currentAccessToken = "new-access"
mockSetAuth({ id: "1", email: "test@test.com" }, "new-access", "new-refresh")
return Promise.resolve()
})
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
// 但我们只关心刷新逻辑是否正确执行
const err = makeAxiosError(401, { detail: "Unauthorized" })
try {
@@ -289,31 +301,45 @@ describe("apiClient - 401 token refresh", () => {
// 重试会因为没有真实网络而失败,忽略
}
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
expect(executeTokenRefresh).toHaveBeenCalled()
expect(mockSetAuth).toHaveBeenCalled()
})
it("handles refresh failure by logging out", async () => {
const mockClearAuth = vi.fn()
vi.mocked(useAuthStore.getState).mockReturnValue({
// After executeTokenRefresh fails, it clears auth (sets accessToken to null)
// and redirects to /login. The promise resolves (doesn't reject).
let currentAccessToken: string | null = "old-access"
vi.mocked(useAuthStore.getState).mockImplementation(() => ({
user: { id: "1", email: "test@test.com" },
accessToken: "old-access",
accessToken: currentAccessToken,
refreshToken: "old-refresh",
isAuthenticated: true,
clearAuth: mockClearAuth,
isAuthenticated: currentAccessToken !== null,
clearAuth: (() => {
currentAccessToken = null
mockClearAuth()
window.location.href = "/login"
}) as any,
setAuth: vi.fn(),
} as any)
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
}))
// Mock executeTokenRefresh: simulates failure → clears auth + redirects
vi.mocked(executeTokenRefresh).mockImplementation(() => {
currentAccessToken = null
mockClearAuth()
window.location.href = "/login"
return Promise.resolve()
})
const err = makeAxiosError(401, { detail: "Unauthorized" })
try {
await responseErrorInterceptor(err)
} catch {
// expected
// expected - rejects because accessToken is null after failed refresh
}
expect(executeTokenRefresh).toHaveBeenCalled()
expect(mockClearAuth).toHaveBeenCalled()
expect(window.location.href).toBe("/")
expect(window.location.href).toBe("/login")
})
})
+43
View File
@@ -0,0 +1,43 @@
# ============================================================
# API 基础镜像(预构建)
# 预装系统依赖 + Python 依赖,业务构建从此镜像开始
# 当 requirements-base.txt 或 requirements.txt 变更时重新构建
# 目标:将 API Image 构建时间从 15-20 分钟降至 3-5 分钟
# ============================================================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 预装系统依赖(gcc 编译 psycopg/pg 扩展,libpq-dev 编译期,libpq5 运行期,ffmpeg 封面取帧)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
libpq5 \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# 创建虚拟环境
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /tmp
# 预装 Python 基础依赖
COPY requirements-base.txt requirements.txt ./
RUN pip install --no-cache-dir \
-i https://mirrors.aliyun.com/pypi/simple/ \
--trusted-host mirrors.aliyun.com \
-r requirements-base.txt -r requirements.txt
# 虚拟环境瘦身
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
# 清理临时文件
RUN rm -f /tmp/requirements-base.txt /tmp/requirements.txt
ENV PYTHONPATH=/app
+12 -66
View File
@@ -1,81 +1,28 @@
# ============================================================
# API Dockerfile - FastAPI 应用
# 优化:多阶段构建 + pip cache mount + 依赖分层缓存
# 优化:从预构建基础镜像开始,仅叠加业务代码
# 基础镜像包含所有系统依赖和 Python 依赖,构建时间 < 5 分钟
# ============================================================
# ==================== Builder 阶段 ====================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 安装编译依赖(仅 builder 需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 创建虚拟环境
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /tmp
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
COPY requirements-base.txt /tmp/requirements-base.txt
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements-base.txt \
&& rm /tmp/requirements-base.txt
# ---- 依赖分层:业务依赖(变化频繁)----
COPY requirements.txt /tmp/requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
# ---- Python 依赖瘦身 ----
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
# ==================== Runtime 阶段 ====================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest
# 构建参数:版本号(CI 传入 commit hash
ARG APP_VERSION=dev
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖,ffmpeg 用于封面兜底取帧)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# 从 builder 复制虚拟环境
COPY --from=builder /opt/venv /opt/venv
# 设置工作目录
WORKDIR /app
# 复制应用代码
COPY apps/api/ /app/apps/api/
COPY packages/ /app/packages/
COPY alembic.ini /app/alembic.ini
COPY migrations/ /app/migrations/
COPY alembic/ /app/alembic/
COPY scripts/ /app/scripts/
# 复制应用代码(按变化频率从低到高排序,最大化层缓存命中)
COPY alembic.ini ./alembic.ini
COPY migrations/ ./migrations/
COPY alembic/ ./alembic/
COPY scripts/ ./scripts/
COPY packages/ ./packages/
COPY apps/api/ ./apps/api/
# 设置环境变量
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
ENV PYTHONPATH=/app
ENV PYTHONPATH=/app:/app/apps/api
ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
@@ -84,5 +31,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
# API 入口点
WORKDIR /app/apps/api
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
-1
View File
@@ -2,7 +2,6 @@
# 修改此文件会触发完整重新构建,请谨慎修改
# 数据库(基础层)
psycopg2-binary==2.9.9
psycopg[binary]==3.2.2
sqlalchemy==2.0.35
alembic==1.13.3
+13 -3
View File
@@ -3,6 +3,9 @@
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
set -eu
# 单次 build 超时时间(秒),防止 docker buildx build 无限挂起
BUILD_TIMEOUT=1500
NO_CACHE_FLAG=""
if [ "$1" = "--no-cache" ]; then
NO_CACHE_FLAG="--no-cache"
@@ -43,7 +46,7 @@ build_with_cache_retry() {
local build_output
local exit_code
set +e
build_output=$(docker buildx build \
build_output=$(timeout ${BUILD_TIMEOUT} docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
@@ -60,6 +63,12 @@ build_with_cache_retry() {
echo "$build_output"
return 0
fi
# 超时退出(exit code 124
if [ $exit_code -eq 124 ]; then
echo "❌ Docker build TIMEOUT after ${BUILD_TIMEOUT}s - build hung and was killed"
echo "$build_output" | tail -20
return $exit_code
fi
# 检测到缓存损坏类错误,清掉本地缓存重试
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
echo "$build_output"
@@ -68,7 +77,7 @@ build_with_cache_retry() {
rm -rf "${LOCAL_CACHE_DIR}"
mkdir -p "${LOCAL_CACHE_DIR}"
# 清理buildx builder的内部snapshot状态
docker buildx prune -f -a >/dev/null 2>&1 || true
docker buildx prune -f -a > /dev/null 2>&1 || true
attempt=$((attempt + 1))
else
# 非缓存类错误,直接输出并返回
@@ -78,7 +87,7 @@ build_with_cache_retry() {
done
# 重试完还是失败,不用本地缓存最后试一次(只从registry读)
echo "⚠️ All cached attempts failed, building without local cache..."
docker buildx build \
timeout ${BUILD_TIMEOUT} docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
@@ -93,6 +102,7 @@ build_with_cache_retry() {
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
echo "Local cache: ${LOCAL_CACHE_DIR}"
echo "Registry cache: ${CACHE_REF}"
echo "Build timeout: ${BUILD_TIMEOUT}s"
echo ""
build_with_cache_retry
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# ============================================================
# 重建 API 基础镜像脚本
# 用途:当 requirements-base.txt 或 requirements.txt 变更时手动触发
# 前提:需要在已登录 ACR 的构建服务器上执行
# ============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_NAME="saas-api-base"
TAG="latest"
FULL_TAG="${REGISTRY}/${IMAGE_NAME}:${TAG}"
echo "========================================="
echo "🔨 Rebuilding API base image"
echo " Registry: ${REGISTRY}"
echo " Image: ${FULL_TAG}"
echo " Context: ${REPO_ROOT}"
echo "========================================="
cd "$REPO_ROOT"
# 构建并推送
docker buildx build \
--platform linux/amd64 \
--tag "${FULL_TAG}" \
--push \
-f infra/docker/api-base.Dockerfile \
.
echo ""
echo "✅ API base image pushed: ${FULL_TAG}"
# 显示镜像大小
docker pull "${FULL_TAG}" > /dev/null 2>&1
docker images "${FULL_TAG}" --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
+186
View File
@@ -0,0 +1,186 @@
"""Unit tests for apps/api/app/api/routes/health.py
覆盖 _check_database() 和 _check_migrations() 中 psycopg3 连接逻辑。
确保增量覆盖率 ≥ 60%(目标覆盖 lines 52, 127)。
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.mark.asyncio
class TestCheckDatabase:
"""Tests for _check_database() health check function."""
@patch("apps.api.app.api.routes.health.settings")
@patch("apps.api.app.api.routes.health.psycopg.connect")
async def test_check_database_success(self, mock_connect, mock_settings):
"""PostgreSQL 连接成功时返回 healthy。"""
mock_settings.USE_IN_MEMORY_DB = False
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
# Mock connection and cursor
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_cursor.fetchone.return_value = (1,)
mock_conn.cursor.return_value = mock_cursor
mock_connect.return_value = mock_conn
from apps.api.app.api.routes.health import _check_database
result = await _check_database()
assert result["status"] == "healthy"
assert result["type"] == "postgresql"
assert result["message"] == "Database connection successful"
mock_connect.assert_called_once_with(
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
)
mock_cursor.execute.assert_called_once_with("SELECT 1")
mock_conn.close.assert_called_once()
@patch("apps.api.app.api.routes.health.settings")
@patch("apps.api.app.api.routes.health.psycopg.connect")
async def test_check_database_connection_failure(self, mock_connect, mock_settings):
"""PostgreSQL 连接失败时返回 unhealthy。"""
mock_settings.USE_IN_MEMORY_DB = False
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
mock_connect.side_effect = Exception("connection refused")
from apps.api.app.api.routes.health import _check_database
result = await _check_database()
assert result["status"] == "unhealthy"
assert result["type"] == "postgresql"
assert "connection refused" in result["message"]
@patch("apps.api.app.api.routes.health.settings")
async def test_check_database_in_memory(self, mock_settings):
"""使用内存数据库时跳过 PostgreSQL 检查。"""
mock_settings.USE_IN_MEMORY_DB = True
from apps.api.app.api.routes.health import _check_database
result = await _check_database()
assert result["status"] == "healthy"
assert result["type"] == "in_memory"
@pytest.mark.asyncio
class TestCheckMigrations:
"""Tests for _check_migrations() health check function."""
@patch("apps.api.app.api.routes.health.settings")
@patch("apps.api.app.api.routes.health.psycopg.connect")
async def test_check_migrations_success(self, mock_connect, mock_settings):
"""所有迁移表存在时返回 healthy。"""
mock_settings.USE_IN_MEMORY_DB = False
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_cursor.fetchone.return_value = (5,) # 5 tables found
mock_conn.cursor.return_value = mock_cursor
mock_connect.return_value = mock_conn
from apps.api.app.api.routes.health import _check_migrations
result = await _check_migrations()
assert result["status"] == "healthy"
assert result["message"] == "Database migrations applied"
mock_connect.assert_called_once_with(
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
)
mock_conn.close.assert_called_once()
@patch("apps.api.app.api.routes.health.settings")
@patch("apps.api.app.api.routes.health.psycopg.connect")
async def test_check_migrations_missing_tables(self, mock_connect, mock_settings):
"""迁移表不完整时返回 unhealthy。"""
mock_settings.USE_IN_MEMORY_DB = False
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_cursor.fetchone.return_value = (2,) # Only 2 of 5 tables
mock_conn.cursor.return_value = mock_cursor
mock_connect.return_value = mock_conn
from apps.api.app.api.routes.health import _check_migrations
result = await _check_migrations()
assert result["status"] == "unhealthy"
assert "Missing tables" in result["message"]
assert "2/5" in result["message"]
@patch("apps.api.app.api.routes.health.settings")
@patch("apps.api.app.api.routes.health.psycopg.connect")
async def test_check_migrations_connection_failure(self, mock_connect, mock_settings):
"""数据库连接失败时返回 unhealthy。"""
mock_settings.USE_IN_MEMORY_DB = False
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
mock_connect.side_effect = Exception("connection refused")
from apps.api.app.api.routes.health import _check_migrations
result = await _check_migrations()
assert result["status"] == "unhealthy"
assert "Migration check failed" in result["message"]
@patch("apps.api.app.api.routes.health.settings")
async def test_check_migrations_in_memory(self, mock_settings):
"""使用内存数据库时跳过迁移检查。"""
mock_settings.USE_IN_MEMORY_DB = True
from apps.api.app.api.routes.health import _check_migrations
result = await _check_migrations()
assert result["status"] == "healthy"
assert "no migrations needed" in result["message"]
@pytest.mark.asyncio
class TestStartupCheck:
"""Tests for startup_check() endpoint."""
@patch("apps.api.app.api.routes.health._check_migrations")
@patch("apps.api.app.api.routes.health._check_database")
async def test_startup_all_healthy(self, mock_db, mock_mig):
"""所有检查通过时返回 started。"""
mock_db.return_value = {"status": "healthy"}
mock_mig.return_value = {"status": "healthy"}
from apps.api.app.api.routes.health import startup_check
result = await startup_check()
assert result["status"] == "started"
@patch("apps.api.app.api.routes.health._check_migrations")
@patch("apps.api.app.api.routes.health._check_database")
async def test_startup_db_unhealthy(self, mock_db, mock_mig):
"""数据库不健康时返回 starting + 503。"""
mock_db.return_value = {"status": "unhealthy", "message": "fail"}
mock_mig.return_value = {"status": "healthy"}
from apps.api.app.api.routes.health import startup_check
result = await startup_check()
assert result.status_code == 503
import json
body = json.loads(result.body)
assert body["status"] == "starting"