Compare commits

..

88 Commits

Author SHA1 Message Date
xiaoxia d1c2443fae Merge branch 'develop' into ci/cache-optimization-plus
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 59s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m36s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m6s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m18s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 35m3s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 47s
2026-07-22 01:04:47 +08:00
xiaoxia 2b0724dd57 ci: PR构建验证 + Agent提交格式化 + buildx清理 (#690)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m25s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m36s
CI/CD Pipeline / Unit Tests (push) Successful in 2m40s
CI/CD Pipeline / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m16s
CI/CD Pipeline / Build Staging API Image (push) Failing after 22m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-22 00:26:52 +08:00
xiaoxia 43815e5f20 ci: 前端npm安装增加国内镜像源,加重试间隔
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m4s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m9s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m15s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m48s
AI Code Review / AI Code Review (pull_request) Successful in 3m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 16m53s
- npm registry改为npmmirror.com国内镜像,加速下载减少失败
- 重试间隔从5s增加到10s,给网络恢复更多时间
2026-07-22 00:17:56 +08:00
xiaoxia b1babaaedd test(unit): P3-1第六波 新增5个领域模块单元测试(37个用例) (#687)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 28s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m54s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m40s
CI/CD Pipeline / Unit Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 17m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m35s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 32s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m36s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 23:33:01 +08:00
xiaoxia 44402f01d6 fix(worker): ffmpeg改用apt安装,彻底解决下载慢+缓存+语法+清理问题 (#689)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m42s
CI/CD Pipeline / Unit Tests (push) Successful in 2m40s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m57s
CI/CD Pipeline / Integration Tests (push) Successful in 1m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 46s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 20m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 22m33s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 22:19:55 +08:00
xiaoxia c424ffcc66 fix(worker): ffmpeg下载脚本改为纯sh兼容,修复dash数组语法错误 (#688)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m19s
CI/CD Pipeline / Unit Tests (push) Successful in 2m36s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m1s
CI/CD Pipeline / Integration Tests (push) Successful in 1m3s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 20:20:55 +08:00
xiaoxia cd27bd087f fix(worker): ffmpeg下载增加完整性校验+多源重试+坏包自动清缓存 (#686)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m48s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m1s
CI/CD Pipeline / Unit Tests (push) Successful in 2m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m48s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 3m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m4s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 19:56:53 +08:00
xiaoxia abc3fbea1b test(unit): P3-1 第五波 新增3个领域模块单元测试(54个用例) (#683)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 34s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m25s
CI/CD Pipeline / Unit Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m4s
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
Co-authored-by: CI Bot <dev@xiaoxiajianji.com>
Co-committed-by: CI Bot <dev@xiaoxiajianji.com>
2026-07-21 19:47:24 +08:00
xiaoxia 07f6705534 test(unit): P3-1 第四波 新增3个领域模块单元测试(65个用例) (#682)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m23s
CI/CD Pipeline / Unit Tests (push) Successful in 2m23s
CI/CD Pipeline / Integration Tests (push) Successful in 1m3s
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
Co-authored-by: CI Bot <dev@xiaoxiajianji.com>
Co-committed-by: CI Bot <dev@xiaoxiajianji.com>
2026-07-21 19:38:28 +08:00
xiaoxia a2a8d163f9 Merge pull request 'perf(ci): 恢复registry cache-to写入,ACR已改为tag可变' (#681) from opt/restore-registry-cache-to into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m23s
CI/CD Pipeline / Unit Tests (push) Successful in 2m39s
CI/CD Pipeline / Integration Tests (push) Successful in 1m3s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m31s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 35m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
2026-07-21 19:01:55 +08:00
xiaoxia d66046aa4b test(unit): P3-1 第三波 新增3个领域模块单元测试(94个用例) (#680)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 57s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m30s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m41s
CI/CD Pipeline / Validate Code Quality And Tests (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 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 19:00:12 +08:00
xiaoxia 9554373f24 test(unit): P3-1 第二波 新增7个领域模块单元测试(231个用例)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m27s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m54s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m32s
CI/CD Pipeline / Unit Tests (push) Successful in 2m37s
CI/CD Pipeline / Integration Tests (push) Successful in 1m3s
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
新增7个领域模块单元测试:classification、edit_plan_clip、filter_presets、generated_video、preset_bgm、template_clip_config、transition_presets,共231个用例
2026-07-21 18:50:37 +08:00
xiaoxia 9f69fde30b fix(#558): 微信登录3个后端修复 - 验证码500 + 绑定500 + state校验 (#684)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 40s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Successful in 2m34s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m2s
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
fix(#558): 微信登录3个后端修复 - 验证码500 + 绑定500 + state校验
2026-07-21 17:51:48 +08:00
xiaoxia 4c5057637a fix(test): 修复useAuth测试中navigate断言缺少replace参数的问题 (#685)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m26s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m30s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 3m2s
CI/CD Pipeline / Unit Tests (push) Successful in 2m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m15s
CI/CD Pipeline / Integration Tests (push) Failing after 1m0s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 40m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 15:30:23 +08:00
xiaoxia faf018030f perf(ci): Dockerfile优化 - cache mount加速 + API多阶段瘦身 (#679)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 45s
CI/CD Pipeline / Unit Tests (push) Successful in 2m22s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m51s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 3m2s
CI/CD Pipeline / Integration Tests (push) Failing after 1m3s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 14:00:29 +08:00
xiaoxia 933973311f perf(ci): 恢复registry cache-to写入,ACR已改为tag可变
AI Code Review / AI Code Review (pull_request) Successful in 2m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 2m46s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 2m51s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 2m28s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m25s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m59s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m12s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m4s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
- 恢复2处--cache-to type=registry写入
- 保留ignore-error=true作为兜底(防止ACR配置回退导致构建失败)
- cache-from的ignore-error也去掉了(tag可变后正常写入不会报错)

前提:阿里云ACR缓存仓库(api-cache/worker-cache/web-cache)已从不可变改为可变tag
2026-07-21 13:54:09 +08:00
xiaoxia cffe84c764 fix(ci): 移除registry cache-to写入,避免ACR tag不可变导致构建卡死 (#678)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m31s
CI/CD Pipeline / Frontend Lint (push) Successful in 9m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 13m36s
CI/CD Pipeline / Unit Tests (push) Successful in 15m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18m41s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 17m49s
CI/CD Pipeline / Build Staging API Image (push) Successful in 19m55s
CI/CD Pipeline / Integration 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 / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 13:39:42 +08:00
xiaoxia ec5b29197e fix(test): 修复useAuth测试中mock setAuth不写localStorage的问题 (#677)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m6s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m50s
CI/CD Pipeline / Unit Tests (push) Successful in 5m20s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m29s
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 10m47s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 17m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m0s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 13:19:57 +08:00
xiaoxia 5e3a3099cf fix(api): 模板编辑器兼容旧模板系统,修复E2E 500错误 (#675)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m28s
CI/CD Pipeline / Frontend Lint (push) Successful in 7m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 11m13s
CI/CD Pipeline / Unit Tests (push) Successful in 11m14s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 11m56s
CI/CD Pipeline / Integration Tests (push) Successful in 2m14s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 13:02:21 +08:00
xiaoxia d9f9bce1bd feat(web): #558 微信登录体验优化 (#676)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 45m57s
CI/CD Pipeline / Frontend Lint (push) Successful in 47m35s
CI/CD Pipeline / Unit Tests (push) Failing after 48m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 50m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m8s
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 / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 11:59:06 +08:00
xiaoxia 8193e7756a fix: AuthenticatedUser.user_id 改为 user.id,修复编辑器500 (#673)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 18m28s
CI/CD Pipeline / Frontend Lint (push) Successful in 19m21s
CI/CD Pipeline / Unit Tests (push) Successful in 20m36s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 31m31s
CI/CD Pipeline / Integration 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 / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 11:26:16 +08:00
xiaoxia 9a1eb14fbb feat(#558): 微信登录前端 - 授权按钮、回调页、绑定弹窗 (#672)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 8m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 10m0s
CI/CD Pipeline / Unit Tests (push) Successful in 10m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 12m2s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 12m32s
CI/CD Pipeline / Integration Tests (push) Successful in 4m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 46s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m8s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 10:47:31 +08:00
xiaoxia beabc932e6 fix(web): 修复生成页 API 调用适配新模板编辑器架构 (#671)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m56s
CI/CD Pipeline / Frontend Lint (push) Successful in 5m57s
CI/CD Pipeline / Unit Tests (push) Successful in 6m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 7m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m49s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 10:33:47 +08:00
xiaoxia 269723ced2 feat(#558): 微信登录 + 绑定手机号邮箱完整后端实现 (#670)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m57s
CI/CD Pipeline / Unit Tests (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 09:21:01 +08:00
xiaoxia 75bcff9620 fix(550): 修复2处遗漏文案 - 保存到配音库 + 视频库素材较少提示 (#669)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 4m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 6m6s
CI/CD Pipeline / Unit Tests (push) Successful in 6m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m47s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m23s
CI/CD Pipeline / Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 10m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m8s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m16s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 08:23:38 +08:00
xiaoxia 15c635b553 fix(p0): 前端API路径全量迁移到 /templates/{templateId}/editor (#666)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 6m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m9s
CI/CD Pipeline / Unit Tests (push) Failing after 7m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 8m19s
CI/CD Pipeline / Integration Tests (push) Successful in 2m15s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 08:08:48 +08:00
xiaoxia 4f7da8e793 fix(e2e): 精确匹配创建计划接口路径 + 增加错误调试日志 (#668)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m43s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m25s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m17s
CI/CD Pipeline / Unit Tests (push) Successful in 5m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m50s
CI/CD Pipeline / Integration Tests (push) Successful in 2m42s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 57s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 07:48:50 +08:00
xiaoxia cda899ad5f fix(e2e): 修复E2E测试API路径从/edit-plans到/template-editors (#667)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 2m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m56s
CI/CD Pipeline / Unit Tests (push) Successful in 3m58s
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 15m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 34s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 01:02:53 +08:00
xiaoxia 3a8da7ea12 fix(e2e): 更新生成流程E2E测试适配7步新流程 (#663)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m2s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m11s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m35s
CI/CD Pipeline / Unit Tests (push) Successful in 3m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m55s
CI/CD Pipeline / Integration Tests (push) Successful in 1m38s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 15m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 57s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 00:37:49 +08:00
xiaoxia df38101bd9 test(unit): P3-1 核心模块单元测试覆盖率提升 - 新增12个模块400+测试 (#661)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 40s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / Unit Tests (push) Successful in 4m6s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 15m52s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 00:21:37 +08:00
xiaoxia 6b1051f1e8 fix(ci): 修复 auto_fix_formatting git checkout 参数顺序错误 (#662)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 41s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m46s
CI/CD Pipeline / Validate Code Quality And Tests (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 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 00:18:58 +08:00
xiaoxia 981d965d89 feat(phase3): 下线/edit-plans/*旧路由 + 清理废弃代码 + 数据库冗余字段清理 (#657)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m42s
CI/CD Pipeline / Unit Tests (push) Successful in 6m2s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m13s
CI/CD Pipeline / Integration Tests (push) Successful in 2m14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 17m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 23:48:36 +08:00
xiaoxia efcc5eafdf fix(ci): registry cache-to加ignore-error,避免ACR tag不可变导致build失败 (#660)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m55s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m40s
CI/CD Pipeline / Unit Tests (push) Successful in 6m53s
CI/CD Pipeline / Integration Tests (push) Successful in 2m31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m45s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 22:25:37 +08:00
xiaoxia ba6cacec8d feat(ci): P2-6 CI健康度每日巡检 - 每天9点自动检查,有失败发飞书报告 (#658)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 3m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 6m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m42s
CI/CD Pipeline / Unit Tests (push) Successful in 9m46s
CI/CD Pipeline / Integration Tests (push) Successful in 4m7s
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 / ACR Image Cleanup (push) Has been cancelled
2026-07-20 22:05:34 +08:00
xiaoxia 29bae54328 fix(e2e): 更新生成页面标题断言,一键生成→智能剪辑 (#659)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 3m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 6m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m50s
CI/CD Pipeline / Unit Tests (push) Successful in 9m14s
CI/CD Pipeline / Integration Tests (push) Successful in 4m6s
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 / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 21:46:41 +08:00
xiaoxia d4fdebd1e5 perf(ci): P2-5 Docker build registry缓存共享 - 12个runner共用缓存 (#656)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 3m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m59s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m8s
CI/CD Pipeline / Unit Tests (push) Successful in 7m13s
CI/CD Pipeline / Integration Tests (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 12m51s
CI/CD Pipeline / Build Staging API Image (push) Failing after 34m14s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 21:10:17 +08:00
xiaoxia 8be5df5a11 perf(ci): P2-5 Frontend Lint瘦身 - 移除重复Vitest,节省4-5min (#655)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 3m59s
CI/CD Pipeline / Validate Code Quality And Tests (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 / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 21:05:17 +08:00
xiaoxia 61dcd196fd feat(ci): Validate阶段增加migration编号连续性检查 (#652)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m10s
CI/CD Pipeline / Frontend Lint (push) Successful in 7m35s
CI/CD Pipeline / Unit Tests (push) Successful in 7m46s
CI/CD Pipeline / Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m22s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m18s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 20:46:48 +08:00
xiaoxia 7260ee282f test: 新增认证模块和封面服务单元测试,覆盖率提升至96%+ (#654)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 59s
CI/CD Pipeline / Validate Code Quality And Tests (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 / 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 20:43:22 +08:00
xiaoxia 9a457d06cb feat(ci): P2-4 基础设施自动重试策略 (#653)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m0s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m3s
CI/CD Pipeline / Frontend Lint (push) Successful in 8m44s
CI/CD Pipeline / Unit Tests (push) Successful in 7m49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 9m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m59s
CI/CD Pipeline / Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m6s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 20:06:51 +08:00
xiaoxia 6aa27c4fb3 feat(ci): P2优化 - Code Review pip兼容 + CI健康度检查脚本 (#649)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m33s
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 / 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 20:00:41 +08:00
xiaoxia 151ec6e3d5 feat(phase2): 剪辑计划API路径从/edit-plans迁移到/template-editors (#645)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m7s
CI/CD Pipeline / Frontend Lint (push) Successful in 7m44s
CI/CD Pipeline / Build Staging API Image (push) Successful in 10m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 12m17s
CI/CD Pipeline / Unit Tests (push) Successful in 7m10s
CI/CD Pipeline / Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 19:06:08 +08:00
xiaoxia d46ef0a336 fix(test): 修复publish_template_from_draft单元测试失败 (#650)
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (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 / 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 19:02:54 +08:00
xiaoxia 419cc3fb6a feat(phase2): 模板发布版本化 + 回滚能力 (#646)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m58s
CI/CD Pipeline / Frontend Lint (push) Successful in 6m32s
CI/CD Pipeline / Unit Tests (push) Failing after 6m48s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m5s
CI/CD Pipeline / Integration Tests (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 9m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m40s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 18:15:00 +08:00
xiaoxia 30db513d52 feat(phase3): 砍掉剪辑计划独立入口,统一模板编辑器定位 (#648)
CI/CD Pipeline / Build Staging API Image (push) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Failing after 0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (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 / 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 / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 18:14:56 +08:00
xiaoxia 01acf68d25 fix(e2e): 修复ESM模式下__dirname未定义导致Staging E2E失败 (#647)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m3s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 7m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m46s
CI/CD Pipeline / Unit Tests (push) Successful in 8m8s
CI/CD Pipeline / Frontend Lint (push) Successful in 8m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 9m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10m15s
CI/CD Pipeline / Integration Tests (push) Successful in 3m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 0s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 0s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 0s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 17:57:03 +08:00
xiaoxia 87f506e18a fix(test): 修复MagicMock枚举导致的3个单元测试失败 (#642)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 6m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m1s
CI/CD Pipeline / Unit Tests (push) Successful in 8m55s
CI/CD Pipeline / Frontend Lint (push) Successful in 9m11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 9m53s
CI/CD Pipeline / Integration Tests (push) Successful in 3m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 11m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m2s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m27s
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-07-20 17:41:10 +08:00
xiaoxia 850db009cb feat(phase2): 模板编辑器子路由全量迁移(38个端点) (#641)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m53s
CI/CD Pipeline / Frontend Lint (push) Successful in 6m19s
CI/CD Pipeline / Unit Tests (push) Failing after 6m27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m8s
CI/CD Pipeline / Integration Tests (push) Successful in 2m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m7s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 14:56:08 +08:00
xiaoxia 3502a03ff5 Merge pull request 'fix(ci): auto-approve/merge只检查分支保护required门禁,避免非required失败阻塞自动合并' (#644) from fix/ci-autoapprove-match-branch-protection into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m38s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m54s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / Unit Tests (push) Failing after 6m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m50s
CI/CD Pipeline / Frontend Lint (push) Successful in 7m44s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 47s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m36s
2026-07-20 14:29:37 +08:00
xiaoxia 30f125bc78 Merge pull request 'fix(ci): auto-fix checkout加--force,避免tarball untracked文件冲突' (#643) from fix/ci-autofix-checkout-force into develop
CI/CD Pipeline / Build Staging Web Image (push) Successful in 33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (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 / 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
2026-07-20 14:28:42 +08:00
xiaoxia ea5a937984 fix(ci): auto-approve/merge只检查分支保护required门禁,避免非required失败阻塞自动合并
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 17s
AI Code Review / AI Code Review (pull_request) Failing after 36s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 4m23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 6m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 9m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 23s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m7s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m54s
2026-07-20 14:19:24 +08:00
xiaoxia 7593c6cde3 fix(ci): auto-fix checkout加--force,避免tarball untracked文件冲突
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 18s
AI Code Review / AI Code Review (pull_request) Successful in 2m0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m48s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 7m10s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m29s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 11m36s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m14s
2026-07-20 14:16:48 +08:00
xiaoxia f06a00bc7b Merge pull request 'fix(ci): P0-2 code-review checkout改用tarball+5次重试,替代actions/checkout@v3' (#635) from fix/ci-p02-checkout-retry into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 49s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m6s
CI/CD Pipeline / Unit Tests (push) Failing after 7m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m17s
CI/CD Pipeline / Frontend Lint (push) Successful in 8m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m52s
CI/CD Pipeline / Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m30s
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-07-20 14:13:52 +08:00
xiaoxia ee628b0772 Merge pull request 'fix(ci): P0-1 Preview Deploy SSH密钥损坏修复 + 完整性自检' (#637) from fix/ci-p01-preview-ssh-key into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (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 / 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 / ACR Image Cleanup (push) Has been cancelled
2026-07-20 14:13:42 +08:00
xiaoxia 4162168161 feat(phase2): 给 /edit-plans/* 所有API加deprecated标记和warning日志 (#640)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 52s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m46s
CI/CD Pipeline / Unit Tests (push) Failing after 6m10s
CI/CD Pipeline / Frontend Lint (push) Successful in 6m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 9m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m12s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 13:55:17 +08:00
xiaoxia ba6e691eeb fix(ci): auto-fix push增加rebase+重试,修复Validate格式修复推送失败 (#636)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m58s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 8m19s
CI/CD Pipeline / Unit Tests (push) Failing after 7m24s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m34s
CI/CD Pipeline / Integration Tests (push) Successful in 4m3s
2026-07-20 13:36:23 +08:00
CI Bot 72e613f4b6 fix(ci): code-review换回actions/checkout@v3(runner层处理不受容器网络影响)
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 2m25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m29s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 4m6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 8m3s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 10m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m58s
Preview Cleanup / Cleanup Preview Environment (pull_request) Failing after 1s
2026-07-20 13:34:02 +08:00
xiaoxia 3bc7c3057a feat(phase2): 模板编辑器草稿 + 路由层 + 发布流程 (#639)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m9s
CI/CD Pipeline / Validate Code Quality And Tests (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 / 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 13:31:50 +08:00
CI Bot 52405bb02a fix(ci): code-review tarball checkout改用gitea.server_url,解决容器内Connection refused
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 16s
AI Code Review / AI Code Review (pull_request) Failing after 16s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m51s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 6m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m55s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m30s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 9m4s
2026-07-20 13:18:07 +08:00
CI Bot 602a604b67 fix(ci): P0-1 preview deploy增加SSH密钥完整性自检,损坏时明确报错
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 36s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 8m3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m49s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 16m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 16m15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m34s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 16s
2026-07-20 12:58:42 +08:00
xiaoxia afae36bf04 fix(e2e): 用真实视频文件替换假buffer,修复Staging E2E上传失败 (#634)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m13s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 6m24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 8m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m40s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m47s
CI/CD Pipeline / Unit Tests (push) Failing after 7m28s
CI/CD Pipeline / Integration Tests (push) Successful in 3m52s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 12:56:34 +08:00
CI Bot 3aa489008f fix(ci): code-review checkout改用tarball+5次重试,替代actions/checkout@v3
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 17s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 17s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 4m18s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 6m10s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 12m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 12m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m55s
2026-07-20 12:51:34 +08:00
xiaoxia f01f4803e4 fix(#549): 预设配音无声 - 字幕对齐TTS配音模式 + ASR缓存 (#628)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m42s
CI/CD Pipeline / Frontend Lint (push) Successful in 6m13s
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 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 12:49:54 +08:00
xiaoxia e5fffe40c1 refactor(phase1): 统一渲染入口 + 模板系统双读兼容 (#630)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m1s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m28s
CI/CD Pipeline / Unit Tests (push) Failing after 5m2s
CI/CD Pipeline / Integration Tests (push) Successful in 1m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 44s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 34s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 12:39:43 +08:00
xiaoxia 8dc8de0c59 fix(ci): 修复auto-fix在tarball checkout模式下git不可用的问题 (#629)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m11s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m32s
CI/CD Pipeline / Unit Tests (push) Successful in 5m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m17s
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 12:27:31 +08:00
xiaoxia dc12d4669f fix(#549): 预设配音无声 - 顶层voice_id+custom_text与tts配置路径不匹配 (#626)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m33s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m53s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m57s
CI/CD Pipeline / Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m3s
CI/CD Pipeline / Integration Tests (push) Successful in 2m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 12:19:38 +08:00
xiaoxia 82929fcd10 fix(#463): 直通模式调速失效 - final_duration未考虑调速导致减速场景被截断 (#625)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m48s
CI/CD Pipeline / Frontend Lint (push) Successful in 5m35s
CI/CD Pipeline / Unit Tests (push) Successful in 6m13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 11:41:08 +08:00
xiaoxia a60969ba50 ci(ci-stability): 为pip install和npm ci添加重试机制 (#619) (#627)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m52s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m3s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m22s
CI/CD Pipeline / Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m5s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 10:56:02 +08:00
xiaoxia 8c67161ea6 feat(ci): 接入AgentLoop Trace上报,实现CI全链路监控 (#598) (#624)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m2s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m28s
CI/CD Pipeline / Unit Tests (push) Successful in 5m22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m50s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m4s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 10:12:34 +08:00
xiaoxia 7ed3fdef65 feat(phase9): AI能力增强合集 - 智能标题/智能配音/生成体验优化 (#623)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m44s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m43s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m2s
CI/CD Pipeline / Frontend Lint (push) Successful in 5m14s
CI/CD Pipeline / Unit Tests (push) Successful in 5m57s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m18s
CI/CD Pipeline / Integration Tests (push) Successful in 2m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m19s
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 10:01:13 +08:00
xiaoxia d42965bba1 refactor: 删除legacy渲染引擎,统一走unified引擎 #599 (#608)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m32s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m33s
CI/CD Pipeline / Unit Tests (push) Successful in 4m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m22s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 9m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m17s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 09:43:49 +08:00
xiaoxia b0573a5e91 feat(#584): 智能素材匹配前端页面框架 (#618)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m1s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m47s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m49s
CI/CD Pipeline / Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Integration Tests (push) Successful in 1m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m1s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 08:23:49 +08:00
xiaoxia 61d7b14635 fix(ci): 修复deploy脚本retry_docker_login引号嵌套问题 (#593) (#622)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m55s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m14s
CI/CD Pipeline / Unit Tests (push) Successful in 3m36s
CI/CD Pipeline / Integration Tests (push) Successful in 1m13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 20m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 21m7s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 01:34:31 +08:00
xiaoxia 718ae17640 ci: 全面根治CI稳定性问题 (#619) - DooD模式PG端口映射 + 常驻PG + 重试机制 (#621)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 44s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m30s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m41s
CI/CD Pipeline / Unit Tests (push) Successful in 3m44s
CI/CD Pipeline / Integration Tests (push) Successful in 1m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 01:08:33 +08:00
xiaoxia d559e6b787 ci: 全面根治CI稳定性问题 (#619) (#620)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 45s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m33s
CI/CD Pipeline / Unit Tests (push) Successful in 3m21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m33s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m54s
CI/CD Pipeline / Integration Tests (push) Successful in 1m52s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 01:00:04 +08:00
xiaoxia dbe8580f85 fix(ci): 迁移Validate/Frontend job到ci-l2 runner,解决ci-check环境Docker端口映射失效 (#616)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m27s
CI/CD Pipeline / Unit Tests (push) Successful in 5m3s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 5m25s
CI/CD Pipeline / Frontend Lint (push) Successful in 5m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 22:20:53 +08:00
xiaoxia df0b3ab452 fix(ci): 增加PG/Redis端口连通性检查,解决容器healthy但映射端口连不上 (#615)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 45s
CI/CD Pipeline / Frontend Lint (push) Successful in 5m20s
CI/CD Pipeline / Unit Tests (push) Successful in 5m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m50s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m2s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 6m7s
CI/CD Pipeline / Integration Tests (push) Successful in 1m34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 34s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m10s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m23s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 21:48:09 +08:00
xiaoxia 91e3080371 fix(ci): ACR cleanup改到Deploy之后运行,保护当前构建tag不被误删 (#614)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 4m30s
CI/CD Pipeline / Unit Tests (push) Successful in 5m1s
CI/CD Pipeline / Integration Tests (push) Failing after 33s
CI/CD Pipeline / Frontend Lint (push) Successful in 5m46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m56s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 44s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m29s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 21:24:44 +08:00
xiaoxia 7aad69435c fix(ci): 修正 preview-deploy 默认 SSH host/user 为正确值 (#613)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 40s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 3m5s
CI/CD Pipeline / Integration Tests (push) Failing after 14s
CI/CD Pipeline / Unit Tests (push) Successful in 3m35s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m48s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 20:46:58 +08:00
xiaoxia 9dee33c54d fix(ci): 修复deploy脚本语法错误(多余双引号) (#612)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (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 / 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 / ACR Image Cleanup (push) Has been cancelled
2026-07-19 20:45:21 +08:00
xiaoxia ab46a0a1cc fix(ci): deploy脚本直接使用完整registry镜像名,修复watchtower自动更新 (#611)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m14s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m20s
CI/CD Pipeline / Unit Tests (push) Successful in 4m23s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m52s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 37s
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 20:15:55 +08:00
xiaoxia f5581ac3da fix(ci): 增量测试选择脚本过滤已删除的测试文件 (#610)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m14s
CI/CD Pipeline / Unit Tests (push) Successful in 4m19s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m28s
CI/CD Pipeline / Integration Tests (push) Successful in 1m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m4s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m39s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 19:54:44 +08:00
xiaoxia 84232ac32d fix(ci): 全面修复 ci-pipeline.yml 的 YAML 格式 - 多行 run 字符串改为块标量 (#609)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m22s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m17s
CI/CD Pipeline / Unit Tests (push) Successful in 4m51s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m59s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m56s
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m14s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 8s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 19:45:45 +08:00
xiaoxia fb9f5cc1c3 feat(P1): #557 剪辑编辑器改为纯模板编辑器 (#607)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 22s
CI/CD Pipeline / Build Staging API Image (push) Failing after 26s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 3m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m56s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m2s
CI/CD Pipeline / Unit Tests (push) Successful in 4m7s
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 19:34:07 +08:00
xiaoxia 291ac95975 fix(ci): 修复docker login YAML格式 - 所有镜像构建失败 (#606)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 26s
CI/CD Pipeline / Build Staging API Image (push) Failing after 26s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m4s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m19s
CI/CD Pipeline / Unit Tests (push) Successful in 4m30s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m25s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
2026-07-19 19:22:53 +08:00
xiaoxia 0a8bfc9bcc ci: 架构精简 - 合并4个workflow为2个,抽取公共步骤为脚本 (#605)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 24s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 29s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m24s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m49s
CI/CD Pipeline / Unit Tests (push) Successful in 4m7s
CI/CD Pipeline / Integration Tests (push) Successful in 1m32s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 19:02:09 +08:00
xiaoxia ffc28c2565 feat(P1): #583 弹窗式预览界面 - 视频卡片网格+Modal预览 (#595)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m30s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m38s
CI/CD Pipeline / Unit Tests (push) Successful in 4m3s
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 12s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 13s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 15s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 20s
CI Build & Deploy Pipeline / ACR Image Cleanup (push) Successful in 27s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m35s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m34s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 18:22:45 +08:00
xiaoxia 74179c05ca fix(ci): ci-build.yml YAML语法错误 - acr-cleanup heredoc缩进修复 (#604)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m7s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m34s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m18s
CI/CD Pipeline / Unit Tests (push) Successful in 4m21s
CI/CD Pipeline / Integration Tests (push) Successful in 1m28s
CI Build & Deploy Pipeline / Build Staging API Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Staging Web Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Waiting to run
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI Build & Deploy Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI Build & Deploy Pipeline / Build Production API Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Production Web Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Production Worker Image (push) Waiting to run
CI Build & Deploy Pipeline / Deploy Production (push) Blocked by required conditions
CI Build & Deploy Pipeline / Production Browser E2E (push) Blocked by required conditions
CI Build & Deploy Pipeline / ACR Image Cleanup (push) Blocked by required conditions
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-19 18:12:14 +08:00
184 changed files with 22283 additions and 16016 deletions
+2 -1
View File
@@ -1 +1,2 @@
trigger: 1784009947
CI trigger file - safe to delete
updated!
-191
View File
@@ -1,191 +0,0 @@
name: Auto Approve CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review]
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 20
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="auto-approve" TRACE_NAME="自动批准 / Auto Approve on CI Green" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING reviewGitea API需要先创建再提交)
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑(pending状态)→ 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
# 其他情况继续等
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="auto-approve" TRACE_NAME="自动批准 / Auto Approve on CI Green" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
-191
View File
@@ -1,191 +0,0 @@
name: Auto Merge CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
jobs:
auto-merge:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 30
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="auto-merge" TRACE_NAME="自动合并 / Auto Merge on CI Green + Approved" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / Integration Tests (pull_request)"
)
echo "检查全部四门禁"
fi
echo
# 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判
echo "等待30秒让CI启动..."
sleep 30
# 405连续计数器:连续多次合并返回405才放弃
MERGE_405_COUNT=0
MAX_405_RETRIES=10
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 180); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# 检查审批状态
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
echo " 审批: $APPROVAL_RESULT"
HAS_APPROVAL=false
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
HAS_APPROVAL=true
fi
# 全部满足 → 合并
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
echo
echo "CI全绿 + 审批通过,执行自动合并"
echo "等待60秒冷却,给Gitea内部状态同步时间..."
sleep 60
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
sleep 30
continue
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "等待超时(30分钟)"
exit 0
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="auto-merge" TRACE_NAME="自动合并 / Auto Merge on CI Green + Approved" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
File diff suppressed because one or more lines are too long
-741
View File
@@ -1,741 +0,0 @@
name: CI/CD Pipeline
on:
push:
branches:
- main
- develop
tags:
- v*
pull_request:
branches:
- main
- develop
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - CI漏触发补跑"
permissions:
contents: read
concurrency:
group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
check-frontend-only:
name: Check if frontend-only change
runs-on: ci-check
if: github.event_name == 'pull_request'
outputs:
skip_backend: ${{ steps.check.outputs.skip_backend }}
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Check changed files
id: check
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
echo "skip_backend=true" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
echo "✅ 纯前端改动,跳过后端检查"
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=true" >> $GITHUB_OUTPUT
echo "🔧 纯后端改动,跳过前端检查"
else
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
echo "🔧 包含全栈变更,运行完整CI"
fi
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
validate:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
name: Validate Code Quality And Tests
runs-on: ci-check
timeout-minutes: 10
permissions:
contents: write
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Verify CI environment
shell: sh
run: 'set -eu
python3 --version
python3 -m pip --version
echo "CI environment is ready"
'
- name: Install dependencies
shell: sh
run: 'set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
# Force source install of black/isort to ensure consistent formatting
# across compiled/source installations on different machines
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1
python3 -m black --version
python3 -m isort --version-number
python3 -m ruff --version
bandit --version
pytest --version
'
- name: Secret detection (detect-secrets)
shell: sh
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n > /tmp/secrets-scan.json 2>&1\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
\ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n"
- name: Calculate changed Python files (incremental scan)
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_REF_NAME:-}\" ]; then\n echo \"PR mode (#${GITHUB_REF_NAME}) - fetching changed files from API\"\n\n PR_NUMBER=$(echo \"$GITHUB_REF\" | sed 's|refs/pull/||; s|/.*||')\n API_URL=\"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100\"\n\n set +e\n RESPONSE=$(curl -s -w \"\\n%{http_code}\" -H \"Authorization: token ${GITHUB_TOKEN}\" \"${API_URL}\")\n HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n BODY=$(echo \"$RESPONSE\" | sed '$d')\n set -e\n\n if [ \"$HTTP_CODE\" = \"200\" ]; then\n CHANGED_PY_FILES=$(echo \"$BODY\" | python3 -c \"\nimport json, sys\ntry:\n files = json.load(sys.stdin)\n py_files = [f['filename'] for f in files\n if f['filename'].endswith('.py') and f['status'] != 'removed']\n print(' '.join(py_files))\nexcept Exception:\n print('')\n\")\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: API returned HTTP $HTTP_CODE, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n"
- name: Run code quality checks
shell: sh
run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n"
- name: Auto-fix formatting (black + isort)
if: failure()
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 scripts/ci/auto_fix_formatting.py
- name: Type check (mypy, hard gate)
shell: sh
run: "bash scripts/ci/mypy_check.sh"
- name: Run security scan (bandit)
shell: sh
run: 'set -eu
bandit -r apps packages -q -ll
'
- name: Python dependency vulnerability scan (pip-audit)
shell: sh
run: "set -eu\necho \"=== Installing pip-audit ===\"\npython3 -m pip install -q pip-audit\npip-audit --version\necho \"\"\necho \"=== Scanning Python dependencies ===\"\nEXIT_CODE=0\nfor req_file in requirements.txt requirements-base.txt requirements-dev.txt; do\n if [ -f \"$req_file\" ]; then\n echo \"--- Scanning $req_file ---\"\n pip-audit -r \"$req_file\" --desc on 2>&1 | head -40 || EXIT_CODE=$?\n echo \"\"\n fi\ndone\necho \"pip-audit scan completed (advisory mode - warnings only, not blocking CI)\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"WARNING: Potential vulnerabilities found in dependencies.\"\nfi\nexit 0\n"
- name: Dead code detection (vulture)
if: always()
shell: sh
run: "set +e\necho \"=== Installing vulture ===\"\npython3 -m pip install -q vulture\nvulture --version\necho \"\"\necho \"=== Running vulture dead code scan (confidence >= 70%) ===\"\necho \"告警模式,不阻断CI。置信度>=90%建议尽快确认。\"\necho \"\"\n# 按置信度从高到低输出,便于优先查看高价值条目\nvulture apps packages scripts \\\n --exclude \"tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py\" \\\n --min-confidence 70 \\\n 2>&1 | sort -t'(' -k2 -rn | head -80\nEXIT_CODE=$?\necho \"\"\necho \"=== vulture scan summary ===\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"发现潜在死代码(可能包含框架装饰器注册的函数,为误报)\"\n echo \"建议:定期人工审查高置信度(>=90%)条目\"\nelse\n echo \"未发现明显死代码 ✅\"\nfi\nexit 0\n"
- name: Validate release scripts syntax
shell: sh
run: 'set -eu
bash -n scripts/backup_postgres.sh
bash -n scripts/restore_postgres_plan.sh
bash -n scripts/init_production_env.sh
'
- name: Validate Alembic migrations (with isolated PG)
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PG_CONTAINER=ci-pg-validate-${GITHUB_RUN_ID:-$$}
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
--shm-size=256m \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=xiaoxia_saas \
-P \
--health-cmd "pg_isready -U postgres" \
--health-interval 3s \
--health-timeout 3s \
--health-retries 20 \
postgres:16-alpine
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
echo "PostgreSQL port: $PG_PORT"
export DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
echo "PostgreSQL is ready on port $PG_PORT"
break
fi
echo "Waiting for PostgreSQL... ($i/30)"
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
test -s /tmp/alembic-upgrade.sql
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
python3 scripts/check_schema_metadata.py
# Initialize git for migration safety diff (CI checkout is tar.gz without .git)
git init > /dev/null 2>&1
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
git fetch origin develop:refs/remotes/origin/develop --depth=100 > /dev/null 2>&1
git add -A > /dev/null 2>&1
git -c user.email=ci@local -c user.name=CI commit -m "ci-tmp" > /dev/null 2>&1
python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
echo "PostgreSQL container cleaned up"
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: 'set +e
NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
unit-tests:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
name: Unit Tests
runs-on: ci-l2
timeout-minutes: 8
env:
USE_IN_MEMORY_DB: 'true'
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Install ffmpeg
shell: sh
run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n"
- name: Install dependencies
shell: sh
run: 'set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
pytest --version
'
- name: Select incremental test files
if: github.event_name == 'pull_request'
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set +e
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
CHANGED_FILES="$CHANGED_FILES" \
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
python3 scripts/ci/select_unit_tests.py
SELECT_EXIT=$?
if [ $SELECT_EXIT -eq 0 ]; then
echo "UNIT_TEST_MODE=incremental" >> $GITHUB_ENV
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
echo "SELECTED_TEST_FILES=$TEST_FILES" >> $GITHUB_ENV
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
else
echo "UNIT_TEST_MODE=full" >> $GITHUB_ENV
echo "SELECTED_TEST_FILES=tests/unit" >> $GITHUB_ENV
echo "全量模式"
fi
- name: Run unit tests with coverage
shell: sh
run: |
set -eu
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
echo "=== 增量测试模式 ==="
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest $SELECTED_TEST_FILES -q
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
# 增量模式下调低覆盖率门槛(跑的文件少覆盖率自然低,不做强校验)
python3 -m coverage report --fail-under=10 > /dev/null || true
else
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest tests/unit -q
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
python3 -m coverage report --fail-under=65 > /dev/null
fi
- name: Diff coverage check (增量行覆盖率)
if: github.event_name == 'pull_request' && env.HAS_APP_CHANGES == 'true'
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
# 获取base分支
BASE_BRANCH="${{ github.base_ref }}"
echo "Base branch: $BASE_BRANCH"
# 初始化git (CI tarball checkout没有.git目录)
# 先备份PR代码,再基于base分支建分支,确保HEAD与base有共同祖先
PR_CODE_DIR="/tmp/pr-code-$$"
mkdir -p "$PR_CODE_DIR"
# 排除隐藏文件(如.env)和后续生成的coverage文件,只备份源码
find . -maxdepth 1 -mindepth 1 ! -name 'coverage.xml' ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
rm -rf .git
git init > /dev/null 2>&1
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
git config user.email "ci@local"
git config user.name "CI"
# 拉取base分支用于对比
git fetch origin $BASE_BRANCH --depth=200
# 基于base分支创建当前分支,确保有共同祖先
git checkout -b ci-pr-branch "origin/$BASE_BRANCH" > /dev/null 2>&1
# 清除base分支的源码,用PR代码覆盖
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
cp -r "$PR_CODE_DIR"/. .
rm -rf "$PR_CODE_DIR"
# 提交当前代码
git add -A > /dev/null 2>&1
git commit -m "ci-tmp" > /dev/null 2>&1
# 根据模式设置门槛
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
# 增量测试模式覆盖不全,门槛设低一些
THRESHOLD=40
echo "增量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
else
THRESHOLD=60
echo "全量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
fi
# 运行diff-cover
set +e
python3 -m diff_cover.diff_cover_tool coverage.xml \
--compare-branch="origin/$BASE_BRANCH" \
--fail-under=$THRESHOLD \
--html-report diff_coverage.html \
2>&1
DIFF_EXIT=$?
set -e
if [ $DIFF_EXIT -ne 0 ]; then
echo ""
echo "❌ 增量覆盖率未达到门槛 (${THRESHOLD}%)"
echo " 请为改动的代码添加单元测试后再提交"
echo ""
echo "=== 覆盖率报告 ==="
python3 -m diff_cover.diff_cover_tool coverage.xml \
--compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30
exit 1
fi
echo "✅ 增量覆盖率达标"
- name: CI failure notification
if: failure()
shell: sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: 'set +e
FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py
'
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: 'set +e
NOTIFY_MODE=failure JOB_NAME="Unit Tests" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
integration-tests:
name: Integration Tests
runs-on: ci-l2
timeout-minutes: 30
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
needs:
- check-frontend-only
- validate
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Verify CI environment
shell: sh
run: 'set -eu
python3 --version
python3 -m pip --version
echo "CI environment is ready"
'
- name: Install dependencies
shell: sh
run: 'set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
pytest --version
'
- name: Install ffmpeg
shell: sh
run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n"
- name: Start Redis
shell: sh
run: "set -eu\nREDIS_CONTAINER=\"ci-redis-${GITHUB_RUN_ID:-$$}\"\necho \"REDIS_CONTAINER=$REDIS_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$REDIS_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$REDIS_CONTAINER\" \\\n -P \\\n --health-cmd \"redis-cli ping\" \\\n --health-interval 2s \\\n --health-timeout 2s \\\n --health-retries 10 \\\n redis:7-alpine\nREDIS_PORT=$(docker port \"$REDIS_CONTAINER\" 6379/tcp | cut -d: -f2)\necho \"Redis port: $REDIS_PORT\"\necho \"REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 15); do\n if docker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"Redis is ready on port $REDIS_PORT\"\n break\n fi\n echo \"Waiting for Redis... ($i/15)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" | grep -q healthy\n"
- name: Start PostgreSQL for integration tests
shell: sh
run: "set -eu\nPG_CONTAINER=\"ci-pg-${GITHUB_RUN_ID:-$$}\"\necho \"PG_CONTAINER=$PG_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$PG_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$PG_CONTAINER\" \\\n --shm-size=256m \\\n -e POSTGRES_USER=postgres \\\n -e POSTGRES_PASSWORD=postgres \\\n -e POSTGRES_DB=xiaoxia_saas \\\n -P \\\n --health-cmd \"pg_isready -U postgres\" \\\n --health-interval 5s \\\n --health-timeout 5s \\\n --health-retries 12 \\\n postgres:16\n# 获取随机映射的端口\nPG_PORT=$(docker port \"$PG_CONTAINER\" 5432/tcp | cut -d: -f2)\necho \"PostgreSQL port: $PG_PORT\"\necho \"DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 30); do\n if docker inspect --format='{{.State.Health.Status}}' \"$PG_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"PostgreSQL is ready on port $PG_PORT\"\n break\n fi\n echo \"Waiting for PostgreSQL... ($i/30)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}'\
\ \"$PG_CONTAINER\" | grep -q healthy\n"
- name: Apply migrations for integration tests
shell: sh
run: 'set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
'
- name: Run integration tests
shell: sh
run: "set -eu\npython3 -m pip install -q pytest-rerunfailures\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run --append \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m \"not performance\"\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证\n"
- name: Run API performance baseline tests
shell: sh
continue-on-error: true
run: "set +e\necho \"=== API 性能基线测试 ===\"\nPERF_OUTPUT=$(mktemp)\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m pytest tests/integration/test_api_performance.py \\\n -v --timeout=120 -p no:cacheprovider 2>&1 | tee \"$PERF_OUTPUT\"\nPERF_EXIT=$?\n\n# 提取性能统计\necho \"\"\necho \"=== 性能测试摘要 ===\"\ngrep \"PERF_STATS:\" \"$PERF_OUTPUT\" || echo \"PERF_STATS: 未找到统计数据\"\ngrep \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo \"PERF_RESULT: 未找到详细结果\"\n\n# 统计通过率\nTOTAL=$(grep -c \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo 0)\nPASSED=$(grep \"PERF_RESULT: PASS\" \"$PERF_OUTPUT\" | wc -l)\nFAILED=$(grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | wc -l)\n\necho \"\"\necho \"性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标\"\n\nif [ \"$FAILED\" -gt 0 ]; then\n echo \"\"\n echo \"⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:\"\n grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | while read line; do\n echo \" $line\"\n done\n echo \"\"\n echo \"性能测试失败不阻塞主流水线,但建议尽快优化。\"\nelse\n echo \"✅ 所有接口性能达标!\"\nfi\n\nrm -f \"$PERF_OUTPUT\"\
\n# 始终返回 0,不阻塞流水线\nexit 0\n"
- name: Cleanup PostgreSQL & Redis
if: always()
shell: sh
run: 'docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true
docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true
echo "PostgreSQL container cleaned up"
echo "Redis container cleaned up"
'
- name: Coverage summary
if: always()
shell: sh
env:
COVERAGE_THRESHOLD: '40'
run: 'set +e
echo "=== 覆盖率汇总 ==="
python3 scripts/ci_coverage_summary.py
'
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: 'set +e
NOTIFY_MODE=failure JOB_NAME="Integration Tests" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
frontend-lint:
name: Frontend Lint
runs-on: ci-check
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Install dependencies
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/eslint\" ] && [ -x \"node_modules/.bin/tsc\" ] && [ -x \"node_modules/.bin/prettier\" ] && [ -x \"node_modules/.bin/vitest\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n # Post-install integrity check: verify all critical tools exist\n if [ ! -x \"node_modules/.bin/eslint\" ] || [ ! -x \"node_modules/.bin/tsc\" ] || [ ! -x \"node_modules/.bin/prettier\" ] || [ ! -x \"node_modules/.bin/vitest\" ]; then\n echo \"Post-install check failed: critical binaries missing, cleaning and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n"
- name: Run ESLint
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install eslint src --ext .ts,.tsx --max-warnings 0'\n"
- name: Run TypeScript type check
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install tsc --noEmit'\n"
- name: Run Prettier check
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\"'\n"
- name: Run Vitest tests
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run src/test'\n"
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: 'set +e
NOTIFY_MODE=failure JOB_NAME="Frontend Lint" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
frontend-unit-test:
name: Frontend Unit Tests
runs-on: ci-check
timeout-minutes: 15
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_frontend != 'true'
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Install dependencies
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/vitest\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n # Post-install integrity check\n if [ ! -x \"node_modules/.bin/vitest\" ]; then\n echo \"Post-install check failed: vitest missing, cleaning and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n"
- name: Run Vitest with coverage
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run --coverage'\n"
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: 'set +e
NOTIFY_MODE=failure JOB_NAME="Frontend Unit Tests" python3 scripts/ci_notify.py
' - name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
+81
View File
@@ -0,0 +1,81 @@
name: CI Health Daily Report
on:
schedule:
- cron: '0 1 * * *' # UTC 01:00 = 北京时间 09:00
workflow_dispatch:
permissions:
contents: read
jobs:
ci-health-report:
name: CI健康度每日巡检
runs-on: saas
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run CI health check and report
shell: sh
env:
GITEA_TOKEN: ${{ github.token }}
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
echo "=== CI健康度每日巡检 ==="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
python3 scripts/ci/ci_health_report.py --limit 30
EXIT_CODE=$?
echo ""
echo "巡检完成 (exit code: $EXIT_CODE)"
# 永远成功,不影响CI状态(通知失败不应该标红)
exit 0
+1337
View File
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -21,15 +21,11 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="ci-trigger-monitor" TRACE_NAME="CI触发监控 / Monitor CI Trigger Reliability" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Check CI trigger status for all open PRs
env:
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
@@ -42,12 +38,15 @@ jobs:
python3 scripts/ci_trigger_monitor.py
# 监控脚本永远不fail,避免告警风暴
exit 0
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="ci-trigger-monitor" TRACE_NAME="CI触发监控 / Monitor CI Trigger Reliability" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+24 -15
View File
@@ -20,21 +20,26 @@ jobs:
if: ${{ !gitea.event.pull_request.draft }}
steps:
- name: Checkout
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="code-review" TRACE_NAME="AI代码审查 / AI Code Review" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Install dependencies
run: |
# 确保 python3-pip 可用(兼容不同基础镜像)
if ! python3 -m pip --version >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq python3-pip python3-venv >/dev/null 2>&1
fi
# 部分镜像 ensurepip 方式兜底
if ! python3 -m pip --version >/dev/null 2>&1; then
python3 -m ensurepip --upgrade 2>/dev/null || curl -sS https://bootstrap.pypa.io/get-pip.py | python3
fi
python3 -m pip install --upgrade pip
python3 -m pip install requests
@@ -59,12 +64,16 @@ jobs:
python3 scripts/ci_code_review.py
# 审查脚本异常不影响 CI 通过
continue-on-error: true
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="code-review" TRACE_NAME="AI代码审查 / AI Code Review" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+49 -74
View File
@@ -64,14 +64,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Production Smoke Test" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Production health check & smoke test
id: smoke
shell: sh
@@ -114,16 +106,19 @@ jobs:
echo "======================================"
exit $SMOKE_EXIT
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Production Smoke Test" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests:
name: Staging API Integration Tests
runs-on: saas
@@ -178,14 +173,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging API Integration Tests" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Run API smoke test on staging
id: smoke
shell: sh
@@ -268,16 +255,19 @@ jobs:
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging API Integration Tests" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e:
name: Staging Browser E2E
runs-on: saas
@@ -332,14 +322,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging Browser E2E" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Run Playwright E2E on staging
id: e2e
shell: sh
@@ -374,16 +356,19 @@ jobs:
echo "=========================================="
exit $EXIT_CODE
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging Browser E2E" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: saas
@@ -507,14 +492,6 @@ jobs:
echo "耗时: ${ELAPSED}s"
echo "======================================"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Performance Baseline Check" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Generate performance report
id: report
shell: sh
@@ -639,16 +616,19 @@ jobs:
exit 0
fi
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Performance Baseline Check" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: saas
@@ -724,20 +704,15 @@ jobs:
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
# 但其他失败的 job 已经让整体流水线标记为失败
fi
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Daily Check Report" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Daily Check Report" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+368
View File
@@ -0,0 +1,368 @@
name: PR Automation
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
permissions:
contents: read
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 20
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: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING review
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑 → 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
auto-merge:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 30
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: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / PR Build API Image (pull_request)"
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
"CI/CD Pipeline / PR Build Web Image (pull_request)"
)
echo "检查required门禁(与分支保护一致)"
fi
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 405连续计数器
MERGE_405_COUNT=0
MAX_405_RETRIES=10
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 180); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# 检查审批状态
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
echo " 审批: $APPROVAL_RESULT"
HAS_APPROVAL=false
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
HAS_APPROVAL=true
fi
# 全部满足 → 合并
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
echo
echo "CI全绿 + 审批通过,执行自动合并"
echo "等待60秒冷却,给Gitea内部状态同步时间..."
sleep 60
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
sleep 30
continue
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "等待超时(30分钟)"
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+9 -14
View File
@@ -61,14 +61,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Extract PR number
shell: sh
run: |
@@ -201,12 +193,15 @@ jobs:
> /dev/null
echo "Cleanup comment posted"
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+21 -16
View File
@@ -72,14 +72,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: |
@@ -165,8 +157,8 @@ jobs:
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
preview_user="${PREVIEW_SSH_USER:-root}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
@@ -192,6 +184,16 @@ jobs:
exit 1
fi
# SSH密钥完整性自检
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
echo "ERROR: SSH密钥损坏(private key contents do not match public"
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
head -2 "$key_path"
exit 1
fi
echo "SSH key integrity check passed"
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
@@ -280,12 +282,15 @@ jobs:
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
- name: Report trace end (always)
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+1
View File
@@ -53,3 +53,4 @@ frontend-v21-ui-prototype-final.html
!.vscode/
!.vscode/settings.json
.vscode/extensions.json
.coverage
+64
View File
@@ -0,0 +1,64 @@
"""Phase 2 - 模板发布版本化:version字段 + 发布历史表
Revision ID: 047
Revises: 046
Create Date: 2026-07-20
Changes:
1. edit_templates 加 version 字段(INT,默认1,每次发布+1)
2. 新建 edit_template_versions 表存发布历史快照,支持回滚
"""
import sqlalchemy as sa
from alembic import op
revision = "047_template_versioning"
down_revision = "046_task_title"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# 1. edit_templates 加 version 字段
op.add_column(
"edit_templates",
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
)
# 2. 新建 edit_template_versions 发布历史表
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS edit_template_versions (
id VARCHAR(36) PRIMARY KEY,
template_id VARCHAR(32) NOT NULL,
version INTEGER NOT NULL,
name VARCHAR(200) NOT NULL DEFAULT '',
editing_mode VARCHAR(30) NOT NULL DEFAULT 'one_take',
config JSONB NOT NULL DEFAULT '{}',
clip_configs JSONB NOT NULL DEFAULT '[]',
change_note VARCHAR(500) NOT NULL DEFAULT '',
published_by VARCHAR(36) NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_edit_template_versions_template_id " "ON edit_template_versions(template_id)"
)
)
conn.execute(
sa.text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_edit_template_versions_template_version "
"ON edit_template_versions(template_id, version)"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS edit_template_versions"))
op.drop_column("edit_templates", "version")
+37
View File
@@ -0,0 +1,37 @@
"""Phase 3 - 清理 EditPlan 表冗余字段
Revision ID: 048
Revises: 047
Create Date: 2026-07-21
Changes:
1. 删除 edit_plans.result_count 字段(剪辑计划独立功能遗留,模板草稿不用,
生成结果数由 generation_tasks.result_count 承载)
"""
import sqlalchemy as sa
from alembic import op
revision = "048_cleanup_result_count"
down_revision = "047_template_versioning"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 删除 result_count 字段(剪辑计划独立功能遗留字段)
op.drop_column("edit_plans", "result_count")
def downgrade() -> None:
# 回滚:恢复 result_count 字段,默认值 0
op.add_column(
"edit_plans",
sa.Column(
"result_count",
sa.Integer,
nullable=False,
server_default="0",
),
)
+97
View File
@@ -0,0 +1,97 @@
"""#558 - 微信登录:手机号绑定字段 + 验证码表
Revision ID: 049
Revises: 048
Create Date: 2026-07-21
Changes:
1. users 表新增 phone_verified / binding_completed_at 字段(phone 字段已在 029 中添加)
2. users 表 phone 字段添加唯一索引(幂等)
3. 新建 verification_codes 表(统一管理邮箱+手机验证码)
"""
import sqlalchemy as sa
from alembic import context, op
revision = "049_wechat_login_phone"
down_revision = "048_cleanup_result_count"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
"""检查列是否已存在。离线模式下返回 False。"""
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"),
{"table": table, "column": column},
)
return result.first() is not None
def _index_exists(index_name: str) -> bool:
"""检查索引是否已存在。离线模式下返回 False。"""
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
{"index_name": index_name},
)
return result.first() is not None
def upgrade() -> None:
# 1. users 表新增手机号验证状态字段(幂等)
if not _column_exists("users", "phone_verified"):
op.add_column(
"users",
sa.Column(
"phone_verified",
sa.Boolean,
nullable=False,
server_default=sa.text("false"),
),
)
if not _column_exists("users", "binding_completed_at"):
op.add_column(
"users",
sa.Column("binding_completed_at", sa.DateTime, nullable=True),
)
# 2. phone 字段唯一索引(幂等 - 029 加了字段但没加索引)
if not _index_exists("ix_users_phone"):
op.create_index("ix_users_phone", "users", ["phone"], unique=True)
# 3. verification_codes 表
op.create_table(
"verification_codes",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("recipient", sa.String(255), nullable=False, index=True),
sa.Column("code", sa.String(10), nullable=False),
sa.Column("code_type", sa.String(32), nullable=False, index=True),
sa.Column("expires_at", sa.DateTime, nullable=False),
sa.Column("used_at", sa.DateTime, nullable=True),
sa.Column("attempts", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime, nullable=False),
sa.Index(
"ix_verification_recipient_type",
"recipient",
"code_type",
"created_at",
),
)
def downgrade() -> None:
op.drop_table("verification_codes")
if _index_exists("ix_users_phone"):
op.drop_index("ix_users_phone", table_name="users")
if _column_exists("users", "binding_completed_at"):
op.drop_column("users", "binding_completed_at")
if _column_exists("users", "phone_verified"):
op.drop_column("users", "phone_verified")
+4 -4
View File
@@ -5,7 +5,6 @@ from app.api.routes.auth import router as auth_router
from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.edit_plans import router as edit_plans_router
from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
@@ -16,6 +15,7 @@ from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
from app.api.routes.templates import router as templates_router
from app.api.routes.templates_editor import router as templates_editor_router
from app.api.routes.titles import router as titles_router
from app.api.routes.tts import router as tts_router
from app.api.routes.upload import router as upload_router
@@ -120,9 +120,9 @@ api_router.include_router(
tags=["Template"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
tags=["EditPlan"],
templates_editor_router,
prefix="/templates/{template_id}/editor",
tags=["TemplateEditor"],
)
api_router.include_router(
tts_router,
+206
View File
@@ -81,6 +81,9 @@ class CurrentUserResponse(BaseModel):
username: str
display_name: str
email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
class PasswordResetRequestModel(BaseModel):
@@ -259,12 +262,16 @@ async def get_current_user_info(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> CurrentUserResponse:
user = authenticated_user.user
binding_complete = user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
return CurrentUserResponse(
user_id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
email_verified=user.email_verified,
phone=user.phone or "",
phone_verified=user.phone_verified,
binding_complete=binding_complete,
)
@@ -380,3 +387,202 @@ async def wechat_sync(
raise HTTPException(status_code=400, detail=error)
return WechatSyncResponse(**response.to_dict())
# ==================== 微信网页登录(OAuth ====================
class WechatAuthUrlResponse(BaseModel):
auth_url: str
state: str
class WechatCallbackRequest(BaseModel):
code: str
state: str = ""
class WechatLoginResponse(BaseModel):
access_token: str
refresh_token: str
user_id: str
display_name: str
avatar_url: str = ""
is_new_user: bool
binding_complete: bool
expires_in: int
@router.get("/wechat/url", response_model=WechatAuthUrlResponse)
async def get_wechat_auth_url() -> WechatAuthUrlResponse:
"""获取微信扫码登录授权链接"""
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
oauth_service = get_wechat_oauth_service()
auth_url, state = oauth_service.generate_auth_url()
return WechatAuthUrlResponse(auth_url=auth_url, state=state)
@router.post("/wechat/callback", response_model=WechatLoginResponse)
async def wechat_callback(
request: WechatCallbackRequest,
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatLoginResponse:
"""微信登录回调处理"""
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
# 1. 用 code 换微信用户信息
oauth_service = get_wechat_oauth_service()
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
if err:
raise HTTPException(status_code=400, detail=err)
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
use_case = WechatSyncUseCase(user_repository=user_repository)
sync_request = SyncRequest(
openid=wechat_user.openid,
unionid=wechat_user.unionid,
nickname=wechat_user.nickname,
avatar_url=wechat_user.avatar_url,
source="web",
)
response, err = use_case.execute(sync_request)
if err:
raise HTTPException(status_code=400, detail=err)
# 3. 判断绑定状态
user = user_repository.find_by_id(response.user_id)
binding_complete = False
if user:
binding_complete = (
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
return WechatLoginResponse(
access_token=response.access_token,
refresh_token=response.refresh_token,
user_id=response.user_id,
display_name=response.nickname,
avatar_url=response.avatar_url or wechat_user.avatar_url,
is_new_user=response.is_new_user,
binding_complete=binding_complete,
expires_in=response.expires_in,
)
# ==================== 验证码 & 绑定 ====================
class SendVerificationCodeRequest(BaseModel):
target: str # phone / email
value: str
purpose: str # bind / login / reset_password
class SendVerificationCodeResponse(BaseModel):
expires_in: int
resend_after: int
class BindContactRequest(BaseModel):
phone: str = ""
phone_code: str = ""
email: str = ""
email_code: str = ""
class BindContactResponse(BaseModel):
success: bool
user: dict
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
async def send_verification_code(
request: SendVerificationCodeRequest,
) -> SendVerificationCodeResponse:
"""发送验证码(手机或邮箱)"""
from app.dependencies import get_db_session
from packages.adapters.sms.sms_service import get_sms_service
from packages.adapters.smtp import get_email_service
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
SQLAlchemyVerificationCodeRepository,
)
from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest
from packages.application.auth.bind_contact_use_case import (
SendVerificationCodeUseCase,
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db_session())
repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=repo)
sms_service = get_sms_service()
email_service = get_email_service()
use_case = SendVerificationCodeUseCase(
verification_code_service=vc_service,
sms_service=sms_service,
email_service=email_service,
)
uc_request = UseCaseRequest(
target=request.target,
value=request.value,
purpose=request.purpose,
)
response, err = use_case.execute(uc_request)
if err:
raise HTTPException(status_code=400, detail=err)
return SendVerificationCodeResponse(
expires_in=response.expires_in,
resend_after=response.resend_after,
)
@router.post("/bind-contact", response_model=BindContactResponse)
async def bind_contact(
request: BindContactRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> BindContactResponse:
"""绑定手机号和/或邮箱(需登录态)"""
from app.dependencies import get_db_session
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
SQLAlchemyVerificationCodeRepository,
)
from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest
from packages.application.auth.bind_contact_use_case import (
BindContactUseCase,
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db_session())
vc_repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=vc_repo)
use_case = BindContactUseCase(
user_repository=user_repository,
verification_code_service=vc_service,
)
uc_request = UseCaseRequest(
user_id=current_user.user.id,
phone=request.phone,
phone_code=request.phone_code,
email=request.email,
email_code=request.email_code,
)
response, err = use_case.execute(uc_request)
if err:
raise HTTPException(status_code=400, detail=err)
return BindContactResponse(success=True, user=response.to_dict()["user"])
# ==================== 当前用户信息扩展 ====================
# 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充)
# 通过给 get_current_user_info 返回值补充字段实现
File diff suppressed because it is too large Load Diff
@@ -1,311 +0,0 @@
"""片段调整 API.
- PUT /clips/{clip_id}/speed 调速
- PUT /clips/{clip_id}/volume 音量调节
- PUT /clips/{clip_id}/trim 裁剪(trim in/out
- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim
- POST /{plan_id}/clips/batch-speed 批量调速
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class SpeedAdjustRequest(BaseModel):
"""调速请求"""
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
class VolumeAdjustRequest(BaseModel):
"""音量调节请求"""
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.01.0=原音量)")
class TrimAdjustRequest(BaseModel):
"""裁剪请求"""
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
class ClipAdjustmentsRequest(BaseModel):
"""统一调整请求"""
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
trim_start: Optional[float] = Field(default=None, ge=0.0)
trim_end: Optional[float] = Field(default=None, ge=0.0)
class BatchSpeedRequest(BaseModel):
"""批量调速请求"""
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
class ClipAdjustResponse(BaseModel):
"""片段调整响应"""
clip_id: str
speed: float
volume: float
trim_start: float
trim_end: float
duration: float
class BatchSpeedResponse(BaseModel):
"""批量调速响应"""
updated_count: int
plan_id: str
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_clip_config(clip) -> dict:
config = getattr(clip, "config", {}) or {}
if not isinstance(config, dict):
config = {}
return config
def _get_volume(clip) -> float:
config = _get_clip_config(clip)
return float(config.get("volume", 1.0))
def _get_trim(clip) -> tuple[float, float]:
config = _get_clip_config(clip)
trim_start = float(config.get("trim_start", 0.0))
trim_end = float(config.get("trim_end", 0.0))
return trim_start, trim_end
def _build_response(clip) -> ClipAdjustResponse:
trim_start, trim_end = _get_trim(clip)
return ClipAdjustResponse(
clip_id=clip.id,
speed=clip.playback_speed,
volume=_get_volume(clip),
trim_start=trim_start,
trim_end=trim_end,
duration=clip.duration,
)
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
"""验证裁剪时长不超过总时长"""
if trim_start + trim_end >= total_duration:
raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s")
# ── Routes ───────────────────────────────────────────────────────────────────
def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
svc = EditPlanService(db)
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = svc.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
return svc, plan, clip
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
def adjust_speed(
clip_id: str,
body: SpeedAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""调整片段播放速度"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
updated = svc.update_clip(clip_id, playback_speed=body.speed)
logger.info(
"调整片段速度: clip_id=%s speed=%.2f by user=%s",
clip_id,
body.speed,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
def adjust_volume(
clip_id: str,
body: VolumeAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""调整片段音量"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
# 更新 config.volume
config = dict(_get_clip_config(clip))
config["volume"] = body.volume
updated = svc.update_clip(clip_id, config=config)
logger.info(
"调整片段音量: clip_id=%s volume=%.2f by user=%s",
clip_id,
body.volume,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
def adjust_trim(
clip_id: str,
body: TrimAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""裁剪片段(trim in/out"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
# 验证裁剪时长
try:
_validate_trim(body.trim_start, body.trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 更新 config
config = dict(_get_clip_config(clip))
config["trim_start"] = body.trim_start
config["trim_end"] = body.trim_end
updated = svc.update_clip(clip_id, config=config)
logger.info(
"裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s",
clip_id,
body.trim_start,
body.trim_end,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
def adjust_all(
clip_id: str,
body: ClipAdjustmentsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""统一调整片段的 speed / volume / trim"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
update_kwargs = {}
config_updates = {}
if body.speed is not None:
update_kwargs["playback_speed"] = body.speed
if body.volume is not None:
config_updates["volume"] = body.volume
if body.trim_start is not None:
config_updates["trim_start"] = body.trim_start
if body.trim_end is not None:
config_updates["trim_end"] = body.trim_end
# 验证 trim
current_trim_start, current_trim_end = _get_trim(clip)
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
if body.trim_start is not None or body.trim_end is not None:
try:
_validate_trim(new_trim_start, new_trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
if config_updates:
config = dict(_get_clip_config(clip))
config.update(config_updates)
update_kwargs["config"] = config
if not update_kwargs:
return _build_response(clip)
updated = svc.update_clip(clip_id, **update_kwargs)
logger.info(
"统一调整片段: clip_id=%s speed=%s volume=%s by user=%s",
clip_id,
body.speed,
body.volume,
current_user.user.id,
)
return _build_response(updated)
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse)
def batch_adjust_speed(
plan_id: str,
body: BatchSpeedRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> BatchSpeedResponse:
"""批量调整计划内所有片段的播放速度"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id, limit=500, skip=0)
count = 0
for clip in clips:
svc.update_clip(clip.id, playback_speed=body.speed)
count += 1
logger.info(
"批量调速: plan_id=%s count=%d speed=%.2f by user=%s",
plan_id,
count,
body.speed,
current_user.user.id,
)
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
-199
View File
@@ -1,199 +0,0 @@
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/ai-recommend AI 推荐片段方案
- POST /{plan_id}/generate-cover AI 生成封面
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
AIRecommendClipItem,
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/{plan_id}/ai-recommend",
response_model=AIRecommendResponse,
)
def ai_recommend_clips(
plan_id: str,
body: AIRecommendRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> AIRecommendResponse:
"""AI 推荐片段方案
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
流程:
1. 验证计划存在且状态为 draft/editing
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
3. 清除计划现有片段,按推荐方案重新创建
4. 更新计划 configcover/title/subtitle/bgm)和 total_duration
5. 返回推荐方案详情
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
)
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id=plan_id,
template_id=plan.template_id,
asset_ids=body.asset_ids,
editing_mode=body.editing_mode,
target_duration=body.target_duration,
)
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
try:
svc.delete_all_clips(plan_id)
for clip_data in result["clips"]:
svc.create_clip(
plan_id=plan_id,
clip_type=clip_data["clip_type"],
order=clip_data["order"],
text_content=clip_data.get("text_content", ""),
duration=clip_data["duration"],
transition_effect=clip_data.get("transition_effect", "cut"),
asset_id=clip_data.get("asset_id", ""),
start_time=clip_data.get("start_time", 0.0),
config=clip_data.get("config", {}),
)
normalized_config = normalize_plan_config(result.get("config", {}))
svc.update_plan(
plan_id,
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception as _e:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
except Exception as rollback_err:
logger.error(
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
plan_id,
rollback_err,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
) from _e
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
plan_id,
len(result["clips"]),
result["total_duration"],
current_user.user.id,
)
return AIRecommendResponse(
plan_id=plan_id,
clips=[
AIRecommendClipItem(
clip_type=c["clip_type"],
order=c["order"],
text_content=c.get("text_content", ""),
duration=c["duration"],
transition_effect=c.get("transition_effect", "cut"),
asset_id=c.get("asset_id", ""),
start_time=c.get("start_time", 0.0),
config=c.get("config", {}),
)
for c in result["clips"]
],
config=normalized_config,
total_duration=result["total_duration"],
confidence=result["confidence"],
)
@router.post(
"/{plan_id}/generate-cover",
response_model=GenerateCoverResponse,
)
def generate_cover(
plan_id: str,
body: GenerateCoverRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateCoverResponse:
"""AI 生成封面
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
current_config = dict(plan.config)
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"AI 封面生成: plan_id=%s type=%s by user=%s",
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(
plan_id=plan_id,
cover=cover_data,
)
-415
View File
@@ -1,415 +0,0 @@
"""剪辑计划片段(Clip)CRUD 路由。"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.domain.edit_plan_clip import EditPlanClipStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
from pydantic import BaseModel, Field
class EditPlanClipResponse(BaseModel):
"""剪辑片段响应体"""
id: str
plan_id: str
clip_type: str
order: int
asset_id: str = ""
text_content: str = ""
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
status: str
config: dict[str, Any] = Field(default_factory=dict)
created_at: Optional[str] = None
updated_at: Optional[str] = None
class EditPlanClipListResponse(BaseModel):
"""剪辑片段列表响应体"""
items: List[EditPlanClipResponse]
total: int
class EditPlanClipCreateRequest(BaseModel):
"""创建剪辑片段请求体"""
clip_type: str = Field(
..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等"
)
order: int = Field(..., ge=0, description="排序序号")
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
class EditPlanClipUpdateRequest(BaseModel):
"""更新剪辑片段请求体"""
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
返回 plan 对象供后续使用,避免重复查询。
"""
from app.services.edit_plan_service import EditPlanService
from ._helpers import check_project_access
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _clip_to_response(clip) -> EditPlanClipResponse:
"""将领域对象转换为响应体"""
return EditPlanClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
config=clip.config or {},
created_at=clip.created_at.isoformat() if clip.created_at else None,
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
)
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanClipListResponse)
def list_clips(
plan_id: str,
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
skip: int = Query(0, ge=0, description="分页偏移"),
limit: int = Query(100, ge=1, le=500, description="每页数量"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipListResponse:
"""获取剪辑计划的片段列表"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
total = svc.count_clips(plan_id, status=status_enum)
return EditPlanClipListResponse(
items=[_clip_to_response(c) for c in clips],
total=total,
)
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED)
def create_clip(
plan_id: str,
body: EditPlanClipCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""创建剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clip = svc.create_clip(
plan_id=plan_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
return _clip_to_response(clip)
@router.get("/{clip_id}", response_model=EditPlanClipResponse)
def get_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""获取剪辑片段详情"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
return _clip_to_response(clip)
@router.put("/{clip_id}", response_model=EditPlanClipResponse)
def update_clip(
plan_id: str,
clip_id: str,
body: EditPlanClipUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""更新剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
try:
updated = svc.update_clip(
clip_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return _clip_to_response(updated)
@router.delete("/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
deleted = svc.delete_clip(clip_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return None
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
class SplitClipRequest(BaseModel):
"""分割片段请求体"""
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
class MergeClipsRequest(BaseModel):
"""合并片段请求体"""
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
@router.post(
"/{clip_id}/split",
response_model=dict[str, Any],
summary="分割片段",
status_code=status.HTTP_200_OK,
)
def split_clip(
plan_id: str,
clip_id: str,
body: SplitClipRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将一个片段从指定时间点分割为两个片段。
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
try:
result = svc.split_clip(clip_id, body.split_time)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
left = result["left_clip"]
right = result["right_clip"]
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return {
"left_clip": {
"id": left.id,
"plan_id": left.plan_id,
"clip_type": left.clip_type,
"order": left.order,
"duration": left.duration,
"start_time": left.start_time,
},
"right_clip": {
"id": right.id,
"plan_id": right.plan_id,
"clip_type": right.clip_type,
"order": right.order,
"duration": right.duration,
"start_time": right.start_time,
},
}
@router.post(
"/merge",
response_model=dict[str, Any],
summary="合并多个连续片段",
status_code=status.HTTP_200_OK,
)
def merge_clips(
plan_id: str,
body: MergeClipsRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将多个连续的同类型片段合并为一个片段。
合并要求:
- 至少 2 个片段
- 属于同一剪辑计划
- order 连续
- 类型相同
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 校验所有片段都属于该 plan
for cid in body.clip_ids:
clip = svc.get_clip(cid)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {cid}",
)
try:
merged = svc.merge_clips(body.clip_ids)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"合并片段: plan_id=%s clip_count=%d by user=%s",
plan_id,
len(body.clip_ids),
current_user.user.id,
)
return {
"id": merged.id,
"plan_id": merged.plan_id,
"clip_type": merged.clip_type,
"order": merged.order,
"duration": merged.duration,
"text_content": merged.text_content,
}
@@ -1,241 +0,0 @@
"""剪辑计划片段批量操作 API。"""
from __future__ import annotations
import logging
from typing import Any, List
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class ClipReorderItem(BaseModel):
"""重排序条目"""
clip_id: str
new_order: int = Field(..., ge=0, description="新的排序序号")
class ClipReorderRequest(BaseModel):
"""片段重排序请求"""
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
class ClipReorderResponse(BaseModel):
"""片段重排序响应"""
success: bool
updated_count: int
message: str = ""
class ClipBatchDeleteRequest(BaseModel):
"""批量删除片段请求"""
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
class ClipBatchDeleteResponse(BaseModel):
"""批量删除片段响应"""
success: bool
deleted_count: int
message: str = ""
class ClipsFromAssetsRequest(BaseModel):
"""从素材批量创建片段请求"""
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
clip_type: str = Field(default="main", description="片段类型,默认 main")
class ClipsFromAssetsResponse(BaseModel):
"""从素材批量创建片段响应"""
success: bool
created_count: int
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
from app.services.edit_plan_service import EditPlanService
from ._helpers import check_project_access
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.post("/reorder", response_model=ClipReorderResponse)
def reorder_clips(
plan_id: str,
body: ClipReorderRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipReorderResponse:
"""批量重排序片段
前端拖拽调整顺序后,一次性提交所有变更的 order。
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
clip_ids = [item.clip_id for item in body.items]
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
invalid_ids = [cid for cid in clip_ids if cid not in existing_ids]
if invalid_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}",
)
# 执行重排序
updated_count = 0
for item in body.items:
try:
svc.update_clip(item.clip_id, order=item.new_order)
updated_count += 1
except ValueError as e:
logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e)
logger.info(
"批量重排序片段: plan_id=%s count=%d by user=%s",
plan_id,
updated_count,
current_user.user.id,
)
return ClipReorderResponse(
success=True,
updated_count=updated_count,
message=f"成功更新 {updated_count} 个片段的顺序",
)
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse)
def batch_delete_clips(
plan_id: str,
body: ClipBatchDeleteRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipBatchDeleteResponse:
"""批量删除片段
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
valid_ids = [cid for cid in body.clip_ids if cid in existing_ids]
skipped = len(body.clip_ids) - len(valid_ids)
# 执行删除
deleted_count = 0
for clip_id in valid_ids:
if svc.delete_clip(clip_id):
deleted_count += 1
message = f"成功删除 {deleted_count} 个片段"
if skipped > 0:
message += f",跳过 {skipped} 个不存在的片段"
logger.info(
"批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s",
plan_id,
deleted_count,
skipped,
current_user.user.id,
)
return ClipBatchDeleteResponse(
success=True,
deleted_count=deleted_count,
message=message,
)
@router.post("/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets(
plan_id: str,
body: ClipsFromAssetsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(追加到时间线末尾)
一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。
自动触发编辑状态回退(completed/failed → editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clips = svc.create_clips_from_assets(
plan_id=plan_id,
asset_ids=body.asset_ids,
clip_type=body.clip_type,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
clip_ids = [c.id for c in clips]
logger.info(
"从素材批量创建片段: plan_id=%s count=%d by user=%s",
plan_id,
len(clips),
current_user.user.id,
)
return ClipsFromAssetsResponse(
success=True,
created_count=len(clips),
message=f"成功创建 {len(clips)} 个片段",
clip_ids=clip_ids,
)
-315
View File
@@ -1,315 +0,0 @@
"""封面管理 API.
- GET /{plan_id}/cover 获取封面配置
- PUT /{plan_id}/cover 更新封面配置
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
- POST /{plan_id}/cover/smart 智能选帧生成封面
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.services import EditPlanService
from app.services.cover_service import CoverService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class CoverConfigResponse(BaseModel):
"""封面配置响应"""
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
image_url: str = Field(default="", description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
class CoverUpdateRequest(BaseModel):
"""更新封面配置请求"""
type: Optional[str] = Field(default=None, description="封面类型")
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
class CoverExtractRequest(BaseModel):
"""从片段抽帧生成封面请求"""
clip_id: str = Field(..., description="片段 ID")
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
class CoverSmartRequest(BaseModel):
"""智能选帧请求"""
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
class CoverGenerateResponse(BaseModel):
"""封面生成响应"""
type: str = Field(..., description="封面类型")
image_url: str = Field(..., description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse)
def get_cover(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""获取封面配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
cover = CoverService.get_cover_config(plan.config or {})
return CoverConfigResponse(**cover)
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse)
def update_cover(
plan_id: str,
body: CoverUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""更新封面配置
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current_cover = CoverService.get_cover_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_cover = {**current_cover, **updates}
# 验证 type 值
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
if "type" in updates and updates["type"] not in valid_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
)
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["cover"] = new_cover
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
result = CoverService.get_cover_config(updated_plan.config or {})
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
return CoverConfigResponse(**result)
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse)
def extract_cover(
plan_id: str,
body: CoverExtractRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""从指定片段的指定时间点抽帧生成封面"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 获取片段对应的素材
clip = svc.get_clip(body.clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {body.clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材,无法抽帧",
)
# 抽帧生成封面
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.extract_cover_from_clip(
plan_id=plan_id,
asset_id=clip.asset_id,
frame_time=body.frame_time,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"封面抽帧失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
plan_id,
body.clip_id,
body.frame_time,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse)
def smart_cover(
plan_id: str,
body: CoverSmartRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""智能选帧生成封面
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 确定使用哪个片段
clip_id = body.clip_id
asset_id = ""
if clip_id:
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材",
)
asset_id = clip.asset_id
else:
# 找第一个有素材的视频片段
clips = svc.list_clips(plan_id, limit=50, skip=0)
for c in clips:
if c.asset_id and c.clip_type == "video":
asset_id = c.asset_id
clip_id = c.id
break
if not asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有找到可用的视频片段",
)
# 智能选帧
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.generate_smart_cover(
plan_id=plan_id,
asset_id=asset_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"智能封面生成失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
plan_id,
clip_id,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
@@ -1,274 +0,0 @@
"""导出设置 API.
- GET /{plan_id}/export 获取导出配置
- PUT /{plan_id}/export 更新导出配置
- GET /export-presets 导出预设列表
"""
from __future__ import annotations
import logging
import re
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, validator
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── 导出预设 ──────────────────────────────────────────────────────────────────
EXPORT_PRESETS = [
{
"id": "export_1080p_30",
"name": "1080P 高清",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 8000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "balanced",
"description": "竖屏高清,适合短视频平台",
"size_hint": "约 10MB/分钟",
},
{
"id": "export_1080p_60",
"name": "1080P 高帧率",
"resolution": "1080x1920",
"fps": 60,
"video_bitrate": 12000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "high",
"description": "60帧高帧率,流畅运动画面",
"size_hint": "约 18MB/分钟",
},
{
"id": "export_720p_30",
"name": "720P 流畅",
"resolution": "720x1280",
"fps": 30,
"video_bitrate": 4000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "fast",
"description": "快速导出,文件较小",
"size_hint": "约 5MB/分钟",
},
{
"id": "export_4k_30",
"name": "4K 超清",
"resolution": "2160x3840",
"fps": 30,
"video_bitrate": 20000,
"audio_bitrate": 192,
"format": "mp4",
"quality_preset": "best",
"description": "4K超清画质,专业品质",
"size_hint": "约 30MB/分钟",
},
{
"id": "export_1080p_30_mov",
"name": "1080P ProRes",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 15000,
"audio_bitrate": 256,
"format": "mov",
"quality_preset": "high",
"description": "MOV格式,适合后期剪辑",
"size_hint": "约 25MB/分钟",
},
]
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
VALID_FORMATS = {"mp4", "mov"}
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
# ── Schemas ──────────────────────────────────────────────────────────────────
class ExportConfigResponse(BaseModel):
"""导出配置响应"""
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
watermark_enabled: bool
watermark_text: str
class ExportUpdateRequest(BaseModel):
"""更新导出配置请求"""
resolution: Optional[str] = None
fps: Optional[int] = Field(default=None, ge=15, le=60)
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
format: Optional[str] = None
quality_preset: Optional[str] = None
watermark_enabled: Optional[bool] = None
watermark_text: Optional[str] = None
@validator("resolution")
def validate_resolution(cls, v):
if v is None:
return v
if not RESOLUTION_PATTERN.match(v):
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
w, h = v.split("x")
if int(w) < 100 or int(h) < 100:
raise ValueError("分辨率数值过小")
if int(w) > 4096 or int(h) > 4096:
raise ValueError("分辨率数值过大,最大 4096x4096")
return v
@validator("format")
def validate_format(cls, v):
if v is None:
return v
if v not in VALID_FORMATS:
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
return v
@validator("quality_preset")
def validate_quality_preset(cls, v):
if v is None:
return v
if v not in VALID_QUALITY_PRESETS:
raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}")
return v
class ExportPresetItem(BaseModel):
"""导出预设条目"""
id: str
name: str
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
description: str
size_hint: str
class ExportPresetListResponse(BaseModel):
"""导出预设列表响应"""
items: List[ExportPresetItem]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_export_config(plan_config: dict) -> dict:
e = plan_config.get("export", {})
if not isinstance(e, dict):
e = {}
return {
"resolution": e.get("resolution", "1080x1920"),
"fps": e.get("fps", 30),
"video_bitrate": e.get("video_bitrate", 8000),
"audio_bitrate": e.get("audio_bitrate", 128),
"format": e.get("format", "mp4"),
"quality_preset": e.get("quality_preset", "balanced"),
"watermark_enabled": e.get("watermark_enabled", False),
"watermark_text": e.get("watermark_text", ""),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/export-presets", response_model=ExportPresetListResponse)
def list_export_presets(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ExportPresetListResponse:
"""获取导出预设列表"""
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
return ExportPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/export", response_model=ExportConfigResponse)
def get_export_config(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""获取导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_export_config(plan.config or {})
return ExportConfigResponse(**config)
@router.put("/{plan_id}/export", response_model=ExportConfigResponse)
def update_export_config(
plan_id: str,
body: ExportUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""更新导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current = _get_export_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_export = {**current, **updates}
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["export"] = new_export
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
result = _get_export_config(updated_plan.config or {})
logger.info(
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
plan_id,
result["resolution"],
result["fps"],
current_user.user.id,
)
return ExportConfigResponse(**result)
@@ -1,197 +0,0 @@
"""滤镜调色 API.
- GET /filter-presets 滤镜预设列表
- GET /{plan_id}/filter 获取全局滤镜配置
- PUT /{plan_id}/filter 更新全局滤镜配置
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.filter_presets import (
FilterPreset,
get_filter_preset,
list_filter_presets,
)
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class FilterPresetResponse(BaseModel):
"""滤镜预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
class FilterConfigResponse(BaseModel):
"""滤镜配置响应"""
enabled: bool
preset_id: str
intensity: int
brightness: float
contrast: float
saturation: float
warmth: float
class FilterUpdateRequest(BaseModel):
"""更新滤镜配置请求"""
enabled: Optional[bool] = None
preset_id: Optional[str] = None
intensity: Optional[int] = Field(default=None, ge=0, le=100)
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
class FilterPresetListResponse(BaseModel):
"""滤镜预设列表响应"""
items: List[FilterPresetResponse]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
return FilterPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
)
def _get_filter_config(plan_config: dict) -> dict:
"""从 plan.config 中提取滤镜配置"""
f = plan_config.get("filter", {})
if not isinstance(f, dict):
f = {}
return {
"enabled": f.get("enabled", False),
"preset_id": f.get("preset_id", "filter_none"),
"intensity": f.get("intensity", 100),
"brightness": f.get("brightness", 0.0),
"contrast": f.get("contrast", 1.0),
"saturation": f.get("saturation", 1.0),
"warmth": f.get("warmth", 0.0),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/filter-presets", response_model=FilterPresetListResponse)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> FilterPresetListResponse:
"""获取滤镜预设列表"""
presets = list_filter_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return FilterPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse)
def get_filter(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""获取剪辑计划的全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_filter_config(plan.config or {})
return FilterConfigResponse(**config)
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse)
def update_filter(
plan_id: str,
body: FilterUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""更新全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证 preset_id
updates = body.model_dump(exclude_none=True)
if "preset_id" in updates:
preset = get_filter_preset(updates["preset_id"])
if preset is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的滤镜预设: {updates['preset_id']}",
)
# 合并更新
current = _get_filter_config(plan.config or {})
new_filter = {**current, **updates}
# 如果设为原图 preset,自动关闭
if new_filter["preset_id"] == "filter_none":
new_filter["enabled"] = False
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["filter"] = new_filter
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
result = _get_filter_config(updated_plan.config or {})
logger.info(
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
plan_id,
result["preset_id"],
result["intensity"],
current_user.user.id,
)
return FilterConfigResponse(**result)
@@ -1,412 +0,0 @@
"""剪辑计划生成相关 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/generate 触发剪辑渲染生成
- GET /{plan_id}/generation-status 查询生成进度
- GET /{plan_id}/generations 查询关联的生成记录
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
router = APIRouter()
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
def _auto_fallback_assign_assets(
svc: EditPlanService,
plan_id: str,
plan_check,
) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = []
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
) -> None:
"""自动兜底 4: 项目有视频素材库时,自动选取 ready 视频素材分配给无素材片段
注:原先需要 material_mode=="auto" 才触发,但全代码库没有任何地方设置为 auto,
导致这道兜底防线永远不生效。现改为:只要有 project_id 且存在无素材片段,
就自动从项目视频素材库选取素材兜底,确保一键生成等场景能正常出片。
"""
if not clips_without_asset:
return
if not plan_check.project_id:
return
import random
logger.info(
"自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
plan_id,
len(clips_without_asset),
)
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total")
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
def generate_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
前置条件:计划状态必须为 editing,且至少有一个片段。
流程:
1. 验证计划状态为 editing
2. 将 pending 片段标记为 ready
3. 创建 GenerationTask
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
svc = EditPlanService(db)
plan_check = svc.get_plan(plan_id)
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# 自动兜底流程
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
# 核心生成流程
try:
clip_count = svc.mark_clips_ready(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
# 从 plan.config 中读取 asset_ids 并传递给 GenerationTask
config_asset_ids = (plan.config or {}).get("asset_ids", [])
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id=plan.project_id or "",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
)
)
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception as _e:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
) from _e
@router.get(
"/{plan_id}/generation-status",
response_model=EditPlanGenerationStatusResponse,
)
def get_generation_status(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度"""
svc = EditPlanService(db)
try:
gen_status = svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
plan = gen_status["plan"]
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
# 从 plan.config 中取渲染结果 URL,转换为签名 URL
raw_video_url = (plan.config or {}).get("rendered_url", "")
video_url = ""
if raw_video_url:
try:
video_url = storage_service.get_download_url(raw_video_url, expires_seconds=86400)
except Exception as e:
logger.warning("生成视频签名URL失败,返回原始URL: plan_id=%s error=%s", plan_id, e)
video_url = raw_video_url
# 从 gen_status 中取进度、错误信息、任务状态
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
# 如果计划已完成但进度还是0,补100
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan_status_val,
generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
@router.get(
"/{plan_id}/generations",
response_model=EditPlanGenerationsResponse,
)
def list_plan_generations(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationsResponse:
"""查询剪辑计划关联的所有生成记录"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from app.schemas.generation_task import GenerationTaskResponse
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
@@ -1,257 +0,0 @@
"""剪辑计划时间线 & 模板生成 API 端点。
从 edit_plans.py 拆分,包含:
- GET /{plan_id}/timeline 时间线场景数据
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
"""
from __future__ import annotations
import logging
from typing import Any, List
from app.api.routes._helpers import auto_select_video_assets, check_project_access
from app.api.routes.edit_plans import (
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
_PlanClipItem,
_to_response,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Timeline Schemas ──────────────────────────────────────────────────────────
class TimelineSceneResponse(BaseModel):
"""时间线场景"""
scene: str = Field(..., description="场景描述")
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
duration: float = Field(..., ge=0, description="时长(秒)")
color: str = Field(..., description="展示颜色")
clip_id: str = Field(default="", description="关联的片段 ID")
clip_type: str = Field(default="", description="片段类型")
class TimelineResponse(BaseModel):
"""时间线响应"""
plan_id: str
total_duration: float
scenes: List[TimelineSceneResponse]
# clip_type → 颜色映射
_CLIP_TYPE_COLORS = {
"intro": "#6366f1",
"title": "#6366f1",
"product": "#818cf8",
"showcase": "#10b981",
"scene": "#10b981",
"subtitle": "#f59e0b",
"text": "#f59e0b",
"cta": "#ef4444",
"outro": "#ef4444",
"voiceover": "#8b5cf6",
"transition": "#64748b",
}
_DEFAULT_COLOR = "#6366f1"
def _format_time(seconds: float) -> str:
"""将秒数格式化为 M:SS"""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m}:{s:02d}"
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
"""根据 clip_type 和 text_content 生成场景描述"""
type_labels = {
"intro": "开场",
"title": "标题",
"product": "产品展示",
"showcase": "场景展示",
"scene": "场景",
"subtitle": "字幕",
"text": "文字",
"cta": "结尾 CTA",
"outro": "结尾",
"voiceover": "配音",
"transition": "转场",
}
label = type_labels.get(clip_type, clip_type or "片段")
if text_content:
short = text_content[:20].strip()
if short:
return f"{label} - {short}"
return label
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get(
"/{plan_id}/timeline",
response_model=TimelineResponse,
)
def get_plan_timeline(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> TimelineResponse:
"""获取剪辑计划的时间线场景数据"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
clips.sort(key=lambda c: c.order)
scenes: List[TimelineSceneResponse] = []
current_time = 0.0
for clip in clips:
start = current_time
end = start + clip.duration
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
scenes.append(
TimelineSceneResponse(
scene=scene_label,
time=f"{_format_time(start)} - {_format_time(end)}",
duration=clip.duration,
color=color,
clip_id=clip.id,
clip_type=clip.clip_type,
)
)
current_time = end
total_duration = sum(s.duration for s in scenes) or plan.total_duration
return TimelineResponse(
plan_id=plan_id,
total_duration=total_duration,
scenes=scenes,
)
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划"""
from app.services import EditTemplateService
if body.project_id:
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材
resolved_asset_ids = list(body.asset_ids)
if not resolved_asset_ids and body.project_id:
auto_assets = auto_select_video_assets(
project_id=body.project_id,
asset_library_repo=asset_library_repository,
asset_repo=asset_repository,
logger=logger,
)
if auto_assets:
resolved_asset_ids = auto_assets
logger.info(
"generate-from-template 自动选素材: project_id=%s count=%d",
body.project_id,
len(auto_assets),
)
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=resolved_asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
# 把 asset_ids 写入 plan.config,供生成时兜底分配使用
if resolved_asset_ids:
from app.services import EditPlanService
from packages.domain.config_schemas import normalize_plan_config
svc = EditPlanService(db)
current_config = plan.config or {}
if current_config.get("asset_ids") != resolved_asset_ids:
current_config["asset_ids"] = resolved_asset_ids
plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config))
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%d",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
transition_duration=c.transition_duration,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
@@ -1,272 +0,0 @@
"""转场特效 API.
- GET /transition-presets 转场预设列表
- PUT /clips/{clip_id}/transition 设置单个片段转场
- POST /{plan_id}/transitions/batch 批量设置转场(所有片段)
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.transition_presets import (
TransitionPreset,
get_transition_preset,
list_transition_presets,
)
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class TransitionPresetResponse(BaseModel):
"""转场预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
default_duration: float
min_duration: float
max_duration: float
class TransitionUpdateRequest(BaseModel):
"""更新转场请求"""
effect: str = Field(..., description="转场效果 ID")
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
class BatchTransitionRequest(BaseModel):
"""批量设置转场请求"""
effect: str = Field(..., description="转场效果 ID")
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
apply_to: str = Field(
default="all",
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
)
class ClipTransitionResponse(BaseModel):
"""片段转场信息响应"""
clip_id: str
effect: str
duration: float
class BatchTransitionResponse(BaseModel):
"""批量转场响应"""
updated_count: int
plan_id: str
class TransitionPresetListResponse(BaseModel):
"""转场预设列表响应"""
items: List[TransitionPresetResponse]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: TransitionPreset) -> TransitionPresetResponse:
return TransitionPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
default_duration=p.default_duration,
min_duration=p.min_duration,
max_duration=p.max_duration,
)
def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple[str, float]:
"""验证转场效果和时长,返回 (effect, duration)"""
preset = get_transition_preset(effect)
if preset is None:
raise ValueError(f"无效的转场效果: {effect}")
# 硬切特殊处理,时长强制为0
if effect == "transition_none" or preset.transition == "none":
return "cut", 0.0
final_duration = duration if duration is not None else preset.default_duration
if final_duration < preset.min_duration:
final_duration = preset.min_duration
if final_duration > preset.max_duration:
final_duration = preset.max_duration
return preset.transition, round(final_duration, 3)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/transition-presets", response_model=TransitionPresetListResponse)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> TransitionPresetListResponse:
"""获取转场预设列表"""
presets = list_transition_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return TransitionPresetListResponse(items=items, total=len(items))
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse)
def update_clip_transition(
clip_id: str,
body: TransitionUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipTransitionResponse:
"""设置单个片段的转场效果"""
svc = EditPlanService(db)
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = svc.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证转场参数
try:
effect, duration = _validate_transition(body.effect, body.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 更新片段
updated_clip = svc.update_clip(
clip_id,
transition_effect=effect,
transition_duration=duration,
)
logger.info(
"更新片段转场: clip_id=%s effect=%s duration=%.3f by user=%s",
clip_id,
effect,
duration,
current_user.user.id,
)
return ClipTransitionResponse(
clip_id=clip_id,
effect=updated_clip.transition_effect,
duration=updated_clip.transition_duration,
)
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse)
def batch_update_transitions(
plan_id: str,
body: BatchTransitionRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> BatchTransitionResponse:
"""批量设置计划内所有片段的转场效果
apply_to 说明:
- all: 所有片段
- except_first: 除第一个片段外(第一个片段不需要前转场)
- except_last: 除最后一个片段外
- middle: 只设置中间片段(除首尾)
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证转场参数
try:
effect, duration = _validate_transition(body.effect, body.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 获取所有片段
clips = svc.list_clips(plan_id, limit=500, skip=0)
if not clips:
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
# 确定应用范围
total = len(clips)
if total <= 1:
# 只有一个片段时,只有 all 模式才应用
if body.apply_to != "all":
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
# 按 order 排序
clips_sorted = sorted(clips, key=lambda c: c.order)
indices_to_update = []
if body.apply_to == "all":
indices_to_update = list(range(total))
elif body.apply_to == "except_first":
indices_to_update = list(range(1, total))
elif body.apply_to == "except_last":
indices_to_update = list(range(total - 1))
elif body.apply_to == "middle":
if total <= 2:
indices_to_update = []
else:
indices_to_update = list(range(1, total - 1))
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的 apply_to: {body.apply_to}",
)
# 批量更新
count = 0
for idx in indices_to_update:
clip = clips_sorted[idx]
svc.update_clip(
clip.id,
transition_effect=effect,
transition_duration=duration,
)
count += 1
logger.info(
"批量更新转场: plan_id=%s count=%d effect=%s apply_to=%s by user=%s",
plan_id,
count,
effect,
body.apply_to,
current_user.user.id,
)
return BatchTransitionResponse(updated_count=count, plan_id=plan_id)
+1 -3
View File
@@ -32,9 +32,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
ALLOWED_FLAGS = {
"render_engine",
}
ALLOWED_FLAGS: set[str] = set()
def _get_feature_flag_store() -> RedisFeatureFlagStore:
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -113,7 +113,7 @@ def update_video_review_status(
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
logger.info(
"Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id
"Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user.id
)
return _to_video_response(item, storage)
@@ -197,7 +197,7 @@ def batch_download_videos(
# 发送 celery 任务
task = celery_app.send_task(
"worker.batch_download_videos",
args=[request.video_ids, current_user.user_id],
args=[request.video_ids, current_user.user.id],
)
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
+2 -464
View File
@@ -39,70 +39,6 @@ class EditPlanService:
# ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
def list_plans(
self,
*,
template_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditPlan]:
"""列出剪辑计划
Args:
template_id: 按模板 ID 筛选
project_id: 按项目 ID 筛选
status: 按状态筛选
skip: 分页偏移
limit: 每页数量
"""
if project_id:
return self._plan_repo.list_by_project(
project_id,
status=status,
skip=skip,
limit=limit,
)
if template_id:
return self._plan_repo.list_by_template(
template_id,
status=status,
skip=skip,
limit=limit,
)
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
def count_plans(
self,
*,
template_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
) -> int:
"""统计计划数量
Note:
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
"""
if project_id:
all_matching = self._plan_repo.list_by_project(
project_id,
status=status,
skip=0,
limit=10000,
)
return len(all_matching)
if template_id:
all_matching = self._plan_repo.list_by_template(
template_id,
status=status,
skip=0,
limit=10000,
)
return len(all_matching)
return self._plan_repo.count(status=status)
def get_plan(self, plan_id: str) -> Optional[EditPlan]:
"""获取计划详情"""
return self._plan_repo.get(plan_id)
@@ -124,7 +60,7 @@ class EditPlanService:
project_id: str = "",
created_by_user_id: str = "",
) -> EditPlan:
"""创建剪辑计划
"""创建剪辑计划(基础 CRUD,供内部测试与脚本使用)
Raises:
ValueError: 参数校验失败
@@ -190,23 +126,6 @@ class EditPlanService:
logger.info("更新剪辑计划: id=%s", plan_id)
return result
def delete_plan(self, plan_id: str) -> bool:
"""删除剪辑计划及其所有片段
Returns:
bool: 是否删除成功
"""
existing = self._plan_repo.get(plan_id)
if existing is None:
return False
# 先删除所有片段
self._clip_repo.delete_by_plan(plan_id)
# 再删除计划
self._plan_repo.delete(plan_id)
logger.info("删除剪辑计划: id=%s", plan_id)
return True
# ── 状态机流转 ──────────────────────────────────────────────────────────
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
@@ -447,63 +366,6 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count
def create_clips_from_assets(
self,
plan_id: str,
asset_ids: list[str],
*,
clip_type: str = "main",
) -> list[EditPlanClip]:
"""从素材批量创建片段(追加到时间线末尾)。
Args:
plan_id: 计划 ID
asset_ids: 素材 ID 列表(按顺序追加)
clip_type: 片段类型
Returns:
list[EditPlanClip]: 创建的片段列表
"""
if not asset_ids:
return []
# 确保计划存在 + 自动回退状态
self.get_plan_or_raise(plan_id)
self._auto_resume_editing(plan_id)
# 查询素材信息(取 duration)
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = self._clip_repo.session # type: ignore[attr-defined]
assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
asset_map = {a.id: a for a in assets}
# 从现有片段数量开始追加
existing_count = self._clip_repo.count(plan_id=plan_id)
# 批量创建片段
created: list[EditPlanClip] = []
for i, asset_id in enumerate(asset_ids):
asset = asset_map.get(asset_id)
duration = asset.duration if asset and asset.duration else 0.0
clip = self.create_clip(
plan_id=plan_id,
clip_type=clip_type,
order=existing_count + i,
asset_id=asset_id,
duration=duration,
)
created.append(clip)
logger.info(
"从素材批量创建片段: plan_id=%s count=%d",
plan_id,
len(created),
)
return created
# ── 渲染生成流程 ────────────────────────────────────────────────────────
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
@@ -674,249 +536,7 @@ class EditPlanService:
return merged_clip
# ── 字幕管理 ──────────────────────────────────────────────────────────
def list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]:
"""获取片段的所有字幕
Returns:
List[dict]: 字幕列表,按 start 时间排序
"""
clip = self.get_clip_or_raise(clip_id)
config = clip.config or {}
subtitles = config.get("subtitles", [])
# 按开始时间排序
subtitles.sort(key=lambda s: s.get("start", 0))
return subtitles
def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]:
"""获取单条字幕"""
subtitles = self.list_subtitles(clip_id)
for s in subtitles:
if s.get("id") == subtitle_id:
return s
return None
def add_subtitle(
self,
clip_id: str,
start: float,
end: float,
text: str,
*,
style: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""添加一条字幕
Args:
clip_id: 片段 ID
start: 开始时间(秒,相对于片段)
end: 结束时间(秒)
text: 字幕文本
style: 样式配置(字体、大小、颜色、位置等)
Returns:
dict: 新增的字幕条目
Raises:
ValueError: 时间非法或文本为空
"""
clip = self.get_clip_or_raise(clip_id)
if start < 0 or end <= start:
raise ValueError(f"字幕时间非法: start={start}, end={end}")
if not text.strip():
raise ValueError("字幕文本不能为空")
if end > clip.duration + 0.001:
raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}")
self._auto_resume_editing(clip.plan_id)
from uuid import uuid4
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
subtitle = {
"id": uuid4().hex,
"start": round(start, 3),
"end": round(end, 3),
"text": text.strip(),
"style": style or {},
}
subtitles.append(subtitle)
subtitles.sort(key=lambda s: s.get("start", 0))
config["subtitles"] = subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info(
"添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs",
clip_id,
subtitle["id"],
start,
end,
)
return subtitle
def update_subtitle(
self,
clip_id: str,
subtitle_id: str,
*,
start: Optional[float] = None,
end: Optional[float] = None,
text: Optional[str] = None,
style: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""更新一条字幕
Returns:
dict: 更新后的字幕条目
Raises:
ValueError: 字幕不存在或参数非法
"""
clip = self.get_clip_or_raise(clip_id)
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
found = False
for i, s in enumerate(subtitles):
if s.get("id") == subtitle_id:
# 更新字段
updated_s = dict(s)
if start is not None:
updated_s["start"] = round(start, 3)
if end is not None:
updated_s["end"] = round(end, 3)
if text is not None:
if not text.strip():
raise ValueError("字幕文本不能为空")
updated_s["text"] = text.strip()
if style is not None:
updated_s["style"] = style
# 校验时间
if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]:
raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}")
if updated_s["end"] > clip.duration + 0.001:
raise ValueError("字幕结束时间不能超过片段时长")
subtitles[i] = updated_s
found = True
break
if not found:
raise ValueError(f"字幕不存在: {subtitle_id}")
self._auto_resume_editing(clip.plan_id)
subtitles.sort(key=lambda s: s.get("start", 0))
config["subtitles"] = subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)]
def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool:
"""删除一条字幕
Returns:
bool: 是否删除成功
"""
clip = self.get_clip_or_raise(clip_id)
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
if len(new_subtitles) == len(subtitles):
return False
self._auto_resume_editing(clip.plan_id)
config["subtitles"] = new_subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
return True
def batch_update_subtitles(
self,
clip_id: str,
subtitles: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""批量更新字幕(全量替换,用于批量编辑或导入)
Args:
clip_id: 片段 ID
subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留
Returns:
List[dict]: 更新后的字幕列表
"""
clip = self.get_clip_or_raise(clip_id)
from uuid import uuid4
validated = []
for s in subtitles:
start = float(s.get("start", 0))
end = float(s.get("end", 0))
text = str(s.get("text", ""))
if start < 0 or end <= start:
raise ValueError(f"字幕时间非法: start={start}, end={end}")
if not text.strip():
continue # 跳过空字幕
if end > clip.duration + 0.001:
raise ValueError(f"字幕结束时间不能超过片段时长: end={end}")
subtitle_id = s.get("id") or uuid4().hex
validated.append(
{
"id": subtitle_id,
"start": round(start, 3),
"end": round(end, 3),
"text": text.strip(),
"style": s.get("style", {}),
}
)
validated.sort(key=lambda s: s["start"])
self._auto_resume_editing(clip.plan_id)
config = dict(clip.config) if clip.config else {}
config["subtitles"] = validated
clip.config = config
self._clip_repo.update(clip)
logger.info(
"批量更新字幕: clip_id=%s count=%d",
clip_id,
len(validated),
)
return validated
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
"""获取计划及其所有片段
Returns:
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
"""
plan = self.get_plan_or_raise(plan_id)
clips = self._clip_repo.list_by_plan(plan_id)
return {
"plan": plan,
"clips": clips,
}
# ── 渲染生成流程 ────────────────────────────────────────────────────────
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
"""获取渲染进度状态
@@ -1026,85 +646,3 @@ class EditPlanService:
updated_at=plan.updated_at,
)
return self._plan_repo.update(updated)
# ── 复制计划 ────────────────────────────────────────────────────────────
def copy_plan(
self,
plan_id: str,
*,
new_name: Optional[str] = None,
project_id: Optional[str] = None,
) -> EditPlan:
"""复制一个剪辑计划(含所有片段配置)。
新计划状态为 editing,不含生成任务和结果记录。
Args:
plan_id: 源计划 ID
new_name: 新计划名称,不传则为「原名 - 副本」
project_id: 新计划的项目 ID,不传则复用源计划
Returns:
EditPlan: 新创建的计划
Raises:
ValueError: 源计划不存在
"""
source = self.get_plan_or_raise(plan_id)
source_clips = self._clip_repo.list_by_plan(plan_id)
# 新计划名称
name = new_name or f"{source.name} - 副本"
new_project_id = project_id if project_id is not None else source.project_id
# 复制 plan 配置(去除渲染结果相关字段)
new_config = dict(source.config)
new_config.pop("rendered_url", None)
new_config.pop("rendered_storage_key", None)
new_config.pop("generation_task_id", None)
# 创建新计划
new_plan = EditPlan.create(
template_id=source.template_id,
name=name,
config=new_config,
total_duration=source.total_duration,
project_id=new_project_id,
created_by_user_id=source.created_by_user_id,
source_edit_plan_id=plan_id,
)
# 强制切到 editing 状态
if new_plan.status != EditPlanStatus.EDITING:
try:
new_plan.start_editing()
except ValueError:
pass
created_plan = self._plan_repo.create(new_plan)
logger.info(
"复制剪辑计划: source=%s target=%s name=%s clips=%d",
plan_id,
created_plan.id,
name,
len(source_clips),
)
# 复制所有片段
for clip in source_clips:
new_clip = self.create_clip(
plan_id=created_plan.id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
config=dict(clip.config) if clip.config else None,
)
logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order)
return self.get_plan_or_raise(created_plan.id)
@@ -41,6 +41,11 @@ class EditTemplateService:
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
from packages.adapters.sqlalchemy_impl.template_version_repository import (
SQLAlchemyTemplateVersionRepository,
)
self._version_repo = SQLAlchemyTemplateVersionRepository(db)
self._db = db
# ── 模板 CRUD ──────────────────────────────────────────────────────────
@@ -533,3 +538,422 @@ class EditTemplateService:
"template": created_template,
"clip_configs": created_configs,
}
# ── 模板草稿(编辑器)相关 ──────────────────────────────────────────────────
def get_template_draft(self, template_id: str) -> Optional[Any]:
"""获取模板的草稿剪辑计划
通过 template_id + config.is_template_draft=True 标记查找。
每个模板有且仅有一个草稿计划。
Args:
template_id: 模板 ID
Returns:
EditPlan | None: 草稿剪辑计划,不存在则返回 None
"""
from packages.domain.edit_plan import EditPlan # noqa: F401
plans = self._plan_repo.list_by_template(template_id, limit=50)
for plan in plans:
config = plan.config or {}
if config.get("is_template_draft") is True:
return plan
return None
def create_template_draft(
self,
template_id: str,
user_id: str,
*,
project_id: str = "",
) -> Any:
"""基于模板创建草稿剪辑计划
草稿与普通剪辑计划的区别:
- config.is_template_draft = True
- 不绑定具体素材(空素材列表)
- 用于模板编辑器的编辑上下文
Args:
template_id: 模板 ID
user_id: 创建者用户 ID
project_id: 所属项目 ID(可选)
Returns:
EditPlan: 创建的草稿剪辑计划
Raises:
ValueError: 模板不存在,或草稿已存在
"""
from .plan_generator_service import PlanGeneratorService
# 检查模板是否存在
template = self.get_template_or_raise(template_id)
# 检查草稿是否已存在
existing = self.get_template_draft(template_id)
if existing is not None:
raise ValueError(f"模板草稿已存在: {template_id}")
# 读取模板片段配置
clip_configs = self.list_clip_configs(template_id)
# 基于模板生成计划(空素材)
generator = PlanGeneratorService(self._db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=[],
project_id=project_id,
created_by_user_id=user_id,
name=f"{template.name} - 草稿",
)
plan = result["plan"]
# 标记为模板草稿
plan_config = plan.config or {}
plan_config["is_template_draft"] = True
plan.config = plan_config
plan = self._plan_repo.update(plan)
logger.info(
"创建模板草稿: template_id=%s draft_plan_id=%s user_id=%s",
template_id,
plan.id,
user_id,
)
return plan
def get_or_create_draft(
self,
template_id: str,
user_id: str,
*,
project_id: str = "",
) -> Any:
"""获取或创建模板草稿
首次访问模板编辑器时自动创建草稿。
Args:
template_id: 模板 ID
user_id: 操作用户 ID
project_id: 所属项目 ID(可选)
Returns:
EditPlan: 草稿剪辑计划
"""
draft = self.get_template_draft(template_id)
if draft is not None:
return draft
return self.create_template_draft(template_id, user_id, project_id=project_id)
def publish_template_from_draft(
self,
template_id: str,
draft_plan_id: str,
*,
change_note: str = "",
published_by: str = "",
) -> Any:
"""将草稿剪辑计划的内容发布(同步)到模板
将草稿的配置和片段结构同步到模板,相当于"保存"编辑结果。
使用事务保证一致性,失败则回滚。
同步规则:
- 草稿 plan.config → template.config(过滤掉草稿特有字段)
- 草稿 clips → template_clip_configs(先删后插)
- 草稿 editing_mode → template.editing_mode
- 不更新模板名称、描述等元信息(由专门的接口处理)
Args:
template_id: 模板 ID
draft_plan_id: 草稿剪辑计划 ID
Returns:
EditTemplate: 更新后的模板
Raises:
ValueError: 模板/草稿不存在,或草稿不属于该模板
"""
from packages.domain.template_clip_config import TemplateClipConfig
# 1. 校验模板和草稿
template = self.get_template_or_raise(template_id)
draft = self._plan_repo.get(draft_plan_id)
if draft is None:
raise ValueError(f"草稿计划不存在: {draft_plan_id}")
if draft.template_id != template_id:
raise ValueError(f"草稿不属于该模板: plan_template_id={draft.template_id}")
config = draft.config or {}
if config.get("is_template_draft") is not True:
raise ValueError("指定的计划不是模板草稿")
# 2. 读取草稿片段
draft_clips = self._plan_clip_repo.list_by_plan(draft_plan_id)
draft_clips.sort(key=lambda c: c.order)
# 3. 提取 editing_mode
editing_mode = config.get("editing_mode", "one_take")
# 4. 提取模板配置(去掉草稿/运行时字段)
draft_config = draft.config or {}
template_config: dict[str, Any] = {}
skip_keys = {
"is_template_draft",
"asset_ids",
"source_edit_plan_id",
"generation_task_id",
}
for key, value in draft_config.items():
if key not in skip_keys:
template_config[key] = value
# 5. 事务更新
try:
# 5.0 先保存旧版快照(发布前的状态),用于回滚
old_version = template.version or 1
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion
old_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=old_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{old_version} 快照(发布前)",
published_by=published_by,
)
self._version_repo.create(old_snapshot)
# 更新模板元信息
template.config = template_config
template.editing_mode = editing_mode
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 批量删除旧的片段配置(外层事务统一提交)
self._clip_config_repo.delete_by_template(template_id, commit=False)
# 创建新的片段配置
created_configs: list[TemplateClipConfig] = []
for clip in draft_clips:
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import (
TransitionEffect,
)
transition = TransitionEffect(clip.transition_effect)
except (ValueError, ImportError):
transition = TransitionEffect.CUT # type: ignore
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip.clip_type)
except (ValueError, ImportError):
clip_type = ClipType.MAIN # type: ignore
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(config_obj)
created_configs.append(created)
self._db.commit()
logger.info(
"发布模板草稿: template_id=%s draft_plan_id=%s clip_count=%d",
template_id,
draft_plan_id,
len(created_configs),
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"发布模板草稿失败: template_id=%s draft_plan_id=%s error=%s",
template_id,
draft_plan_id,
exc,
)
raise
# ── 版本历史与回滚 ────────────────────────────────────────────────────
def list_template_versions(self, template_id: str, limit: int = 50) -> list[Any]:
"""列出模板的发布版本历史(按版本号倒序)"""
self.get_template_or_raise(template_id) # 校验存在性
return self._version_repo.list_by_template(template_id, limit=limit)
def rollback_to_version(self, template_id: str, version: int) -> Any:
"""回滚模板到指定历史版本
流程:
1. 校验目标版本存在
2. 保存当前状态为新版本快照(当前版本号)
3. 用目标版本的快照覆盖模板 config + clip_configs
4. 版本号 +1(回滚本身也是一次发布)
Returns:
EditTemplate: 回滚后的模板
Raises:
ValueError: 模板/版本不存在
"""
from packages.domain.template_clip_config import TemplateClipConfig
template = self.get_template_or_raise(template_id)
# 1. 读取目标版本快照
target_version = self._version_repo.get_by_version(template_id, version)
if target_version is None:
raise ValueError(f"模板 {template_id} 不存在版本 {version}")
current_version = template.version or 1
try:
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion
current_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=current_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{current_version} 快照(回滚到 v{version} 前)",
published_by="rollback",
)
self._version_repo.create(current_snapshot)
# 3. 覆盖模板配置 + editing_mode + name + preview_url
template.config = dict(target_version.config)
template.editing_mode = target_version.editing_mode
if target_version.name:
template.name = target_version.name
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 4. 先删后插 clip_configs(批量删除避免N+1
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
)
self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete(
synchronize_session=False
)
for clip_snap in target_version.clip_configs:
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip_snap.get("clip_type", "main"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import ClipType
clip_type = ClipType.MAIN
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip_snap.get("order", 0),
min_duration=clip_snap.get("min_duration", 0.0),
max_duration=clip_snap.get("max_duration", 0.0),
text_template=clip_snap.get("text_template", ""),
transition_effect=transition,
config=clip_snap.get("config", {}) or {},
)
self._clip_config_repo.create(config_obj)
self._db.commit()
logger.info(
"模板回滚成功: template_id=%s from_v=%d to_v=%d new_v=%d",
template_id,
current_version,
version,
updated_template.version,
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"模板回滚失败: template_id=%s target_version=%d error=%s",
template_id,
version,
exc,
)
raise
+41 -17
View File
@@ -1,5 +1,9 @@
import { expect, test, type APIRequestContext } from "@playwright/test"
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
@@ -48,7 +52,7 @@ type AssetListResponse = {
test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 180_000 })
test("walks through 5-step wizard and starts generation", async ({ page, request }) => {
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
test.setTimeout(180_000)
await routeBrowserApiToTestApi(page)
@@ -88,6 +92,8 @@ test.describe("Core generation flow", () => {
// Upload source video
const sourceFileName = "e2e-gen-source.mp4"
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, {
headers,
multipart: {
@@ -96,7 +102,7 @@ test.describe("Core generation flow", () => {
file: {
name: sourceFileName,
mimeType: "video/mp4",
buffer: Buffer.from("e2e source data"),
buffer: sampleVideoBuffer,
},
},
})
@@ -171,7 +177,7 @@ test.describe("Core generation flow", () => {
// Navigate to generate page
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 20_000,
})
@@ -190,38 +196,56 @@ test.describe("Core generation flow", () => {
await materialLabel.locator("input[type='checkbox']").check()
await page.getByRole("button", { name: "下一步" }).click()
// Step 3: title
// Step 3: preview (纯展示页,AI 智能匹配预览)
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click()
// Step 4: title
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
const titleText = `E2E Test ${suffix}`
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
await page.getByRole("button", { name: "下一步" }).click()
// Step 4: voice
// Step 5: voice
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
await firstVoiceCard.click()
await page.getByRole("button", { name: "下一步" }).click()
// Step 5: confirm and generate
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click()
// Step 7: confirm and generate
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
// Wait for plan creation API to be called
const createPlanPromise = page.waitForResponse(
(response) =>
response.url().includes("/edit-plans") &&
response.request().method() === "POST" &&
!response.url().includes("/generate"),
// Wait for generation API to be called
// 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
// 等 generate 接口返回,确认生成流程启动
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/editor/generate")
},
{ timeout: 30_000 },
)
// Click generate button
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
// Verify plan was created successfully
const planResp = await createPlanPromise
expect(planResp.ok()).toBeTruthy()
const planData = (await planResp.json()) as { id: string }
expect(planData.id).toBeTruthy()
// Verify generation was triggered successfully
const genResp = await generatePromise
if (!genResp.ok()) {
const body = await genResp.text()
console.error(
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
)
}
expect(genResp.ok()).toBeTruthy()
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
expect(genData.plan_id).toBeTruthy()
expect(genData.generation_task_id).toBeTruthy()
// Generation may fail in test env (no worker), that's OK
// Just verify the flow started - check page shows generation-related UI
+12 -6
View File
@@ -1,5 +1,9 @@
import { expect, test, type APIRequestContext } from "@playwright/test"
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
@@ -116,15 +120,17 @@ test.describe("Core media upload flow", () => {
timeout: 20_000,
})
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, {
headers,
multipart: {
project_id: projectData.id,
library_id: libraryData.id,
file: {
name: "e2e-sample.MOV",
mimeType: "video/quicktime",
buffer: Buffer.from("playwright mov upload smoke"),
name: "e2e-sample.mp4",
mimeType: "video/mp4",
buffer: sampleVideoBuffer,
},
},
})
@@ -152,7 +158,7 @@ test.describe("Core media upload flow", () => {
mime_type?: string
}>
}
const asset = data.items.find((item) => item.name === "e2e-sample.MOV")
const asset = data.items.find((item) => item.name === "e2e-sample.mp4")
return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing"
},
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
@@ -169,12 +175,12 @@ test.describe("Core media upload flow", () => {
await expect(page.locator(".xx-assets-content")).toBeVisible({
timeout: 20_000,
})
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
await expect(page.getByText("e2e-sample.mp4", { exact: true })).toBeVisible({
timeout: 20_000,
})
// Verify asset card shows status
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.MOV" })
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.mp4" })
await expect(assetCard).toBeVisible()
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible()
+64
View File
@@ -121,3 +121,67 @@ export const verifyEmail = async (token: string): Promise<{ message: string }> =
const response = await apiClient.post("/auth/verify-email", { token })
return response.data
}
/* ========== 微信登录 ========== */
export interface WechatAuthUrlResponse {
auth_url: string
state: string
}
export interface WechatCallbackResponse {
access_token: string
refresh_token?: string | null
user_id: string
display_name: string
avatar_url: string
is_new_user: boolean
binding_complete: boolean
expires_in: number
}
export interface SendVerificationCodeRequest {
target: "email" | "phone"
value: string
purpose: "bind" | "login" | "reset_password"
}
export interface BindContactRequest {
target: "email" | "phone"
value: string
code: string
}
export interface BindContactResponse {
message: string
user: User
}
// 获取微信授权链接
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
const response = await apiClient.get("/auth/wechat/url")
return response.data
}
// 微信回调登录
export const wechatCallback = async (
code: string,
state: string,
): Promise<WechatCallbackResponse> => {
const response = await apiClient.post("/auth/wechat/callback", { code, state })
return response.data
}
// 发送验证码
export const sendVerificationCode = async (
data: SendVerificationCodeRequest,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/send-verification-code", data)
return response.data
}
// 绑定联系方式
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
const response = await apiClient.post("/auth/bind-contact", data)
return response.data
}
+1 -1
View File
@@ -30,7 +30,7 @@ export interface BgmPresetsQuery {
keyword?: string
}
/** BGM 混音配置(嵌入剪辑计划 */
/** BGM 混音配置(嵌入模板 */
export interface BgmMixConfig {
/** 是否启用 BGM */
enabled: boolean
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* 剪辑计划编辑器 API
* 模板编辑器 API
* 对接后端 /api/v1/templates 路由
*/
import apiClient from "./client"
@@ -1,5 +1,5 @@
/**
* API Edit Plans Schema
* 稿 API Template Editor Schema
* API
*/
import apiClient from "./client"
@@ -18,7 +18,7 @@ import type {
* API Schema
* ============================================================ */
/** 剪辑计划状态枚举 */
/** 模板草稿状态枚举 */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
@@ -71,7 +71,7 @@ export interface SegmentTransitionConfig {
duration: number
}
/** 剪辑计划中的单个片段(config 内部 segments 项) */
/** 模板草稿中的单个片段(config 内部 segments 项) */
export interface EditPlanSegment {
segment_order: number
duration_min: number
@@ -83,7 +83,7 @@ export interface EditPlanSegment {
trim_config?: SegmentTrimConfig
}
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
/** 模板草稿 config 完整类型(对齐后端 config JSON 结构) */
export interface EditPlanConfig {
title_config?: TitleConfig
subtitle_config?: SubtitleConfig
@@ -123,7 +123,7 @@ export interface EditPlanConfig {
material_mode?: string
}
/** 剪辑计划(后端响应) */
/** 模板草稿(后端响应) */
export interface EditPlan {
id: string
template_id: string
@@ -137,17 +137,17 @@ export interface EditPlan {
updated_at: string
}
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
/** 创建模板草稿请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest {
template_id: string
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源剪辑计划 ID(从剪辑计划跳转到智能剪辑时关联) */
/** 来源模板草稿 ID(从模板编辑器跳转到智能剪辑时关联) */
source_edit_plan_id?: string
}
/** 更新剪辑计划请求 */
/** 更新模板草稿请求 */
export interface UpdateEditPlanRequest {
name?: string
config?: EditPlanConfig
@@ -163,7 +163,7 @@ export interface GenerateResponse {
clip_count: number
}
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */
/** 模板草稿关联的生成记录(实际是 GenerationTask 对象) */
export interface EditPlanGeneration {
id: string // 即 generation_task_id
source_edit_plan_id: string
@@ -189,6 +189,7 @@ export interface ClipStatusItem {
asset_id?: string
text_content?: string
duration?: number
error_message?: string
}
/** 生成状态轮询响应 */
@@ -196,7 +197,10 @@ export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情(对应后端 GeneratedVideoResponse */
@@ -323,7 +327,7 @@ export interface MediaAsset {
* API
* ============================================================ */
/** 剪辑计划列表查询参数 */
/** 模板草稿列表查询参数 */
export interface EditPlanListParams {
page?: number
page_size?: number
@@ -331,7 +335,7 @@ export interface EditPlanListParams {
status?: string
}
/** 剪辑计划列表分页响应 */
/** 模板草稿列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[]
total: number
@@ -339,73 +343,73 @@ export interface EditPlanListResponse {
page_size: number
}
/** 获取剪辑计划列表(支持分页和筛选) */
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个剪辑计划 */
export async function getEditPlan(planId: string): Promise<EditPlan> {
const response = await apiClient.get(`/edit-plans/${planId}`)
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建剪辑计划 */
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/edit-plans", data)
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新剪辑计划 */
/** 更新模板草稿 */
export async function updateEditPlan(
planId: string,
templateId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/edit-plans/${planId}`, data)
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data
}
/** 删除剪辑计划 */
export async function deleteEditPlan(planId: string): Promise<void> {
await apiClient.delete(`/edit-plans/${planId}`)
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发剪辑计划生成 */
export async function generateEditPlan(planId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/edit-plans/${planId}/generate`)
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取剪辑计划生成状态(轮询用) */
export async function getGenerationStatus(planId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/edit-plans/${planId}/generation-status`)
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** AI 推荐片段方案 */
export async function aiRecommendClips(
planId: string,
templateId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(`/edit-plans/${planId}/ai-recommend`, data)
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
planId: string,
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/edit-plans/${planId}/generate-cover`, data)
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
return response.data
}
/** 获取剪辑计划关联的生成记录 */
export async function getEditPlanGenerations(planId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/edit-plans/${planId}/generations`)
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
@@ -416,8 +420,8 @@ export async function getGenerationTaskResults(taskId: string): Promise<Generate
}
/** 取消生成任务 */
export async function cancelGeneration(planId: string): Promise<void> {
await apiClient.post(`/edit-plans/${planId}/cancel`)
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/* ============================================================
@@ -489,43 +493,51 @@ export interface EditPlanClipListParams {
/** 获取片段列表 */
export async function getEditPlanClips(
planId: string,
templateId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(`/edit-plans/${planId}/clips`, {
params,
})
const response = await apiClient.get<EditPlanClipListResponse>(
`/templates/${templateId}/editor/clips`,
{
params,
},
)
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(planId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`)
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
planId: string,
templateId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/edit-plans/${planId}/clips`, data)
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
planId: string,
templateId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`, data)
const response = await apiClient.put<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
data,
)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(planId: string, clipId: string): Promise<void> {
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`)
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
}
/* ============================================================
@@ -562,11 +574,11 @@ export interface ClipsFromAssetsResponse {
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
planId: string,
templateId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/edit-plans/${planId}/clips/reorder`,
`/templates/${templateId}/editor/clips/reorder`,
{ items },
)
return response.data
@@ -574,11 +586,11 @@ export async function reorderEditPlanClips(
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
planId: string,
templateId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/edit-plans/${planId}/clips/batch-delete`,
`/templates/${templateId}/editor/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
@@ -586,12 +598,12 @@ export async function batchDeleteEditPlanClips(
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
planId: string,
templateId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/edit-plans/${planId}/clips/from-assets`,
`/templates/${templateId}/editor/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
@@ -607,9 +619,15 @@ export interface CopyEditPlanRequest {
project_id?: string
}
/** 复制剪辑计划(含所有片段配置) */
export async function copyEditPlan(planId: string, data?: CopyEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(`/edit-plans/${planId}/copy`, data || {})
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
+5 -5
View File
@@ -4,12 +4,12 @@
* - GET /api/v1/templates — 模板列表(分页/筛选)
* - GET /api/v1/templates/{id} — 模板详情
* - POST /api/v1/templates/{id}/copy — 复制模板
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
* - POST /api/v1/templates/{id}/generate — 从模板生成
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
*/
import apiClient from "./client"
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"
import type { EditPlanConfig } from "./editPlans"
import type { EditPlanConfig } from "./templateEditor"
/* ──────────── 类型定义 ──────────── */
@@ -76,14 +76,14 @@ export interface TemplateListResponse {
page_size: number
}
/** 从模板生成剪辑计划请求 */
/** 从模板生成请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[]
name?: string
config?: EditPlanConfig
}
/** 从模板生成剪辑计划响应 */
/** 从模板生成响应 */
export interface GenerateFromTemplateResponse {
plan_id: string
template_id: string
@@ -134,7 +134,7 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
return response.data
}
/** 从模板生成剪辑计划 */
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
@@ -12,8 +12,8 @@
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
import "./AssetSelector.css"
import { Input, Select, Button } from "@/components/ui"
import type { MediaAsset } from "@/api/editPlans"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/editPlans"
import type { MediaAsset } from "@/api/templateEditor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/templateEditor"
/* ──────────── 类型 ──────────── */
@@ -0,0 +1,182 @@
import React, { useState, useEffect, useRef } from "react"
import { Modal, Tabs, Form, Input, Button, message } from "antd"
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
interface BindContactModalProps {
open: boolean
onSuccess?: (user: BindContactResponse["user"]) => void
onCancel?: () => void
}
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [codeLoading, setCodeLoading] = useState(false)
const [countdown, setCountdown] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (countdown > 0) {
timerRef.current = setInterval(() => {
setCountdown((prev) => prev - 1)
}, 1000)
} else if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [countdown])
useEffect(() => {
if (open) {
form.resetFields()
setCountdown(0)
}
}, [open, form])
const handleSendCode = async () => {
try {
const value = form.getFieldValue(activeTab === "email" ? "email" : "phone")
if (!value) {
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
return
}
setCodeLoading(true)
await sendVerificationCode({
target: activeTab,
value,
purpose: "bind",
})
message.success("验证码已发送")
setCountdown(60)
} catch (error) {
// error handled by interceptor
} finally {
setCodeLoading(false)
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setLoading(true)
const target = activeTab
const value = target === "email" ? values.email : values.phone
const result = await bindContact({
target,
value,
code: values.code,
})
message.success("绑定成功")
onSuccess?.(result.user)
} catch (error) {
// error handled by interceptor
} finally {
setLoading(false)
}
}
return (
<Modal
title="绑定联系方式"
open={open}
onCancel={onCancel}
footer={null}
destroyOnHidden
maskClosable={false}
>
<p style={{ color: "#666", marginBottom: 16 }}></p>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as "email" | "phone")}
items={[
{
key: "email",
label: "邮箱绑定",
children: (
<Form form={form} layout="vertical">
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: "请输入邮箱" },
{ type: "email", message: "请输入有效的邮箱地址" },
]}
>
<Input placeholder="请输入邮箱地址" size="large" />
</Form.Item>
<Form.Item
name="code"
label="验证码"
rules={[{ required: true, message: "请输入验证码" }]}
>
<div style={{ display: "flex", gap: 8 }}>
<Input placeholder="请输入验证码" size="large" style={{ flex: 1 }} />
<Button
size="large"
onClick={handleSendCode}
loading={codeLoading}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
</Button>
</div>
</Form.Item>
</Form>
),
},
{
key: "phone",
label: "手机绑定",
children: (
<Form form={form} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: "请输入手机号" },
{ pattern: /^1[3-9]\d{9}$/, message: "请输入有效的手机号" },
]}
>
<Input placeholder="请输入手机号" size="large" />
</Form.Item>
<Form.Item
name="code"
label="验证码"
rules={[{ required: true, message: "请输入验证码" }]}
>
<div style={{ display: "flex", gap: 8 }}>
<Input placeholder="请输入验证码" size="large" style={{ flex: 1 }} />
<Button
size="large"
onClick={handleSendCode}
loading={codeLoading}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
</Button>
</div>
</Form.Item>
</Form>
),
},
]}
/>
<div style={{ marginTop: 16 }}>
<Button type="primary" block size="large" loading={loading} onClick={handleSubmit}>
</Button>
</div>
</Modal>
)
}
export default BindContactModal
+1 -1
View File
@@ -59,7 +59,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/subscription/upgrade": "升级订阅",
"/app/subscription/billing": "账单管理",
"/app/profile": "个人设置",
"/app/editing-planner": "剪辑规划",
"/app/editing-planner": "模板制作",
"/app/my-templates": "我的模板",
"/app/voice-clone": "我的音色",
"/app/voice-materials": "配音库",
+1 -12
View File
@@ -81,12 +81,7 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/my-templates",
icon: React.createElement(FolderOutlined),
},
{
key: "edit-plans",
label: "剪辑计划",
path: "/app/edit-plans",
icon: React.createElement(UnorderedListOutlined),
},
{
key: "generate",
label: "智能剪辑",
@@ -142,12 +137,6 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/editing-planner",
icon: React.createElement(EditOutlined),
},
{
key: "edit-plans",
label: "剪辑计划",
path: "/app/edit-plans",
icon: React.createElement(UnorderedListOutlined),
},
],
},
{
+41 -9
View File
@@ -20,24 +20,56 @@ export const useLogin = () => {
const data = await mutation.mutateAsync(credentials)
const refreshToken = data.refresh_token ?? null
// 先保存 token
localStorage.setItem("access_token", data.access_token)
if (refreshToken) {
localStorage.setItem("refresh_token", refreshToken)
} else {
localStorage.removeItem("refresh_token")
}
// 获取用户信息
const user = await authApi.getCurrentUser()
setAuth(user, data.access_token, refreshToken)
navigate("/")
// 跳转到登录前页面或首页
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
return data
}
return { ...mutation, mutateAsync: login }
}
// 微信登录 Hook(用于回调后处理登录状态)
export const useWechatCallback = () => {
const navigate = useNavigate()
const setAuth = useAuthStore((state) => state.setAuth)
const setUser = useAuthStore((state) => state.setUser)
const mutation = useMutation({
mutationFn: ({ code, state }: { code: string; state: string }) =>
authApi.wechatCallback(code, state),
})
const handleCallback = async (code: string, state: string) => {
// 校验 state
const savedState = localStorage.getItem("wechat_state")
if (!savedState || savedState !== state) {
throw new Error("安全校验失败")
}
localStorage.removeItem("wechat_state")
const result = await mutation.mutateAsync({ code, state })
const user = await authApi.getCurrentUser()
setAuth(user, result.access_token, result.refresh_token)
return { ...result, user }
}
// 绑定成功后跳转
const finishLogin = () => {
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
}
return { ...mutation, handleCallback, finishLogin, setUser }
}
// 注册 Hook
export const useRegister = () => {
const navigate = useNavigate()
+29 -3
View File
@@ -1,10 +1,11 @@
/**
* 登录页面 - V21 完全对标
*/
import React from "react"
import React, { useState } from "react"
import { Form, Input, Checkbox, message } from "antd"
import { Link, useNavigate } from "react-router-dom"
import { useLogin } from "@/hooks/useAuth"
import { getWechatAuthUrl } from "@/api/auth"
import Button from "@/components/ui/Button"
import "./Login.css"
@@ -18,6 +19,7 @@ const Login: React.FC = () => {
const navigate = useNavigate()
const loginMutation = useLogin()
const [form] = Form.useForm()
const [wechatLoading, setWechatLoading] = useState(false)
const onFinish = async (values: LoginFormValues) => {
try {
@@ -33,6 +35,29 @@ const Login: React.FC = () => {
}
}
const handleWechatLogin = async () => {
try {
setWechatLoading(true)
const result = await getWechatAuthUrl()
// 保存 state 到 localStorage 用于回调时验证
localStorage.setItem("wechat_state", result.state)
// 记录登录前的来源页,登录成功后跳回
const from = window.location.pathname + window.location.search
if (from !== "/login" && from !== "/register") {
localStorage.setItem("login_redirect", from)
} else {
localStorage.removeItem("login_redirect")
}
// 跳转到微信授权页
window.location.href = result.auth_url
} catch (error) {
if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("微信登录暂不可用,请稍后重试")
} finally {
setWechatLoading(false)
}
}
return (
<div className="xx-auth-page">
<div className="xx-auth-card">
@@ -100,10 +125,11 @@ const Login: React.FC = () => {
<button
type="button"
className="xx-btn-wechat"
onClick={() => message.info("微信登录功能开发中")}
onClick={handleWechatLogin}
disabled={wechatLoading}
>
<span className="xx-wechat-icon">💬</span>
{wechatLoading ? "加载中..." : "微信登录"}
</button>
</div>
+141
View File
@@ -0,0 +1,141 @@
/**
* 微信登录回调页
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin, message } from "antd"
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import BindContactModal from "@/components/auth/BindContactModal"
const WechatCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setAuth = useAuthStore((state) => state.setAuth)
const [loading, setLoading] = useState(true)
const [showBindModal, setShowBindModal] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const code = searchParams.get("code")
const state = searchParams.get("state")
if (!code || !state) {
setError("无效的回调参数")
setLoading(false)
return
}
const handleCallback = async () => {
try {
// 校验 state,防止 CSRF
const savedState = localStorage.getItem("wechat_state")
if (!savedState || savedState !== state) {
setError("安全校验失败,请重新登录")
setLoading(false)
return
}
localStorage.removeItem("wechat_state")
const result = await wechatCallback(code, state)
// 获取用户信息
const userData = await getCurrentUser()
const user: User = normalizeUser(userData)
setAuth(user, result.access_token, result.refresh_token)
if (result.binding_complete) {
// 已绑定,跳转到登录前页面或首页
message.success("登录成功")
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} else {
// 未绑定,显示绑定弹窗
setLoading(false)
setShowBindModal(true)
}
} catch (err) {
setError("登录失败,请重试")
setLoading(false)
}
}
handleCallback()
}, [searchParams, navigate, setAuth])
const handleBindSuccess = (user: User) => {
const setUser = useAuthStore.getState().setUser
setUser(user)
setShowBindModal(false)
message.success("绑定成功")
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
}
const handleBindCancel = () => {
setShowBindModal(false)
navigate("/login")
}
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<Spin size="large" />
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/login")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
)
}
return (
<BindContactModal
open={showBindModal}
onSuccess={handleBindSuccess}
onCancel={handleBindCancel}
/>
)
}
export default WechatCallback
-515
View File
@@ -1,515 +0,0 @@
/**
* 剪辑计划管理页面
* 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成
*/
import { useState, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Table, Tabs, Select, Tag, Button, message, Popconfirm, Tooltip } from "antd"
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
EditOutlined,
DeleteOutlined,
FileTextOutlined,
ThunderboltOutlined,
CopyOutlined,
UnorderedListOutlined,
StopOutlined,
} from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import {
getEditPlans,
deleteEditPlan,
generateEditPlan,
cancelGeneration,
copyEditPlan,
type EditPlan,
type EditPlanStatus,
type EditPlanListParams,
} from "@/api/editPlans"
import { getTemplatesList, type TemplateItem } from "@/api/templates"
import "./edit-plans.css"
/* ──────────── 常量 ──────────── */
/** 状态 Tab 配置 */
const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "draft", label: "草稿" },
{ key: "editing", label: "编辑中" },
{ key: "rendering", label: "渲染中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
]
/** 状态标签配置 */
const STATUS_CONFIG: Record<
EditPlanStatus,
{ label: string; color: string; icon: React.ReactNode }
> = {
draft: {
label: "草稿",
color: "default",
icon: <FileTextOutlined />,
},
editing: {
label: "编辑中",
color: "processing",
icon: <EditOutlined />,
},
rendering: {
label: "渲染中",
color: "warning",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
cancelled: {
label: "已取消",
color: "default",
icon: <StopOutlined />,
},
}
/* ──────────── 工具函数 ──────────── */
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "-"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
if (m === 0) return `${s}`
return `${m}${s > 0 ? `${s}` : ""}`
}
/** 格式化时间 */
const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-"
const date = new Date(dateStr)
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
/* ──────────── 主组件 ──────────── */
export default function EditPlans() {
const navigate = useNavigate()
const queryClient = useQueryClient()
// 筛选状态
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">("all")
const [templateFilter, setTemplateFilter] = useState<string>("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 查询参数
const queryParams: EditPlanListParams = {
page,
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(templateFilter !== "all" && { template_id: templateFilter }),
}
// 获取剪辑计划列表
const {
data: planData,
isLoading,
error,
} = useQuery({
queryKey: ["edit-plans", queryParams],
queryFn: () => getEditPlans(queryParams),
refetchInterval: (query) => {
// 有进行中的计划时自动刷新
const plans = query.state.data?.items ?? []
const hasRunning = plans.some((p) => p.status === "rendering" || p.status === "editing")
return hasRunning ? 5000 : false
},
})
// 获取模板列表(用于筛选下拉)
const { data: templates } = useQuery({
queryKey: ["templates-list-simple"],
queryFn: getTemplatesList,
})
const plans = planData?.items ?? []
const total = planData?.total ?? 0
// 模板名称映射
const templateNameMap = new Map<string, string>()
;(templates ?? []).forEach((t: TemplateItem) => {
templateNameMap.set(t.id, t.name)
})
// 删除计划
const deleteMutation = useMutation({
mutationFn: deleteEditPlan,
onSuccess: () => {
message.success("剪辑计划已删除")
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
},
onError: () => {
message.error("删除失败,请稍后重试")
},
})
// 重新生成
const regenerateMutation = useMutation({
mutationFn: generateEditPlan,
onSuccess: () => {
message.success("已重新提交生成")
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
},
onError: () => {
message.error("重新生成失败,请稍后重试")
},
})
// 取消生成
const cancelMutation = useMutation({
mutationFn: cancelGeneration,
onSuccess: () => {
message.success("已提交取消请求")
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
},
onError: () => {
message.error("取消失败,请稍后重试")
},
})
// 复制计划
const copyMutation = useMutation({
mutationFn: ({ planId, name }: { planId: string; name?: string }) =>
copyEditPlan(planId, name ? { name } : undefined),
onSuccess: (newPlan) => {
message.success("计划已复制")
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
// 自动跳转到新计划的编辑器
navigate(`/app/editing-planner?planId=${newPlan.id}`)
},
onError: () => {
message.error("复制失败,请稍后重试")
},
})
// 跳转到剪辑编辑器
const handleEdit = useCallback(
(plan: EditPlan) => {
navigate(`/app/editing-planner?planId=${plan.id}`)
},
[navigate],
)
// 表格列定义
const columns: ColumnsType<EditPlan> = [
{
title: "计划名称",
dataIndex: "name",
key: "name",
width: 240,
ellipsis: true,
render: (name: string, record: EditPlan) => (
<Tooltip title={name}>
<span className="plan-name" onClick={() => handleEdit(record)}>
{name}
</span>
</Tooltip>
),
},
{
title: "模板",
dataIndex: "template_id",
key: "template_id",
width: 140,
ellipsis: true,
render: (templateId: string) => {
const name = templateNameMap.get(templateId)
return (
<Tag color="blue" className="plan-template-tag">
{name || templateId.slice(0, 8)}
</Tag>
)
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: EditPlanStatus) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="plan-status-tag">
{config.label}
</Tag>
)
},
},
{
title: "时长",
dataIndex: "total_duration",
key: "total_duration",
width: 100,
render: (seconds: number) => <span className="plan-duration">{formatDuration(seconds)}</span>,
},
{
title: "视频数",
dataIndex: "result_count",
key: "result_count",
width: 80,
align: "center",
render: (count: number) => (
<span className="plan-result-count">{count > 0 ? count : "—"}</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 130,
render: (time: string) => <span className="plan-time">{formatTime(time)}</span>,
},
{
title: "更新时间",
dataIndex: "updated_at",
key: "updated_at",
width: 130,
render: (time: string) => <span className="plan-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 240,
fixed: "right",
render: (_: unknown, record: EditPlan) => (
<div className="plan-actions">
<Tooltip title="片段管理">
<Button
type="link"
size="small"
icon={<UnorderedListOutlined />}
onClick={() => navigate(`/app/edit-plans/${record.id}/clips`)}
className="plan-action-btn"
>
</Button>
</Tooltip>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
className="plan-action-btn"
>
</Button>
{record.status === "rendering" && (
<Popconfirm
title="确认取消生成"
description="确定要取消当前生成任务吗?此操作不可恢复。"
onConfirm={() => cancelMutation.mutate(record.id)}
okText="确定"
cancelText="再等等"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
loading={cancelMutation.isPending}
className="plan-action-btn plan-cancel-btn"
>
</Button>
</Popconfirm>
)}
{(record.status === "failed" ||
record.status === "completed" ||
record.status === "cancelled") && (
<Popconfirm
title="确认重新生成"
description="确定要重新生成这个剪辑计划吗?"
onConfirm={() => regenerateMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<ThunderboltOutlined />}
loading={regenerateMutation.isPending}
className="plan-action-btn plan-regenerate-btn"
>
</Button>
</Popconfirm>
)}
<Popconfirm
title="复制计划"
description="确定要复制这个剪辑计划吗?将创建一个编辑中的新副本。"
onConfirm={() =>
copyMutation.mutate({
planId: record.id,
name: `${record.name} 副本`,
})
}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<CopyOutlined />}
loading={copyMutation.isPending}
className="plan-action-btn"
>
</Button>
</Popconfirm>
<Popconfirm
title="确认删除"
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
onConfirm={() => deleteMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
className="plan-action-btn"
>
</Button>
</Popconfirm>
</div>
),
},
]
// 错误处理
if (error) {
return (
<div className="edit-plans-page">
<div className="edit-plans-error">
<CloseCircleOutlined />
<p></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
)
}
return (
<div className="edit-plans-page">
{/* 页面标题 */}
<div className="edit-plans-header">
<div className="edit-plans-header-text">
<h2></h2>
<p></p>
</div>
<Button type="primary" onClick={() => navigate("/app/templates")}>
</Button>
</div>
{/* 筛选栏 */}
<div className="edit-plans-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={(key) => {
setStatusFilter(key as EditPlanStatus | "all")
setPage(1)
}}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="edit-plans-status-tabs"
/>
{/* 模板筛选 */}
<Select
value={templateFilter}
onChange={(value) => {
setTemplateFilter(value)
setPage(1)
}}
options={[
{ value: "all", label: "全部模板" },
...(templates ?? []).map((t: TemplateItem) => ({
value: t.id,
label: t.name,
})),
]}
style={{ minWidth: 180 }}
placeholder="选择模板"
className="edit-plans-template-filter"
/>
</div>
{/* 计划表格 */}
<Table
columns={columns}
dataSource={plans}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
scroll={{ x: 900 }}
className="edit-plans-table"
locale={{
emptyText: (
<div className="edit-plans-empty">
<ClockCircleOutlined />
<p></p>
<Button
type="primary"
style={{ marginTop: 12 }}
onClick={() => navigate("/app/templates")}
>
</Button>
</div>
),
}}
/>
</div>
)
}
@@ -1,621 +0,0 @@
/**
* 剪辑计划片段管理页面
* 对接后端 PR#389 片段 CRUD API
* 功能:列表查看、创建、编辑、删除、批量删除、拖拽排序、从素材导入
*/
import { useState, useCallback } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import {
Table,
Button,
Space,
message,
Popconfirm,
Modal,
Form,
Input,
InputNumber,
Select,
Tag,
Drawer,
Empty,
Card,
} from "antd"
import {
ArrowLeftOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
UploadOutlined,
OrderedListOutlined,
SaveOutlined,
} from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import {
getEditPlan,
getEditPlanClips,
createEditPlanClip,
updateEditPlanClip,
deleteEditPlanClip,
batchDeleteEditPlanClips,
reorderEditPlanClips,
createClipsFromAssets,
getMediaAssets,
type EditPlanClip,
type EditPlanClipStatus,
} from "@/api/editPlans"
import "./plan-clips.css"
/* ──────────── 常量 ──────────── */
const CLIP_TYPE_OPTIONS = [
{ value: "main", label: "主片段" },
{ value: "intro", label: "片头" },
{ value: "outro", label: "片尾" },
{ value: "overlay", label: "叠加层" },
{ value: "background", label: "背景" },
{ value: "b_roll", label: "B-roll" },
]
const STATUS_COLORS: Record<EditPlanClipStatus, string> = {
pending: "default",
processing: "processing",
ready: "success",
failed: "error",
}
const STATUS_LABELS: Record<EditPlanClipStatus, string> = {
pending: "待处理",
processing: "处理中",
ready: "就绪",
failed: "失败",
}
const TRANSITION_OPTIONS = [
{ value: "cut", label: "硬切" },
{ value: "fade", label: "淡入淡出" },
{ value: "dissolve", label: "溶解" },
{ value: "zoom", label: "缩放" },
{ value: "slide_left", label: "左滑" },
{ value: "slide_right", label: "右滑" },
{ value: "slide_up", label: "上滑" },
{ value: "slide_down", label: "下滑" },
{ value: "wipe_left", label: "左擦除" },
{ value: "wipe_right", label: "右擦除" },
{ value: "wipe_up", label: "上擦除" },
{ value: "wipe_down", label: "下擦除" },
{ value: "circlecrop", label: "圆形裁切" },
{ value: "rectcrop", label: "矩形裁切" },
]
/* ──────────── 组件 ──────────── */
const PlanClipsManager: React.FC = () => {
const { planId } = useParams<{ planId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
/* ── 计划信息 ── */
const { data: plan, isLoading: planLoading } = useQuery({
queryKey: ["editPlan", planId],
queryFn: () => getEditPlan(planId!),
enabled: !!planId,
})
/* ── 片段列表 ── */
const { data: clipsData, isLoading: clipsLoading } = useQuery({
queryKey: ["editPlanClips", planId],
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
enabled: !!planId,
})
const clips = clipsData?.items ?? []
/* ── 选中的片段(批量操作) ── */
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
/* ── 编辑弹窗 ── */
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingClip, setEditingClip] = useState<EditPlanClip | null>(null)
const [editForm] = Form.useForm()
const [editLoading, setEditLoading] = useState(false)
/* ── 素材导入抽屉 ── */
const [importDrawerOpen, setImportDrawerOpen] = useState(false)
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
const [importLoading, setImportLoading] = useState(false)
const { data: assets } = useQuery({
queryKey: ["mediaAssets"],
queryFn: () => getMediaAssets(),
enabled: importDrawerOpen,
})
/* ── 重新排序模式 ── */
const [reorderMode, setReorderMode] = useState(false)
const [reorderItems, setReorderItems] = useState<EditPlanClip[]>([])
/* ── 列定义 ── */
const columns: ColumnsType<EditPlanClip> = [
{
title: "序号",
dataIndex: "order",
width: 70,
render: (_, __, index) => index + 1,
},
{
title: "类型",
dataIndex: "clip_type",
width: 100,
render: (type: string) => {
const opt = CLIP_TYPE_OPTIONS.find((o) => o.value === type)
return <Tag>{opt?.label || type}</Tag>
},
},
{
title: "素材",
dataIndex: "asset_id",
width: 150,
ellipsis: true,
render: (assetId: string) =>
assetId ? (
<code className="clip-asset-id">{assetId.slice(0, 12)}...</code>
) : (
<span style={{ color: "#999" }}></span>
),
},
{
title: "文本内容",
dataIndex: "text_content",
ellipsis: true,
render: (text: string) => text || <span style={{ color: "#999" }}>-</span>,
},
{
title: "时长",
dataIndex: "duration",
width: 90,
render: (d: number) => `${d?.toFixed(1) || 0}s`,
},
{
title: "转场",
dataIndex: "transition_effect",
width: 100,
render: (effect: string) => {
const opt = TRANSITION_OPTIONS.find((o) => o.value === effect)
return opt?.label || effect || "硬切"
},
},
{
title: "播放速度",
dataIndex: "playback_speed",
width: 90,
render: (s: number) => `${s || 1.0}x`,
},
{
title: "状态",
dataIndex: "status",
width: 90,
render: (status: EditPlanClipStatus) => (
<Tag color={STATUS_COLORS[status] || "default"}>{STATUS_LABELS[status] || status}</Tag>
),
},
{
title: "操作",
key: "action",
width: 140,
fixed: "right",
render: (_, record) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEditClip(record)}
>
</Button>
<Popconfirm
title="删除片段"
description="确定删除这个片段吗?"
onConfirm={() => handleDeleteClip(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
]
/* ── 编辑片段 ── */
const handleEditClip = useCallback(
(clip: EditPlanClip) => {
setEditingClip(clip)
editForm.setFieldsValue({
clip_type: clip.clip_type,
asset_id: clip.asset_id,
text_content: clip.text_content,
duration: clip.duration,
start_time: clip.start_time,
transition_effect: clip.transition_effect,
transition_duration: clip.transition_duration,
playback_speed: clip.playback_speed,
})
setEditModalOpen(true)
},
[editForm],
)
const handleNewClip = useCallback(() => {
setEditingClip(null)
editForm.resetFields()
editForm.setFieldsValue({
clip_type: "main",
duration: 5,
transition_effect: "cut",
transition_duration: 0,
playback_speed: 1.0,
})
setEditModalOpen(true)
}, [editForm])
const handleSaveClip = async () => {
if (!planId) return
try {
const values = await editForm.validateFields()
setEditLoading(true)
if (editingClip) {
// 更新
await updateEditPlanClip(planId, editingClip.id, values)
message.success("片段已更新")
} else {
// 新建
const maxOrder = clips.length > 0 ? Math.max(...clips.map((c) => c.order)) : -1
await createEditPlanClip(planId, {
...values,
order: maxOrder + 1,
})
message.success("片段已创建")
}
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
setEditModalOpen(false)
} catch (err) {
console.error(err)
message.error(editingClip ? "更新失败" : "创建失败")
} finally {
setEditLoading(false)
}
}
/* ── 删除片段 ── */
const handleDeleteClip = async (clipId: string) => {
if (!planId) return
try {
await deleteEditPlanClip(planId, clipId)
message.success("已删除")
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
setSelectedRowKeys((prev) => prev.filter((k) => k !== clipId))
} catch {
message.error("删除失败")
}
}
/* ── 批量删除 ── */
const handleBatchDelete = async () => {
if (!planId || selectedRowKeys.length === 0) return
try {
await batchDeleteEditPlanClips(
planId,
selectedRowKeys.map((k) => String(k)),
)
message.success(`已删除 ${selectedRowKeys.length} 个片段`)
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
setSelectedRowKeys([])
} catch {
message.error("批量删除失败")
}
}
/* ── 从素材导入 ── */
const handleImportFromAssets = async () => {
if (!planId || selectedAssetIds.length === 0) return
try {
setImportLoading(true)
const res = await createClipsFromAssets(planId, selectedAssetIds)
message.success(`已导入 ${res.created_count} 个片段`)
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
setImportDrawerOpen(false)
setSelectedAssetIds([])
} catch {
message.error("导入失败")
} finally {
setImportLoading(false)
}
}
/* ── 排序模式 ── */
const enterReorderMode = () => {
setReorderItems([...clips].sort((a, b) => a.order - b.order))
setReorderMode(true)
}
const moveClip = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= reorderItems.length) return
const newItems = [...reorderItems]
const [moved] = newItems.splice(fromIndex, 1)
newItems.splice(toIndex, 0, moved)
setReorderItems(newItems)
}
const saveReorder = async () => {
if (!planId) return
const items = reorderItems.map((clip, index) => ({
clip_id: clip.id,
new_order: index,
}))
try {
await reorderEditPlanClips(planId, items)
message.success("排序已保存")
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
setReorderMode(false)
} catch {
message.error("排序保存失败")
}
}
const cancelReorder = () => {
setReorderMode(false)
setReorderItems([])
}
/* ── 渲染 ── */
const displayClips = reorderMode ? reorderItems : [...clips].sort((a, b) => a.order - b.order)
return (
<div className="plan-clips-page">
{/* 顶部 */}
<div className="plan-clips-header">
<div className="plan-clips-header-left">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/app/edit-plans")}
>
</Button>
<div className="plan-clips-title">
<h2>{plan?.name || "加载中..."}</h2>
<p>
{planLoading
? "加载中..."
: `${clipsData?.total || 0} 个片段 · ${plan?.status || ""}`}
</p>
</div>
</div>
<div className="plan-clips-header-right">
<Space>
<Button icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>
</Button>
{reorderMode ? (
<>
<Button onClick={cancelReorder}></Button>
<Button type="primary" icon={<SaveOutlined />} onClick={saveReorder}>
</Button>
</>
) : (
<>
<Button
icon={<OrderedListOutlined />}
onClick={enterReorderMode}
disabled={clips.length === 0}
>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleNewClip}>
</Button>
</>
)}
</Space>
</div>
</div>
{/* 批量操作栏 */}
{!reorderMode && selectedRowKeys.length > 0 && (
<div className="plan-clips-batch-bar">
<span> {selectedRowKeys.length} </span>
<Popconfirm
title="批量删除"
description={`确定删除选中的 ${selectedRowKeys.length} 个片段吗?`}
onConfirm={handleBatchDelete}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
)}
{/* 排序列表 */}
{reorderMode && (
<Card className="plan-clips-reorder-card" title="拖拽调整顺序">
<div className="plan-clips-reorder-list">
{reorderItems.map((clip, index) => (
<div key={clip.id} className="plan-clips-reorder-item">
<span className="reorder-index">{index + 1}</span>
<span className="reorder-type">
{CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)?.label ||
clip.clip_type}
</span>
<span className="reorder-content">
{clip.text_content || clip.asset_id || "无内容"}
</span>
<span className="reorder-duration">{clip.duration.toFixed(1)}s</span>
<Space>
<Button
size="small"
onClick={() => moveClip(index, index - 1)}
disabled={index === 0}
>
</Button>
<Button
size="small"
onClick={() => moveClip(index, index + 1)}
disabled={index === reorderItems.length - 1}
>
</Button>
</Space>
</div>
))}
</div>
</Card>
)}
{/* 片段列表 */}
{!reorderMode && (
<div className="plan-clips-table-wrap">
<Table
rowKey="id"
columns={columns}
dataSource={displayClips}
loading={clipsLoading}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={false}
locale={{
emptyText: (
<Empty
description="暂无片段,点击上方按钮添加或从素材导入"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
),
}}
scroll={{ x: 1000 }}
/>
</div>
)}
{/* 编辑弹窗 */}
<Modal
title={editingClip ? "编辑片段" : "添加片段"}
open={editModalOpen}
onCancel={() => setEditModalOpen(false)}
onOk={handleSaveClip}
confirmLoading={editLoading}
okText="保存"
cancelText="取消"
width={560}
>
<Form form={editForm} layout="vertical">
<Form.Item
label="片段类型"
name="clip_type"
rules={[{ required: true, message: "请选择类型" }]}
>
<Select options={CLIP_TYPE_OPTIONS} />
</Form.Item>
<Form.Item label="素材 ID" name="asset_id">
<Input placeholder="关联的素材 ID(可选)" />
</Form.Item>
<Form.Item label="文本内容" name="text_content">
<Input.TextArea rows={3} placeholder="字幕/配音文案等" />
</Form.Item>
<div style={{ display: "flex", gap: 16 }}>
<Form.Item label="起始时间(秒)" name="start_time" style={{ flex: 1 }}>
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="时长(秒)" name="duration" style={{ flex: 1 }}>
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</div>
<div style={{ display: "flex", gap: 16 }}>
<Form.Item label="转场效果" name="transition_effect" style={{ flex: 1 }}>
<Select options={TRANSITION_OPTIONS} />
</Form.Item>
<Form.Item label="转场时长" name="transition_duration" style={{ flex: 1 }}>
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</div>
<Form.Item label="播放速度" name="playback_speed">
<InputNumber min={0.1} max={10} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</Form>
</Modal>
{/* 素材导入抽屉 */}
<Drawer
title="从视频库导入"
open={importDrawerOpen}
onClose={() => setImportDrawerOpen(false)}
width={480}
extra={
<Button
type="primary"
onClick={handleImportFromAssets}
loading={importLoading}
disabled={selectedAssetIds.length === 0}
>
{selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""}
</Button>
}
>
{assets && assets.length > 0 ? (
<div className="asset-import-list">
{assets.map((asset) => (
<div
key={asset.id}
className={`asset-import-item ${
selectedAssetIds.includes(asset.id) ? "selected" : ""
}`}
onClick={() => {
setSelectedAssetIds((prev) =>
prev.includes(asset.id)
? prev.filter((id) => id !== asset.id)
: [...prev, asset.id],
)
}}
>
<div className="asset-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} />
) : (
<div className="asset-thumb-placeholder">{asset.type}</div>
)}
</div>
<div className="asset-info">
<div className="asset-name" title={asset.name}>
{asset.name}
</div>
<div className="asset-meta">
{asset.type}
{asset.duration ? ` · ${asset.duration.toFixed(1)}s` : ""}
</div>
</div>
</div>
))}
</div>
) : (
<Empty description="视频库为空" />
)}
</Drawer>
</div>
)
}
export default PlanClipsManager
@@ -1,260 +0,0 @@
/**
* 剪辑计划管理页面样式
*/
/* ── 页面容器 ──────────────────────────────────────────── */
.edit-plans-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
/* ── 页面头部 ──────────────────────────────────────────── */
.edit-plans-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
}
.edit-plans-header-text h2 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.edit-plans-header-text p {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 筛选栏 ────────────────────────────────────────────── */
.edit-plans-filters {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.edit-plans-status-tabs {
flex: 1;
}
.edit-plans-status-tabs .ant-tabs-nav {
margin-bottom: 0 !important;
}
.edit-plans-status-tabs .ant-tabs-tab {
padding: 8px 16px !important;
font-size: 14px;
}
.edit-plans-status-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--primary-500, #6366f1) !important;
font-weight: 500;
}
.edit-plans-status-tabs .ant-tabs-ink-bar {
background: var(--primary-500, #6366f1) !important;
}
.edit-plans-template-filter {
min-width: 180px;
}
/* ── 表格 ──────────────────────────────────────────────── */
.edit-plans-table {
background: var(--bg-surface, #fff);
border-radius: var(--radius-lg, 12px);
overflow: hidden;
border: 1px solid var(--border-primary, #e2e8f0);
}
.edit-plans-table .ant-table {
font-size: 14px;
}
.edit-plans-table .ant-table-thead > tr > th {
background: var(--bg-tertiary, #f8fafc) !important;
border-bottom: 1px solid var(--border-primary, #e2e8f0);
font-weight: 500;
color: var(--text-secondary, #64748b);
font-size: 13px;
padding: 12px 16px;
}
.edit-plans-table .ant-table-tbody > tr > td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-light, #f1f5f9);
}
.edit-plans-table .ant-table-tbody > tr:hover > td {
background: var(--bg-hover, #f8fafc) !important;
}
/* ── 计划名称 ──────────────────────────────────────────── */
.plan-name {
font-weight: 500;
color: var(--text-primary, #1e293b);
cursor: pointer;
transition: color 0.2s;
}
.plan-name:hover {
color: var(--primary-500, #6366f1);
}
/* ── 状态标签 ──────────────────────────────────────────── */
.plan-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
.plan-status-tag.ant-tag-default {
background: #f1f5f9;
color: #64748b;
border-color: transparent;
}
.plan-status-tag.ant-tag-processing {
background: #eff6ff;
color: #2563eb;
border-color: transparent;
}
.plan-status-tag.ant-tag-success {
background: #f0fdf4;
color: #16a34a;
border-color: transparent;
}
.plan-status-tag.ant-tag-error {
background: #fef2f2;
color: #dc2626;
border-color: transparent;
}
.plan-status-tag.ant-tag-warning {
background: #fffbeb;
color: #d97706;
border-color: transparent;
}
/* ── 时长 ──────────────────────────────────────────────── */
.plan-duration {
font-variant-numeric: tabular-nums;
color: var(--text-secondary, #64748b);
}
/* ── 时间 ──────────────────────────────────────────────── */
.plan-time {
color: var(--text-secondary, #64748b);
font-size: 13px;
}
/* ── 操作按钮 ──────────────────────────────────────────── */
.plan-actions {
display: flex;
gap: 4px;
}
.plan-action-btn {
padding: 4px 8px !important;
font-size: 13px !important;
}
.plan-action-btn.ant-btn-link {
color: var(--primary-500, #6366f1);
}
.plan-action-btn.ant-btn-link:hover {
color: var(--primary-600, #4f46e5);
}
.plan-regenerate-btn {
color: var(--primary-500, #6366f1) !important;
}
.plan-regenerate-btn:hover {
color: var(--primary-600, #4f46e5) !important;
}
/* ── 空状态 ────────────────────────────────────────────── */
.edit-plans-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.edit-plans-empty .anticon {
font-size: 48px;
color: var(--text-disabled, #cbd5e1);
margin-bottom: 16px;
}
.edit-plans-empty p {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 错误状态 ──────────────────────────────────────────── */
.edit-plans-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: var(--bg-surface, #fff);
border-radius: var(--radius-lg, 12px);
border: 1px solid var(--border-primary, #e2e8f0);
}
.edit-plans-error .anticon {
font-size: 48px;
color: #ef4444;
margin-bottom: 16px;
}
.edit-plans-error p {
margin: 0 0 16px;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 响应式 ────────────────────────────────────────────── */
@media (max-width: 768px) {
.edit-plans-page {
padding: 16px;
}
.edit-plans-header {
flex-direction: column;
gap: 12px;
}
.edit-plans-filters {
flex-direction: column;
align-items: stretch;
}
.edit-plans-status-tabs {
width: 100%;
}
.edit-plans-template-filter {
width: 100%;
}
}
@@ -1,203 +0,0 @@
/* 剪辑计划片段管理页面 */
.plan-clips-page {
padding: 24px;
min-height: 100vh;
background: #f5f7fa;
}
.plan-clips-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.plan-clips-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.plan-clips-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.plan-clips-title p {
margin: 4px 0 0;
font-size: 13px;
color: #6b7280;
}
.plan-clips-batch-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 20px;
margin-bottom: 16px;
background: #e6f4ff;
border-radius: 8px;
font-size: 14px;
color: #1677ff;
}
.plan-clips-table-wrap {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.clip-asset-id {
font-size: 12px;
color: #6b7280;
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
}
/* 排序模式 */
.plan-clips-reorder-card {
margin-bottom: 16px;
border-radius: 12px;
}
.plan-clips-reorder-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.plan-clips-reorder-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: grab;
transition: all 0.2s;
}
.plan-clips-reorder-item:hover {
border-color: #1677ff;
background: #f0f7ff;
}
.reorder-index {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: #1677ff;
color: #fff;
border-radius: 50%;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.reorder-type {
flex-shrink: 0;
font-size: 12px;
color: #6b7280;
padding: 2px 8px;
background: #eef2ff;
border-radius: 4px;
}
.reorder-content {
flex: 1;
font-size: 14px;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reorder-duration {
flex-shrink: 0;
font-size: 13px;
color: #6b7280;
font-variant-numeric: tabular-nums;
}
/* 素材导入 */
.asset-import-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.asset-import-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.asset-import-item:hover {
border-color: #1677ff;
background: #f0f7ff;
}
.asset-import-item.selected {
border-color: #1677ff;
background: #e6f4ff;
}
.asset-thumb {
width: 56px;
height: 40px;
border-radius: 4px;
overflow: hidden;
background: #f3f4f6;
flex-shrink: 0;
}
.asset-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #9ca3af;
text-transform: uppercase;
}
.asset-info {
flex: 1;
min-width: 0;
}
.asset-name {
font-size: 14px;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-meta {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* 剪辑计划编辑器 — V21 原型 1:1 还原样式
* 模板编辑器 — V21 原型 1:1 还原样式
* 颜色:对齐全局V21设计系统,使用 var(--bg-primary)、var(--bg-secondary)、var(--primary) 等
* 布局:3行(顶栏52 + 模式栏56 + 三栏主体flex
* 面板宽度:左240 / 右280
+9 -563
View File
@@ -1,10 +1,10 @@
/**
* 剪辑计划编辑器 — V8 原型 1:1 还原
* 模板编辑器 — 制作/编辑剪辑模板
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*/
import React, { useState, useCallback, useEffect, useRef } from "react"
import { useSearchParams } from "react-router-dom"
import { message, Modal, Progress, Button } from "antd"
import { message, Modal } from "antd"
import { useQuery } from "@tanstack/react-query"
import type {
EditingTemplate,
@@ -20,31 +20,8 @@ import {
getTemplateCategories,
MODE_LABELS,
} from "@/api/editingPlanner"
import type {
EditPlanGeneration,
EditPlanConfig,
GeneratedVideo,
MediaAsset,
TransitionEffect,
TitleConfig,
} from "@/api/editPlans"
import {
getMediaAssets,
getEditPlanGenerations,
getEditPlan,
createEditPlan,
updateEditPlan,
generateEditPlan,
getGenerationStatus,
getGenerationTaskResults,
cancelGeneration,
getEditPlanClips,
createEditPlanClip,
batchDeleteEditPlanClips,
type EditPlanClip,
type CreateEditPlanClipRequest,
type ClipStatusItem,
} from "@/api/editPlans"
import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/templateEditor"
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/templateEditor"
import { useUndoRedo } from "./hooks/useUndoRedo"
import type {
ClipData,
@@ -97,7 +74,6 @@ import GreenScreenPanel from "./components/GreenScreenPanel"
import StickerPanel from "./components/StickerPanel"
import SaveModal from "./components/SaveModal"
import GenerationHistoryModal from "./components/GenerationHistoryModal"
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
import "./EditingPlanner.css"
@@ -230,26 +206,8 @@ const EditingPlanner: React.FC = () => {
setSelectedAssetIds(ids)
}
/* ── 生成历史 ── */
const [genHistoryOpen, setGenHistoryOpen] = useState(false)
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([])
const [genHistoryLoading, setGenHistoryLoading] = useState(false)
/* ── 剪辑计划(从列表页编辑进入时) ── */
const [loadedPlanId, setLoadedPlanId] = useState<string | null>(urlPlanId || null)
/* ── 生成进度 ── */
const [generating, setGenerating] = useState(false)
const [genProgress, setGenProgress] = useState(0)
const [genTotalClips, setGenTotalClips] = useState(0)
const [genDoneClips, setGenDoneClips] = useState(0)
const [generated, setGenerated] = useState(false)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
const [genError, setGenError] = useState<string | null>(null)
const [cancelling, setCancelling] = useState(false)
const [genCancelled, setGenCancelled] = useState(false)
const [genClipStatuses, setGenClipStatuses] = useState<ClipStatusItem[]>([])
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/* ── 模板草稿(从模板列表编辑进入时) ── */
const [loadedPlanId] = useState<string | null>(urlPlanId || null)
/* ── 播放 ── */
const [isPlaying, setIsPlaying] = useState(false)
@@ -378,7 +336,7 @@ const EditingPlanner: React.FC = () => {
}, [loadedTemplateId, resetClips])
/**
* 加载已有剪辑计划数据到编辑器
* 加载已有模板草稿数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
useEffect(() => {
@@ -498,7 +456,7 @@ const EditingPlanner: React.FC = () => {
setTimeout(() => resetClips(mapped), 100)
}
})
.catch(() => message.error("加载剪辑计划失败"))
.catch(() => message.error("加载模板草稿失败"))
}, [loadedPlanId, resetClips])
/* ──────────── 计算 ──────────── */
@@ -772,58 +730,6 @@ const EditingPlanner: React.FC = () => {
setStickerSettings(config)
}, [])
/** 构建剪辑计划 config(编辑器状态 → API config */
const buildPlanConfig = (): EditPlanConfig => ({
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
})
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
const handleOpenSaveModal = () => {
setSaveModalOpen(true)
@@ -908,247 +814,6 @@ const EditingPlanner: React.FC = () => {
}
}
/**
* 将本地编辑的片段同步到后端 clips 表
* 策略:先删除后端所有片段,再批量创建(简单可靠,生成前使用)
*/
const syncClipsToBackend = async (planId: string): Promise<void> => {
if (clips.length === 0) return
// 1. 获取并删除后端现有片段
const existing = await getEditPlanClips(planId, { limit: 500 })
if (existing.items.length > 0) {
await batchDeleteEditPlanClips(
planId,
existing.items.map((c) => c.id),
)
}
// 2. 批量创建新片段(并发 3 个)
const clipDataList: CreateEditPlanClipRequest[] = clips.map((c, i) => ({
clip_type: c.type === "voice" ? "voiceover" : "main",
order: i,
asset_id: c.media_asset_id || "",
text_content: c.script_text || "",
start_time: 0,
duration: c.duration,
transition_effect: c.transition?.type || "cut",
transition_duration: c.transition?.duration || 0,
playback_speed: c.speed?.rate || 1.0,
config: {
tts_config: c.tts_config || null,
trim_config: c.trim_config || null,
template_segment_id: c.template_segment_id || null,
},
}))
// 并发控制:最多同时 3 个请求
const results: EditPlanClip[] = []
const concurrency = 3
for (let i = 0; i < clipDataList.length; i += concurrency) {
const batch = clipDataList.slice(i, i + concurrency)
const batchResults = await Promise.all(batch.map((data) => createEditPlanClip(planId, data)))
results.push(...batchResults)
}
console.log(`[片段同步] 创建了 ${results.length} 个片段`)
}
/**
* 剪辑计划生成
* 1. 有 planId → 更新计划配置 + 同步片段 + 触发生成
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 同步片段 + 触发生成
* 3. 触发生成后轮询状态,完成后获取视频结果
*/
const handleGoToGenerate = async () => {
if (!loadedTemplateId) {
message.warning("请先选择一个模板")
return
}
if (clips.length === 0) {
message.warning("请先添加片段")
return
}
setGenerating(true)
setGenerated(false)
setGeneratedVideos([])
setGenError(null)
setGenProgress(0)
setGenCancelled(false)
try {
const config = buildPlanConfig()
let planId = loadedPlanId
if (planId) {
// 已有计划 → 先重置状态为 draftfailed/editing 等非 draft 状态会被后端拒绝更新和生成)
try {
await updateEditPlan(planId, { status: "draft" })
} catch (resetErr) {
console.warn("[状态重置跳过]", resetErr)
}
// 再更新配置
try {
await updateEditPlan(planId, {
config,
total_duration: totalDuration,
})
} catch (updateErr) {
console.warn("[计划更新跳过]", updateErr)
}
} else {
// 无计划 → 创建新计划
const plan = await createEditPlan({
template_id: loadedTemplateId,
name: draftName || "未命名计划",
config,
total_duration: totalDuration,
})
planId = plan.id
setLoadedPlanId(planId)
// 更新 URL 参数(不刷新页面)
const params = new URLSearchParams(window.location.search)
params.set("planId", planId)
window.history.replaceState(null, "", `?${params.toString()}`)
}
// 同步片段到后端 clips 表(生成前必须同步,后端生成从 clips 表读)
try {
await syncClipsToBackend(planId)
} catch (syncErr) {
console.warn("[片段同步失败]", syncErr)
message.warning("片段同步失败,将使用模板默认配置生成")
// 同步失败不阻塞生成,后端有模板兜底
}
// 触发生成
const genRes = await generateEditPlan(planId)
setGenTotalClips(genRes.clip_count)
message.info("已提交生成,等待处理...")
// 开始轮询
startPolling(planId)
} catch (err) {
console.error("[生成失败]", err)
setGenError("生成提交失败,请重试")
setGenerating(false)
}
}
/** 轮询生成状态,每 2 秒一次 */
const startPolling = (planId: string) => {
const poll = async () => {
try {
const status = await getGenerationStatus(planId)
// 计算进度
const total = status.clips.length || genTotalClips
const done = status.clips.filter(
(c) => c.status === "completed" || c.status === "failed",
).length
setGenDoneClips(done)
setGenTotalClips(total)
setGenClipStatuses(status.clips || [])
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5)
if (status.plan_status === "completed") {
setGenProgress(100)
setGenerating(false)
setGenerated(true)
// 获取视频结果
if (status.generation_task_id) {
try {
const videos = await getGenerationTaskResults(status.generation_task_id)
setGeneratedVideos(videos)
} catch (e) {
console.error("[获取视频结果失败]", e)
}
}
message.success("视频生成完成!")
return // 停止轮询
}
if (status.plan_status === "failed") {
setGenerating(false)
setGenError("生成失败,请重试")
return // 停止轮询
}
if (status.plan_status === "cancelled") {
setGenerating(false)
setGenError("生成已取消")
setGenCancelled(true)
message.info("生成任务已取消")
return // 停止轮询
}
// 继续轮询
genTimerRef.current = setTimeout(poll, 2000)
} catch (err) {
console.error("[轮询状态失败]", err)
genTimerRef.current = setTimeout(poll, 5000) // 出错后 5 秒重试
}
}
// 首次延迟 2 秒后开始
genTimerRef.current = setTimeout(poll, 2000)
}
/** 清理轮询定时器 */
useEffect(() => {
return () => {
if (genTimerRef.current) clearTimeout(genTimerRef.current)
}
}, [])
/** 取消生成任务 */
const handleCancelGeneration = async () => {
const targetId = loadedPlanId
if (!targetId) return
Modal.confirm({
title: "确认取消生成",
content: "取消后已开始的生成任务,已生成的片段不会保留。确定要取消吗?",
okText: "确认取消",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: async () => {
try {
setCancelling(true)
await cancelGeneration(targetId)
message.success("已提交取消请求")
// 轮询会继续运行直到检测到 cancelled 状态
} catch (err) {
console.error("[取消失败]", err)
message.error("取消失败,请稍后重试")
} finally {
setCancelling(false)
}
},
})
}
/* 查看生成历史 */
const handleViewGenHistory = async () => {
const targetId = loadedPlanId || loadedTemplateId
if (!targetId) {
message.warning("请先加载一个模板或计划")
return
}
setGenHistoryOpen(true)
setGenHistoryLoading(true)
try {
const items = await getEditPlanGenerations(targetId)
setGenHistory(items)
} catch {
message.error("加载生成历史失败")
} finally {
setGenHistoryLoading(false)
}
}
/* ──────────── 渲染 ──────────── */
return (
@@ -1157,7 +822,7 @@ const EditingPlanner: React.FC = () => {
<div className="ep-top-bar">
<div className="ep-top-bar-left">
<span className="ep-logo"></span>
<span className="ep-app-title"></span>
<span className="ep-app-title"></span>
<span className="ep-divider">|</span>
<span className="ep-template-name">{currentTemplate?.name || "未选择模板"}</span>
</div>
@@ -1181,13 +846,6 @@ const EditingPlanner: React.FC = () => {
<button className="ep-btn ep-btn-secondary" onClick={handleOpenSaveModal}>
💾
</button>
<button
className="ep-btn ep-btn-primary"
onClick={handleGoToGenerate}
disabled={generating}
>
{loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成"}
</button>
</div>
</div>
@@ -1352,10 +1010,6 @@ const EditingPlanner: React.FC = () => {
<span>🎬 {MODE_LABELS[currentMode]}</span>
<span className="ep-status-sep">|</span>
<span>📐 : {currentTemplate?.segments.length || 0}</span>
<span className="ep-status-sep">|</span>
<button className="ep-status-link" onClick={handleViewGenHistory}>
📋
</button>
</div>
</div>
@@ -1376,214 +1030,6 @@ const EditingPlanner: React.FC = () => {
onCancel={() => setSaveModalOpen(false)}
/>
{/* ═══ 生成历史弹窗 ═══ */}
<GenerationHistoryModal
open={genHistoryOpen}
loading={genHistoryLoading}
history={genHistory}
onClose={() => setGenHistoryOpen(false)}
onCancel={async () => {
Modal.confirm({
title: "确认取消生成",
content: "确定要取消这个生成任务吗?此操作不可恢复。",
okText: "确认取消",
cancelText: "再等等",
okButtonProps: { danger: true },
onOk: async () => {
if (!loadedPlanId) return
try {
await cancelGeneration(loadedPlanId)
message.success("已提交取消请求")
// 刷新历史列表
handleViewGenHistory()
} catch (err) {
console.error("[取消失败]", err)
message.error("取消失败,请稍后重试")
}
},
})
}}
cancelLoading={cancelling}
/>
{/* ═══ 生成进度弹窗 ═══ */}
<Modal
title={
genError
? "生成失败"
: genCancelled
? "已取消生成"
: generated
? "生成完成"
: "正在生成视频"
}
open={generating || generated || !!genError || genCancelled}
footer={
generated
? [
<Button
key="close"
onClick={() => {
setGenerated(false)
setGenerating(false)
setGenError(null)
}}
>
</Button>,
generatedVideos.length > 0 && (
<Button
key="download"
type="primary"
onClick={() => {
const v = generatedVideos[0]
const url = v.download_url || v.file_url
if (url) {
const a = document.createElement("a")
a.href = url
a.download = v.name || "video.mp4"
a.target = "_blank"
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
}}
>
</Button>
),
]
: generating
? [
<Button key="cancel" danger loading={cancelling} onClick={handleCancelGeneration}>
</Button>,
]
: genCancelled
? [
<Button
key="close"
type="primary"
onClick={() => {
setGenCancelled(false)
setGenerating(false)
}}
>
</Button>,
]
: genError
? [
<Button
key="close"
onClick={() => {
setGenError(null)
setGenerating(false)
}}
>
</Button>,
]
: null
}
closable={!generating}
maskClosable={false}
width={520}
>
{generating && (
<div style={{ padding: "16px 0" }}>
<Progress percent={genProgress} status="active" />
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
{genDoneClips}/{genTotalClips}
</p>
{genClipStatuses.length > 0 && (
<div className="ep-gen-clip-list">
{genClipStatuses.map((clip, index) => (
<div key={clip.clip_id || index} className="ep-gen-clip-item">
<span className="ep-gen-clip-index">{index + 1}</span>
<span className="ep-gen-clip-name">
{clip.text_content
? clip.text_content.slice(0, 20)
: clip.clip_type || `片段${index + 1}`}
</span>
<span className={`ep-gen-clip-status status-${clip.status}`}>
{clip.status === "completed"
? "✓ 完成"
: clip.status === "failed"
? "✗ 失败"
: clip.status === "processing"
? "⟳ 处理中"
: "⏳ 等待中"}
</span>
</div>
))}
</div>
)}
<p
style={{
color: "var(--text-secondary)",
fontSize: 12,
marginTop: 12,
}}
>
</p>
</div>
)}
{genCancelled && (
<div style={{ padding: "24px 0", textAlign: "center" }}>
<p></p>
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}></p>
</div>
)}
{generated && generatedVideos.length > 0 && (
<div style={{ padding: "8px 0" }}>
<video
src={generatedVideos[0].file_url || generatedVideos[0].download_url}
controls
preload="metadata"
style={{ width: "100%", maxHeight: 320, borderRadius: 8 }}
/>
<p
style={{
marginTop: 8,
textAlign: "center",
color: "var(--text-secondary)",
}}
>
{generatedVideos[0].name}
</p>
</div>
)}
{generated && !generatedVideos.length && (
<div style={{ padding: "24px 0", textAlign: "center" }}>
<p></p>
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
</p>
</div>
)}
{genError && (
<div
style={{
padding: "16px 0",
textAlign: "center",
color: "#ff4d4f",
}}
>
<p>{genError}</p>
<Button
onClick={() => {
setGenError(null)
setGenerating(false)
}}
>
</Button>
</div>
)}
</Modal>
{/* ═══ BGM 选择器 Drawer ═══ */}
<BgmSelector
open={bgmDrawerOpen}
+1 -1
View File
@@ -6,7 +6,7 @@ import React, { useRef, useState, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import type { TemplateMode } from "@/api/editingPlanner"
import type { ClipData, ClipType } from "../types"
import { TRANSITION_OPTIONS } from "@/api/editPlans"
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
import type { AssetItem } from "@/api/assets"
interface SubtitleSettings {
+3 -3
View File
@@ -1,11 +1,11 @@
/**
* 生成历史弹窗 — 展示当前剪辑计划的生成任务记录
* 生成历史弹窗 — 展示当前模板草稿的生成任务记录
* 从 EditingPlanner 拆分,避免主文件过大
*/
import React from "react"
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
import type { EditPlanGeneration } from "@/api/editPlans"
import { PLAN_STATUS_LABELS } from "@/api/editPlans"
import type { EditPlanGeneration } from "@/api/templateEditor"
import { PLAN_STATUS_LABELS } from "@/api/templateEditor"
interface GenerationHistoryModalProps {
open: boolean
@@ -7,7 +7,7 @@ import React, { useCallback } from "react"
import { Drawer } from "antd"
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
import { DEFAULT_INTRO_OUTRO } from "../types"
import { TRANSITION_OPTIONS } from "@/api/editPlans"
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
/* ──────────── 常量 ──────────── */
+1 -1
View File
@@ -5,7 +5,7 @@
import React, { useState } from "react"
import type { EditingTemplate } from "@/api/editingPlanner"
import { MODE_LABELS } from "@/api/editingPlanner"
import type { MediaAsset } from "@/api/editPlans"
import type { MediaAsset } from "@/api/templateEditor"
import AssetSelector from "@/components/AssetSelector/AssetSelector"
interface MediaPanelProps {
+1 -1
View File
@@ -5,7 +5,7 @@
*/
import React from "react"
import type { ClipData, ClipType } from "../types"
import type { TitleConfig } from "@/api/editPlans"
import type { TitleConfig } from "@/api/templateEditor"
import type { CoverConfig } from "../types"
interface SubtitleSettings {
+1 -1
View File
@@ -10,7 +10,7 @@
*/
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import { TRANSITION_OPTIONS } from "@/api/editPlans"
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
interface TimelinePanelProps {
clips: ClipData[]
@@ -5,7 +5,7 @@
*/
import React, { useCallback } from "react"
import { Drawer, Slider } from "antd"
import { TRANSITION_OPTIONS } from "@/api/editPlans"
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
import type { TransitionConfig, TransitionType } from "../types"
import { DEFAULT_TRANSITION } from "../types"
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* 剪辑计划片段管理 Hook
* 模板片段管理 Hook
* 对接后端 PR#389 片段 CRUD API,替代原来的 config.segments 模式
*
* 功能:
@@ -18,7 +18,7 @@ import type {
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
ClipReorderItem,
} from "@/api/editPlans"
} from "@/api/templateEditor"
import {
getEditPlanClips,
createEditPlanClip,
@@ -27,7 +27,7 @@ import {
reorderEditPlanClips,
batchDeleteEditPlanClips,
createClipsFromAssets,
} from "@/api/editPlans"
} from "@/api/templateEditor"
import { useUndoRedo } from "./useUndoRedo"
const QUERY_KEY = "editPlanClips"
+872 -195
View File
File diff suppressed because it is too large Load Diff
+890
View File
@@ -273,6 +273,120 @@
color: var(--info-color);
}
/* ============================================================
AI 智能配音推荐
============================================================ */
.xx-voice-recommend-section {
padding: 16px;
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 14px;
margin-bottom: 16px;
}
.xx-voice-recommend-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.xx-voice-recommend-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-voice-recommend-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-voice-recommend-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #fff;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.xx-voice-recommend-card:hover {
border-color: var(--primary-color, #4f46e5);
transform: translateX(2px);
}
.xx-voice-recommend-card.selected {
border-color: var(--primary-color, #4f46e5);
background: var(--primary-soft, #eef2ff);
}
.xx-voice-recommend-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #a5b4fc, #c4b5fd);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.xx-voice-recommend-info {
flex: 1;
min-width: 0;
}
.xx-voice-recommend-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1e293b);
margin-bottom: 2px;
}
.xx-voice-recommend-desc {
font-size: 11px;
color: var(--text-tertiary, #94a3b8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-voice-recommend-check {
width: 20px;
height: 20px;
background: var(--primary-color, #4f46e5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.xx-voice-recommend-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
font-size: 13px;
color: var(--text-secondary, #64748b);
}
.xx-voice-recommend-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
}
/* ============================================================
配音卡片(步骤4 choice-list 变体)
============================================================ */
@@ -548,6 +662,147 @@
}
}
/* ============================================================
生成进度 / 结果卡片
============================================================ */
.xx-gen-progress-card {
padding: 16px;
background: linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%);
border: 1px solid #bfdbfe;
border-radius: 12px;
}
.xx-gen-progress-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.xx-gen-progress-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-color, #4f46e5);
font-size: 18px;
flex-shrink: 0;
}
.xx-gen-progress-info {
flex: 1;
min-width: 0;
}
.xx-gen-progress-phase {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin-bottom: 2px;
}
.xx-gen-progress-sub {
font-size: 12px;
color: var(--text-secondary, #64748b);
}
.xx-gen-progress-percent {
font-size: 20px;
font-weight: 700;
color: var(--primary-color, #4f46e5);
flex-shrink: 0;
}
.xx-gen-progress-bar {
width: 100%;
height: 6px;
background: rgba(79, 70, 229, 0.15);
border-radius: 3px;
overflow: hidden;
margin-bottom: 10px;
}
.xx-gen-progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 3px;
transition: width 0.3s ease;
}
.xx-gen-progress-tip {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
text-align: center;
}
.xx-gen-success-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 12px;
}
.xx-gen-success-icon {
flex-shrink: 0;
}
.xx-gen-success-info {
flex: 1;
min-width: 0;
}
.xx-gen-success-title {
font-size: 15px;
font-weight: 600;
color: #166534;
margin-bottom: 4px;
}
.xx-gen-success-sub {
font-size: 12px;
color: #15803d;
}
.xx-gen-error-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 12px;
}
.xx-gen-error-icon {
flex-shrink: 0;
margin-top: 2px;
}
.xx-gen-error-info {
flex: 1;
min-width: 0;
}
.xx-gen-error-title {
font-size: 14px;
font-weight: 600;
color: #991b1b;
margin-bottom: 4px;
}
.xx-gen-error-msg {
font-size: 12px;
color: #b91c1c;
line-height: 1.5;
word-break: break-all;
}
/* ============================================================
确认生成(步骤5)摘要
============================================================ */
@@ -1137,6 +1392,418 @@
border-radius: 20px;
}
/* ── 智能素材匹配 ── */
.xx-smart-match-section {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
.xx-smart-match-input-area {
padding: 16px;
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 14px;
}
.xx-smart-match-label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin-bottom: 10px;
}
.xx-smart-match-input {
width: 100%;
min-height: 80px;
padding: 12px;
font-size: 13px;
line-height: 1.6;
color: var(--text-primary, #1e293b);
background: #fff;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 10px;
resize: vertical;
box-sizing: border-box;
transition: border-color 0.2s;
font-family: inherit;
}
.xx-smart-match-input:focus {
outline: none;
border-color: var(--primary-color, #4f46e5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.xx-smart-match-input::placeholder {
color: var(--text-tertiary, #94a3b8);
}
.xx-smart-match-input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
.xx-smart-match-tip {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
}
.xx-smart-match-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-smart-match-results-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.xx-smart-match-results-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-smart-match-results-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.xx-link-btn {
background: none;
border: none;
color: var(--primary-color, #4f46e5);
font-size: 12px;
cursor: pointer;
padding: 2px 4px;
}
.xx-link-btn:hover {
text-decoration: underline;
}
.xx-smart-match-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.xx-smart-match-card {
background: #fff;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
}
.xx-smart-match-card:hover {
border-color: var(--primary-color, #4f46e5);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.xx-smart-match-card.selected {
border-color: var(--primary-color, #4f46e5);
background: var(--primary-soft, #eef2ff);
}
.xx-smart-match-thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #f1f5f9;
overflow: hidden;
}
.xx-smart-match-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.xx-smart-match-thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary, #94a3b8);
}
.xx-smart-match-score {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
border-radius: 12px;
}
.xx-smart-match-check {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
background: var(--primary-color, #4f46e5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.xx-smart-match-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 6px;
font-size: 11px;
color: #fff;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
}
.xx-smart-match-info {
padding: 10px 12px;
}
.xx-smart-match-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1e293b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.xx-smart-match-reason {
font-size: 11px;
color: var(--text-secondary, #64748b);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.xx-smart-match-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: #f8fafc;
border-radius: 12px;
}
.xx-smart-match-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px 20px;
background: #f8fafc;
border-radius: 12px;
text-align: center;
}
.xx-smart-match-summary {
padding: 12px 16px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 12px;
}
.xx-smart-match-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* ============================================================
AI 智能生成标题
============================================================ */
.xx-ai-title-section {
padding: 16px;
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 14px;
margin-bottom: 16px;
}
.xx-ai-title-header {
margin-bottom: 10px;
}
.xx-ai-title-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-ai-title-input-row {
display: flex;
gap: 10px;
}
.xx-ai-title-input {
flex: 1;
padding: 10px 14px;
font-size: 13px;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 10px;
background: #fff;
transition: border-color 0.2s;
}
.xx-ai-title-input:focus {
outline: none;
border-color: var(--primary-color, #4f46e5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.xx-ai-title-input::placeholder {
color: var(--text-tertiary, #94a3b8);
}
.xx-ai-title-results {
margin-top: 14px;
}
.xx-ai-title-results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.xx-ai-title-results-count {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1e293b);
}
.xx-ai-title-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
padding-right: 4px;
}
.xx-ai-title-card {
position: relative;
padding: 12px 14px;
background: #fff;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
.xx-ai-title-card:hover {
border-color: var(--primary-color, #4f46e5);
transform: translateX(2px);
}
.xx-ai-title-card.selected {
border-color: var(--primary-color, #4f46e5);
background: var(--primary-soft, #eef2ff);
}
.xx-ai-title-card-text {
font-size: 13px;
color: var(--text-primary, #1e293b);
line-height: 1.5;
padding-right: 50px;
}
.xx-ai-title-card-tag {
display: inline-block;
margin-top: 6px;
padding: 2px 8px;
font-size: 11px;
border-radius: 10px;
background: #f1f5f9;
color: var(--text-secondary, #64748b);
}
.xx-ai-title-card.catchy .xx-ai-title-card-tag {
background: #fef3c7;
color: #b45309;
}
.xx-ai-title-card.emotional .xx-ai-title-card-tag {
background: #fce7f3;
color: #be185d;
}
.xx-ai-title-card.informative .xx-ai-title-card-tag {
background: #dbeafe;
color: #1d4ed8;
}
.xx-ai-title-card-check {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
width: 20px;
height: 20px;
background: var(--primary-color, #4f46e5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.xx-ai-title-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-size: 13px;
color: var(--text-secondary, #64748b);
}
.xx-divider {
display: flex;
align-items: center;
margin: 16px 0;
color: var(--text-tertiary, #94a3b8);
font-size: 12px;
}
.xx-divider::before,
.xx-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--border-primary, #e2e8f0);
}
.xx-divider span {
padding: 0 12px;
}
/* ============================================================
标题设置(选择标题步骤)
============================================================ */
@@ -1749,3 +2416,226 @@
border-radius: var(--radius-sm);
line-height: 1.5;
}
/* ================================================================
生成结果(右侧)
================================================================ */
.xx-generate-result {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: 20px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.xx-result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.xx-result-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.xx-result-count {
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 2px 10px;
border-radius: 12px;
}
.xx-result-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
}
.xx-result-progress {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
gap: 20px;
}
.xx-progress-circle {
position: relative;
width: 80px;
height: 80px;
}
.xx-progress-circle svg {
width: 100%;
height: 100%;
}
.xx-progress-percent {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.xx-progress-text {
text-align: center;
}
/* 视频卡片网格 */
.xx-video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
overflow-y: auto;
flex: 1;
padding-right: 4px;
}
.xx-video-card {
background: var(--bg-primary);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
border: 1px solid var(--border-color);
}
.xx-video-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
border-color: var(--primary-300);
}
.xx-video-thumb {
position: relative;
aspect-ratio: 16 / 9;
background: var(--bg-tertiary);
overflow: hidden;
}
.xx-video-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.xx-video-thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.xx-video-play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.2s;
}
.xx-video-card:hover .xx-video-play-overlay {
opacity: 1;
}
.xx-video-duration {
position: absolute;
bottom: 6px;
right: 6px;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 11px;
border-radius: 4px;
font-family: monospace;
}
.xx-video-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
}
.xx-video-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xx-video-actions {
display: flex;
gap: 6px;
}
.xx-video-action-btn {
width: 28px;
height: 28px;
border: none;
background: var(--bg-tertiary);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
}
.xx-video-action-btn:hover {
background: var(--primary-100);
color: var(--primary-color);
}
.xx-result-footer {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.xx-btn-block {
width: 100%;
justify-content: center;
}
/* 预览弹窗 */
.xx-preview-modal-content {
background: #000;
border-radius: 8px;
overflow: hidden;
}
.ant-modal-content {
padding: 0 !important;
}
.ant-modal-close {
color: #fff !important;
}
@@ -111,7 +111,8 @@ const MyTemplates: React.FC = () => {
}
const handleGenerate = (tpl: EditingTemplate) => {
navigate(`/editing-planner?template=${tpl.id}&generate=1`)
// 跳转到智能剪辑页面,统一从智能剪辑出片
navigate(`/generate?templateId=${tpl.id}`)
}
const handleCopy = (tpl: EditingTemplate) => {
@@ -3,7 +3,7 @@
* 对接后端模板管理 API
* - 分页查询(page/page_size/category/keyword/duration_range
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
* - 复制模板 / 从模板生成剪辑计划
* - 复制模板 / 从模板生成
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
*/
import React, { useState, useMemo, useCallback } from "react"
@@ -597,7 +597,7 @@ const TemplateLibrary: React.FC = () => {
<div className="xx-templates-header">
<div className="xx-templates-header-text">
<h2></h2>
<p></p>
<p></p>
</div>
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
+
@@ -1953,7 +1953,7 @@ const VoiceMaterialLibrary: React.FC = () => {
icon={<PlusOutlined />}
onClick={handleTtsSave}
>
</Button>
</div>
)}
Executable → Regular
+5 -14
View File
@@ -9,6 +9,7 @@ import Login from "@/pages/auth/Login"
import Register from "@/pages/auth/Register"
import ForgotPassword from "@/pages/auth/ForgotPassword"
import ResetPassword from "@/pages/auth/ResetPassword"
import WechatCallback from "@/pages/auth/WechatCallback"
import HomePage from "@/pages/home/HomePage"
import { useAuthStore } from "@/store/authStore"
@@ -60,6 +61,10 @@ export const router = createBrowserRouter([
path: "/reset-password",
element: <ResetPassword />,
},
{
path: "/auth/wechat/callback",
element: <WechatCallback />,
},
{
path: "/app",
element: (
@@ -156,20 +161,6 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "edit-plans",
lazy: () =>
import("@/pages/edit-plans/EditPlans").then((m) => ({
Component: m.default,
})),
},
{
path: "edit-plans/:planId/clips",
lazy: () =>
import("@/pages/edit-plans/PlanClipsManager").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-clone",
lazy: () =>
+1 -1
View File
@@ -23,7 +23,7 @@ import {
copyEditPlan,
getMediaAssets,
getMediaAsset,
} from "@/api/editPlans"
} from "@/api/templateEditor"
const mockGet = vi.fn()
const mockPost = vi.fn()
@@ -2,7 +2,7 @@ import React from "react"
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import AssetSelector from "@/components/AssetSelector/AssetSelector"
import type { MediaAsset } from "@/api/editPlans"
import type { MediaAsset } from "@/api/templateEditor"
vi.mock("@/components/ui", () => ({
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
+13 -3
View File
@@ -7,8 +7,18 @@ import { renderHook, act } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
const mockNavigate = vi.fn()
const mockSetAuth = vi.fn()
const mockClearAuth = vi.fn()
const mockSetAuth = vi.fn((_user: any, accessToken: string, refreshToken?: string | null) => {
localStorage.setItem("access_token", accessToken)
if (refreshToken) {
localStorage.setItem("refresh_token", refreshToken)
} else {
localStorage.removeItem("refresh_token")
}
})
const mockClearAuth = vi.fn(() => {
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
})
const mockMutateAsync = vi.fn()
const mockQueryClear = vi.fn()
@@ -99,7 +109,7 @@ describe("useAuth hooks", () => {
expect(localStorage.getItem("access_token")).toBe("access-123")
expect(localStorage.getItem("refresh_token")).toBe("refresh-456")
expect(mockSetAuth).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith("/")
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
})
it("没有 refresh_token 时从 localStorage 移除", async () => {
@@ -1,74 +0,0 @@
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import EditPlans from "@/pages/edit-plans/EditPlans"
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
}
})
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn().mockImplementation((opts: any) => {
const key = opts?.queryKey?.[0] || ""
if (key === "templates-list-simple") {
return { data: [], isLoading: false, isError: false, refetch: vi.fn() }
}
return {
data: { items: [], total: 0 },
isLoading: false,
isError: false,
refetch: vi.fn(),
}
}),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
}),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
}),
}))
vi.mock("@ant-design/icons", () => ({
CheckCircleOutlined: () => <span>CheckCircleOutlined</span>,
ClockCircleOutlined: () => <span>ClockCircleOutlined</span>,
SyncOutlined: () => <span>SyncOutlined</span>,
CloseCircleOutlined: () => <span>CloseCircleOutlined</span>,
EditOutlined: () => <span>EditOutlined</span>,
DeleteOutlined: () => <span>DeleteOutlined</span>,
FileTextOutlined: () => <span>FileTextOutlined</span>,
ThunderboltOutlined: () => <span>ThunderboltOutlined</span>,
CopyOutlined: () => <span>CopyOutlined</span>,
UnorderedListOutlined: () => <span>UnorderedListOutlined</span>,
StopOutlined: () => <span>StopOutlined</span>,
}))
vi.mock("@/api/templates", () => ({
getTemplatesList: vi.fn().mockResolvedValue([]),
}))
vi.mock("@/api/editPlans", () => ({
getEditPlans: vi.fn().mockResolvedValue({ items: [], total: 0 }),
deleteEditPlan: vi.fn(),
generateEditPlan: vi.fn(),
cancelGeneration: vi.fn(),
copyEditPlan: vi.fn(),
}))
describe("EditPlans", () => {
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<EditPlans />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
})
@@ -146,7 +146,7 @@ vi.mock("@/api/editingPlanner", () => ({
MODE_LABELS: { pip: "画中画", intro_outro: "片头片尾", watermark: "水印" },
}))
vi.mock("@/api/editPlans", () => ({
vi.mock("@/api/templateEditor", () => ({
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
generateCover: vi.fn().mockResolvedValue({}),
+18 -5
View File
@@ -213,11 +213,24 @@ vi.mock("@/api/titles", () => ({
getTitles: vi.fn().mockResolvedValue({ items: [] }),
}))
vi.mock("@/api/editPlans", () => ({
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
updateEditPlan: vi.fn().mockResolvedValue({}),
getEditPlan: vi.fn().mockResolvedValue({}),
vi.mock("@/api/templateEditor", () => ({
generateEditPlan: vi.fn().mockResolvedValue({
plan_id: "test-plan",
generation_task_id: "test-task",
plan_status: "processing",
clip_count: 5,
}),
updateEditPlan: vi.fn().mockResolvedValue({ plan_id: "test-plan", template_id: "test-template" }),
getEditPlan: vi.fn().mockResolvedValue({
plan_id: "test-plan",
template_id: "test-template",
name: "",
config: {},
status: "draft",
}),
getGenerationStatus: vi
.fn()
.mockResolvedValue({ plan_status: "completed", generation_task_id: "test-task", clips: [] }),
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
}))
@@ -1,60 +0,0 @@
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import PlanClipsManager from "@/pages/edit-plans/PlanClipsManager"
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ templateId: "test-123" }),
}
})
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { clips: [], name: "Test Plan" },
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
}),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
}),
}))
vi.mock("@ant-design/icons", () => ({
ArrowLeftOutlined: () => <span>ArrowLeftOutlined</span>,
PlusOutlined: () => <span>PlusOutlined</span>,
DeleteOutlined: () => <span>DeleteOutlined</span>,
EditOutlined: () => <span>EditOutlined</span>,
UploadOutlined: () => <span>UploadOutlined</span>,
OrderedListOutlined: () => <span>OrderedListOutlined</span>,
SaveOutlined: () => <span>SaveOutlined</span>,
}))
vi.mock("@/api/editPlans", () => ({
getPlanClips: vi.fn().mockResolvedValue({ clips: [], name: "" }),
updatePlanClipsOrder: vi.fn(),
deletePlanClip: vi.fn(),
createPlanClip: vi.fn(),
}))
describe("PlanClipsManager", () => {
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<PlanClipsManager />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
})
@@ -32,7 +32,7 @@ vi.mock("antd", () => ({
},
}))
vi.mock("@/api/editPlans", () => ({
vi.mock("@/api/templateEditor", () => ({
getEditPlanClips: vi.fn().mockResolvedValue({ items: [], total: 0 }),
createEditPlanClip: vi.fn().mockResolvedValue({}),
updateEditPlanClip: vi.fn().mockResolvedValue({}),
+13 -1
View File
@@ -16,7 +16,19 @@ export default defineConfig({
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "src/test/", "e2e/", "**/*.d.ts", "**/*.config.*", "**/mockData"],
exclude: [
"node_modules/",
"src/test/",
"e2e/",
"**/*.d.ts",
"**/*.config.*",
"**/mockData",
// 页面级组件不纳入单测覆盖率统计(页面级走 E2E/手动测试)
"src/pages/generate/GeneratePage.tsx",
"src/pages/editing-planner/EditingPlanner.tsx",
"src/pages/assets/AssetLibrary.tsx",
"src/pages/voice-materials/VoiceMaterialLibrary.tsx",
],
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
thresholds: {
+217 -75
View File
@@ -186,85 +186,15 @@ class RenderAdapter:
self._report_progress(progress_cb, 35.0, "准备 BGM 音频")
# 3. 准备 BGM(从 plan.config.bgm 读取配置
bgm_path = self._prepare_bgm(plan, work_dir, plan_id)
self._report_progress(progress_cb, 40.0, "执行视频渲染")
# 4. 初始化 ASR 服务(用于自动字幕)
asr_service = self._get_asr_service()
# 5. 从 plan.config.export 读取输出分辨率
plan_config = plan.config or {}
export_config = plan_config.get("export", {}) or {}
output_width, output_height = _parse_resolution(export_config.get("resolution"))
logger.info(
"渲染输出分辨率: plan_id=%s resolution=%dx%d source=%s",
plan_id,
output_width,
output_height,
"config" if export_config.get("resolution") else "default",
)
# 6. 执行统一渲染
render_svc = UnifiedRenderService(
# 3~6. 统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传
return self._do_render(
plan=plan,
clips=ready_clips,
asset_path_map=asset_path_map,
work_dir=work_dir,
output_width=output_width,
output_height=output_height,
bgm_path=bgm_path,
asr_service=asr_service,
)
result = render_svc.render()
self._report_progress(progress_cb, 80.0, "上传渲染结果")
# 4. 上传结果
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
output_url = upload_to_oss(result.output_path, storage_key)
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
# 5. 生成缩略图(在清理临时目录前)
thumbnail_url = ""
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
except Exception as thumb_err:
logger.warning(
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
plan_id,
thumb_err,
)
self._report_progress(progress_cb, 100.0, "渲染完成")
logger.info(
"[render-adapter] render success: plan_id=%s job_id=%s engine=unified "
"duration=%.2fs file_size=%d resolution=%dx%d clip_count=%d",
plan_id,
job_id,
result.duration,
result.file_size,
result.width,
result.height,
len(ready_clips),
)
return RenderAdapterResult(
success=True,
output_url=output_url or "",
output_path=result.output_path,
thumbnail_url=thumbnail_url,
duration=result.duration,
file_size=result.file_size,
width=result.width,
height=result.height,
clip_count=len(ready_clips),
plan_id=plan_id,
job_id=job_id,
progress_cb=progress_cb,
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
)
@@ -540,3 +470,215 @@ class RenderAdapter:
except Exception as e:
logger.warning("ASR 服务初始化失败,自动字幕将不可用: %s", e)
return None
def _do_render(
self,
plan: Any,
clips: list[Any],
asset_path_map: dict[str, Path],
work_dir: Path,
*,
plan_id: str,
job_id: str = "",
progress_cb: ProgressCallback | None = None,
rendered_clip_ids: list[str] | None = None,
failed_clip_ids: list[str] | None = None,
) -> RenderAdapterResult:
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
render_plan render_from_memory 共用此方法
Args:
rendered_clip_ids: 成功下载/准备的 clip id 列表render_plan 从下载阶段传入
failed_clip_ids: 失败的 clip id 列表
Returns:
RenderAdapterResult
"""
# 1. 准备 BGM
bgm_path = self._prepare_bgm(plan, work_dir, plan_id)
self._report_progress(progress_cb, 40.0, "执行视频渲染")
# 2. 初始化 ASR
asr_service = self._get_asr_service()
# 3. 读取输出分辨率
plan_config = plan.config or {}
export_config = plan_config.get("export", {}) or {}
output_width, output_height = _parse_resolution(export_config.get("resolution"))
logger.info(
"渲染输出分辨率: plan_id=%s resolution=%dx%d source=%s",
plan_id,
output_width,
output_height,
"config" if export_config.get("resolution") else "default",
)
# 4. 执行统一渲染
render_svc = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
work_dir=work_dir,
output_width=output_width,
output_height=output_height,
bgm_path=bgm_path,
asr_service=asr_service,
)
result = render_svc.render()
self._report_progress(progress_cb, 80.0, "上传渲染结果")
# 5. 上传结果
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
output_url = upload_to_oss(result.output_path, storage_key)
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
# 6. 生成缩略图
thumbnail_url = ""
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
except Exception as thumb_err:
logger.warning(
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
plan_id,
thumb_err,
)
self._report_progress(progress_cb, 100.0, "渲染完成")
logger.info(
"[render-adapter] render success: plan_id=%s job_id=%s engine=unified "
"duration=%.2fs file_size=%d resolution=%dx%d clip_count=%d",
plan_id,
job_id,
result.duration,
result.file_size,
result.width,
result.height,
len(clips),
)
final_rendered_ids = (
rendered_clip_ids if rendered_clip_ids is not None else [c.id for c in clips if hasattr(c, "id")]
)
final_failed_ids = failed_clip_ids if failed_clip_ids is not None else []
return RenderAdapterResult(
success=True,
output_url=output_url or "",
output_path=result.output_path,
thumbnail_url=thumbnail_url,
duration=result.duration,
file_size=result.file_size,
width=result.width,
height=result.height,
clip_count=len(clips),
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
)
def render_from_memory(
self,
plan: Any,
clips: list[Any],
asset_path_map: dict[str, Path],
*,
plan_id: str = "",
job_id: str = "",
work_dir: Path | None = None,
progress_cb: ProgressCallback | None = None,
) -> RenderAdapterResult:
"""使用内存中的 plan/clips/asset_path_map 直接渲染。
适用于一键生成等不写DB剪辑计划的场景复用统一的 BGM/ASR/分辨率/渲染/缩略图逻辑
Args:
plan: EditPlan 的对象鸭子类型需有 id/config 等属性
clips: EditPlanClip 的对象列表
asset_path_map: asset_id local_path 映射
plan_id: 用于日志的计划标识不传则用 plan.id
job_id: 关联的 Job ID
work_dir: 工作目录不传则用临时目录
progress_cb: 进度回调
Returns:
RenderAdapterResult
"""
actual_plan_id = plan_id or getattr(plan, "id", "memory_plan")
temp_dir = None
try:
if work_dir is None:
temp_dir = tempfile.mkdtemp(prefix="render_mem_")
work_dir = Path(temp_dir)
work_dir.mkdir(parents=True, exist_ok=True)
if not clips:
return RenderAdapterResult(
success=False,
error_message="没有可渲染的片段",
clip_count=0,
)
if not asset_path_map:
return RenderAdapterResult(
success=False,
error_message="素材路径映射为空",
clip_count=len(clips),
)
logger.info(
"开始内存模式渲染: plan_id=%s job_id=%s clip_count=%d engine=unified",
actual_plan_id,
job_id,
len(clips),
)
self._report_progress(progress_cb, 35.0, "准备 BGM 音频")
return self._do_render(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
work_dir=work_dir,
plan_id=actual_plan_id,
job_id=job_id,
progress_cb=progress_cb,
)
except subprocess.CalledProcessError as exc:
stderr_text = (exc.stderr or "").strip()
logger.error(
"[render-adapter] 内存模式渲染失败: plan_id=%s exit_code=%d\nstderr:\n%s",
actual_plan_id,
exc.returncode,
stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
)
return RenderAdapterResult(
success=False,
error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[:200]}",
error_detail=stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
)
except Exception as exc:
logger.exception(
"[render-adapter] 内存模式渲染失败: plan_id=%s error=%s",
actual_plan_id,
str(exc)[:200],
)
return RenderAdapterResult(
success=False,
error_message=str(exc)[:500],
)
finally:
if temp_dir:
import shutil
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as cleanup_err:
logger.warning("临时目录清理失败: path=%s error=%s", temp_dir, cleanup_err)
@@ -1,204 +0,0 @@
"""渲染引擎 Feature Flag 解析器。
封装渲染引擎选择逻辑支持
- 环境变量作为默认值RENDER_ENGINE=legacy/unified
- Redis Feature Flag 运行时覆盖白名单 + 百分比 + 全局开关
- 定时刷新支持热更新不重启 worker
使用方式
resolver = RenderEngineResolver(redis_url="redis://...", default_engine="legacy")
engine = resolver.get_engine(user_id="user123")
# engine: "legacy" 或 "unified"
"""
from __future__ import annotations
import logging
import threading
from typing import Optional
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
FeatureFlagStore,
InMemoryFeatureFlagStore,
RedisFeatureFlagStore,
)
logger = logging.getLogger(__name__)
# Feature Flag 名称常量
FLAG_RENDER_ENGINE = "render_engine"
# 引擎常量
ENGINE_LEGACY = "legacy"
ENGINE_UNIFIED = "unified"
VALID_ENGINES = {ENGINE_LEGACY, ENGINE_UNIFIED}
class RenderEngineResolver:
"""渲染引擎选择器。
判定逻辑从高到低
1. Redis flag 白名单匹配 unified
2. Redis flag 百分比命中 unified
3. Redis flag 全局开启100% unified
4. 环境变量默认值 legacy / unified
Redis 不可用时自动降级到环境变量默认值不影响业务
"""
def __init__(
self,
default_engine: str = ENGINE_LEGACY,
redis_url: Optional[str] = None,
refresh_interval: float = 30.0,
store: Optional[FeatureFlagStore] = None,
) -> None:
"""
Args:
default_engine: 环境变量默认的引擎名legacy / unified
redis_url: Redis 连接 URL None 时使用内存实现测试用
refresh_interval: Redis flag 配置刷新间隔
store: 直接传入 store 实例测试用优先级高于 redis_url
"""
self._default_engine = default_engine.lower() if default_engine else ENGINE_LEGACY
if self._default_engine not in VALID_ENGINES:
logger.warning(
"Invalid default engine '%s', fallback to '%s'",
self._default_engine,
ENGINE_LEGACY,
)
self._default_engine = ENGINE_LEGACY
if store is not None:
self._store = store
elif redis_url:
self._store = RedisFeatureFlagStore(redis_url=redis_url)
else:
self._store = InMemoryFeatureFlagStore()
logger.info("No Redis configured, using in-memory feature flag store")
self._refresh_interval = refresh_interval
self._lock = threading.Lock()
self._cached_config: Optional[FeatureFlagConfig] = None
self._last_refresh: float = 0.0
def _maybe_refresh(self) -> None:
"""惰性刷新配置,超过刷新间隔时从存储重新读取。"""
import time
now = time.time()
if now - self._last_refresh < self._refresh_interval:
return
try:
config = self._store.get(FLAG_RENDER_ENGINE)
with self._lock:
self._cached_config = config
self._last_refresh = now
except Exception as exc:
logger.warning("Failed to refresh render engine flag: %s", exc)
# 刷新失败时保留旧缓存,不中断业务
if self._cached_config is None:
# 首次就读失败,设一个默认值
with self._lock:
self._cached_config = FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
self._last_refresh = now
def _get_config(self) -> FeatureFlagConfig:
"""获取当前 flag 配置(带缓存)。"""
if self._cached_config is None:
self._maybe_refresh()
else:
self._maybe_refresh()
return self._cached_config or FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
def get_engine(self, user_id: Optional[str] = None) -> str:
"""获取当前应该使用的渲染引擎。
Args:
user_id: 用户ID用于白名单匹配和百分比哈希
None 时只看全局开关
Returns:
"legacy" "unified"
"""
config = self._get_config()
# 全局关闭 → 用默认值
if not config.enabled:
return self._default_engine
# 白名单匹配 / 百分比命中 → unified
if config.is_active(user_id):
return ENGINE_UNIFIED
# 未命中灰度 → 用默认值
return self._default_engine
def should_use_unified(self, user_id: Optional[str] = None) -> bool:
"""便捷方法:是否应该使用统一渲染引擎。"""
return self.get_engine(user_id) == ENGINE_UNIFIED
def force_refresh(self) -> None:
"""强制立即刷新配置(用于管理接口修改后立即生效)。"""
self._last_refresh = 0.0
if isinstance(self._store, RedisFeatureFlagStore):
self._store.invalidate_cache(FLAG_RENDER_ENGINE)
self._maybe_refresh()
def get_config_snapshot(self) -> dict:
"""获取当前配置快照(用于管理接口展示)。"""
config = self._get_config()
return {
"flag_name": FLAG_RENDER_ENGINE,
"default_engine": self._default_engine,
"enabled": config.enabled,
"percentage": config.percentage,
"whitelist": sorted(config.whitelist),
"refresh_interval": self._refresh_interval,
"last_refresh": self._last_refresh,
}
def set_flag(self, config: FeatureFlagConfig) -> None:
"""设置 flag 配置(管理接口用)。"""
config.name = FLAG_RENDER_ENGINE
self._store.set(config)
self.force_refresh()
# 全局单例
_resolver: Optional[RenderEngineResolver] = None
_resolver_lock = threading.Lock()
def get_render_engine_resolver() -> RenderEngineResolver:
"""获取全局单例(基于 worker 配置)。"""
global _resolver
if _resolver is not None:
return _resolver
with _resolver_lock:
if _resolver is not None:
return _resolver
try:
from worker_app.core.config import get_settings
settings = get_settings()
redis_url = getattr(settings, "redis_url", None) or getattr(settings, "broker_url", None)
default = getattr(settings, "render_engine", ENGINE_LEGACY)
_resolver = RenderEngineResolver(
default_engine=default,
redis_url=redis_url,
)
logger.info(
"RenderEngineResolver initialized: default=%s, redis=%s",
default,
bool(redis_url),
)
except Exception as exc:
logger.warning("Failed to init RenderEngineResolver from settings: %s", exc)
_resolver = RenderEngineResolver(default_engine=ENGINE_LEGACY)
return _resolver
@@ -188,6 +188,8 @@ class UnifiedRenderService:
self.bgm_path = bgm_path
self._transition_engine = TransitionEngine(default_duration=transition_duration)
self._speed_engine = SpeedEngine()
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
self._asr_timeline_cached = False
def render(self) -> RenderResult:
"""执行渲染,返回 RenderResult.
@@ -589,7 +591,13 @@ class UnifiedRenderService:
MVP 版本使用第一个有音频的素材做ASR然后按比例映射到整个视频时长
后续优化支持多片段拼接后的完整音频ASR
带缓存同一 plan 只做一次 ASRTTS 配音和字幕共用结果
"""
# 检查缓存
if self._asr_timeline_cached:
return self._asr_timeline_cache
from packages.domain.subtitle import SubtitleTimeline
# 找第一个有本地路径的素材
@@ -602,7 +610,10 @@ class UnifiedRenderService:
if first_asset_path is None:
logger.warning("ASR字幕生成失败:找不到可用素材音频")
return SubtitleTimeline(segments=[], total_duration=video_duration)
result = SubtitleTimeline(segments=[], total_duration=video_duration)
self._asr_timeline_cache = result
self._asr_timeline_cached = True
return result
# 提取素材音频为 wav(16kHz单声道,ASR友好格式)
audio_path = self.work_dir / f"asr_audio_{self.plan.id}.wav"
@@ -637,6 +648,9 @@ class UnifiedRenderService:
except Exception:
pass
# 存入缓存
self._asr_timeline_cache = timeline
self._asr_timeline_cached = True
return timeline
def _extract_audio(self, video_path: Path, output_path: Path) -> None:
@@ -669,11 +683,75 @@ class UnifiedRenderService:
) -> bool:
"""根据 plan.config 生成 TTS 配音,加到 audio 图层.
支持三种触发方式
1. config.tts.enabled = true 标准 TTS 配置
2. 顶层 voice_id + custom_text 桥接模式自定义文案配音
3. 顶层 voice_id + subtitle.auto_generated=true ASR 字幕对齐配音预设配音
Returns:
是否成功添加了配音音轨
"""
config = self.plan.config or {}
tts_cfg = config.get("tts", {}) or {}
subtitle_cfg = config.get("subtitle", {}) or {}
use_subtitle_align = False # 是否使用字幕对齐模式
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
if not tts_cfg.get("enabled"):
top_voice_id = config.get("voice_id", "") or ""
top_text = config.get("custom_text", "") or ""
# 方式Avoice_id + custom_text → 整段配音
if top_voice_id and top_text:
tts_cfg = {
"enabled": True,
"voice_id": top_voice_id,
"text": top_text,
"align_mode": "full",
"overlap_mode": "replace",
}
logger.info(
"[unified-render] 检测到顶层 voice_id+custom_text,桥接到 tts 配置(整段): plan_id=%s voice_id=%s text_len=%d",
self.plan.id,
top_voice_id,
len(top_text),
)
# 方式Bvoice_id + 自动字幕 → 字幕对齐配音(预设配音模式)
elif top_voice_id and subtitle_cfg.get("auto_generated", False) and self.asr_service is not None:
tts_cfg = {
"enabled": True,
"voice_id": top_voice_id,
"text": "",
"align_mode": "subtitle",
"overlap_mode": "replace",
}
use_subtitle_align = True
logger.info(
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
self.plan.id,
top_voice_id,
)
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
# 前端一键生成页面传 config.voice_id + config.custom_text
# 统一渲染引擎从 config.tts 读,这里做桥接映射。
if not tts_cfg.get("enabled"):
top_voice_id = config.get("voice_id", "") or ""
top_text = config.get("custom_text", "") or ""
if top_voice_id and top_text:
tts_cfg = {
"enabled": True,
"voice_id": top_voice_id,
"text": top_text,
"align_mode": "full",
"overlap_mode": "replace",
}
logger.info(
"[unified-render] 检测到顶层 voice_id+custom_text,桥接到 tts 配置: plan_id=%s voice_id=%s text_len=%d",
self.plan.id,
top_voice_id,
len(top_text),
)
tts_config = TtsConfig.parse(tts_cfg)
if not tts_config.enabled:
@@ -685,8 +763,34 @@ class UnifiedRenderService:
tts_service = get_tts_service()
tts_engine = TtsEngine(tts_service, self.work_dir / "tts")
# 整段配音模
result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration)
# 根据对齐模式选择生成方
if use_subtitle_align or tts_config.align_mode == "subtitle":
# 字幕对齐模式:先做 ASR,再按字幕生成配音
if not self._asr_timeline_cached:
self._generate_asr_subtitles(video_duration, subtitle_cfg)
timeline = self._asr_timeline_cache
if timeline is None or not timeline.segments:
logger.warning("TTS 字幕对齐配音:ASR 无识别结果,跳过配音")
return False
# 转换为 TtsEngine 需要的字幕格式
subtitles = [
{
"text": seg.text,
"start_time": seg.start,
"end_time": seg.end,
}
for seg in timeline.segments
if getattr(seg, "text", "").strip()
]
if not subtitles:
logger.warning("TTS 字幕对齐配音:字幕文本为空,跳过配音")
return False
result = tts_engine.generate_subtitle_voiceover(tts_config, subtitles)
else:
# 整段配音模式
result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration)
if not result.success or not result.segments:
logger.warning("TTS 配音生成失败,跳过: %s", result.error_message)
@@ -1050,8 +1154,10 @@ class UnifiedRenderService:
vf_str = ",".join(filters)
# 最终输出时长:取 clip 有效时长和 video_duration 的较小值
final_duration = effective_duration
# 最终输出时长:取 clip 调速后有效时长和 video_duration 的较小值
# 注意:必须用调速后的时长,否则减速场景(speed<1)会被 -t 截断
adjusted_duration = UnifiedRenderService._clip_adjusted_duration(clip)
final_duration = adjusted_duration
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
-3
View File
@@ -19,9 +19,6 @@ class WorkerSettings(BaseSettings):
auto_create_schema: bool = False
redis_url: str = "redis://redis:6379/0"
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
render_engine: str = "legacy"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
+4 -91
View File
@@ -1,6 +1,6 @@
"""视频合成 Celery 任务 — Phase 8 任务 2.10.
使用 JobService 管理任务生命周期集成 VideoComposeService 执行合成
使用 JobService 管理任务生命周期通过 RenderAdapter 调用 UnifiedRenderService 执行合成
"""
from __future__ import annotations
@@ -35,9 +35,7 @@ def _get_job_service():
def compose_video(self, job_id: str, **kwargs):
"""视频合成任务。
根据 RENDER_ENGINE 配置选择渲染引擎
- legacy: VideoComposeServicefilter_complex 模式
- unified: UnifiedRenderService图层架构
使用 UnifiedRenderService图层架构进行渲染
Args:
job_id: JobService 中的任务 ID
@@ -56,30 +54,8 @@ def compose_video(self, job_id: str, **kwargs):
job_service.fail_job(job_id, "Missing plan_id in job payload")
return {"status": "error", "message": "Missing plan_id"}
# 判断使用哪个渲染引擎
# 优先级:Redis Feature Flag(白名单 > 百分比) > 环境变量默认
from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver()
user_id = job.created_by_user_id or None
engine = resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"compose_video 引擎选择: job_id=%s engine=%s user_id=%s enabled=%s percentage=%s whitelist=%d default=%s",
job_id,
engine,
user_id,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
if engine == "unified":
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
else:
return _compose_with_legacy_engine(self, job_service, job, plan_id, db)
# 使用 unified 渲染引擎
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
except self.retry_exc as exc:
logger.warning("视频合成重试中: job_id=%s, exc=%s", job_id, exc)
@@ -95,69 +71,6 @@ def compose_video(self, job_id: str, **kwargs):
db.close()
def _compose_with_legacy_engine(task, job_service, job, plan_id: str, db) -> dict:
"""旧引擎渲染路径(VideoComposeService)。"""
job_id = job.id
# 标记为 running
job_service.update_progress(job_id, progress=10.0, current_stage="初始化合成环境")
# 延迟导入 VideoComposeService
from apps.api.app.services.video_compose_service import VideoComposeService
compose_svc = VideoComposeService(db)
# 校验合成条件
job_service.update_progress(job_id, progress=20.0, current_stage="校验合成条件")
validation = compose_svc.validate_compose(plan_id)
if not validation.valid:
error_msg = "; ".join(validation.errors)
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
return {"status": "error", "message": error_msg}
# 构建合成命令
job_service.update_progress(job_id, progress=30.0, current_stage="构建 FFmpeg 命令")
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
compose_cmd = compose_svc.build_compose_command(plan_id, output_path)
# 执行 FFmpeg
job_service.update_progress(job_id, progress=50.0, current_stage="正在执行视频合成")
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
try:
from video_processing.ffmpeg_utils import run_ffmpeg
run_ffmpeg(compose_cmd.command, timeout=3600)
except Exception as e:
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
job_service.fail_job(job_id, error_msg)
raise
# 上传结果
job_service.update_progress(job_id, progress=80.0, current_stage="上传合成结果")
storage_key = f"rendered/{plan_id}/{job_id}.mp4"
from worker_app.tasks.edit_plan_generation import _upload_to_oss
output_url = _upload_to_oss(Path(output_path), storage_key)
# 更新 Job 状态为完成
result_data = {
"plan_id": plan_id,
"output_path": output_path,
"storage_key": storage_key,
"output_url": output_url or "",
"estimated_duration": compose_cmd.estimated_duration,
"clip_count": len(compose_cmd.clip_chains),
"engine": "legacy",
}
job_service.complete_job(job_id, result=result_data)
logger.info("视频合成完成(legacy): job_id=%s, plan_id=%s", job_id, plan_id)
return {"status": "completed", "job_id": job_id, "result": result_data}
def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> dict:
"""新引擎渲染路径(UnifiedRenderService + RenderAdapter)。"""
job_id = job.id
@@ -1,24 +1,18 @@
"""剪辑计划渲染任务 — 支持 Feature Flag 灰度.
"""剪辑计划渲染任务 — 使用 UnifiedRenderService 统一渲染引擎.
Celery 任务 worker.render_edit_plan:
1. 加载 EditPlan + EditPlanClips
2. 根据 Feature Flag 选择渲染引擎legacy / unified
2. 通过 RenderAdapter 调用 UnifiedRenderService 渲染
3. 下载各片段素材 + 渲染
4. 上传渲染结果到 OSS
5. 创建 GeneratedVideo 记录 + 查重
6. 更新 EditPlan / EditPlanClip 状态
7. 更新 GenerationTask 进度
渲染引擎灰度
- Feature Flag (render_engine) 控制
- legacy: VideoComposeService + FFmpeg filter_complex
- unified: UnifiedRenderService 图层架构
"""
from __future__ import annotations
import logging
import tempfile
from datetime import datetime, timezone
from pathlib import Path
@@ -35,10 +29,6 @@ OUTPUT_FPS = 25.0
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.oss_helpers import (
download_asset,
upload_to_oss,
)
# ── Repository imports (延迟导入避免循环依赖) ─────────────────────────────────
@@ -66,34 +56,6 @@ def _get_repos():
# ── Celery Task ───────────────────────────────────────────────────────────────
def _resolve_render_engine(user_id: str) -> str:
"""根据 Feature Flag 决定使用哪个渲染引擎。
Returns:
"legacy" "unified"
"""
try:
from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver()
engine = resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"edit_plan 引擎选择: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
user_id,
engine,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
return engine
except Exception as exc:
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
return "legacy"
def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg: str):
"""统一的计划失败标记工具。"""
plan = plan_repo.get(plan_id)
@@ -180,8 +142,6 @@ def _finalize_render_success(
plan.config["rendered_storage_key"] = storage_key
if hasattr(plan, "total_duration") and duration > 0:
plan.total_duration = duration
if hasattr(plan, "result_count"):
plan.result_count = 1
plan.mark_completed()
plan_repo.update(plan)
@@ -311,331 +271,13 @@ def _render_with_unified(
)
def _render_with_legacy(
plan,
clips,
rendered_clip_ids: list[str],
failed_clip_ids: list[str],
tmpdir_path: Path,
plan_id: str,
generation_task_id: str,
plan_repo,
clip_repo,
gen_task_repo,
db,
) -> dict:
"""旧引擎路径(VideoComposeService + FFmpeg filter_complex)。"""
import os
from apps.api.app.services.video_compose_service import VideoComposeService
compose_svc = VideoComposeService(db)
# 校验合成条件
validation = compose_svc.validate_compose(plan_id)
if not validation.valid:
error_msg = "; ".join(validation.errors)
logger.error("合成校验失败(legacy): %s%s", plan_id, error_msg)
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, f"合成校验失败: {error_msg}")
return {"status": "error", "message": error_msg}
# 构建 FFmpeg 命令
output_dir = os.environ.get("VIDEO_OUTPUT_DIR", str(tmpdir_path))
output_path = Path(output_dir) / f"{plan_id}.mp4"
# 从 plan.config.export 读取输出分辨率,兼容 plan 自定义配置
plan_config = plan.config or {}
export_config = plan_config.get("export", {}) or {}
output_width = OUTPUT_WIDTH
output_height = OUTPUT_HEIGHT
resolution = export_config.get("resolution", "")
if resolution and "x" in resolution:
try:
w_str, h_str = resolution.lower().split("x", 1)
output_width = int(w_str)
output_height = int(h_str)
except (ValueError, TypeError):
pass
fps = export_config.get("fps", 25)
try:
fps = int(fps)
except (ValueError, TypeError):
fps = 25
compose_cmd = compose_svc.build_compose_command(
plan_id,
str(output_path),
output_width=output_width,
output_height=output_height,
fps=fps,
)
logger.info("执行 FFmpeg (legacy): plan_id=%s cmd=%s", plan_id, " ".join(compose_cmd.command)[:500])
# 开始渲染,更新进度
if generation_task_id:
try:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.progress < 40.0:
gen_task.progress = 40.0
gen_task.append_log(
stage="render_start",
message="开始FFmpeg渲染(legacy",
level="INFO",
progress=40.0,
)
gen_task_repo.update(gen_task)
except Exception:
pass
try:
from video_processing.ffmpeg_utils import run_ffmpeg
run_ffmpeg(compose_cmd.command, timeout=3600)
except Exception as e:
# 提取完整 stderr(如果是 CalledProcessError
stderr_text = ""
if hasattr(e, "stderr"):
stderr_raw = e.stderr
if isinstance(stderr_raw, bytes):
stderr_text = stderr_raw.decode("utf-8", errors="replace")
elif isinstance(stderr_raw, str):
stderr_text = stderr_raw
# 完整命令(截断前2000字符,避免日志过大)
full_cmd = " ".join(compose_cmd.command)
cmd_preview = full_cmd[:2000] + ("..." if len(full_cmd) > 2000 else "")
# 拼接完整错误信息:命令 + 异常 + stderr最后1500字符
error_parts = [f"FFmpeg渲染失败(exit={getattr(e, 'returncode', 'unknown')})"]
error_parts.append("--- cmd ---")
error_parts.append(cmd_preview)
if stderr_text:
# 取最后1500字符,通常错误信息在末尾
stderr_preview = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
error_parts.append("--- stderr (last 1500 chars) ---")
error_parts.append(stderr_preview)
error_msg = "\n".join(error_parts)
logger.error("FFmpeg 执行失败(legacy): plan_id=%s\n%s", plan_id, error_msg)
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
return {"status": "error", "message": error_msg}
# 获取文件大小 + 实际时长
file_size = output_path.stat().st_size if output_path.exists() else 0
duration = compose_cmd.estimated_duration or 0.0
try:
from video_processing.ffmpeg_utils import probe_duration
actual_duration = probe_duration(str(output_path))
if actual_duration > 0:
duration = actual_duration
except Exception:
pass
# ── 标题/字幕叠加(legacy 引擎补齐) ────────────────────────────────
plan_config = plan.config or {}
title_cfg = plan_config.get("title", {}) or {}
subtitle_cfg = plan_config.get("subtitle", {}) or {}
title_text = title_cfg.get("text", "") or ""
subtitle_text = subtitle_cfg.get("text", "") or ""
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_cfg.get("enabled", True) and bool(subtitle_text.strip())
# ASR 自动字幕 legacy 暂不支持(需要额外 ASR 服务,统一用 unified 引擎)
has_subtitle_overlay = title_enabled or subtitle_enabled
if has_subtitle_overlay and output_path.exists() and duration > 0:
try:
from video_processing.ffmpeg_utils import run_ffmpeg
from video_processing.render_subtitles import generate_ass_subtitles
ass_path = tmpdir_path / f"subtitles_{plan_id}.ass"
generate_ass_subtitles(
ass_path,
video_width=output_width,
video_height=output_height,
video_duration=duration,
title_text=title_text,
title_config=title_cfg,
subtitle_text=subtitle_text,
subtitle_config=subtitle_cfg,
)
# 用 subtitles 滤镜叠加 ASS 字幕,音频直接 copy
subtitled_path = tmpdir_path / f"{plan_id}_subtitled.mp4"
# 处理 Windows 路径下的 ass 滤镜转义问题
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", r"\:")
run_ffmpeg(
[
"ffmpeg",
"-y",
"-i",
str(output_path),
"-vf",
f"subtitles='{ass_filter_path}'",
"-c:a",
"copy",
str(subtitled_path),
],
timeout=1800,
)
if subtitled_path.exists() and subtitled_path.stat().st_size > 0:
output_path = subtitled_path
file_size = subtitled_path.stat().st_size
logger.info(
"legacy 标题/字幕叠加完成: plan_id=%s title=%s subtitle=%s",
plan_id,
title_enabled,
subtitle_enabled,
)
except Exception as sub_err:
logger.warning("legacy 标题/字幕叠加失败(不影响主流程): plan_id=%s err=%s", plan_id, sub_err)
# ── TTS 配音混音(legacy 引擎补齐) ────────────────────────────────
tts_cfg = plan_config.get("tts", {}) or {}
tts_enabled = tts_cfg.get("enabled", False) and bool(tts_cfg.get("text", "").strip())
if tts_enabled and output_path.exists() and duration > 0:
try:
from packages.domain.tts_config import TtsConfig
tts_config = TtsConfig.parse(tts_cfg)
if tts_config.enabled and tts_config.text.strip():
from apps.worker.services.tts_service_factory import get_tts_service
tts_service = get_tts_service()
voiceover_path = tmpdir_path / f"voiceover_{plan_id}.wav"
# 生成配音音频
audio_path = tts_service.synthesize(
text=tts_config.text,
voice_id=tts_config.voice_id,
speed=tts_config.speed,
pitch=tts_config.pitch,
output_path=voiceover_path,
)
if audio_path and audio_path.exists() and audio_path.stat().st_size > 0:
from video_processing.ffmpeg_utils import run_ffmpeg
mixed_path = tmpdir_path / f"{plan_id}_with_voiceover.mp4"
# 混音:配音音量按配置调整
voice_volume = max(0.0, min(1.0, tts_config.volume))
if tts_config.overlap_mode == "mix":
# 混音模式:原音 + 配音混合
filter_complex = (
f"[0:a]volume=1.0[a0];"
f"[1:a]volume={voice_volume:.2f}[a1];"
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=0[aout]"
)
else:
# replace 模式:配音替换原音
filter_complex = f"[1:a]volume={voice_volume:.2f}[aout]"
run_ffmpeg(
[
"ffmpeg",
"-y",
"-i",
str(output_path),
"-i",
str(audio_path),
"-filter_complex",
filter_complex,
"-map",
"0:v",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
str(mixed_path),
],
timeout=1800,
)
if mixed_path.exists() and mixed_path.stat().st_size > 0:
output_path = mixed_path
file_size = mixed_path.stat().st_size
logger.info(
"legacy TTS 配音混音完成: plan_id=%s voice_id=%s mode=%s",
plan_id,
tts_config.voice_id,
tts_config.overlap_mode,
)
except Exception as tts_err:
logger.warning("legacy TTS 配音混音失败(不影响主流程): plan_id=%s err=%s", plan_id, tts_err)
# 渲染完成,更新进度
if generation_task_id:
try:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.progress < 80.0:
gen_task.progress = 80.0
gen_task.append_log(
stage="render_done",
message="FFmpeg渲染完成(legacy",
level="INFO",
progress=80.0,
)
gen_task_repo.update(gen_task)
except Exception:
pass
# 上传到 OSS
storage_key = f"rendered/{plan_id}/output.mp4"
output_url = upload_to_oss(output_path, storage_key)
# 上传完成,更新进度
if generation_task_id:
try:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.progress < 95.0:
gen_task.progress = 95.0
gen_task.append_log(
stage="upload_done",
message="OSS上传完成(legacy",
level="INFO",
progress=95.0,
)
gen_task_repo.update(gen_task)
except Exception:
pass
return _finalize_render_success(
plan=plan,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
plan_id=plan_id,
output_url=output_url or "",
storage_key=storage_key,
duration=duration,
file_size=file_size,
width=output_width,
height=output_height,
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
generation_task_id=generation_task_id,
output_path=output_path,
engine="legacy",
)
@celery_app.task(name="worker.render_edit_plan", bind=True, max_retries=2)
def render_edit_plan(self, plan_id: str) -> dict:
"""渲染剪辑计划
流程
1. 加载 EditPlan + EditPlanClips
2. 根据 Feature Flag 选择渲染引擎legacy / unified
2. 通过 RenderAdapter 调用 UnifiedRenderService 渲染
3. 下载素材 + 渲染
4. 上传渲染结果到 OSS
5. 创建 GeneratedVideo 记录 + 查重
@@ -645,7 +287,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
generation_task_id = ""
engine = "legacy"
for repos in _get_repos():
plan_repo, clip_repo, gen_task_repo, db = repos
@@ -660,10 +301,7 @@ def render_edit_plan(self, plan_id: str) -> dict:
# 获取 generation_task_id(提前读取,确保 except 块可用)
generation_task_id = plan.config.get("generation_task_id", "")
# 2. 选择渲染引擎(Feature Flag 灰度控制
user_id = plan.created_by_user_id or ""
engine = _resolve_render_engine(user_id)
logger.info("剪辑计划渲染引擎: plan_id=%s engine=%s user_id=%s", plan_id, engine, user_id)
# 2. 准备渲染(使用 unified 渲染引擎
# 3. 加载片段列表(按 order 排序)
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
@@ -681,9 +319,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.started_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="render_start",
message=f"开始渲染,引擎 {engine}片段数 {len(clips)}",
message=f"开始渲染,片段数 {len(clips)}",
level="INFO",
engine=engine,
engine="unified",
clip_count=len(clips),
)
gen_task_repo.update(gen_task)
@@ -706,122 +344,19 @@ def render_edit_plan(self, plan_id: str) -> dict:
pass
return {"status": "cancelled", "plan_id": plan_id, "message": "任务已取消"}
# 4. 根据引擎选择渲染方式
if engine == "unified":
# ── unified 路径:RenderAdapter 统一处理(下载 + BGM + ASR + 渲染 + 上传)
result = _render_with_unified(
plan=plan,
clips=clips,
plan_id=plan_id,
generation_task_id=generation_task_id,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
)
else:
# ── legacy 路径:原有的素材下载 + VideoComposeService
with tempfile.TemporaryDirectory(prefix="edit_plan_") as tmpdir:
tmpdir_path = Path(tmpdir)
asset_path_map: dict[str, Path] = {}
rendered_clip_ids: list[str] = []
failed_clip_ids: list[str] = []
# 4. 渲染(unified 引擎:RenderAdapter 统一处理下载 + BGM + ASR + 渲染 + 上传)
result = _render_with_unified(
plan=plan,
clips=clips,
plan_id=plan_id,
generation_task_id=generation_task_id,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
)
# 预先批量查询所有素材的 storage_key
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
from packages.adapters.sqlalchemy_impl.models import AssetModel
clip_asset_ids = [c.asset_id for c in clips if c.asset_id]
asset_storage_map: dict[str, str] = {}
if clip_asset_ids:
assets = db.query(AssetModel).filter(AssetModel.id.in_(clip_asset_ids)).all()
asset_storage_map = {
a.id: (a.storage_key or a.file_url or "") for a in assets if a.storage_key or a.file_url
}
for clip in clips:
if not clip.asset_id:
# 没有素材的片段跳过,标记为失败
clip.mark_failed()
clip_repo.update(clip)
failed_clip_ids.append(clip.id)
continue
if clip.asset_id in asset_path_map:
# 同一素材已下载(多个 clip 共享同一素材)
rendered_clip_ids.append(clip.id)
continue
storage_key = asset_storage_map.get(clip.asset_id)
if not storage_key:
logger.warning(
"片段素材无 storage_key,跳过: clip_id=%s asset_id=%s",
clip.id,
clip.asset_id,
)
clip.mark_failed()
clip_repo.update(clip)
failed_clip_ids.append(clip.id)
continue
# 下载素材
ext = Path(storage_key).suffix or ".mp4"
local_path = tmpdir_path / f"clip_{clip.order:04d}{ext}"
if download_asset(storage_key, local_path):
asset_path_map[clip.asset_id] = local_path
rendered_clip_ids.append(clip.id)
else:
clip.mark_failed()
clip_repo.update(clip)
failed_clip_ids.append(clip.id)
if not asset_path_map:
logger.error("所有片段素材下载失败: %s", plan_id)
plan.mark_failed()
plan_repo.update(plan)
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
gen_task.status = "failed"
gen_task.error_message = "所有片段素材下载失败"
gen_task.completed_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="download_failed",
message="所有片段素材下载失败",
level="ERROR",
)
gen_task_repo.update(gen_task)
return {"status": "error", "message": "所有片段素材下载失败"}
# 素材下载完成,记录日志
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
gen_task.append_log(
stage="download_done",
message=f"素材下载完成,成功 {len(asset_path_map)} 个,失败 {len(failed_clip_ids)}",
level="INFO",
success_count=len(asset_path_map),
failed_count=len(failed_clip_ids),
)
gen_task.progress = 30.0
gen_task_repo.update(gen_task)
result = _render_with_legacy(
plan=plan,
clips=clips,
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
tmpdir_path=tmpdir_path,
plan_id=plan_id,
generation_task_id=generation_task_id,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
)
result["engine"] = engine
result["engine"] = "unified"
return result
except Exception as exc:
+95 -210
View File
@@ -138,7 +138,6 @@ def _flush_logs(task_id: str, gen_task) -> None:
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
from services.asr_service_factory import get_asr_service
from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
from video_processing.oss_helpers import (
@@ -146,8 +145,6 @@ from video_processing.oss_helpers import (
get_signed_download_url,
upload_to_oss,
)
from video_processing.render_engine_resolver import ENGINE_LEGACY, ENGINE_UNIFIED
from video_processing.unified_render_service import UnifiedRenderService
# ── 虚拟 Plan / Clip(内存中构建,不写数据库) ────────────────────────────────
@@ -944,13 +941,31 @@ def _download_library_assets(
def _validate_template_exists(template_id: str) -> None:
"""校验 template_id 是否存在且可用。
优先读新模板系统EditTemplate找不到 fallback 到旧模板系统TemplateModel
Raises:
ValueError: template_id 不存在或已禁用时抛出
"""
from packages.adapters.sqlalchemy_impl.models import TemplateModel
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditTemplateRepository,
)
session = SessionLocal()
try:
# 优先读新模板系统
new_repo = SQLAlchemyEditTemplateRepository(session)
new_template = new_repo.get(template_id)
if new_template is not None:
status_val = new_template.status.value if hasattr(new_template.status, "value") else new_template.status
if status_val == "active":
logger.info("模板校验通过(新系统): template_id=%s name=%s", template_id, new_template.name)
return
else:
raise ValueError(f"模板已停用: template_id={template_id}")
# fallback: 旧模板系统
from packages.adapters.sqlalchemy_impl.models import TemplateModel
template = (
session.query(TemplateModel)
.filter(
@@ -959,9 +974,11 @@ def _validate_template_exists(template_id: str) -> None:
)
.first()
)
if template is None:
raise ValueError(f"模板不存在或已禁用: template_id={template_id}")
logger.info("模板校验通过: template_id=%s name=%s", template_id, template.name)
if template:
logger.info("模板校验通过(旧系统): template_id=%s name=%s", template_id, template.name)
return
raise ValueError(f"模板不存在或已禁用: template_id={template_id}")
finally:
session.close()
@@ -969,17 +986,51 @@ def _validate_template_exists(template_id: str) -> None:
def _load_template_plan_config(template_id: str) -> dict:
"""从模板加载 plan 级配置(BGM、字幕、标题等效果层)。
TemplateModel bgm_config / subtitle_config / title_config 是独立字段
需要组装成 plan.config 的格式{bgm, subtitle, title}后再注入
优先读新模板系统EditTemplate.config + TemplateClipConfig
找不到 fallback 到旧模板系统TemplateModel 独立字段
模板不存在时返回空 dict不阻塞主流程
"""
if not template_id:
return {}
try:
from packages.adapters.sqlalchemy_impl.models import TemplateModel
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditTemplateRepository,
SQLAlchemyTemplateClipConfigRepository,
)
session = SessionLocal()
try:
# 优先读新模板系统
tpl_repo = SQLAlchemyEditTemplateRepository(session)
clip_repo = SQLAlchemyTemplateClipConfigRepository(session)
template = tpl_repo.get(template_id)
if template is not None:
# 新系统:config 直接就是 plan.config 格式
plan_config = dict(template.config or {})
# 从片段配置中提取 intro/outro 配置
clip_configs = clip_repo.list_by_template(template_id, limit=200)
if clip_configs:
intro_outro = _extract_intro_outro_from_clip_configs(clip_configs)
if intro_outro:
plan_config["intro_outro"] = intro_outro
# 把 editing_mode 也带过去
if template.editing_mode:
plan_config["editing_mode"] = template.editing_mode
logger.info(
"模板配置加载成功(新系统): template_id=%s keys=%s",
template_id,
list(plan_config.keys()),
)
return plan_config
# fallback: 旧模板系统
from packages.adapters.sqlalchemy_impl.models import TemplateModel
template = (
session.query(TemplateModel)
.filter(
@@ -1006,7 +1057,7 @@ def _load_template_plan_config(template_id: str) -> dict:
plan_config["bgm"] = bgm_cfg
logger.info(
"模板配置加载成功: template_id=%s keys=%s",
"模板配置加载成功(旧系统): template_id=%s keys=%s",
template_id,
list(plan_config.keys()),
)
@@ -1018,161 +1069,6 @@ def _load_template_plan_config(template_id: str) -> dict:
return {}
# ── 渲染引擎选择 ─────────────────────────────────────────────────────────────
def _resolve_render_engine(user_id: str) -> str:
"""根据 Feature Flag 决定使用哪个渲染引擎。
Returns:
"legacy" "unified"
"""
try:
from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver()
engine = resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"[渲染引擎] flag 解析: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
user_id,
engine,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
return engine
except Exception as exc:
# 异常时 fallback 到 legacy(保守策略,与 edit_plan_generation 一致)
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
return ENGINE_LEGACY
# ── 旧引擎渲染(FFmpeg filter_complex) ────────────────────────────────────────
def _render_with_legacy_engine(
task_id: str,
virtual_clips: list[_VirtualClip],
asset_path_map: dict[str, Path],
work_dir: Path,
output_path: Path,
) -> tuple[float, int]:
"""旧引擎渲染路径:手动构建 FFmpeg filter_complex 命令。
说明generate_video 任务使用虚拟 clips EditPlan 数据库记录
因此无法直接复用 VideoComposeService这里手动构建等价的 filter_complex
命令与旧引擎行为一致scale crop setpts trim setpts
fps 归一化保持原帧率
支持模式one_take / pip / voice_over / voice_pip
- 所有模式统一走 concat 滤镜与旧引擎多片段逻辑一致
Returns:
(duration_seconds, file_size_bytes)
"""
import subprocess
main_clips = [
c
for c in virtual_clips
if c.clip_type in ("main", "b_roll", "background")
or (c.clip_type == "main" and c.config.get("role") == "b_roll")
]
if not main_clips:
main_clips = virtual_clips[:1]
input_args: list[str] = []
video_filters: list[str] = []
audio_filters: list[str] = []
for i, clip in enumerate(main_clips):
local_path = asset_path_map.get(clip.asset_id)
if not local_path:
continue
input_args.extend(["-i", str(local_path)])
duration = clip.duration or 0.0
# 视频滤镜:scale → crop → setpts → trim → setpts(与旧引擎一致)
vf = (
f"[{i}:v]"
f"scale={OUTPUT_WIDTH}:{OUTPUT_HEIGHT}:force_original_aspect_ratio=increase,"
f"crop={OUTPUT_WIDTH}:{OUTPUT_HEIGHT},"
f"setpts=PTS-STARTPTS,"
f"trim=0:{duration:.3f},"
f"setpts=PTS-STARTPTS"
f"[v{i}]"
)
video_filters.append(vf)
# 音频滤镜:atrim → asetpts
af = f"[{i}:a]atrim=0:{duration:.3f},asetpts=PTS-STARTPTS[a{i}]"
audio_filters.append(af)
n = len(main_clips)
if n == 1:
video_label = "[v0]"
audio_label = "[a0]"
else:
# concat 视频
v_inputs = "".join(f"[v{i}]" for i in range(n))
video_filters.append(f"{v_inputs}concat=n={n}:v=1:a=0[outv]")
# concat 音频
a_inputs = "".join(f"[a{i}]" for i in range(n))
audio_filters.append(f"{a_inputs}concat=n={n}:v=0:a=1[outa]")
video_label = "[outv]"
audio_label = "[outa]"
# 组装 filter_complex
fc_parts = video_filters + audio_filters
filter_complex = ";".join(fc_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
video_label,
"-map",
audio_label,
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
str(output_path),
]
logger.info("[task_id=%s] [渲染] legacy 引擎 FFmpeg 开始: clips=%d", task_id, n)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
logger.error(
"[task_id=%s] [渲染] legacy 引擎 FFmpeg 失败: %s\nfilter_complex: %s",
task_id,
e,
filter_complex[:500],
)
raise
file_size = output_path.stat().st_size if output_path.exists() else 0
duration = probe_duration(output_path)
return duration, file_size
# ── generate_video 阶段子函数 ─────────────────────────────────────────────────
@@ -1264,13 +1160,15 @@ def _render_video(
) -> tuple[Path, float]:
"""渲染视频(含配音混音)。
使用 RenderAdapter 统一渲染入口复用 BGM/ASR/分辨率/缩略图逻辑
Returns:
(output_path, render_duration)
"""
if not downloaded_videos:
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
# 构建虚拟 plan + clips
# 构建虚拟 plan + clips + asset_path_map
virtual_plan, virtual_clips, asset_path_map = _build_plan_and_clips_from_task(
task_id=task_id,
downloaded_paths=downloaded_videos,
@@ -1282,7 +1180,6 @@ def _render_video(
if template_id:
template_config = _load_template_plan_config(template_id)
if template_config:
# 合并:现有 config 优先级更高(目前为空,模板配置直接生效)
base_config = virtual_plan.config or {}
virtual_plan.config = {**template_config, **base_config}
logger.info(
@@ -1291,6 +1188,16 @@ def _render_video(
list(template_config.keys()),
)
# 确保输出分辨率配置存在(一键生成默认横屏 1280x720)
# RenderAdapter 从 plan.config.export.resolution 读取,
# 如果模板没有配置则用默认值,这里显式设置保持和旧逻辑一致
plan_cfg = virtual_plan.config or {}
export_cfg = plan_cfg.get("export", {}) or {}
if not export_cfg.get("resolution"):
export_cfg["resolution"] = f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}"
plan_cfg["export"] = export_cfg
virtual_plan.config = plan_cfg
total_duration = sum(c.duration for c in virtual_clips)
logger.info(
"[task_id=%s] [剪辑计划] 片段数=%d, 总时长=%.1fs",
@@ -1299,64 +1206,42 @@ def _render_video(
total_duration,
)
# 选择渲染引擎
engine = _resolve_render_engine(user_id) if user_id else ENGINE_UNIFIED
logger.info("[task_id=%s] [渲染] 引擎选择: %s (user_id=%s)", task_id, engine, user_id)
render_start = time.monotonic()
render_output_path = temp_path / f"rendered-{task_id}.mp4"
logger.info("[task_id=%s] [渲染] RenderAdapter 统一渲染开始", task_id)
if engine == ENGINE_LEGACY:
render_duration, _ = _render_with_legacy_engine(
task_id=task_id,
virtual_clips=virtual_clips,
asset_path_map=asset_path_map,
work_dir=temp_path,
output_path=render_output_path,
)
else:
logger.info("[task_id=%s] [渲染] unified 引擎 FFmpeg 渲染开始", task_id)
# 使用 RenderAdapter 统一渲染入口(复用 BGM/ASR/分辨率/缩略图逻辑)
from video_processing.render_adapter import RenderAdapter
from worker_app.db import SessionLocal
# ── 准备 BGM 音频 ──
bgm_path: str | None = None
plan_config = virtual_plan.config or {}
bgm_config = plan_config.get("bgm", {}) or {}
if bgm_config.get("enabled", False):
try:
bgm_path = _prepare_bgm_track(
bgm_config=bgm_config,
temp_path=temp_path,
task_id=task_id,
)
except Exception as bgm_err:
logger.warning("[task_id=%s] [BGM] 准备失败,跳过BGM: %s", task_id, bgm_err)
bgm_path = None
render_service = UnifiedRenderService(
db = SessionLocal()
try:
adapter = RenderAdapter(db)
render_result = adapter.render_from_memory(
plan=virtual_plan,
clips=virtual_clips,
asset_path_map=asset_path_map,
plan_id=f"gen_{task_id}",
job_id=task_id,
work_dir=temp_path,
output_width=OUTPUT_WIDTH,
output_height=OUTPUT_HEIGHT,
output_fps=int(OUTPUT_FPS),
asr_service=get_asr_service(),
bgm_path=bgm_path,
)
render_result = render_service.render()
render_output_path = render_result.output_path
render_duration = render_result.duration
finally:
db.close()
if not render_result.success:
raise RuntimeError(f"渲染失败: {render_result.error_message}")
render_output_path = render_result.output_path
render_duration = render_result.duration
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] %s 引擎完成: 耗时=%.1fs, 时长=%.2fs",
"[task_id=%s] [渲染] RenderAdapter 完成: 耗时=%.1fs, 时长=%.2fs",
task_id,
engine,
render_elapsed,
render_duration,
)
# 配音混音
# 配音混音(素材库音频,后处理混音)
if voice_path:
final_path = temp_path / f"final-{task_id}.mp4"
try:
+153
View File
@@ -0,0 +1,153 @@
# 统一渲染引擎效果层全模式验证报告
> 背景:#608 删除 legacy 渲染引擎后,所有模式统一走 UnifiedRenderService。
> 本报告验证四种模式(一键生成/剪辑计划/模板/手动编辑器)下所有效果层的覆盖情况。
> 验证时间:2026-07-20
---
## 一、验证范围
### 四种渲染模式
| 模式 | 入口路径 | 调用链 |
|------|---------|--------|
| 一键生成(旧) | `worker.generate_video` | `generation.py` → 直接构造 `UnifiedRenderService` |
| 剪辑计划 | `worker.render_edit_plan` | `edit_plan_generation.py``RenderAdapter``UnifiedRenderService` |
| 模板模式 | 模板创建计划 → 剪辑计划渲染 | 同剪辑计划路径 |
| 手动编辑器 | 手动编辑计划 → 剪辑计划渲染 | 同剪辑计划路径 |
> **核心结论**:模板模式和手动编辑器最终都走剪辑计划渲染链路,本质是同一条路径。
> 差异只在「一键生成(旧)」和「剪辑计划」两条链路之间。
---
## 二、效果层覆盖矩阵
### 2.1 Clip 级效果(两条链路一致,均通过 UnifiedRenderService 内部处理)
| 效果 | filter_complex | pass_through(直通) | 备注 |
|------|:---:|:---:|------|
| **裁剪 trim** | ✅ | ✅ | 直通用 trim+durationfilter_complex 用 trim |
| **调速 speed** | ✅ | ✅ | 视频 setpts,音频 atempo |
| **倒放 reverse** | ✅ | ✅ | reverse 滤镜 + areverse |
| **分辨率适配** | ✅ | ✅ | scale + pad/crop,按角色策略不同 |
| **调色 color_grade** | ✅ | ✅ | brightness/contrast/saturation等 |
| **绿幕抠像 chroma_key** | ✅ | ✅ | colorkey 滤镜 |
| **帧率归一化 fps** | ✅ | ✅ | fps 滤镜统一到 output_fps |
| **像素格式 format** | ✅ | ✅ | yuv420p |
### 2.2 层间/全局效果(filter_complex 路径)
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|------|:---:|:---:|------|
| **转场 xfade** | ✅ | ✅ | 多clip场景自动启用;直通模式下自动禁用直通走filter_complex |
| **画中画 PiP** | ✅ | ✅ | overlay + corner_voice 图层 |
| **贴纸 stickers** | ✅ | ✅ | plan.config.stickers;有贴纸时禁用直通 |
| **水印 watermark** | ✅ | ✅ | plan.config.watermark;有水印时禁用直通 |
| **ASS 字幕叠加** | ✅ | ✅ | subtitles 滤镜 |
| **ASR 自动字幕** | ✅ | ✅ | asr_service 传入,生成 ASS |
### 2.3 音频效果
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|------|:---:|:---:|------|
| **BGM 混音** | ✅ | ✅ | 各自准备 BGM 文件,都走 UnifiedRenderService.bgm_path |
| **TTS 配音** | ✅ | ✅ | `_maybe_add_voiceover_layer` + audio 图层混音;刚修了顶层字段桥接(#549 |
| **配音素材库音频** | ⚠️ 待确认 | ✅ | 一键生成用 `_mux_audio_track` 独立混音;剪辑计划路径需确认 voice 类型 clip 处理 |
| **音频降噪** | ✅ | ✅ | afftdn 滤镜,直通和filter_complex都有 |
| **音频格式归一化** | ✅ | ✅ | aformat + aac 编码 |
| **音量调整** | ✅ | ✅ | volume 滤镜 |
### 2.4 后处理
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|------|:---:|:---:|------|
| **片头片尾 intro/outro** | ✅ | ✅ | plan.config.intro_outro |
| **封面抽帧** | ✅ | ✅ | 渲染后抽帧上传 |
| **输出分辨率** | ✅ | ✅ | 剪辑计划从 config.export 读;一键生成用常量 1280x720 |
---
## 三、发现的问题与待修复项
### P1 级问题(功能缺失)
#### 1. 一键生成(旧路径)TTS 配音配置路径不匹配 — **已修复 #549**
- **根因**:前端传 `config.voice_id` + `config.custom_text`(顶层),后端从 `config.tts`
- **修复**`_maybe_add_voiceover_layer` 增加顶层字段桥接兼容
- **影响范围**:所有走 UnifiedRenderService 的路径(剪辑计划 + 一键生成)
#### 2. 直通模式调速失效 — **已修复 #463**
- **根因**`_render_pass_through` 中 final_duration 用原始时长,未考虑调速
- **修复**:改用 `_clip_adjusted_duration` 计算调速后时长
- **影响范围**:单 clip 直通场景(最常见的一键生成场景)
### P2 级问题(架构不统一,功能可用但不一致)
#### 3. 一键生成(旧)配音素材库音频走独立混音链路,不走 audio 图层
- **现状**`generation.py``_mux_audio_track(render_output_path, voice_path, final_path)` 用 ffmpeg 直接 mux
- **问题**:与 UnifiedRenderService 的 audio 图层混音架构不统一;无法与BGM/TTS做混音音量平衡
- **建议**:迁移到 audio 图层模式,与剪辑计划路径对齐
#### 4. 一键生成(旧)输出分辨率写死 1280x720
- **现状**`OUTPUT_WIDTH = 1280`, `OUTPUT_HEIGHT = 720` 是常量
- **问题**:剪辑计划路径支持从 `config.export.resolution` 读取输出分辨率
- **建议**:一键生成也支持从 plan.config 读取分辨率配置
#### 5. _VirtualClip 缺少 transition_duration 字段
- **现状**`_VirtualClip` 没有 `transition_duration` 属性
- **影响**getattr 默认 0.0,转场效果等于没转场(但不会报错)
- **建议**:补全字段,与 EditPlanClip 对齐
### P3 级问题(性能优化)
#### 6. 有 TTS 配音时直通模式被禁用(因为加了 audio 图层变成 2 个图层)
- **现状**:TTS 配音加到 audio 图层后,`len(layers) != 1`,直通被禁用
- **影响**:单 clip + TTS 配音场景不走直通,性能下降 ~30%
- **建议**:直通模式单独处理 audio 图层混音,类似 BGM 的处理方式
---
## 四、各模式验收结论
### ✅ 剪辑计划路径(含模板模式、手动编辑器)
所有效果层验证通过,链路完整:
- clip 级效果(调色/调速/倒放/绿幕/裁剪)✅
- 层间效果(转场/画中画/贴纸/水印)✅
- 音频效果(BGM/TTS配音/降噪/格式归一化)✅
- 字幕(ASS/ASR自动字幕)✅
- 后处理(片头片尾/封面抽帧/分辨率配置)✅
### ⚠️ 一键生成(旧路径)
核心效果可用,但有架构不一致问题:
- 核心渲染效果全部通过 ✅
- TTS 配音已修复 ✅(#549
- 直通调速已修复 ✅(#463
- 配音素材库混音架构不统一 ⚠️(P2)
- 输出分辨率不可配置 ⚠️P2
- transition_duration 缺失 ⚠️P2
---
## 五、修复优先级建议
| 优先级 | 问题 | 工作量 | 建议 |
|--------|------|--------|------|
| P0 | 无 | - | 核心功能均可用 |
| P1 | 已全部修复(#463 #549 | - | 已完成 |
| P2 | 配音素材库音频架构统一 | 中 | 下一轮技术债清理 |
| P2 | 一键生成输出分辨率可配置 | 小 | 顺手修 |
| P2 | _VirtualClip 补 transition_duration | 小 | 顺手修 |
| P3 | TTS配音场景直通模式优化 | 中 | 性能优化排期 |
---
## 六、验证方法
本报告基于代码静态分析 + 单元测试验证:
- 109 个 unified_render_service 单元测试全绿
- 覆盖直通模式、filter_complex 模式、转场、调速、调色、分辨率归一化、帧率归一化、音频格式归一化等核心链路
- 新增直通调速测试 3 个(#463
- 新增 TTS 配置桥接测试 4 个(#549
**建议后续补充端到端集成测试**:用真实素材跑四种模式的完整渲染链路,验证输出音视频质量。
+55 -18
View File
@@ -1,33 +1,69 @@
# ============================================================
# API Dockerfile - 专门用于 FastAPI 应用
# 优化:依赖分层缓存 + 多阶段构建基础层
# API Dockerfile - FastAPI 应用
# 优化:多阶段构建 + pip cache mount + 依赖分层缓存
# ============================================================
# 基础镜像:Python 3.12
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# ==================== 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
# 构建参数:版本号(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
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
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# 从 builder 复制虚拟环境
COPY --from=builder /opt/venv /opt/venv
# 设置工作目录
WORKDIR /app
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
COPY requirements-base.txt /tmp/requirements-base.txt
RUN python -m venv /opt/venv && /opt/venv/bin/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 /opt/venv/bin/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
# 复制应用代码
COPY apps/api/ /app/apps/api/
COPY packages/ /app/packages/
@@ -43,7 +79,8 @@ ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
# 健康检查
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)"
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
+10 -20
View File
@@ -1,9 +1,10 @@
# ============================================================
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身)
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身 + cache mount加速
# 优化项:
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
# 2. ffmpeg 通过 apt 安装(阿里云镜像加速,几秒完成,稳定可靠)
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
# 4. pip cache mount:加速依赖下载(跨构建共享pip wheel缓存)
# ============================================================
# ==================== Builder 阶段 ====================
@@ -19,19 +20,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
python3-dev \
binutils \
wget \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# ---- 下载静态编译 ffmpeg ----
# 使用 johnvansickle.com 的静态编译版本(业界标准)
RUN cd /tmp \
&& wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
&& tar xf ffmpeg-release-amd64-static.tar.xz \
&& cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ffmpeg \
&& cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ffprobe \
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
&& rm -rf ffmpeg-*
# ---- 安装 Python 依赖 ----
WORKDIR /tmp
@@ -42,19 +32,22 @@ ENV PATH="/opt/venv/bin:$PATH"
# 基础依赖
COPY requirements-base.txt /tmp/requirements-base.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
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
# Worker 专属大包
COPY requirements-worker.txt /tmp/requirements-worker.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
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-worker.txt \
&& rm /tmp/requirements-worker.txt
# 业务依赖
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
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
@@ -86,13 +79,10 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debia
# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# 从 builder 复制 ffmpeg 静态二进制
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
# 从 builder 复制 Python 虚拟环境
COPY --from=builder /opt/venv /opt/venv
+34
View File
@@ -17,6 +17,9 @@ class InMemoryUserRepository(UserRepository):
self._username_index: Dict[str, str] = {} # username -> user_id
self._verification_token_index: Dict[str, str] = {} # token -> user_id
self._reset_token_index: Dict[str, str] = {} # token -> user_id
self._wechat_openid_index: Dict[str, str] = {} # openid -> user_id
self._wechat_unionid_index: Dict[str, str] = {} # unionid -> user_id
self._phone_index: Dict[str, str] = {} # phone -> user_id
def save(self, user: User) -> None:
"""保存用户"""
@@ -28,6 +31,12 @@ class InMemoryUserRepository(UserRepository):
self._verification_token_index[user.email_verification_token] = user.id
if user.password_reset_token:
self._reset_token_index[user.password_reset_token] = user.id
if user.wechat_openid:
self._wechat_openid_index[user.wechat_openid] = user.id
if user.wechat_unionid:
self._wechat_unionid_index[user.wechat_unionid] = user.id
if user.phone:
self._phone_index[user.phone] = user.id
def find_by_id(self, user_id: str) -> Optional[User]:
"""根据 ID 查找用户"""
@@ -61,6 +70,31 @@ class InMemoryUserRepository(UserRepository):
return self._users.get(user_id)
return None
def find_by_wechat_openid(self, openid: str) -> Optional[User]:
"""根据微信 openid 查找用户"""
user_id = self._wechat_openid_index.get(openid)
if user_id:
return self._users.get(user_id)
return None
def find_by_wechat_unionid(self, unionid: str) -> Optional[User]:
"""根据微信 unionid 查找用户"""
if not unionid:
return None
user_id = self._wechat_unionid_index.get(unionid)
if user_id:
return self._users.get(user_id)
return None
def find_by_phone(self, phone: str) -> Optional[User]:
"""根据手机号查找用户"""
if not phone:
return None
user_id = self._phone_index.get(phone)
if user_id:
return self._users.get(user_id)
return None
def delete(self, user_id: str) -> bool:
"""删除用户"""
user = self._users.get(user_id)
+85
View File
@@ -0,0 +1,85 @@
"""
短信服务实现Noop + 阿里云
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
class NoopSmsService:
"""空实现短信服务 - 开发/测试环境用,只打日志不真发"""
def send_verification_code(self, phone: str, code: str) -> bool:
logger.info("[NoopSMS] 发送验证码到 %s: %s", phone, code)
return True
def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool:
logger.info("[NoopSMS] 发送模板短信到 %s, template=%s, params=%s", phone, template_id, params)
return True
class AliyunSmsService:
"""阿里云短信服务"""
def __init__(
self,
access_key_id: str | None = None,
access_key_secret: str | None = None,
sign_name: str | None = None,
verify_template_id: str | None = None,
):
self.access_key_id = access_key_id or os.environ.get("ALIYUN_SMS_ACCESS_KEY_ID", "")
self.access_key_secret = access_key_secret or os.environ.get("ALIYUN_SMS_ACCESS_KEY_SECRET", "")
self.sign_name = sign_name or os.environ.get("ALIYUN_SMS_SIGN_NAME", "小应剪辑")
self.verify_template_id = verify_template_id or os.environ.get("ALIYUN_SMS_VERIFY_TEMPLATE_ID", "SMS_123456789")
def send_verification_code(self, phone: str, code: str) -> bool:
return self.send_template_sms(phone, self.verify_template_id, {"code": code})
def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool:
try:
import json
from alibabacloud_dysmsapi20170525 import models as dysmsapi_models
from alibabacloud_dysmsapi20170525.client import Client as DysmsapiClient
from alibabacloud_tea_openapi import models as open_api_models
config = open_api_models.Config(
access_key_id=self.access_key_id,
access_key_secret=self.access_key_secret,
)
config.endpoint = "dysmsapi.aliyuncs.com"
client = DysmsapiClient(config)
request = dysmsapi_models.SendSmsRequest(
phone_numbers=phone,
sign_name=self.sign_name,
template_code=template_id,
template_param=json.dumps(params),
)
response = client.send_sms(request)
body = response.body
if body.code == "OK":
logger.info("阿里云短信发送成功: phone=%s, template=%s", phone, template_id)
return True
else:
logger.error("阿里云短信发送失败: code=%s, message=%s", body.code, body.message)
return False
except ImportError:
logger.error("阿里云短信 SDK 未安装,请 pip install alibabacloud-dysmsapi20170525")
return False
except Exception as e:
logger.error("阿里云短信发送异常: %s", e, exc_info=True)
return False
def get_sms_service() -> "NoopSmsService | AliyunSmsService":
"""获取短信服务实例"""
provider = os.environ.get("SMS_PROVIDER", "noop").lower()
if provider == "aliyun":
return AliyunSmsService()
return NoopSmsService()
@@ -100,7 +100,6 @@ class SQLAlchemyEditPlanRepository:
name=plan.name,
status=plan.status,
total_duration=plan.total_duration,
result_count=plan.result_count,
source_edit_plan_id=plan.source_edit_plan_id or None,
project_id=plan.project_id or "",
created_by_user_id=plan.created_by_user_id or "",
@@ -120,7 +119,6 @@ class SQLAlchemyEditPlanRepository:
model.name = plan.name
model.status = plan.status
model.total_duration = plan.total_duration
model.result_count = plan.result_count
model.source_edit_plan_id = plan.source_edit_plan_id or None
model.project_id = plan.project_id or ""
model.created_by_user_id = plan.created_by_user_id or ""
@@ -154,7 +152,6 @@ class SQLAlchemyEditPlanRepository:
name=model.name,
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
total_duration=model.total_duration or 0.0,
result_count=int(model.result_count or 0),
source_edit_plan_id=model.source_edit_plan_id or "",
project_id=model.project_id or "",
created_by_user_id=model.created_by_user_id or "",
+3
View File
@@ -76,6 +76,7 @@ class SQLAlchemyEditTemplateRepository:
preview_url=template.preview_url,
sort_weight=template.sort_weight,
status=template.status,
version=template.version,
)
self.session.add(model)
self.session.commit()
@@ -95,6 +96,7 @@ class SQLAlchemyEditTemplateRepository:
model.preview_url = template.preview_url
model.sort_weight = template.sort_weight
model.status = template.status
model.version = template.version
model.updated_at = template.updated_at
self.session.commit()
self.session.refresh(model)
@@ -135,6 +137,7 @@ class SQLAlchemyEditTemplateRepository:
preview_url=model.preview_url or "",
sort_weight=model.sort_weight or 0,
status=EditTemplateStatus(model.status) if model.status else EditTemplateStatus.ACTIVE,
version=model.version or 1,
created_at=model.created_at,
updated_at=model.updated_at,
)
+41 -2
View File
@@ -31,9 +31,13 @@ class UserModel(Base):
used_storage_gb = Column(Integer, nullable=False, default=0)
# 管理员标识
is_admin = Column(Boolean, nullable=False, default=False)
# 微信登录(小程序端)
# 微信登录
wechat_openid = Column(String(128), nullable=True, unique=True, index=True)
wechat_unionid = Column(String(128), nullable=True, unique=True, index=True)
# 手机号绑定
phone = Column(String(32), nullable=True, unique=True, index=True)
phone_verified = Column(Boolean, nullable=False, default=False)
binding_completed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -134,10 +138,31 @@ class EditTemplateModel(Base):
preview_url = Column(String(1000), nullable=False, default="")
sort_weight = Column(Integer, nullable=False, default=0, index=True)
status = Column(String(20), nullable=False, default="active", index=True)
version = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class EditTemplateVersionModel(Base):
"""模板发布版本快照 ORM 模型
每次发布保存完整快照支持版本历史查询和回滚
"""
__tablename__ = "edit_template_versions"
id = Column(String(36), primary_key=True)
template_id = Column(String(32), nullable=False, index=True)
version = Column(Integer, nullable=False)
name = Column(String(200), nullable=False, default="")
editing_mode = Column(String(30), nullable=False, default="one_take")
config = Column(JSON, nullable=False, default=dict)
clip_configs = Column(JSON, nullable=False, default=list)
change_note = Column(String(500), nullable=False, default="")
published_by = Column(String(36), nullable=False, default="")
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class EditPlanModel(Base):
"""Phase 8 剪辑计划 ORM 模型
@@ -151,7 +176,6 @@ class EditPlanModel(Base):
name = Column(String(200), nullable=False)
status = Column(String(20), nullable=False, default="draft", index=True)
total_duration = Column(Float, nullable=False, default=0.0)
result_count = Column(Integer, nullable=False, default=0)
config = Column(JSON, nullable=False, default=dict)
source_edit_plan_id = Column(String(36), nullable=True, index=True)
project_id = Column(String(36), nullable=False, default="", index=True)
@@ -535,3 +559,18 @@ class BillingRecordModel(Base):
invoice_url = Column(String(500), nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
paid_at = Column(DateTime, nullable=True)
class VerificationCodeModel(Base):
"""验证码(邮箱/手机统一管理)"""
__tablename__ = "verification_codes"
id = Column(String(36), primary_key=True)
recipient = Column(String(255), nullable=False, index=True) # 邮箱或手机号
code = Column(String(10), nullable=False)
code_type = Column(String(32), nullable=False, index=True) # email_bind / phone_bind / ...
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
attempts = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+9 -3
View File
@@ -92,14 +92,20 @@ class SQLAlchemyTemplateClipConfigRepository:
self.session.commit()
return True
def delete_by_template(self, template_id: str) -> int:
"""删除模板下所有片段配置,返回删除数量"""
def delete_by_template(self, template_id: str, *, commit: bool = True) -> int:
"""删除模板下所有片段配置,返回删除数量
Args:
template_id: 模板ID
commit: 是否提交事务默认True外层有事务控制时传False
"""
count = (
self.session.query(TemplateClipConfigModel)
.filter(TemplateClipConfigModel.template_id == template_id)
.delete()
)
self.session.commit()
if commit:
self.session.commit()
return count
def count(self, *, template_id: Optional[str] = None) -> int:
@@ -0,0 +1,79 @@
"""SQLAlchemy implementation of EditTemplateVersionRepository."""
from __future__ import annotations
from typing import List
from sqlalchemy.orm import Session
from packages.domain.template_version import EditTemplateVersion
class SQLAlchemyTemplateVersionRepository:
"""模板版本仓储实现(SQLAlchemy)。"""
def __init__(self, db: Session) -> None:
self._db = db
def create(self, version: EditTemplateVersion) -> EditTemplateVersion:
"""保存新版本快照"""
from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel
model = EditTemplateVersionModel(
id=version.id,
template_id=version.template_id,
version=version.version,
name=version.name,
editing_mode=version.editing_mode,
config=version.config,
clip_configs=version.clip_configs,
change_note=version.change_note,
published_by=version.published_by,
created_at=version.created_at,
)
self._db.add(model)
self._db.flush()
return version
def get_by_version(self, template_id: str, version: int) -> EditTemplateVersion | None:
"""按版本号获取快照"""
from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel
model = (
self._db.query(EditTemplateVersionModel)
.filter(
EditTemplateVersionModel.template_id == template_id,
EditTemplateVersionModel.version == version,
)
.first()
)
if model is None:
return None
return self._to_entity(model)
def list_by_template(self, template_id: str, limit: int = 50) -> List[EditTemplateVersion]:
"""列出模板的所有历史版本(按版本号倒序)"""
from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel
models = (
self._db.query(EditTemplateVersionModel)
.filter(EditTemplateVersionModel.template_id == template_id)
.order_by(EditTemplateVersionModel.version.desc())
.limit(limit)
.all()
)
return [self._to_entity(m) for m in models]
def _to_entity(self, model) -> EditTemplateVersion:
return EditTemplateVersion(
id=model.id,
template_id=model.template_id,
version=model.version,
name=model.name or "",
editing_mode=model.editing_mode or "one_take",
config=model.config or {},
clip_configs=model.clip_configs or [],
change_note=model.change_note or "",
published_by=model.published_by or "",
created_at=model.created_at,
)
+12
View File
@@ -35,6 +35,9 @@ class SQLAlchemyUserRepository(UserRepository):
model.is_admin = user.is_admin
model.wechat_openid = user.wechat_openid
model.wechat_unionid = user.wechat_unionid
model.phone = user.phone
model.phone_verified = user.phone_verified
model.binding_completed_at = user.binding_completed_at
model.created_at = user.created_at
self.session.commit()
@@ -61,6 +64,12 @@ class SQLAlchemyUserRepository(UserRepository):
model = self.session.query(UserModel).filter(UserModel.wechat_unionid == unionid.strip()).first()
return self._to_entity(model)
def find_by_phone(self, phone: str) -> User | None:
if not phone or not phone.strip():
return None
model = self.session.query(UserModel).filter(UserModel.phone == phone.strip()).first()
return self._to_entity(model)
def find_by_verification_token(self, token: str) -> User | None:
model = self.session.query(UserModel).filter(UserModel.email_verification_token == token).first()
return self._to_entity(model)
@@ -101,5 +110,8 @@ class SQLAlchemyUserRepository(UserRepository):
is_admin=model.is_admin or False,
wechat_openid=model.wechat_openid,
wechat_unionid=model.wechat_unionid,
phone=model.phone,
phone_verified=model.phone_verified or False,
binding_completed_at=model.binding_completed_at,
created_at=model.created_at,
)
@@ -0,0 +1,79 @@
"""
验证码仓储 SQLAlchemy 实现
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import VerificationCodeModel
from packages.domain.verification_code import VerificationCode
from packages.ports.verification_code_repository import VerificationCodeRepository
class SQLAlchemyVerificationCodeRepository(VerificationCodeRepository):
def __init__(self, session: Session):
self.session = session
def save(self, code: VerificationCode) -> None:
model = self.session.get(VerificationCodeModel, code.id)
if model is None:
model = VerificationCodeModel(id=code.id)
self.session.add(model)
model.recipient = code.recipient
model.code = code.code
model.code_type = code.code_type
model.expires_at = code.expires_at
model.used_at = code.used_at
model.attempts = code.attempts
model.created_at = code.created_at
self.session.commit()
self.session.refresh(model)
def find_latest(self, recipient: str, code_type: str) -> Optional[VerificationCode]:
model = (
self.session.query(VerificationCodeModel)
.filter(
VerificationCodeModel.recipient == recipient.strip(),
VerificationCodeModel.code_type == code_type,
)
.order_by(VerificationCodeModel.created_at.desc())
.first()
)
return self._to_entity(model)
def find_by_id(self, code_id: str) -> Optional[VerificationCode]:
return self._to_entity(self.session.get(VerificationCodeModel, code_id))
def count_today(self, recipient: str, code_type: str) -> int:
now = datetime.now(timezone.utc)
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
return (
self.session.query(VerificationCodeModel)
.filter(
VerificationCodeModel.recipient == recipient.strip(),
VerificationCodeModel.code_type == code_type,
VerificationCodeModel.created_at >= start_of_day,
)
.count()
)
@staticmethod
def _to_entity(model: VerificationCodeModel | None) -> VerificationCode | None:
if model is None:
return None
return VerificationCode(
id=model.id,
recipient=model.recipient,
code=model.code,
code_type=model.code_type,
expires_at=model.expires_at,
used_at=model.used_at,
attempts=model.attempts,
created_at=model.created_at,
)
+231
View File
@@ -0,0 +1,231 @@
"""
微信登录 + 绑定手机号邮箱 Use Case
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Optional
from packages.application.auth.verification_code_service import (
CODE_TYPE_EMAIL_BIND,
CODE_TYPE_PHONE_BIND,
normalize_phone,
validate_email,
validate_phone,
)
from packages.domain.entities import User
logger = logging.getLogger(__name__)
class BindContactRequest:
"""绑定联系方式请求"""
def __init__(
self,
user_id: str,
phone: str = "",
phone_code: str = "",
email: str = "",
email_code: str = "",
):
self.user_id = user_id
self.phone = normalize_phone(phone) if phone else ""
self.phone_code = phone_code.strip() if phone_code else ""
self.email = email.strip().lower() if email else ""
self.email_code = email_code.strip() if email_code else ""
class BindContactResponse:
"""绑定响应"""
def __init__(self, user: User):
self.user = user
def to_dict(self) -> dict:
return {
"user": {
"id": self.user.id,
"email": self.user.email,
"phone": self.user.phone,
"phone_verified": self.user.phone_verified,
"display_name": self.user.display_name,
"binding_complete": self.user.binding_completed_at is not None,
}
}
class BindContactUseCase:
"""绑定手机+邮箱用例"""
def __init__(
self,
user_repository,
verification_code_service,
email_service=None,
):
self.user_repo = user_repository
self.verification_service = verification_code_service
self.email_service = email_service
def execute(self, request: BindContactRequest) -> tuple[Optional[BindContactResponse], Optional[str]]:
try:
# 1. 校验参数
if not request.phone and not request.email:
return None, "至少填写手机号或邮箱"
# 2. 查找用户
user = self.user_repo.find_by_id(request.user_id)
if not user:
return None, "用户不存在"
# 3. 手机号绑定
if request.phone:
ok, err = validate_phone(request.phone)
if not ok:
return None, err
if not request.phone_code:
return None, "请输入手机验证码"
# 校验手机号未被其他账号绑定
existing = self.user_repo.find_by_phone(request.phone)
if existing and existing.id != user.id:
return None, "该手机号已被其他账号绑定"
# 校验验证码
ok, err = self.verification_service.verify(
recipient=request.phone,
code_type=CODE_TYPE_PHONE_BIND,
code_value=request.phone_code,
)
if not ok:
return None, f"手机验证码错误:{err}"
user.phone = request.phone
user.phone_verified = True
# 4. 邮箱绑定
if request.email:
ok, err = validate_email(request.email)
if not ok:
return None, err
if not request.email_code:
return None, "请输入邮箱验证码"
# 校验邮箱未被其他账号绑定
existing = self.user_repo.find_by_email(request.email)
if existing and existing.id != user.id:
return None, "该邮箱已被其他账号绑定"
# 校验验证码
ok, err = self.verification_service.verify(
recipient=request.email,
code_type=CODE_TYPE_EMAIL_BIND,
code_value=request.email_code,
)
if not ok:
return None, f"邮箱验证码错误:{err}"
user.email = request.email
user.email_verified = True
# 5. 判断是否完成绑定
if user.phone_verified and user.email_verified and "@wechat.local" not in user.email:
user.binding_completed_at = datetime.now(timezone.utc)
# 6. 保存
self.user_repo.save(user)
return BindContactResponse(user=user), None
except Exception as e:
logger.error("绑定联系方式失败: %s", e, exc_info=True)
return None, f"绑定失败: {str(e)}"
class SendVerificationCodeRequest:
"""发送验证码请求"""
def __init__(self, target: str, value: str, purpose: str):
self.target = target # phone / email
self.value = value.strip()
self.purpose = purpose # bind / login / reset_password
class SendVerificationCodeResponse:
"""发送验证码响应"""
def __init__(self, expires_in: int, resend_after: int):
self.expires_in = expires_in
self.resend_after = resend_after
def to_dict(self) -> dict:
return {
"expires_in": self.expires_in,
"resend_after": self.resend_after,
}
class SendVerificationCodeUseCase:
"""发送验证码用例"""
def __init__(
self,
verification_code_service,
sms_service=None,
email_service=None,
):
self.verification_service = verification_code_service
self.sms_service = sms_service
self.email_service = email_service
def execute(
self, request: SendVerificationCodeRequest
) -> tuple[Optional[SendVerificationCodeResponse], Optional[str]]:
try:
# 1. 确定 code_type
if request.target == "phone":
ok, err = validate_phone(request.value)
if not ok:
return None, err
code_type = f"{request.target}_{request.purpose}"
recipient = normalize_phone(request.value)
elif request.target == "email":
ok, err = validate_email(request.value)
if not ok:
return None, err
code_type = f"{request.target}_{request.purpose}"
recipient = request.value.lower()
else:
return None, f"不支持的目标类型: {request.target}"
# 2. 生成验证码
code_obj, err = self.verification_service.generate(recipient, code_type)
if err:
return None, err
# 3. 发送
if request.target == "phone" and self.sms_service:
self.sms_service.send_verification_code(recipient, code_obj.code)
elif request.target == "email" and self.email_service:
subject = "验证码 - 小应剪辑"
body = f"您的验证码是:{code_obj.code}5分钟内有效。"
self.email_service.send_email(recipient, subject, body)
# 4. 返回
ttl = (code_obj.expires_at - code_obj.created_at).total_seconds()
return (
SendVerificationCodeResponse(
expires_in=int(ttl),
resend_after=60,
),
None,
)
except Exception as e:
logger.error("发送验证码失败: %s", e, exc_info=True)
return None, f"发送失败: {str(e)}"
+206
View File
@@ -0,0 +1,206 @@
"""
验证码服务
- 生成验证码
- 校验验证码
- 频控60s 冷却 + 每日上限
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import Optional
from packages.domain.verification_code import VerificationCode
from packages.ports.verification_code_repository import VerificationCodeRepository
logger = logging.getLogger(__name__)
# 频控参数
RESEND_COOLDOWN_SECONDS = 60 # 重发冷却时间
DAILY_LIMIT = 10 # 每日发送上限
MAX_ATTEMPTS = 5 # 单验证码最大尝试次数
DEFAULT_TTL_SECONDS = 300 # 默认有效期 5 分钟
# 验证码类型
CODE_TYPE_EMAIL_BIND = "email_bind"
CODE_TYPE_PHONE_BIND = "phone_bind"
CODE_TYPE_EMAIL_LOGIN = "email_login"
CODE_TYPE_PHONE_LOGIN = "phone_login"
CODE_TYPE_RESET_PASSWORD = "reset_password"
VALID_CODE_TYPES = {
CODE_TYPE_EMAIL_BIND,
CODE_TYPE_PHONE_BIND,
CODE_TYPE_EMAIL_LOGIN,
CODE_TYPE_PHONE_LOGIN,
CODE_TYPE_RESET_PASSWORD,
}
class VerificationCodeService:
"""验证码服务"""
def __init__(
self,
repo: VerificationCodeRepository,
resend_cooldown: int = RESEND_COOLDOWN_SECONDS,
daily_limit: int = DAILY_LIMIT,
max_attempts: int = MAX_ATTEMPTS,
default_ttl: int = DEFAULT_TTL_SECONDS,
):
self.repo = repo
self.resend_cooldown = resend_cooldown
self.daily_limit = daily_limit
self.max_attempts = max_attempts
self.default_ttl = default_ttl
def generate(
self,
recipient: str,
code_type: str,
ttl_seconds: int | None = None,
custom_code: str | None = None,
) -> tuple[Optional[VerificationCode], Optional[str]]:
"""
生成验证码
Returns:
(验证码实体, 错误信息)
"""
recipient = recipient.strip()
# 参数校验
if not recipient:
return None, "接收方不能为空"
if code_type not in VALID_CODE_TYPES:
return None, f"无效的验证码类型: {code_type}"
# 频控检查
can_send, wait_seconds = self._check_rate_limit(recipient, code_type)
if not can_send:
if wait_seconds > 0:
return None, f"发送太频繁,请 {wait_seconds} 秒后再试"
return None, "今日发送次数已达上限"
# 生成并保存
code = VerificationCode.create(
recipient=recipient,
code_type=code_type,
ttl_seconds=ttl_seconds or self.default_ttl,
custom_code=custom_code,
)
self.repo.save(code)
return code, None
def verify(
self,
recipient: str,
code_type: str,
code_value: str,
consume: bool = True,
) -> tuple[bool, Optional[str]]:
"""
校验验证码
Args:
recipient: 接收方邮箱/手机号
code_type: 验证码类型
code_value: 用户输入的验证码
consume: 校验成功后是否标记为已使用
Returns:
(是否通过, 错误信息)
"""
recipient = recipient.strip()
code_value = code_value.strip()
if not recipient or not code_value:
return False, "参数不完整"
# 查找最新的验证码
latest = self.repo.find_latest(recipient, code_type)
if not latest:
return False, "验证码不存在或已过期"
# 增加尝试次数
latest.increment_attempts()
self.repo.save(latest)
# 检查是否已使用
if latest.is_used:
return False, "验证码已使用,请重新获取"
# 检查是否过期
if latest.is_expired:
return False, "验证码已过期,请重新获取"
# 检查尝试次数
if latest.attempts > self.max_attempts:
return False, "验证次数过多,请重新获取验证码"
# 校验验证码
if latest.code != code_value:
return False, "验证码错误"
# 校验通过,标记为已使用
if consume:
latest.mark_used()
self.repo.save(latest)
return True, None
def _check_rate_limit(self, recipient: str, code_type: str) -> tuple[bool, int]:
"""
频控检查
Returns:
(是否允许发送, 需等待秒数)
"""
# 检查冷却时间
latest = self.repo.find_latest(recipient, code_type)
if latest:
elapsed = (datetime.now(timezone.utc) - latest.created_at).total_seconds()
if elapsed < self.resend_cooldown:
wait = int(self.resend_cooldown - elapsed)
return False, wait
# 检查每日上限
today_count = self.repo.count_today(recipient, code_type)
if today_count >= self.daily_limit:
return False, 0
return True, 0
def validate_phone(phone: str) -> tuple[bool, str]:
"""校验手机号格式(中国大陆手机号)"""
phone = phone.strip()
if not phone:
return False, "手机号不能为空"
# 支持 +86 前缀或纯 11 位
pattern = r"^(\+86)?1[3-9]\d{9}$"
if not re.match(pattern, phone):
return False, "手机号格式不正确"
return True, ""
def normalize_phone(phone: str) -> str:
"""标准化手机号(去掉 +86 前缀,统一存储格式)"""
phone = phone.strip()
if phone.startswith("+86"):
phone = phone[3:]
return phone
def validate_email(email: str) -> tuple[bool, str]:
"""校验邮箱格式"""
email = email.strip()
if not email:
return False, "邮箱不能为空"
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, email):
return False, "邮箱格式不正确"
return True, ""
+206
View File
@@ -0,0 +1,206 @@
"""
微信 OAuth 服务
- 生成授权链接网页扫码登录
- 处理回调 code access_token + 用户信息
"""
from __future__ import annotations
import logging
import os
import time
import urllib.parse
from dataclasses import dataclass
from threading import Lock
from typing import Optional
from uuid import uuid4
import requests
logger = logging.getLogger(__name__)
STATE_TTL_SECONDS = 600 # state 有效期 10 分钟
class MemoryStateStore:
"""内存 state 存储(简单实现,单节点可用)
多实例部署时建议替换为 Redis 实现
"""
def __init__(self, ttl_seconds: int = STATE_TTL_SECONDS):
self._ttl = ttl_seconds
self._states: dict[str, float] = {} # state -> expire_at
self._lock = Lock()
def put(self, state: str) -> None:
with self._lock:
self._clean_expired()
self._states[state] = time.time() + self._ttl
def verify_and_consume(self, state: str) -> bool:
with self._lock:
self._clean_expired()
if state in self._states:
del self._states[state]
return True
return False
def _clean_expired(self) -> None:
now = time.time()
expired = [s for s, exp in self._states.items() if exp < now]
for s in expired:
del self._states[s]
@dataclass
class WechatUserInfo:
"""微信用户信息"""
openid: str
unionid: str = ""
nickname: str = ""
avatar_url: str = ""
class WechatOAuthService:
"""微信开放平台 OAuth 服务(网页扫码登录)"""
def __init__(
self,
app_id: str | None = None,
app_secret: str | None = None,
redirect_uri: str | None = None,
state_store=None,
):
self.app_id = app_id or os.environ.get("WECHAT_OPEN_APP_ID", "")
self.app_secret = app_secret or os.environ.get("WECHAT_OPEN_APP_SECRET", "")
self.redirect_uri = redirect_uri or os.environ.get("WECHAT_OPEN_REDIRECT_URI", "")
# state 存储(CSRF 防护),默认内存实现
self._state_store = state_store or MemoryStateStore()
def is_configured(self) -> bool:
"""检查微信配置是否完整"""
return bool(self.app_id and self.app_secret and self.redirect_uri)
def generate_auth_url(self, scope: str = "snsapi_login") -> tuple[str, str]:
"""
生成微信授权链接
Returns:
(授权URL, state)
"""
state = uuid4().hex
# 保存 state 用于回调校验(防 CSRF)
self._state_store.put(state)
if not self.is_configured():
# 未配置时返回 mock URL,方便前端联调
mock_params = urllib.parse.urlencode(
{
"app_id": "mock",
"redirect_uri": self.redirect_uri,
"scope": scope,
"state": state,
}
)
return f"/mock/wechat/auth?{mock_params}", state
params = {
"appid": self.app_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"scope": scope,
"state": state,
}
url = "https://open.weixin.qq.com/connect/qrconnect?" + urllib.parse.urlencode(params) + "#wechat_redirect"
return url, state
def handle_callback(self, code: str, state: str) -> tuple[Optional[WechatUserInfo], Optional[str]]:
"""
处理微信回调
Args:
code: 微信授权码
state: CSRF 状态
Returns:
(微信用户信息, 错误信息)
"""
if not code:
return None, "缺少授权码"
# 校验 state(防 CSRF)—— 一次性使用
if not state or not self._state_store.verify_and_consume(state):
logger.warning("微信回调 state 校验失败: state=%s", state)
return None, "无效的 state 参数,请求可能已过期或被篡改"
if not self.is_configured():
# 开发模式:返回 mock 用户信息
logger.info("微信未配置,使用 mock 用户信息")
return (
WechatUserInfo(
openid=f"mock_{code[:20]}",
unionid=f"mock_union_{code[:16]}",
nickname="微信测试用户",
avatar_url="",
),
None,
)
try:
# 1. 用 code 换 access_token
token_url = "https://api.weixin.qq.com/sns/oauth2/access_token"
token_params = {
"appid": self.app_id,
"secret": self.app_secret,
"code": code,
"grant_type": "authorization_code",
}
token_resp = requests.get(token_url, params=token_params, timeout=10)
token_data = token_resp.json()
if "errcode" in token_data and token_data["errcode"] != 0:
logger.error("微信获取 access_token 失败: %s", token_data)
return None, f"微信授权失败: {token_data.get('errmsg', '未知错误')}"
access_token = token_data["access_token"]
openid = token_data["openid"]
unionid = token_data.get("unionid", "")
# 2. 获取用户信息
user_url = "https://api.weixin.qq.com/sns/userinfo"
user_params = {
"access_token": access_token,
"openid": openid,
"lang": "zh_CN",
}
user_resp = requests.get(user_url, params=user_params, timeout=10)
user_data = user_resp.json()
if "errcode" in user_data and user_data["errcode"] != 0:
logger.error("微信获取用户信息失败: %s", user_data)
return None, f"获取用户信息失败: {user_data.get('errmsg', '未知错误')}"
return (
WechatUserInfo(
openid=openid,
unionid=unionid,
nickname=user_data.get("nickname", ""),
avatar_url=user_data.get("headimgurl", ""),
),
None,
)
except requests.RequestException as e:
logger.error("微信 OAuth 请求异常: %s", e, exc_info=True)
return None, "微信服务暂不可用,请稍后再试"
except Exception as e:
logger.error("微信回调处理异常: %s", e, exc_info=True)
return None, "微信登录处理失败"
def get_wechat_oauth_service() -> WechatOAuthService:
"""获取微信 OAuth 服务单例"""
# TODO: 可替换为 Redis state store
return WechatOAuthService()

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