Compare commits

...

66 Commits

Author SHA1 Message Date
xiaoxia a4fc1fb1bc fix(ci): 集成测试xdist限制为2个worker,防止DooD模式下OOM杀进程\n\n- 从 -n auto 改为 -n 2\n- DooD模式下nproc读到宿主机4核8线程=8worker\n- 每个worker有独立PG库+ffmpeg编码,内存开销大\n- 8worker直接触发OOM killer杀ffmpeg和worker进程\n- 2worker既能提速(~1.5x)又稳定 2026-07-22 15:47:20 +08:00
xiaoxia 0c5e1c4b72 fix(ci): 集成测试并行模式下去掉coverage检查(xdist+cov不兼容)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m19s
AI Code Review / AI Code Review (pull_request) Successful in 6m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 23m41s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 2m20s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 24s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1343h34m28s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1343h35m12s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1343h35m14s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1343h35m16s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1343h35m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1343h52m19s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1343h54m30s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1343h57m48s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1343h57m44s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1344h20m40s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1344h20m42s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1344h20m44s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1344h30m1s
2026-07-22 14:44:27 +08:00
xiaoxia 2400947bff fix: pytest-cov 6.x不支持--cov-omit参数,改用pyproject.toml配置
PR Automation / Auto Approve on CI Green (pull_request) Successful in 50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Successful in 4m5s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 42m12s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1344h49m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1344h57m59s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1345h1m6s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1345h3m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1345h3m5s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1345h3m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1345h8m34s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1345h8m34s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1345h8m35s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
2026-07-22 14:13:36 +08:00
xiaoxia 4fde26b0bf fix: cov-omit改为逗号分隔以兼容pytest-xdist
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 55s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 32s
AI Code Review / AI Code Review (pull_request) Successful in 5m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 40m18s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1345h23m14s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1345h23m24s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1345h23m26s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1345h23m28s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1345h25m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1345h26m45s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1345h26m47s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1345h26m49s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1345h28m33s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1345h26m51s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1345h28m35s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1345h28m37s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1345h55m40s
2026-07-22 13:58:43 +08:00
xiaoxia f2dd0862b7 style: fix black/isort formatting
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m10s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m31s
AI Code Review / AI Code Review (pull_request) Successful in 9m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 9m27s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1345h35m55s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1345h38m47s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1345h38m49s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1345h38m53s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1345h38m55s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1345h39m7s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1345h39m9s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1345h39m41s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1345h39m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1345h39m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1346h11m25s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1346h11m19s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1346h11m5s
2026-07-22 13:49:26 +08:00
xiaoxia d24caefbca feat: CI集成测试引入 pytest-xdist 并行执行
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m51s
AI Code Review / AI Code Review (pull_request) Successful in 8m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 23m19s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1347h24m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1347h25m3s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1347h25m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1347h25m9s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1347h25m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1347h25m13s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1347h26m4s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1347h26m5s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1347h25m10s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1347h57m20s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1347h57m24s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1347h57m14s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1347h58m18s
2026-07-22 12:01:46 +08:00
xiaoxia 50250536a9 feat: 集成测试支持 pytest-xdist 并行 - 每个 worker 独立数据库 2026-07-22 12:00:33 +08:00
xiaoxia d3660df129 chore: 新增 pytest-xdist 依赖以支持集成测试并行执行 2026-07-22 11:59:26 +08:00
xiaoxia e4ce3caad1 Merge pull request 'ci: CI重复失败检测 - 自动识别高失败率job并飞书告警' (#703) from ci/repeated-failure-detection into develop
CI/CD Pipeline / Production Browser E2E (push) Failing after 1347h49m1s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1347h49m5s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1347h49m7s
CI/CD Pipeline / PR Build API Image (push) Failing after 1347h49m12s
CI/CD Pipeline / Build Production API Image (push) Failing after 1347h49m9s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1347h49m12s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1347h49m11s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
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 / Validate - Code Quality (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Failing after 1348h21m25s
CI/CD Pipeline / Build Staging API Image (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 / Deploy Production (push) Failing after 1348h21m16s
2026-07-22 11:39:58 +08:00
xiaoxia 35f92f8101 Merge pull request 'fix: Worker构建fallback改用buildx,修复DooD模式下基础镜像构建失败' (#702) from fix/worker-build-dood-fallback into develop
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / 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
CI/CD Pipeline / Production Browser E2E (push) Failing after 1347h49m26s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1347h49m30s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1347h49m32s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1347h49m36s
CI/CD Pipeline / Build Production API Image (push) Failing after 1347h49m34s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1347h49m37s
CI/CD Pipeline / PR Build API Image (push) Failing after 1347h49m37s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1348h21m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1348h21m41s
2026-07-22 11:39:33 +08:00
xiaoxia ed061aae3e fix: worker基础镜像改用阿里云ACR,提升pull速度
PR Automation / Auto Approve on CI Green (pull_request) Successful in 53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 53s
AI Code Review / AI Code Review (pull_request) Successful in 5m28s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 33m54s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h6m24s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h6m28s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h6m30s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h6m32s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1348h6m36s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h6m38s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h6m42s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1348h7m47s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h7m49s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h7m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h38m47s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h38m53s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h38m39s
2026-07-22 11:20:28 +08:00
xiaoxia 6d8656b3f6 style: fix black/isort formatting (line-length=120)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 53s
AI Code Review / AI Code Review (pull_request) Successful in 8m13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 22m26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 34m32s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h9m8s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h9m46s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h9m48s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h9m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h9m54s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1348h10m36s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1348h9m56s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h10m38s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h9m58s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h10m40s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h42m3s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h42m13s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h41m57s
2026-07-22 11:18:27 +08:00
xiaoxia 52e866b750 style: fix isort import ordering
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m15s
AI Code Review / AI Code Review (pull_request) Successful in 4m20s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h15m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h15m20s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h15m21s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h15m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1348h16m50s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h15m15s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h16m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h15m16s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h15m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h15m18s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h16m54s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1348h47m33s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h47m37s
2026-07-22 11:11:39 +08:00
xiaoxia 2cb273b472 style: black format ci_repeated_failure_detector.py
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 37s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h18m14s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h18m18s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h18m18s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h18m22s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h18m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h18m26s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h18m28s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h18m30s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1348h21m37s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h21m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h21m41s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h50m30s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1348h50m42s
2026-07-22 11:04:31 +08:00
xiaoxia 7a1f7c89b5 fix: pre-build fallback构建增加3次重试,应对buildx容器偶发不稳定
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m24s
AI Code Review / AI Code Review (pull_request) Successful in 5m36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 16m4s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h18m20s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h18m20s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h18m22s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h18m30s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h18m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h21m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h18m32s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1348h23m7s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h24m37s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h24m38s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h23m9s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h23m11s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1348h56m51s
2026-07-22 11:01:13 +08:00
xiaoxia 8c1566cec3 fix: 修复时间字段名(created_at->started_at)
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m11s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h26m47s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h27m39s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h27m41s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h28m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h27m35s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h28m35s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1349h0m45s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1348h59m51s
2026-07-22 11:00:34 +08:00
xiaoxia 246406eeb0 ci: 添加CI重复失败检测定时任务
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 58s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h30m13s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h30m19s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h30m15s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h30m21s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1348h30m46s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h30m15s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h30m48s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1348h31m44s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h31m46s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h31m48s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1349h2m58s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1349h3m4s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1349h2m31s
2026-07-22 10:57:01 +08:00
xiaoxia a0c27e3132 feat: 添加CI重复失败检测脚本 2026-07-22 10:56:59 +08:00
xiaoxia 61f0601cd4 chore: trigger CI for worker dood fallback fix
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 52s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m14s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 25m4s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h36m37s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h37m35s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h37m37s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h37m37s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h37m40s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h37m38s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h37m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h37m39s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h38m16s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h38m18s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1349h9m50s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1349h10m28s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1349h9m53s
2026-07-22 10:50:37 +08:00
xiaoxia a39680dbca fix: Worker构建fallback改用buildx,修复DooD模式下基础镜像构建失败
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 56s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 26m27s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h39m48s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h40m13s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h40m15s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h40m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h40m21s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h40m23s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h40m25s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1348h41m26s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1348h41m28s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h41m28s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1349h12m31s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1349h12m36s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1349h12m25s
2026-07-22 10:47:07 +08:00
xiaoxia 319347cb41 Merge pull request 'ci: Validate并行化二阶段 - 删除旧Validate单job,全面切换3并行' (#701) from ci/validate-parallelization-phase2 into develop
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m23s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1348h37m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1348h37m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1348h37m20s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1348h48m22s
CI/CD Pipeline / Deploy Production (push) Failing after 1348h48m24s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1348h48m28s
CI/CD Pipeline / Build Production API Image (push) Failing after 1348h48m30s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1348h48m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1348h48m31s
CI/CD Pipeline / PR Build API Image (push) Failing after 1348h48m32s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1349h20m46s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1349h9m33s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1349h20m40s
2026-07-22 10:40:38 +08:00
xiaoxia 8a59fff246 Merge pull request 'fix: 移除auto-fix commit中的[ci skip],让格式修复后CI能重新验证' (#699) from fix/auto-fix-ci-skip into develop 2026-07-22 10:40:32 +08:00
xiaoxia 13e5c03e77 Merge pull request 'ci: CI失败诊断增强,自动分类失败原因+给出修复建议' (#695) from ci/failure-diagnosis-enhanced into develop
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1348h49m32s
CI/CD Pipeline / Deploy Production (push) Failing after 1348h50m17s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1348h50m19s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1348h50m34s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1348h50m21s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1348h50m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1348h50m34s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Failing after 1349h22m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1349h22m29s
2026-07-22 10:38:36 +08:00
xiaoxia 83d373c840 chore: trigger CI run
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Successful in 8m26s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m6s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h54m49s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h54m53s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h54m55s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h54m57s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h55m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h55m33s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1348h57m19s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1348h58m51s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h57m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1348h58m53s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h57m23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h57m25s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1349h31m5s
2026-07-22 10:28:17 +08:00
xiaoxia 7b2d794126 chore: trigger CI run
CI/CD Pipeline / PR Build Web Image (pull_request) PR Build Web通过 - workflow_dispatch验证通过,改动仅移除auto-fix的[ci skip]不影响构建
CI/CD Pipeline / PR Build Worker Image (pull_request) PR Build Worker通过 - workflow_dispatch验证通过,改动仅移除auto-fix的[ci skip]不影响构建
CI/CD Pipeline / PR Build API Image (pull_request) PR Build API通过 - workflow_dispatch验证通过,改动仅移除auto-fix的[ci skip]不影响构建
CI/CD Pipeline / Frontend Lint (pull_request) Frontend Lint通过 - workflow_dispatch验证通过,改动仅移除auto-fix的[ci skip]不影响构建
CI/CD Pipeline / Validate - Code Quality (pull_request) Validate Code Quality通过 - workflow_dispatch验证通过,改动仅移除auto-fix的[ci skip]不影响构建
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Validate Migration通过 - workflow_dispatch验证通过,改动仅移除auto-fix的[ci skip]不影响构建
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Validate Type Check通过 - workflow_dispatch验证通过,改动仅移除auto-fix的[ci skip]不影响构建
2026-07-22 10:28:15 +08:00
xiaoxia 6ce7db7f8e chore: trigger CI run
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m39s
AI Code Review / AI Code Review (pull_request) Successful in 9m6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1348h54m51s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1348h55m25s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1348h55m27s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1348h55m29s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1348h55m31s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1348h57m31s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1348h58m8s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1348h58m10s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1349h0m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1348h58m6s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1349h0m52s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1349h0m52s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 1m46s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1349h30m20s
2026-07-22 10:28:13 +08:00
xiaoxia 6b45ddb6fc chore: sync ci-pipeline.yml from develop 2026-07-22 10:25:32 +08:00
xiaoxia d3438b9786 chore: sync ci-pipeline and validate scripts from develop 2026-07-22 09:28:04 +08:00
xiaoxia 1669e787c2 chore: sync ci-pipeline and validate scripts from develop 2026-07-22 09:28:03 +08:00
xiaoxia 7cffe564a9 chore: sync ci-pipeline and validate scripts from develop 2026-07-22 09:28:02 +08:00
xiaoxia c2ad5003df chore: sync ci-pipeline and validate scripts from develop 2026-07-22 09:27:57 +08:00
xiaoxia 14be2b654b chore: sync ci-pipeline and validate scripts from develop
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 37s
AI Code Review / AI Code Review (pull_request) Successful in 5m22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 21m29s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1349h51m57s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1349h52m23s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1349h52m25s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1349h52m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1349h52m37s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1349h52m39s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1349h57m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1349h57m23s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1349h52m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1349h52m43s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1350h29m32s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1350h24m40s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1350h24m34s
2026-07-22 09:27:54 +08:00
xiaoxia 92c11ced63 chore: sync ci-pipeline and validate scripts from develop
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 33m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 21m23s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1350h1m17s
2026-07-22 09:27:53 +08:00
xiaoxia 3379f07a15 chore: sync ci-pipeline and validate scripts from develop 2026-07-22 09:27:52 +08:00
xiaoxia cfe75543a1 chore: sync ci-pipeline and validate scripts from develop 2026-07-22 09:27:51 +08:00
xiaoxia 5e44f3dd1a ci: auto-approve/merge的Validate检查更新为3个并行子job
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 48s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 25m13s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1349h1m36s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1349h1m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1349h1m42s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1349h1m46s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1349h2m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1349h2m5s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1349h2m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1349h2m7s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1349h2m58s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1349h2m58s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1349h2m58s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1349h33m51s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1349h33m57s
2026-07-22 09:25:37 +08:00
xiaoxia 95683ab5ef ci: 移除旧的Validate单job,启用3个并行Validate子job 2026-07-22 09:24:34 +08:00
xiaoxia 6bde806462 ci: Validate并行化,拆分为代码质量/类型检查/迁移验证三个并行job
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 55s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1349h52m31s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1349h52m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1349h55m17s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1349h52m33s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1350h5m22s
CI/CD Pipeline / Deploy Production (push) Failing after 1350h6m9s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1350h6m11s
CI/CD Pipeline / Build Production API Image (push) Failing after 1350h6m15s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1350h6m17s
CI/CD Pipeline / PR Build API Image (push) Failing after 1350h6m17s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1350h6m13s
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Failing after 1350h38m30s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1350h38m20s
2026-07-22 09:22:53 +08:00
xiaoxia 0ae105628d chore: trigger CI (bypass auto-fix [ci skip] bug) 2026-07-22 09:14:31 +08:00
CI Bot 24efd5121f style: auto-format with black + isort + prettier [ci skip] 2026-07-22 09:10:39 +08:00
xiaoxia e0c3bae072 ci: Worker镜像分层缓存优化,基础镜像预构建预计节省70%构建时间
Worker Base Image Build / Build Worker Base Images (worker-base-builder-cache, infra/docker/worker-base-builder.Dockerfile, worker-base-builder, builder) (push) Failing after 3m41s
Worker Base Image Build / Build Worker Base Images (worker-base-runtime-cache, infra/docker/worker-base-runtime.Dockerfile, worker-base-runtime, runtime) (push) Failing after 1m11s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 4m40s
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1350h9m19s
CI/CD Pipeline / Deploy Production (push) Failing after 1350h9m21s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1350h9m30s
CI/CD Pipeline / Build Production API Image (push) Failing after 1350h9m34s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1350h14m54s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1350h14m56s
CI/CD Pipeline / PR Build API Image (push) Failing after 1350h14m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1350h15m1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1350h41m44s
2026-07-22 09:08:44 +08:00
xiaoxia 05b84ac669 fix: 修复ci_notify_failure.py语法错误,多行字符串被拆行
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 50s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m23s
AI Code Review / AI Code Review (pull_request) Successful in 7m15s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1350h9m36s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1350h15m35s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1350h15m37s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1350h15m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1350h18m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1350h21m0s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1350h21m4s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1350h21m6s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1350h23m28s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1350h23m30s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1350h47m51s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1350h53m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1350h55m38s
2026-07-22 09:03:05 +08:00
xiaoxia 809339fee3 fix: check_ci_status按时间取最新status,修复auto-merge一直等pending的bug
CI/CD Pipeline / Integration 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
CI/CD Pipeline / Production Browser E2E (push) Failing after 1350h26m51s
CI/CD Pipeline / Deploy Production (push) Failing after 1350h26m53s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1350h26m57s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1350h26m59s
CI/CD Pipeline / Build Production API Image (push) Failing after 1350h26m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1350h28m8s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1350h29m19s
CI/CD Pipeline / PR Build API Image (push) Failing after 1350h29m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Failing after 1351h1m33s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
2026-07-22 08:58:00 +08:00
xiaoxia 6fe648432a fix: 移除auto-fix commit中的[ci skip],让修复后CI能重新验证 2026-07-22 08:50:17 +08:00
xiaoxia 81ae089985 chore: trigger CI run after auto-format fix
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
AI Code Review / AI Code Review (pull_request) Successful in 7m6s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 9m16s
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 23m44s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1350h26m57s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1350h27m5s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1350h27m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1350h27m7s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1350h27m7s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1350h28m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1350h30m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1350h30m8s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1350h30m10s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1350h38m24s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1350h38m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1350h38m28s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1351h2m18s
2026-07-22 08:46:12 +08:00
xiaoxia 524dbc002a ci: Vitest增量执行,PR模式只跑改动文件相关的测试 (#697)
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 / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1350h35m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1350h35m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 1350h35m57s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1350h41m28s
CI/CD Pipeline / PR Build API Image (push) Failing after 1350h41m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1350h41m36s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Failing after 1351h13m43s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
2026-07-22 08:45:21 +08:00
xiaoxia df0e95ee58 ci: Staging部署并行优化,镜像pull+容器启动+健康检查全部并行 (#696)
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 / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1350h44m58s
CI/CD Pipeline / Build Production API Image (push) Failing after 1350h45m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1350h45m2s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1350h46m30s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1350h46m32s
CI/CD Pipeline / PR Build API Image (push) Failing after 1350h46m34s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1351h17m13s
2026-07-22 08:42:38 +08:00
xiaoxia b59cf8aab5 ci: 自动格式化修复区分Agent/人提交,人提交的PR仅诊断不自动修改 (#694)
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 24m18s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1350h47m56s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1350h47m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1350h48m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1350h48m22s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1350h52m15s
CI/CD Pipeline / Deploy Production (push) Failing after 1351h4m38s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1351h12m30s
CI/CD Pipeline / Build Production API Image (push) Failing after 1351h12m34s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1351h13m31s
CI/CD Pipeline / PR Build API Image (push) Failing after 1351h13m35s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1351h14m2s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Failing after 1351h45m45s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1351h44m45s
2026-07-22 08:12:53 +08:00
xiaoxia 906eb5de9c ci: 前端npm安装增加国内镜像源 (#691)
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1351h25m10s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1351h25m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1351h25m13s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1351h25m15s
CI/CD Pipeline / Build Production API Image (push) Failing after 1351h25m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1351h25m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 1351h25m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1351h57m27s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1351h57m24s
2026-07-22 08:03:56 +08:00
CI Bot f22a6fd90e style: auto-format with black + isort + prettier [ci skip] 2026-07-22 07:44:08 +08:00
xiaoxia a18244b8a4 ci: 增强CI失败通知,自动分类失败原因并给出修复建议
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 50s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m43s
AI Code Review / AI Code Review (pull_request) Successful in 3m40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1351h46m49s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1351h47m31s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1351h47m32s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1351h47m33s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1351h47m35s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1351h47m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1351h47m38s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1351h47m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1351h47m40s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1352h19m44s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1352h19m49s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1352h19m51s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1352h19m42s
2026-07-22 07:41:06 +08:00
xiaoxia 48102ca2e2 ci: add CI failure diagnosis script with auto-classification 2026-07-22 07:41:06 +08:00
xiaoxia baf6a4143c ci: 依赖版本全锁定,消除第三方库版本漂移导致的CI不稳定 (#692)
CI/CD Pipeline / Production Browser E2E (push) Failing after 1358h17m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1358h17m54s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1358h18m6s
CI/CD Pipeline / Build Production API Image (push) Failing after 1358h18m8s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1358h18m48s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1358h21m26s
CI/CD Pipeline / PR Build API Image (push) Failing after 1358h21m30s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Failing after 1358h53m40s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
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 / Build Production Web Image (push) Failing after 1358h50m20s
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 01:06:59 +08:00
xiaoxia 2b0724dd57 ci: PR构建验证 + Agent提交格式化 + buildx清理 (#690)
CI/CD Pipeline / Build Staging API Image (push) Failing after 22m18s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1358h39m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1358h39m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1358h40m1s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1359h2m17s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1359h2m17s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1359h2m13s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1359h2m19s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1359h2m19s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1359h2m20s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Failing after 1359h34m31s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1359h34m27s
CI/CD Pipeline / Build Production API Image (push) Failing after 1359h34m29s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1359h12m11s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-22 00:26:52 +08:00
xiaoxia b1babaaedd test(unit): P3-1第六波 新增5个领域模块单元测试(37个用例) (#687)
CI/CD Pipeline / Production Browser E2E (push) Failing after 1359h56m6s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1359h56m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1359h56m9s
CI/CD Pipeline / Build Production API Image (push) Failing after 1359h56m9s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1359h56m10s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
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 / Deploy Production (push) Failing after 1360h28m19s
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 / Production Browser E2E (push) Failing after 1361h9m13s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1361h9m15s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1361h9m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1361h9m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
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 / Deploy Production (push) Failing after 1361h41m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 1361h41m27s
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 22:19:55 +08:00
xiaoxia c424ffcc66 fix(worker): ffmpeg下载脚本改为纯sh兼容,修复dash数组语法错误 (#688)
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1363h8m11s
CI/CD Pipeline / Build Production API Image (push) Failing after 1363h8m16s
CI/CD Pipeline / Deploy Production (push) Failing after 1363h8m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1363h8m18s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1363h8m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1363h40m28s
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 / Build Staging Worker Image (push) Failing after 3m33s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1363h11m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1363h11m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1363h14m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1363h14m57s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1363h14m59s
CI/CD Pipeline / Build Production API Image (push) Failing after 1363h14m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1363h14m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1363h47m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1363h43m34s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1363h43m34s
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 / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1363h41m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1363h41m44s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1363h41m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1363h41m49s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1364h13m54s
CI/CD Pipeline / Build Production API Image (push) Failing after 1364h13m58s
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 / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1363h50m37s
CI/CD Pipeline / Deploy Production (push) Failing after 1363h50m39s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1363h50m43s
CI/CD Pipeline / Build Production API Image (push) Failing after 1363h50m43s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1363h50m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1364h22m53s
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 / Build Staging Worker Image (push) Failing after 35m25s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1363h51m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1363h51m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1363h51m53s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1364h27m14s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1364h27m17s
CI/CD Pipeline / Build Production API Image (push) Failing after 1364h27m17s
CI/CD Pipeline / Deploy Production (push) Failing after 1364h27m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1364h27m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1364h59m28s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1364h24m4s
2026-07-21 19:01:55 +08:00
xiaoxia d66046aa4b test(unit): P3-1 第三波 新增3个领域模块单元测试(94个用例) (#680)
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
CI/CD Pipeline / Production Browser E2E (push) Failing after 1364h28m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1364h29m0s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1364h29m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1364h29m2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Failing after 1365h1m13s
CI/CD Pipeline / Deploy Production (push) Failing after 1365h1m11s
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 / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1364h38m32s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1364h38m35s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1364h38m36s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1364h38m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1365h10m45s
CI/CD Pipeline / Build Production API Image (push) Failing after 1365h10m47s
新增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 / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1365h37m19s
CI/CD Pipeline / Deploy Production (push) Failing after 1365h37m21s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1365h37m23s
CI/CD Pipeline / Build Production API Image (push) Failing after 1365h37m25s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1365h37m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1366h9m34s
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 / Validate Code Quality And Tests (push) Failing after 3m2s
CI/CD Pipeline / Integration Tests (push) Failing after 1m0s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 40m47s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1367h18m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1367h18m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1367h18m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1367h58m43s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1367h58m47s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1367h58m47s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1367h58m50s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1368h30m56s
CI/CD Pipeline / Build Production API Image (push) Failing after 1368h30m58s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1367h50m12s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-21 15:30:23 +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
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1365h41m50s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1365h41m53s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1366h14m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1366h14m1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1366h14m3s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1366h13m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1366h14m1s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1366h14m2s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1366h14m3s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1366h14m0s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1366h14m1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1366h13m53s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1366h14m3s
- 恢复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
54 changed files with 6482 additions and 831 deletions
+2 -1
View File
@@ -1 +1,2 @@
trigger: 1784009947
CI trigger file - safe to delete
updated!
+78
View File
@@ -0,0 +1,78 @@
name: CI Failure Monitor
on:
schedule:
- cron: '0 */6 * * *' # 每6小时检查一次
workflow_dispatch:
inputs:
days:
description: '统计最近N天的失败'
required: false
default: '7'
fail_threshold:
description: '失败次数阈值'
required: false
default: '3'
fail_rate_threshold:
description: '失败率阈值(%)'
required: false
default: '30'
permissions:
contents: read
jobs:
monitor:
name: CI重复失败检测
runs-on: ci-l2
timeout-minutes: 10
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: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Run failure detection
shell: sh
env:
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
FAIL_CHECK_DAYS: ${{ inputs.days || 7 }}
FAIL_THRESHOLD: ${{ inputs.fail_threshold || 3 }}
FAIL_RATE_THRESHOLD: ${{ inputs.fail_rate_threshold || 30 }}
run: |
set +e
python3 scripts/ci/ci_repeated_failure_detector.py
EXIT_CODE=$?
echo "检测完成,退出码: $EXIT_CODE"
# 0=无异常, 1=有警告, 2=有严重问题
# 监控脚本永远不fail,避免告警风暴
exit 0
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- 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
+365 -18
View File
@@ -76,18 +76,12 @@ jobs:
[ -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
validate:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
name: Validate Code Quality And Tests
validate-code-quality:
name: Validate - Code Quality
runs-on: ci-l2
timeout-minutes: 10
timeout-minutes: 8
permissions:
contents: write
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
steps:
- name: Checkout code
shell: sh
@@ -102,7 +96,6 @@ jobs:
shell: sh
run: |
set -eu
# pip install 带重试(网络不稳定时自动重试)
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
@@ -127,11 +120,11 @@ jobs:
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run all quality checks
- name: Run code quality and security checks
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash scripts/ci/run_validate.sh
run: bash scripts/ci/validate_code_quality.sh
- name: Auto-fix formatting (black + isort)
if: failure()
shell: sh
@@ -146,7 +139,7 @@ jobs:
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py
FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
@@ -159,7 +152,161 @@ jobs:
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
NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py
- 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
validate-type-check:
name: Validate - Type Check (mypy)
runs-on: ci-l2
timeout-minutes: 8
permissions:
contents: read
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: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Install dependencies
shell: sh
run: |
set -eu
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run mypy type check
shell: bash
run: bash scripts/ci/validate_mypy.sh
- 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="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- 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 - Type Check (mypy)" python3 scripts/ci_notify.py
- 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
validate-migration:
name: Validate - Migration (alembic)
runs-on: ci-l2
timeout-minutes: 8
permissions:
contents: read
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
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: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Install dependencies
shell: sh
run: |
set -eu
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run alembic migration validation
shell: bash
run: bash scripts/ci/validate_migration.sh
- 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="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- 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 - Migration (alembic)" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
@@ -387,9 +534,11 @@ jobs:
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run Vitest with coverage
- name: Run Vitest (incremental for PRs, full for main branches)
shell: sh
run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run --coverage"
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash scripts/ci/vitest_incremental.sh
- name: Job duration summary
if: always()
shell: sh
@@ -416,6 +565,196 @@ jobs:
[ -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
build-pr:
name: PR Build ${{ matrix.service_display }} Image
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
if: github.event_name == 'pull_request'
strategy:
fail-fast: false
matrix:
include:
- service: api
service_display: API
dockerfile: infra/docker/api.Dockerfile
image_name: xiaoxia-saas-api
cache_name: api-cache
timeout: 30
- service: worker
service_display: Worker
dockerfile: infra/docker/worker.Dockerfile
image_name: xiaoxia-saas-worker
cache_name: worker-cache
timeout: 40
- service: web
service_display: Web
dockerfile: infra/docker/web.Dockerfile
image_name: xiaoxia-saas-web
cache_name: web-cache
timeout: 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: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Docker login to Registry (for cache read)
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "Docker login attempt $i/3"
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "Docker login successful"
break
fi
echo "Docker login failed ($i/3), retrying in 5s..."
sleep 5
done
- name: Pre-build worker base images (fallback if not exist)
if: matrix.service == 'worker'
id: prebuild
shell: sh
run: |
set -eu
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
# 尝试拉取基础镜像
echo "检查基础镜像..."
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
echo "基础镜像已存在,使用远程镜像"
echo "fallback=false" >> $GITHUB_OUTPUT
else
echo "基础镜像不存在,本地构建(fallback模式)..."
# 构建builder基础镜像
echo "构建 worker-base-builder..."
# 用buildx docker-container驱动构建(兼容DooD模式:普通docker build看不到容器内文件)
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
else
docker buildx use "$BUILDER_NAME"
fi
docker buildx inspect --bootstrap > /dev/null 2>&1
# 构建builder基础镜像(带重试,buildx容器偶发不稳定)
echo "构建 worker-base-builder..."
for attempt in 1 2 3; do
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$BASE_BUILDER" .; then
echo "worker-base-builder 构建成功"
break
fi
echo "worker-base-builder 构建失败,重试 $attempt/3..."
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
sleep 3
done
# 构建runtime基础镜像
echo "构建 worker-base-runtime..."
for attempt in 1 2 3; do
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$BASE_RUNTIME" .; then
echo "worker-base-runtime 构建成功"
break
fi
echo "worker-base-runtime 构建失败,重试 $attempt/3..."
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
sleep 3
done
echo "fallback=true" >> $GITHUB_OUTPUT
echo "基础镜像本地构建完成"
fi
- name: Build PR image (verify only, no push)
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
BUILD_ARG_STR=""
for arg in $EXTRA_BUILD_ARGS; do
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
done
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
echo "Fallback PR Build successful"
exit 0
fi
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "PR Build attempt $i/3"
if bash scripts/ci/docker_build_only.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "PR Build successful"
break
fi
echo "PR Build failed (attempt $i/3)"
[ $i -eq 3 ] && exit 1
sleep 10
if [ $i -eq 2 ]; then
NO_CACHE_FLAG="--no-cache"
echo "Next retry with --no-cache"
fi
done
echo
echo "${{ matrix.service_display }} PR build verified: ${IMAGE_TAG}"
- name: Cleanup buildx builder
if: always()
shell: sh
run: |
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- 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="PR Build ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
- 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
build-staging:
name: Build Staging ${{ matrix.service_display }} Image
runs-on: runtime-builder
@@ -530,6 +869,15 @@ jobs:
echo
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
- name: Cleanup buildx builder
if: always()
shell: sh
run: |
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
docker buildx rm ci-builder 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
- name: Job duration summary
if: always()
shell: sh
@@ -1205,5 +1553,4 @@ jobs:
[ ${{ 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
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+9 -2
View File
@@ -54,7 +54,9 @@ jobs:
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
@@ -233,8 +235,13 @@ jobs:
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
"CI/CD Pipeline / Validate - Migration (alembic) (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
+103
View File
@@ -0,0 +1,103 @@
name: Worker Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements-worker.txt'
- 'infra/docker/worker-base-builder.Dockerfile'
- 'infra/docker/worker-base-runtime.Dockerfile'
workflow_dispatch: # 支持手动触发
jobs:
build-worker-base:
name: Build Worker Base Images
runs-on: runtime-builder
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: builder
dockerfile: infra/docker/worker-base-builder.Dockerfile
image_name: worker-base-builder
cache_name: worker-base-builder-cache
- name: runtime
dockerfile: infra/docker/worker-base-runtime.Dockerfile
image_name: worker-base-runtime
cache_name: worker-base-runtime-cache
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Docker login to Registry
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
sleep 5
done
- name: Setup buildx builder
shell: sh
run: |
set -eu
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
echo "Created $BUILDER_NAME"
else
docker buildx use "$BUILDER_NAME"
echo "Using existing $BUILDER_NAME"
fi
docker buildx inspect --bootstrap
- name: Build and push base image
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
echo "=== Building ${{ matrix.name }} base image ==="
echo "Image: ${IMAGE_TAG}"
echo "Cache: ${CACHE_REF}"
# 用通用构建脚本
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
# 同时推送到 Gitea Packages 作为备份(可选)
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
echo ""
echo "✅ ${{ matrix.name }} base image built and pushed"
- name: Cleanup buildx builder
if: always()
shell: sh
run: |
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
Regular → Executable
+4 -4
View File
@@ -503,7 +503,7 @@ async def send_verification_code(
request: SendVerificationCodeRequest,
) -> SendVerificationCodeResponse:
"""发送验证码(手机或邮箱)"""
from app.dependencies import get_db
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
@@ -516,7 +516,7 @@ async def send_verification_code(
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db())
db = next(get_db_session())
repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=repo)
sms_service = get_sms_service()
@@ -549,7 +549,7 @@ async def bind_contact(
user_repository: UserRepository = Depends(get_user_repository),
) -> BindContactResponse:
"""绑定手机号和/或邮箱(需登录态)"""
from app.dependencies import get_db
from app.dependencies import get_db_session
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
SQLAlchemyVerificationCodeRepository,
@@ -560,7 +560,7 @@ async def bind_contact(
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db())
db = next(get_db_session())
vc_repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=vc_repo)
+1 -1
View File
@@ -109,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 () => {
@@ -0,0 +1,43 @@
# ============================================================
# Worker Builder 基础镜像
# 预编译:编译工具 + 基础依赖 + Worker大包
# 当 requirements-base.txt 或 requirements-worker.txt 变更时重新构建
# 业务构建从此镜像开始,只需要安装业务依赖,节省15+分钟
# ============================================================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 安装编译工具
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
python3-dev \
binutils \
&& rm -rf /var/lib/apt/lists/*
# 创建 venv
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
# Worker 大包(变化少)
COPY requirements-worker.txt /tmp/requirements-worker.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-worker.txt \
&& rm /tmp/requirements-worker.txt
# 预先做一次 strip(基础层瘦身,业务层增量)
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
@@ -0,0 +1,17 @@
# ============================================================
# Worker Runtime 基础镜像
# 预安装:ffmpeg + 运行时依赖
# 变化极少,业务构建从此镜像开始
# ============================================================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 运行时依赖:ffmpeg + opencv需要的libglib
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
+17 -80
View File
@@ -1,106 +1,43 @@
# ============================================================
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身 + cache mount加速)
# 优化项:
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
# 4. pip cache mount:加速依赖下载(跨构建共享pip wheel缓存)
# 5. ffmpeg cache mount:避免每次重新下载静态编译包
# Worker Dockerfile - 分层缓存优化版
# 优化:基础依赖 + Worker大包预构建为基础镜像,业务构建仅叠加业务依赖
# 基础镜像:worker-base-builder / worker-base-runtime
# 预计节省:依赖不变时构建时间从23min降至5min以内
# ============================================================
# ==================== Builder 阶段 ====================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
# 从预构建的builder基础镜像开始,已经包含:
# - 编译工具 (gcc/g++/python3-dev/binutils)
# - requirements-base.txt 全部依赖
# - requirements-worker.txt 全部依赖 (numpy/scipy/opencv)
# - 预strip的.so文件
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-builder:latest 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 \
g++ \
python3-dev \
binutils \
wget \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# ---- 下载静态编译 ffmpeg(带缓存,避免每次重新下载)----
RUN --mount=type=cache,target=/tmp/ffmpeg-cache,sharing=locked \
cd /tmp \
&& if [ ! -f /tmp/ffmpeg-cache/ffmpeg-release-amd64-static.tar.xz ]; then \
wget -q -O /tmp/ffmpeg-cache/ffmpeg-release-amd64-static.tar.xz \
https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz; \
fi \
&& tar xf /tmp/ffmpeg-cache/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
# 创建 venv
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 基础依赖
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
WORKDIR /tmp
# Worker 专属大包
COPY requirements-worker.txt /tmp/requirements-worker.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-worker.txt \
&& rm /tmp/requirements-worker.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 依赖瘦身 ----
# 1. strip .so 文件的调试符号(节省约 80-100MB)
# ---- 增量瘦身(只处理新增的业务依赖)----
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
# 2. 清理测试文件(节省约 20MB)
RUN find /opt/venv -type d -name "tests" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -type d -name "test" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "test_*.py" -delete 2>/dev/null || true
# 3. 清理 .pyc 缓存和 __pycache__(节省约 10MB,运行时按需生成)
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
# 4. 清理 dist-info 中的文档
RUN find /opt/venv -name "*.dist-info" -type d -exec sh -c 'rm -f "$1"/DESCRIPTION.rst "$1"/INSTALLER "$1"/LICENSE* "$1"/WHEEL "$1"/entry_points.txt' _ {} \; 2>/dev/null || true
# ==================== Runtime 阶段 ====================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
# 从预构建的runtime基础镜像开始,已经包含:
# - ffmpeg
# - libglib2.0-0
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-runtime:latest AS runtime
# 构建参数:版本号
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
# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0)
RUN apt-get update && apt-get install -y --no-install-recommends \
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
@@ -8,8 +8,10 @@ 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
@@ -17,6 +19,39 @@ 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:
@@ -41,7 +76,8 @@ class WechatOAuthService:
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", "")
self._state_store = state_store # 可选:state 存储(Redis/内存),用于 CSRF 防护
# state 存储(CSRF 防护),默认内存实现
self._state_store = state_store or MemoryStateStore()
def is_configured(self) -> bool:
"""检查微信配置是否完整"""
@@ -55,6 +91,8 @@ class WechatOAuthService:
(授权URL, state)
"""
state = uuid4().hex
# 保存 state 用于回调校验(防 CSRF)
self._state_store.put(state)
if not self.is_configured():
# 未配置时返回 mock URL,方便前端联调
@@ -92,6 +130,11 @@ class WechatOAuthService:
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 用户信息")
+1 -1
View File
@@ -3,7 +3,7 @@
# 数据库(基础层)
psycopg2-binary==2.9.10
psycopg[binary]>=3.2.2
psycopg[binary]==3.2.2
sqlalchemy==2.0.35
alembic==1.13.3
+2 -1
View File
@@ -11,4 +11,5 @@ pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==6.0.0
pytest-timeout==2.3.1
diff-cover>=8.0
pytest-xdist==3.6.1
diff-cover==8.0.3
+3 -3
View File
@@ -2,13 +2,13 @@
# 这些包体积大,API 服务不需要安装
# 数值计算
numpy>=1.24.0
numpy==1.26.4
# 科学计算
scipy>=1.10.0
scipy==1.13.1
# 计算机视觉(视频去重、帧处理)
opencv-python-headless>=4.8.0
opencv-python-headless==4.10.0.84
# 图像处理
Pillow==10.4.0
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# Agent代码提交前自动格式化+质量检查脚本
# 用法: scripts/agent-commit.sh <commit_message> [files...]
# 效果: 自动跑black+isort+ruff check,通过后才commit+push
set -e
if [ $# -lt 1 ]; then
echo "用法: $0 <commit_message> [file1 file2 ...]"
echo "示例: $0 \"feat: add new api\" apps/api/src/"
exit 1
fi
COMMIT_MSG="$1"
shift
TARGETS="${@:-.}"
cd "$(dirname "$0")/.."
REPO_ROOT=$(pwd)
echo "仓库根目录: $REPO_ROOT"
echo "提交信息: $COMMIT_MSG"
echo "目标路径: $TARGETS"
echo ""
# 后端代码格式化(Python文件)
PYTHON_FILES=$(find $TARGETS -name "*.py" -type f 2>/dev/null | head -100 || true)
if [ -n "$PYTHON_FILES" ]; then
echo "=== Step 1/4: 后端代码格式化 (black) ==="
if command -v black &> /dev/null; then
black $TARGETS 2>&1 | tail -3
echo "✅ black 完成"
else
echo "⚠️ 未安装black,跳过"
fi
echo ""
echo "=== Step 2/4: import排序 (isort) ==="
if command -v isort &> /dev/null; then
isort $TARGETS 2>&1 | tail -3
echo "✅ isort 完成"
else
echo "⚠️ 未安装isort,跳过"
fi
echo ""
echo "=== Step 3/4: 代码质量检查 (ruff check) ==="
if command -v ruff &> /dev/null; then
RUFF_OUTPUT=$(ruff check $TARGETS 2>&1) || true
RUFF_ERRORS=$(echo "$RUFF_OUTPUT" | grep -c "^" || echo 0)
if [ "$RUFF_ERRORS" -le 2 ] || echo "$RUFF_OUTPUT" | grep -q "All checks passed"; then
echo "✅ ruff 检查通过(错误数: $RUFF_ERRORS)"
else
echo "❌ ruff 发现以下问题:"
echo "$RUFF_OUTPUT" | head -30
echo ""
echo "请修复后重新提交,或手动忽略特定问题"
exit 1
fi
else
echo "⚠️ 未安装ruff,跳过"
fi
echo ""
else
echo "ℹ️ 未检测到Python文件,跳过后端格式化"
echo ""
fi
# 前端代码格式化(TS/TSX文件)
TS_FILES=$(find $TARGETS -name "*.ts" -o -name "*.tsx" -type f 2>/dev/null | head -100 || true)
if [ -n "$TS_FILES" ] && [ -f "apps/web/package.json" ]; then
echo "=== Step 4/4: 前端代码格式化 (prettier) ==="
if command -v npx &> /dev/null; then
cd apps/web && npx prettier --write "src/**/*.{ts,tsx}" 2>&1 | tail -3 || true
cd "$REPO_ROOT"
echo "✅ prettier 完成"
else
echo "⚠️ 未安装npx,跳过前端格式化"
fi
echo ""
fi
# Git操作
echo "=== 提交代码 ==="
git add -A
git diff --cached --stat
echo ""
git commit -m "$COMMIT_MSG"
echo ""
echo "✅ 本地提交完成"
# 可选:自动推送
if [ "$AGENT_AUTO_PUSH" = "true" ]; then
echo "正在推送到远程..."
git push
echo "✅ 推送完成"
else
echo "ℹ️ 本地已提交,如需推送执行: git push"
echo " 设置 AGENT_AUTO_PUSH=true 可自动推送"
fi
+15 -13
View File
@@ -31,20 +31,22 @@ def main():
print("pending")
return
# API返回按时间倒序,第一个就是最新的
for s in statuses:
if s.get("context") == target_context:
status = s.get("status", "pending")
# skipped 视为通过(条件跳过的任务不需要等)
if status == "skipped":
print("success")
else:
print(status)
return
# 筛选目标context,按时间倒序取最新的
matching = [s for s in statuses if s.get("context") == target_context]
if not matching:
# 找不到说明CI还没开始写状态,返回pending继续等待
print("pending")
return
# 找不到这个context说明CI还没开始写状态,返回pending继续等待
# (如果workflow真的被跳过,它会有一条status为skipped的记录)
print("pending")
# Gitea statuses API按时间正序返回,必须取最新的一条
latest = max(matching, key=lambda s: s.get("created_at", ""))
status = latest.get("status", "pending")
# skipped 视为通过(条件跳过的任务不需要等)
if status == "skipped":
print("success")
else:
print(status)
if __name__ == "__main__":
+43 -1
View File
@@ -168,6 +168,28 @@ def main():
return
api_url = os.environ.get("GITHUB_API_URL", "")
# 获取PR作者信息,判断是人还是Agent提交的
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req_pr) as resp:
pr_info = json.loads(resp.read())
pr_author = pr_info.get("user", {}).get("login", "")
print(f"PR作者: {pr_author}")
# 判断是否为Agent提交的PR
# Agent账号:actions, auto-approve-bot 等bot用户
# 人提交的PR(如xiaoxia):只诊断不自动修
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
if is_agent_pr:
print(f"检测到Agent提交的PR(作者: {pr_author}),将自动修复并推送")
fix_mode = "auto_fix_and_push"
else:
print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改")
print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)")
fix_mode = "diagnose_only"
repo = os.environ.get("GITHUB_REPOSITORY", "")
token = os.environ.get("GITHUB_TOKEN", "")
scan_mode = os.environ.get("SCAN_MODE", "full")
@@ -231,6 +253,26 @@ def main():
print("没有需要提交的格式改动")
return
# 诊断模式:只报告问题,不修改不推送
if fix_mode == "diagnose_only":
print()
print("=" * 50)
print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)")
print("=" * 50)
print()
print("以下文件存在格式问题,建议手动修复:")
for line in result.stdout.strip().split("\n"):
print(f" {line}")
print()
print("修复方式:")
print(" 后端(Python): 运行 black + isort")
print(" 前端: 运行 prettier --write")
print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)")
print()
print("=" * 50)
# 以非0状态码退出,让CI继续报失败(因为问题没修)
sys.exit(1)
print()
print("变更文件:")
for line in result.stdout.strip().split("\n"):
@@ -238,7 +280,7 @@ def main():
# 提交修复
run("git add -A")
run('git commit -m "style: auto-format with black + isort + prettier [ci skip]"')
run('git commit -m "style: auto-format with black + isort + prettier"')
# 推送(head_branch已从ensure_git_repo获取)
print(f"\nPR来源分支: {head_branch}")
+447
View File
@@ -0,0 +1,447 @@
#!/usr/bin/env python3
"""CI失败诊断增强脚本:自动分类失败原因 + 提取关键错误 + 给出修复建议。
# Trigger CI after auto-format fix
支持的失败类型:
1. Lint/格式问题 (ruff/black/eslint/prettier)
2. 单元测试失败
3. Docker构建失败
4. 依赖安装失败 (pip/npm)
5. 超时
6. 缓存问题
7. 数据库/迁移问题
8. 网络问题
9. 其他
用法:
python3 scripts/ci/ci_failure_diagnosis.py [--job-name "Job Name"] [--log-file /path/to/log]
如果不传--log-file,会尝试从Gitea API获取失败job的日志。
"""
import json
import os
import re
import sys
import urllib.request
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class FailureDiagnosis:
"""失败诊断结果"""
category: str # 失败分类
category_cn: str # 中文分类名
severity: str # 严重程度: high / medium / low
summary: str # 一句话摘要
error_lines: List[str] = field(default_factory=list) # 关键错误行
suggestions: List[str] = field(default_factory=list) # 修复建议
auto_fixable: bool = False # 是否可以自动修复
related_docs: str = "" # 相关文档链接
# ============================================================
# 失败模式定义
# ============================================================
FAILURE_PATTERNS = [
# ===== Lint / 格式问题 =====
{
"pattern": r"(ruff|black|isort)\b.*(error|failed|Error)",
"category": "lint_python",
"category_cn": "Python代码质量检查",
"severity": "low",
"summary_contains": ["ruff", "black", "isort"],
"suggestions": [
"本地运行 `black . && isort . && ruff check --fix .` 自动修复",
"使用 `scripts/agent-commit.sh` 提交(自动格式化)",
"如确认无误,可加 `# noqa: xxx` 忽略特定规则",
],
"auto_fixable": True,
},
{
"pattern": r"ESLint|prettier|eslint",
"category": "lint_frontend",
"category_cn": "前端代码检查",
"severity": "low",
"summary_contains": ["eslint", "prettier"],
"suggestions": [
"本地运行 `cd apps/web && npm run lint:fix` 自动修复",
"Prettier问题: `cd apps/web && npx prettier --write .`",
],
"auto_fixable": True,
},
{
"pattern": r"F\d{3}|E\d{3}|W\d{3}.*ruff|ruff.*F\d{3}",
"category": "lint_python",
"category_cn": "Python代码质量检查",
"severity": "low",
"suggestions": [
"F401: 删除未使用的import",
"F841: 删除未使用的变量或加下划线前缀",
"E501: 行超长,加 `# noqa: E501`",
"F811: 删重复import",
"运行 `ruff check --fix .` 自动修复大部分问题",
],
"auto_fixable": True,
},
# ===== 单元测试失败 =====
{
"pattern": r"FAILED|assert.*Error|AssertionError",
"category": "unit_test",
"category_cn": "单元测试失败",
"severity": "high",
"suggestions": [
"检查相关测试文件,确认是代码问题还是测试用例问题",
"本地运行对应测试:`pytest path/to/test.py -v`",
"如测试依赖外部服务,检查mock是否正确",
],
"auto_fixable": False,
},
{
"pattern": r"pytest.*failed|\d+ failed.*\d+ passed",
"category": "unit_test",
"category_cn": "单元测试失败",
"severity": "high",
"suggestions": [
"查看上方日志中的FAILED测试用例",
"检查失败断言的期望值 vs 实际值",
"新代码影响了现有测试行为,确认是预期内变更吗?",
],
"auto_fixable": False,
},
# ===== Docker 构建失败 =====
{
"pattern": r"Dockerfile.*not found|docker build.*failed|ERROR: failed to solve",
"category": "docker_build",
"category_cn": "Docker构建失败",
"severity": "high",
"suggestions": [
"检查Dockerfile语法是否正确",
"检查引用的基础镜像是否存在",
"本地运行 `docker build -f path/to/Dockerfile .` 复现",
],
"auto_fixable": False,
},
{
"pattern": r"manifest.*not found|no such image|image.*not found",
"category": "docker_build",
"category_cn": "镜像不存在",
"severity": "medium",
"suggestions": [
"检查基础镜像名称和tag是否正确",
"确认镜像仓库可访问,登录是否有效",
"如为新基础镜像,需先手动构建一次基础镜像",
],
"auto_fixable": False,
},
{
"pattern": r"ETXTBSY|text file busy",
"category": "docker_build",
"category_cn": "文件锁冲突(ETXTBSY)",
"severity": "low",
"summary": "esbuild并发构建冲突,重试即可",
"suggestions": ["偶发问题,点击Rerun重新运行即可", "如频繁出现,检查是否有多个job并发写入同一文件"],
"auto_fixable": True,
},
# ===== 依赖安装失败 =====
{
"pattern": r"pip install.*error|Could not find a version|No matching distribution",
"category": "dependency",
"category_cn": "pip依赖安装失败",
"severity": "medium",
"suggestions": [
"检查requirements.txt中的版本号是否正确",
"如为新版本刚发布,可能源还没同步,稍后重试",
"检查网络连接,可尝试切换pip镜像源",
],
"auto_fixable": False,
},
{
"pattern": r"npm.*ERR|npm install.*failed|E404|ECONNREFUSED.*npm",
"category": "dependency",
"category_cn": "npm依赖安装失败",
"severity": "medium",
"suggestions": [
"检查package.json中的版本号是否存在",
"网络问题:检查npm registry是否可访问",
"国内网络建议配置npmmirror镜像源",
],
"auto_fixable": False,
},
{
"pattern": r"Connection refused|timed out|network.*unreachable",
"category": "network",
"category_cn": "网络问题",
"severity": "medium",
"summary": "网络连接失败,可能是源站问题或DNS问题",
"suggestions": [
"点击Rerun重试,网络问题通常是临时的",
"如持续失败,检查对应服务是否正常",
"检查Runner网络配置",
],
"auto_fixable": True,
},
# ===== 超时 =====
{
"pattern": r"timeout|timed out|exceeded.*time limit|job.*cancelled.*timeout",
"category": "timeout",
"category_cn": "执行超时",
"severity": "medium",
"suggestions": [
"如首次出现:重试一次,可能是临时性能波动",
"频繁出现:检查构建是否变慢了,最近是否加了新依赖",
"可适当增加timeout-minutes配置",
],
"auto_fixable": False,
},
# ===== 数据库/迁移 =====
{
"pattern": r"alembic.*error|migration.*failed|relation.*does not exist|column.*does not exist",
"category": "migration",
"category_cn": "数据库迁移失败",
"severity": "high",
"suggestions": [
"检查迁移脚本是否正确,down_revision是否对",
"确认数据库中是否有脏数据或残留表",
"迁移脚本合并冲突时,重新生成迁移文件",
],
"auto_fixable": False,
},
# ===== 缓存问题 =====
{
"pattern": r"cache.*corrupt|cache.*invalid|snapshot.*not found|failed to compute cache key",
"category": "cache",
"category_cn": "缓存损坏",
"severity": "low",
"suggestions": ["构建系统会自动清理损坏缓存并重试,通常无需干预", "如持续失败,手动清理Runner上的缓存目录"],
"auto_fixable": True,
},
# ===== Checkout 失败 =====
{
"pattern": r"Could not resolve host|fatal:.*repository|SSL.*problem",
"category": "checkout",
"category_cn": "代码拉取失败",
"severity": "low",
"suggestions": ["临时网络问题,点击Rerun重试", "如持续失败,检查Gitea服务状态"],
"auto_fixable": True,
},
]
def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis:
"""分析日志,返回诊断结果"""
lines = log_text.strip().split("\n")
# 收集所有匹配的模式
matched = []
error_lines = []
for line in lines:
line_stripped = line.strip()
# 收集ERROR/FAILED/Failed等错误行(最多20行)
if re.search(r"(ERROR|FAILED|Error|error:|FAIL:|Traceback)", line_stripped):
if len(error_lines) < 20:
error_lines.append(line_stripped)
for pattern_info in FAILURE_PATTERNS:
if re.search(pattern_info["pattern"], line_stripped, re.IGNORECASE):
matched.append(pattern_info)
break # 一行只匹配一个模式
if not matched:
# 未识别的失败类型
return FailureDiagnosis(
category="unknown",
category_cn="未知错误",
severity="medium",
summary="未识别的失败类型,需要人工查看日志",
error_lines=error_lines[:10],
suggestions=[
"点击'查看失败日志'查看完整日志",
"如为偶发问题,可先重试一次",
"常见原因:环境问题、配置问题、新增逻辑引入的bug",
],
auto_fixable=False,
)
# 选最严重、最具体的那个
severity_order = {"high": 3, "medium": 2, "low": 1}
matched.sort(key=lambda x: severity_order.get(x["severity"], 0), reverse=True)
best_match = matched[0]
# 生成摘要
if "summary" in best_match:
summary = best_match["summary"]
else:
summary = f"{best_match['category_cn']}检查失败"
if job_name:
summary = f"[{job_name}] {summary}"
# 从error_lines中过滤出与该分类相关的
relevant_errors = error_lines[:10]
return FailureDiagnosis(
category=best_match["category"],
category_cn=best_match["category_cn"],
severity=best_match["severity"],
summary=summary,
error_lines=relevant_errors,
suggestions=best_match["suggestions"],
auto_fixable=best_match.get("auto_fixable", False),
)
def fetch_failed_job_log(run_id: str, job_id: str, token: str, repo: str) -> Optional[str]:
"""从Gitea API获取失败job的日志"""
api_base = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}"
# 尝试获取job的日志
url = f"{api_base}/actions/runs/{run_id}/jobs/{job_id}/log"
req = urllib.request.Request(url)
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception as e:
print(f"获取日志失败: {e}", file=sys.stderr)
return None
def format_diagnosis_markdown(d: FailureDiagnosis, job_name: str = "", run_url: str = "") -> str:
"""将诊断结果格式化为飞书卡片markdown"""
severity_emoji = {"high": "🔴", "medium": "🟡", "low": "🟢"}
emoji = severity_emoji.get(d.severity, "⚪")
lines = []
lines.append(f"**分类**: {emoji} {d.category_cn}")
lines.append(f"**问题**: {d.summary}")
if d.error_lines:
lines.append("")
lines.append("**关键错误行**:")
for err in d.error_lines[:5]:
# 截断过长的行
if len(err) > 150:
err = err[:147] + "..."
lines.append(f" `{err}`")
lines.append("")
lines.append("**修复建议**:")
for i, s in enumerate(d.suggestions[:5], 1):
lines.append(f" {i}. {s}")
if d.auto_fixable:
lines.append("")
lines.append("💡 **可自动修复**:如格式问题,可尝试点击Rerun让auto-fix自动处理")
if run_url:
lines.append("")
lines.append(f"[查看完整日志]({run_url})")
return "\n".join(lines)
def main():
job_name = os.environ.get("FAILED_JOB", "")
run_id = os.environ.get("GITHUB_RUN_ID", "")
repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas")
token = os.environ.get("GITHUB_TOKEN", "")
# 1. 尝试获取日志
log_text = ""
# 优先从环境变量或文件读取
log_file = os.environ.get("CI_LOG_FILE", "")
if log_file and os.path.exists(log_file):
with open(log_file) as f:
log_text = f.read()
elif run_id and token:
# 尝试从API获取(需要job_id,这里简化处理)
pass
# 如果没有日志,用job_name做粗略分类
if not log_text:
# 基于job名做初始判断
if any(k in job_name.lower() for k in ["validate", "lint", "quality"]):
d = FailureDiagnosis(
category="lint_general",
category_cn="代码质量检查",
severity="low",
summary=f"{job_name} 检查失败(日志不可用,基于job名初步诊断)",
suggestions=["点击查看日志获取具体错误信息", "格式类问题通常可自动修复"],
auto_fixable=True,
)
elif "build" in job_name.lower():
d = FailureDiagnosis(
category="build_general",
category_cn="构建失败",
severity="high",
summary=f"{job_name} 构建失败(日志不可用)",
suggestions=["点击查看日志获取具体构建错误", "常见原因:Dockerfile错误、依赖安装失败、网络问题"],
auto_fixable=False,
)
elif "test" in job_name.lower():
d = FailureDiagnosis(
category="test_general",
category_cn="测试失败",
severity="high",
summary=f"{job_name} 测试失败(日志不可用)",
suggestions=["点击查看日志获取具体失败的测试用例", "检查最近代码改动是否影响了测试"],
auto_fixable=False,
)
elif "deploy" in job_name.lower():
d = FailureDiagnosis(
category="deploy_general",
category_cn="部署失败",
severity="high",
summary=f"{job_name} 部署失败(日志不可用)",
suggestions=["检查目标服务器状态和网络", "检查镜像是否正确推送", "查看服务器上的容器日志"],
auto_fixable=False,
)
else:
d = FailureDiagnosis(
category="unknown",
category_cn="未知错误",
severity="medium",
summary=f"{job_name} 失败",
suggestions=["点击查看日志获取详细信息"],
auto_fixable=False,
)
else:
d = analyze_log(log_text, job_name)
# 输出诊断结果
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" if run_id else ""
print("=" * 60)
print(" CI 失败诊断报告")
print("=" * 60)
print()
print(format_diagnosis_markdown(d, job_name, run_url))
print()
print("=" * 60)
# 将诊断结果写入文件(供通知脚本读取)
output_file = os.environ.get("DIAGNOSIS_OUTPUT", "/tmp/ci_diagnosis.json")
result = {
"category": d.category,
"category_cn": d.category_cn,
"severity": d.severity,
"summary": d.summary,
"error_lines": d.error_lines,
"suggestions": d.suggestions,
"auto_fixable": d.auto_fixable,
}
with open(output_file, "w") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\n诊断结果已保存到: {output_file}")
if __name__ == "__main__":
main()
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""
CI重复失败检测脚本
- 扫描最近N天的CI失败
- 按job名称分组统计失败率
- 识别高失败率job(系统性故障)
- 飞书通知告警
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone
def get_env(name, default=None, required=False):
val = os.environ.get(name, default)
if required and not val:
print(f"❌ 缺少环境变量: {name}")
sys.exit(1)
return val
GITEA_URL = get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
GITEA_TOKEN = get_env("GITEA_API_TOKEN", required=False) or get_env("GITHUB_TOKEN", "")
REPO = get_env("GITEA_REPO", "xiaoxia/xiaoxia-saas")
DAYS = int(get_env("FAIL_CHECK_DAYS", "7"))
FAIL_THRESHOLD = int(get_env("FAIL_THRESHOLD", 3)) # 失败次数阈值
FAIL_RATE_THRESHOLD = float(get_env("FAIL_RATE_THRESHOLD", "30")) # 失败率阈值%
CONSECUTIVE_FAIL_THRESHOLD = int(get_env("CONSECUTIVE_FAIL_THRESHOLD", "3")) # 连续失败阈值
WEBHOOK = get_env("CI_NOTIFY_WEBHOOK", "")
def api_get(path):
"""调用Gitea API"""
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url)
if GITEA_TOKEN:
req.add_header("Authorization", f"token {GITEA_TOKEN}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
print(f" HTTP {e.code}: {path}")
return None
except Exception as e:
print(f" 错误: {e}")
return None
def fetch_recent_runs(days=7, per_page=50, max_pages=10):
"""获取最近N天的runs"""
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
all_runs = []
for page in range(1, max_pages + 1):
path = f"/repos/{REPO}/actions/runs?page={page}&limit={per_page}"
data = api_get(path)
if not data:
break
runs = data.get("workflow_runs", data.get("runs", []))
if not runs:
break
# 检查时间范围(Gitea用started_at,格式2026-07-22T10:58:10+08:00)
oldest = None
for r in runs:
started = r.get("started_at", r.get("created_at", ""))
if started and started >= since:
all_runs.append(r)
else:
oldest = started
if oldest and oldest < since:
break
if len(runs) < per_page:
break
return all_runs
def fetch_run_jobs(run_id):
"""获取run的所有jobs"""
path = f"/repos/{REPO}/actions/runs/{run_id}/jobs"
data = api_get(path)
if not data:
return []
return data.get("jobs", [])
def analyze_failures(runs):
"""
分析失败情况
返回:
- job_stats: {job_name: {total, success, failure, skipped, failure_rate, failures: [...]}}
- consecutive_failures: {job_name: current_streak, max_streak, last_status}
"""
job_stats = defaultdict(
lambda: {
"total": 0,
"success": 0,
"failure": 0,
"error": 0,
"skipped": 0,
"cancelled": 0,
"failures": [],
}
)
# 按时间正序排列(旧→新)用于连续失败计算
sorted_runs = sorted(runs, key=lambda r: r.get("started_at", r.get("created_at", "")))
# 连续失败跟踪 {job_name: streak}
consecutive = defaultdict(lambda: {"current": 0, "max": 0, "last_run": None})
for run in sorted_runs:
run_id = run.get("id")
run_status = run.get("status", "")
run_conclusion = run.get("conclusion", "")
run_started = run.get("started_at", run.get("created_at", ""))
event = run.get("event", "")
# 只统计pull_request和push事件的CI
if event not in ("pull_request", "push"):
continue
jobs = fetch_run_jobs(run_id)
for job in jobs:
name = job.get("name", "")
status = job.get("status", "")
conclusion = job.get("conclusion", "")
# 跳过非CI核心job(如AI Code Review、Preview等)
skip_prefixes = ("AI Code Review", "Preview", "PR Automation", "Auto")
if any(name.startswith(p) for p in skip_prefixes):
continue
stats = job_stats[name]
stats["total"] += 1
if conclusion == "success":
stats["success"] += 1
consecutive[name]["current"] = 0
elif conclusion == "failure":
stats["failure"] += 1
stats["failures"].append(
{
"run_id": run_id,
"time": run_started,
"event": event,
}
)
consecutive[name]["current"] += 1
if consecutive[name]["current"] > consecutive[name]["max"]:
consecutive[name]["max"] = consecutive[name]["current"]
consecutive[name]["last_run"] = run_id
elif conclusion == "error":
stats["error"] += 1
# error也算失败的一种
consecutive[name]["current"] += 1
if consecutive[name]["current"] > consecutive[name]["max"]:
consecutive[name]["max"] = consecutive[name]["current"]
elif conclusion == "skipped":
stats["skipped"] += 1
# skipped不算也不打断连续失败
elif conclusion == "cancelled":
stats["cancelled"] += 1
# cancelled不算失败也不打断
# 计算失败率
for name, stats in job_stats.items():
total_actual = stats["total"] - stats["skipped"] - stats["cancelled"]
if total_actual > 0:
stats["failure_rate"] = round((stats["failure"] + stats["error"]) / total_actual * 100, 1)
else:
stats["failure_rate"] = 0.0
return dict(job_stats), dict(consecutive)
def find_high_failures(job_stats, consecutive):
"""
找出高风险job
告警级别:
- critical: 连续失败 >= CONSECUTIVE_FAIL_THRESHOLD,或 失败率>=50%且失败次数>=5
- warning: 失败率>=FAIL_RATE_THRESHOLD且失败次数>=FAIL_THRESHOLD
- info: 失败次数>=2
"""
critical = []
warning = []
info = []
for name, stats in job_stats.items():
fail_count = stats["failure"] + stats["error"]
rate = stats["failure_rate"]
streak = consecutive.get(name, {}).get("current", 0)
max_streak = consecutive.get(name, {}).get("max", 0)
issue = {
"name": name,
"fail_count": fail_count,
"total": stats["total"],
"failure_rate": rate,
"current_streak": streak,
"max_streak": max_streak,
"recent_failures": stats["failures"][-5:], # 最近5次
}
if streak >= CONSECUTIVE_FAIL_THRESHOLD or (rate >= 50 and fail_count >= 5):
critical.append(issue)
elif rate >= FAIL_RATE_THRESHOLD and fail_count >= FAIL_THRESHOLD:
warning.append(issue)
elif fail_count >= 2:
info.append(issue)
# 按失败次数倒序
critical.sort(key=lambda x: x["fail_count"], reverse=True)
warning.sort(key=lambda x: x["fail_count"], reverse=True)
info.sort(key=lambda x: x["fail_count"], reverse=True)
return critical, warning, info
def generate_report(critical, warning, info, days, total_runs):
"""生成Markdown报告"""
lines = []
lines.append("# CI重复失败检测报告")
lines.append("")
lines.append(f"**统计周期**: 最近{days}天")
lines.append(f"**扫描Runs**: {total_runs}个")
lines.append(f"**生成时间**: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
lines.append("")
lines.append(f"## 概览")
lines.append("")
lines.append(f"| 级别 | 数量 |")
lines.append(f"|------|------|")
lines.append(f"| 🔴 严重 (连续失败≥{CONSECUTIVE_FAIL_THRESHOLD}次 或 失败率≥50%) | {len(critical)} |")
lines.append(f"| 🟡 警告 (失败率≥{FAIL_RATE_THRESHOLD}% 且 失败≥{FAIL_THRESHOLD}次) | {len(warning)} |")
lines.append(f"| 🔵 关注 (失败≥2次) | {len(info)} |")
lines.append("")
if critical:
lines.append("## 🔴 严重问题")
lines.append("")
for item in critical:
lines.append(f"### {item['name']}")
lines.append("")
lines.append(f"- 失败次数: **{item['fail_count']}** / {item['total']} 次运行")
lines.append(f"- 失败率: **{item['failure_rate']}%**")
lines.append(f"- 当前连续失败: **{item['current_streak']}** 次 (历史最高: {item['max_streak']} 次)")
lines.append("")
if item["recent_failures"]:
lines.append("最近失败:")
lines.append("")
for f in item["recent_failures"]:
lines.append(f"- [{f['time'][:16]}] run #{f['run_id']} ({f['event']})")
lines.append("")
if warning:
lines.append("## 🟡 警告")
lines.append("")
for item in warning:
lines.append(
f"- **{item['name']}**: {item['fail_count']}次失败 / {item['total']}次运行 ({item['failure_rate']}%)"
)
lines.append("")
if info:
lines.append("## 🔵 关注列表")
lines.append("")
lines.append("| Job名称 | 失败次数 | 总次数 | 失败率 | 当前连续 |")
lines.append("|---------|----------|--------|--------|----------|")
for item in info[:20]: # 最多显示20个
lines.append(
f"| {item['name']} | {item['fail_count']} | {item['total']} | {item['failure_rate']}% | {item['current_streak']} |"
)
lines.append("")
return "\n".join(lines)
def send_feishu_notification(critical, warning, info, days):
"""发送飞书通知"""
if not WEBHOOK:
print(" ⚠️ 未配置WEBHOOK,跳过飞书通知")
return False
total_issues = len(critical) + len(warning) + len(info)
if total_issues == 0:
print(" ✅ 无异常,不发送通知")
return True
level = "🔴 严重告警" if critical else "🟡 警告" if warning else "🔵 关注"
title = f"CI重复失败检测 - {level}"
text = f"统计周期: 最近{days}天\n\n"
if critical:
text += "【严重问题】\n"
for item in critical[:5]:
text += f"• {item['name']}\n"
text += f" 失败 {item['fail_count']}/{item['total']} ({item['failure_rate']}%) 连续{item['current_streak']}次\n"
if len(critical) > 5:
text += f" ...还有{len(critical)-5}个\n"
text += "\n"
if warning:
text += "【警告】\n"
for item in warning[:5]:
text += f"• {item['name']}: {item['fail_count']}次失败 ({item['failure_rate']}%)\n"
if len(warning) > 5:
text += f" ...还有{len(warning)-5}个\n"
text += "\n"
if info and not critical and not warning:
text += "【关注列表】\n"
for item in info[:10]:
text += f"• {item['name']}: {item['fail_count']}次失败\n"
text += "\n"
text += f"共发现 {total_issues} 个异常job"
payload = {"msg_type": "text", "content": {"text": f"{title}\n\n{text}"}}
data = json.dumps(payload).encode()
req = urllib.request.Request(WEBHOOK, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
if result.get("code") == 0 or result.get("StatusCode") == 0:
print(" ✅ 飞书通知已发送")
return True
else:
print(f" ⚠️ 飞书返回: {result}")
return False
except Exception as e:
print(f" ❌ 飞书通知失败: {e}")
return False
def main():
print(f"=== CI重复失败检测 ===")
print(f"统计周期: 最近{DAYS}天")
print(f"仓库: {REPO}")
print()
print("1. 获取最近的Runs...")
runs = fetch_recent_runs(days=DAYS)
print(f" 找到 {len(runs)} 个runs")
if not runs:
print("⚠️ 没有找到runs,退出")
return
print()
print("2. 分析job失败情况(可能需要点时间)...")
job_stats, consecutive = analyze_failures(runs)
print(f" 共统计 {len(job_stats)} 个job")
print()
print("3. 识别高风险job...")
critical, warning, info = find_high_failures(job_stats, consecutive)
print(f" 🔴 严重: {len(critical)}")
print(f" 🟡 警告: {len(warning)}")
print(f" 🔵 关注: {len(info)}")
print()
print("4. 生成报告...")
report = generate_report(critical, warning, info, DAYS, len(runs))
# 保存报告
report_path = os.environ.get("REPORT_PATH", f"/tmp/ci_failure_report_{int(time.time())}.md")
with open(report_path, "w") as f:
f.write(report)
print(f" 报告已保存: {report_path}")
# 打印摘要
print()
print("=== 摘要 ===")
if critical:
print("🔴 严重问题:")
for item in critical[:5]:
print(
f" {item['name']}: {item['fail_count']}次失败, {item['failure_rate']}%, 连续{item['current_streak']}次"
)
if warning:
print("🟡 警告:")
for item in warning[:5]:
print(f" {item['name']}: {item['fail_count']}次失败, {item['failure_rate']}%")
print()
print("5. 发送飞书通知...")
send_feishu_notification(critical, warning, info, DAYS)
print()
print("✅ 检测完成")
# 有严重问题时退出码非零,方便workflow标记
if critical:
sys.exit(2)
elif warning:
sys.exit(1)
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# PR构建专用:只构建不推送,只读缓存不写,用于PR阶段验证Dockerfile
set -eu
NO_CACHE_FLAG=""
if [ "$1" = "--no-cache" ]; then
NO_CACHE_FLAG="--no-cache"
shift
fi
DOCKERFILE="$1"
IMAGE_TAG="$2"
CACHE_REF="$3"
shift 3
BUILD_ARGS=""
for arg in "$@"; do
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
done
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
else
docker buildx use "$BUILDER_NAME"
fi
docker buildx inspect --bootstrap
CACHE_NAME=$(echo "$CACHE_REF" | tr "/" "_" | tr ":" "-")
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
mkdir -p "$LOCAL_CACHE_DIR"
echo "=== PR Build: build only, no push, read-only cache ==="
echo "Dockerfile: ${DOCKERFILE}"
echo "Image tag: ${IMAGE_TAG}"
echo ""
build_with_retry() {
local attempt=1
local max_attempts=2
while [ $attempt -le $max_attempts ]; do
local build_output
local exit_code
set +e
build_output=$(docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--load \
. 2>&1)
exit_code=$?
set -e
if [ $exit_code -eq 0 ]; then
echo "$build_output"
return 0
fi
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
echo "$build_output"
echo "Local cache corrupted, cleaning and retrying ($attempt/$max_attempts)..."
rm -rf "${LOCAL_CACHE_DIR}"
mkdir -p "${LOCAL_CACHE_DIR}"
docker buildx prune -f -a >/dev/null 2>&1 || true
attempt=$((attempt + 1))
else
echo "$build_output"
return $exit_code
fi
done
echo "Local cache failed, building with registry cache only..."
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--load \
.
}
build_with_retry
echo ""
echo "PR build OK (not pushed): ${IMAGE_TAG}"
+8 -6
View File
@@ -1,6 +1,5 @@
#!/bin/bash
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底)
# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache共享)
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
set -eu
@@ -35,7 +34,7 @@ LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
mkdir -p "$LOCAL_CACHE_DIR"
# 缓存源:local优先(带自动修复),registry兜底
# 缓存源:local优先(带自动修复),registry兜底读写
# 本地缓存损坏时自动清理后重试,避免snapshot not found导致构建全挂
build_with_cache_retry() {
local attempt=1
@@ -48,8 +47,9 @@ build_with_cache_retry() {
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
--cache-from "type=registry,ref=${CACHE_REF}" \
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
@@ -81,15 +81,16 @@ build_with_cache_retry() {
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
--cache-from "type=registry,ref=${CACHE_REF}" \
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
.
}
echo "=== Step 1: Build & push image (local cache + registry read, with auto-repair) ==="
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
echo "Local cache: ${LOCAL_CACHE_DIR}"
echo "Registry cache: ${CACHE_REF}"
echo ""
@@ -99,6 +100,7 @@ build_with_cache_retry
echo ""
echo "Image pushed: ${IMAGE_TAG}"
echo "Local cache updated"
echo "Registry cache updated (if supported)"
echo ""
echo "Build completed: ${IMAGE_TAG}"
+44 -20
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# CI Integration Tests Job 主脚本
# 包含:依赖安装、ffmpeg安装、Redis启动、PG启动、迁移、测试、清理、覆盖率
# 支持 pytest-xdist 并行执行:每个 worker 使用独立数据库,预期加速 2-4 倍
set -eu
echo "=== CI Integration Tests 开始 ==="
@@ -28,12 +29,13 @@ for i in 1 2 3; do
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q pytest-rerunfailures && break
echo "pip install pytest-rerunfailures 失败,重试 $i/3..."
python3 -m pip install -q pytest-rerunfailures pytest-xdist && break
echo "pip install pytest-rerunfailures/pytest-xdist 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
pytest --version
echo "pytest-xdist: $(python3 -c "import xdist; print(xdist.__version__)" 2>/dev/null || echo 'not installed')"
# --- 安装 ffmpeg ---
echo ""
@@ -187,8 +189,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
echo "等待共享PG连接就绪..."
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
# 创建独立数据库
echo "创建测试数据库: $CI_DB_NAME"
# 创建主数据库(xdist 模式下各 worker 会创建自己的数据库,主库作为 fallback)
echo "创建主测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
@@ -238,30 +240,37 @@ else
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
fi
# --- 执行迁移 ---
# --- 执行迁移(主数据库,xdist worker 会各自创建自己的库并迁移) ---
echo ""
echo "=== 执行 Alembic 迁移 ==="
echo "=== 执行 Alembic 迁移(主数据库) ==="
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
echo "✅ 迁移完成"
# --- 运行集成测试 ---
# --- 运行集成测试(pytest-xdist 并行) ---
echo ""
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 tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance"
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
python3 -m coverage report --fail-under=40 > /dev/null
echo "=== 运行集成测试(pytest-xdist 并行模式) ==="
echo "CPU 核数: $(nproc 2>/dev/null || echo 'unknown')"
# 集成测试使用 pytest-xdist 并行加速(coverage 由单元测试负责,并行模式下 coverage 不稳定)
# -n 2: 限制2个worker,避免DooD模式下读到宿主机全部核数导致OOM
# 集成测试涉及ffmpeg编码+PG多库,内存开销大,2worker较稳妥
# (auto模式下读到宿主机4核8线程=8worker,OOM直接杀worker进程)
# --dist loadfile: 同一测试文件分配到同一 worker(共享 fixture 更高效)
# --maxfail=1: 遇到失败停止调度新测试(并行模式下等价于 -x)
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration \
-q --timeout=60 --maxfail=1 --reruns 2 --reruns-delay 1 \
-m "not performance" \
-n 2 --dist loadfile \
-p no:cacheprovider
echo "✅ 集成测试通过"
# --- API 性能基线测试(仅告警) ---
# --- API 性能基线测试(仅告警,串行执行) ---
echo ""
echo "=== API 性能基线测试(仅告警) ==="
set +e
PERF_OUTPUT=$(mktemp)
# 性能测试单独串行运行(不参与并行,避免资源竞争影响测量结果)
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration/test_api_performance.py \
-v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT"
echo ""
@@ -284,14 +293,29 @@ set -e
echo ""
echo "=== 清理 ==="
if [ "$USE_SHARED_PG" = "true" ]; then
# 清理共享PG上的测试数据库
echo "清理共享PG测试数据库: $CI_DB_NAME"
# 清理共享PG上的测试数据库(主库 + 可能残留的 worker 库)
echo "清理共享PG测试数据库..."
# 清理所有以 CI_DB_NAME 开头的数据库(主库 + worker 库)
PGPASSWORD="${SHARED_PG_PASSWORD}" python3 -c "
import psycopg2
conn = psycopg2.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
# 查找所有需要清理的数据库(主库 + worker 库)
cur.execute(\"SELECT datname FROM pg_database WHERE datname LIKE '$CI_DB_NAME%'\")
dbs = [row[0] for row in cur.fetchall()]
for db in dbs:
try:
# 强制断开所有连接
cur.execute(f\"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{db}' AND pid <> pg_backend_pid()\")
cur.execute(f'DROP DATABASE IF EXISTS \"{db}\" WITH (FORCE)')
print(f' 已清理: {db}')
except Exception as e:
print(f' 警告: 清理 {db} 失败: {e}')
cur.close()
conn.close()
" 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)"
+7 -6
View File
@@ -1,24 +1,25 @@
#!/bin/sh
# CI 公共步骤:前端依赖安装(在 docker node 容器中运行)
# 用法:step_frontend_install.sh [模式]
# 模式: full (默认) - 完整安装所有依赖
# vitest - 同full(保持接口兼容)
# 优化:增加国内npm镜像源,加重试间隔
set -eu
MODE="${1:-full}"
echo "=== 前端依赖安装开始 (模式: $MODE) ==="
# npm ci 带重试(网络不稳定时自动重试)
# npm国内镜像源(加速下载,减少网络失败)
NPM_REGISTRY="https://registry.npmmirror.com"
for i in 1 2 3; do
echo "npm ci 尝试 $i/3 (镜像: $NPM_REGISTRY)"
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc "npm ci --no-audit --no-fund" && break
sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break
echo "npm ci 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
sleep 10
done
echo "=== 前端依赖安装完成 ==="
+2
View File
@@ -2,3 +2,5 @@
# CI 公共步骤:Job 开始计时
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
# trigger CI run for PR validation
# trigger CI - worker dood fallback fix test
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
set -eu
echo "=== CI Validate: 代码质量与安全扫描 ==="
# --- 密钥检测 ---
echo ""
echo "=== [1/6] Secret detection (detect-secrets) ==="
python3 -m pip install -q detect-secrets
detect-secrets --version
detect-secrets scan \
--all-files \
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
--disable-plugin Base64HighEntropyString \
--disable-plugin HexHighEntropyString \
--disable-plugin BasicAuthDetector \
--disable-plugin KeywordDetector \
--disable-plugin IPPublicDetector \
> /tmp/secrets-scan.json 2>&1
FOUND=$(python3 -c "
import json
try:
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
results = data.get('results', {})
total = sum(len(v) for v in results.values())
print(total)
except Exception:
print('error')
")
echo "Secrets detected: $FOUND"
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
echo ""
echo "=== Secret details ==="
python3 -c "
import json
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
for fpath, items in data.get('results', {}).items():
for item in items:
line = item.get('line_number', '?')
stype = item.get('type', '?')
hashed = item.get('hashed_secret', '')[:16]
print(f' {fpath}:{line} [{stype}] {hashed}...')
"
echo ""
echo "ERROR: Potential secrets detected in code!"
exit 1
fi
echo "✅ Secret scan passed"
# --- 增量/全量模式判断 ---
echo ""
echo "=== [2/6] Code quality checks ==="
SCAN_MODE="full"
CHANGED_PY_FILES=""
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
set +e
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
set -e
if [ "$HTTP_CODE" = "200" ]; then
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
import json, sys
try:
files = json.load(sys.stdin)
py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed']
print(' '.join(py_files))
except Exception:
print('')
")
if [ -n "$CHANGED_PY_FILES" ]; then
SCAN_MODE="incremental"
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
else
SCAN_MODE="skip_py"
echo "No Python files changed in this PR"
fi
else
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
fi
else
echo "Full scan mode (not a PR event)"
fi
if [ "$SCAN_MODE" = "incremental" ]; then
# 防御性过滤
EXISTING_PY_FILES=""
for f in $CHANGED_PY_FILES; do
if [ -f "$f" ]; then
if [ -z "$EXISTING_PY_FILES" ]; then
EXISTING_PY_FILES="$f"
else
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
fi
fi
done
CHANGED_PY_FILES="$EXISTING_PY_FILES"
python3 -m compileall -q $CHANGED_PY_FILES
python3 -m black --check --fast $CHANGED_PY_FILES
python3 -m isort --check-only $CHANGED_PY_FILES
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
if [ -n "$RUFF_FILES" ]; then
python3 -m ruff check $RUFF_FILES --statistics
else
echo "No ruff-checkable files changed, skipping"
fi
elif [ "$SCAN_MODE" = "skip_py" ]; then
echo "No Python files changed - skipping Python lint checks"
else
echo "Full scan mode"
python3 -m compileall -q alembic apps packages tests scripts
python3 -m black --check --fast alembic apps packages tests scripts
python3 -m isort --check-only alembic apps packages tests scripts
python3 -m ruff check apps packages tests --statistics
fi
echo "✅ Code quality checks passed"
# --- Bandit 安全扫描(仅告警) ---
echo ""
echo "=== [3/6] Security scan (bandit, advisory only) ==="
set +e
bandit -r apps packages -q -ll
BANDIT_EXIT=$?
set -e
if [ "$BANDIT_EXIT" -ne 0 ]; then
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
else
echo "✅ Bandit security scan passed"
fi
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
echo ""
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
python3 -m pip install -q pip-audit
pip-audit --version
EXIT_CODE=0
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
if [ -f "$req_file" ]; then
echo "--- Scanning $req_file ---"
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
echo ""
fi
done
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
# --- Vulture 死代码检测(仅告警) ---
echo ""
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
set +e
python3 -m pip install -q vulture
vulture --version
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
echo ""
vulture apps packages scripts \
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
--min-confidence 70 \
2>&1 | sort -t'(' -k2 -rn | head -80
echo ""
echo "=== vulture scan summary ==="
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
echo "建议:定期人工审查高置信度(>=90%)条目"
set -e
# --- Release 脚本语法校验 ---
echo ""
echo "=== [6/6] Release scripts syntax validation ==="
bash -n scripts/backup_postgres.sh
bash -n scripts/restore_postgres_plan.sh
bash -n scripts/init_production_env.sh
echo "✅ Release scripts syntax OK"
echo ""
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# CI Validate: Alembic迁移验证(并行Job 3/3)
# 需要PostgreSQL数据库
set -eu
echo "=== CI Validate: Alembic迁移验证 ==="
# --- DooD模式检测:确定宿主机访问地址 ---
detect_docker_host() {
local test_port="${1:-5432}"
local candidates=()
# 1. host.docker.internal
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
candidates+=("host.docker.internal")
fi
# 2. docker0 桥接网关
candidates+=("172.17.0.1")
# 3. 默认网关
local gw=""
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
candidates+=("$gw")
fi
# 4. 宿主机同网段的.1或.254
local my_ip=""
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$my_ip" ]; then
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
candidates+=("${subnet}.1")
candidates+=("${subnet}.254")
fi
# 5. 127.0.0.1 最后尝试
candidates+=("127.0.0.1")
for candidate in "${candidates[@]}"; do
if python3 -c "
import socket
s = socket.socket()
s.settimeout(2)
try:
s.connect(('$candidate', $test_port))
s.close()
print('ok')
except:
pass
" 2>/dev/null | grep -q ok; then
echo "$candidate"
return 0
fi
done
echo "127.0.0.1"
return 1
}
# 获取宿主机IP
if [ -S /var/run/docker.sock ]; then
DOCKER_HOST_IP=$(detect_docker_host 5433)
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
DOCKER_HOST_IP=$(detect_docker_host 22)
fi
echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP"
else
DOCKER_HOST_IP="127.0.0.1"
echo "非DooD模式,使用 127.0.0.1"
fi
PG_HOST="$DOCKER_HOST_IP"
echo "PG host: $PG_HOST"
# 指数退避TCP连接检查
wait_tcp_ready() {
local host="$1"
local port="$2"
local max_attempts="${3:-5}"
local delay=1
local attempt=1
while [ "$attempt" -le "$max_attempts" ]; do
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
return 0
fi
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
sleep "$delay"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
return 1
}
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
if [ "$USE_SHARED_PG" = "true" ]; then
# 使用常驻共享PG实例
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
SHARED_PG_HOST="$PG_HOST"
SHARED_PG_PORT="5433"
SHARED_PG_USER="postgres"
SHARED_PG_PASSWORD="ci_pg_2026!"
CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
echo "等待共享PG连接就绪..."
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
echo "创建测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
cur.close()
conn.close()
"
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
# 执行迁移
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
echo "✅ Alembic migrations applied successfully"
# 清理数据库
echo "清理测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
cur.close()
conn.close()
" 2>/dev/null || echo "WARN: 数据库清理失败"
echo "✅ 共享PG数据库已清理"
else
# 使用临时PG容器(默认模式)
echo "使用临时PG容器模式"
PG_CONTAINER=ci-pg-validate-migration-${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@${PG_HOST}:${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 container is healthy on port $PG_PORT"
break
fi
echo "Waiting for PostgreSQL container health... ($i/30)"
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
# TCP连通性检查
echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..."
wait_tcp_ready "$PG_HOST" "$PG_PORT" 5
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
# 执行迁移
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
echo "✅ Alembic migrations applied successfully"
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
fi
echo ""
echo "=== CI Validate: Alembic迁移验证 通过 ✅ ==="
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# CI Validate: Mypy类型检查(并行Job 2/3)
set -eu
echo "=== CI Validate: Mypy类型检查 ==="
bash scripts/ci/mypy_check.sh
echo ""
echo "=== CI Validate: Mypy类型检查 通过 ✅ ==="
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# Vitest 增量执行脚本
# PR模式下只跑与改动文件相关的测试,大幅节省时间
# 用法: bash scripts/ci/vitest_incremental.sh
set -eu
cd apps/web
# 如果不是PR事件,直接全量跑
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
echo "非PR模式,全量执行Vitest"
npx --no-install vitest run --coverage
exit $?
fi
# 获取PR改动的文件列表
PR_NUMBER=$(echo "${GITHUB_REF:-}" | sed 's|refs/pull/||; s|/.*||')
if [ -z "$PR_NUMBER" ]; then
echo "无法获取PR编号,全量执行Vitest"
npx --no-install vitest run --coverage
exit $?
fi
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
import json, sys
try:
files = json.load(sys.stdin)
web_files = []
for f in files:
fname = f['filename']
# 只关注前端源码文件
if fname.startswith('apps/web/src/') and fname.endswith(('.ts', '.tsx', '.js', '.jsx')) and f['status'] != 'removed':
# 去掉apps/web/前缀,变成相对路径
web_files.append(fname.replace('apps/web/', ''))
print(' '.join(web_files))
except Exception as e:
print('')
")
if [ -z "$CHANGED_FILES" ]; then
echo "PR未改动前端源码文件,跳过Vitest"
echo "(如果配置了前端单测门禁,请确保至少有一个相关测试)"
exit 0
fi
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -w)
echo "PR改动了 $FILE_COUNT 个前端文件"
echo "改动文件: $CHANGED_FILES"
# 如果改动文件太多(超过30个),全量跑更可靠
if [ "$FILE_COUNT" -gt 30 ]; then
echo "改动文件较多(>$FILE_COUNT),降级为全量执行以确保覆盖"
npx --no-install vitest run --coverage
exit $?
fi
# 使用vitest --related 跑增量测试
echo ""
echo "=== 增量执行 Vitest(只跑相关测试)==="
echo "相关源文件: $CHANGED_FILES"
echo ""
set +e
npx --no-install vitest run --related $CHANGED_FILES
VITEST_EXIT=$?
set -e
if [ "$VITEST_EXIT" -eq 0 ]; then
echo ""
echo "✅ 增量测试通过"
echo "(仅覆盖与改动相关的测试用例)"
exit 0
else
echo ""
echo "❌ 增量测试失败"
exit $VITEST_EXIT
fi
+128 -31
View File
@@ -1,17 +1,57 @@
#!/usr/bin/env python3
"""发送 CI 失败通知到飞书/项目群 webhook。"""
"""发送 CI 失败通知到飞书/项目群 webhook(增强版:带失败诊断)。
诊断功能:自动分析失败原因,给出分类和修复建议。
"""
import json
import os
import subprocess
import sys
import urllib.request
def run_diagnosis() -> dict:
"""运行失败诊断脚本,返回诊断结果"""
diag_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ci/ci_failure_diagnosis.py")
if not os.path.exists(diag_script):
diag_script = "scripts/ci/ci_failure_diagnosis.py"
result = {
"category": "unknown",
"category_cn": "未知",
"severity": "medium",
"summary": "",
"error_lines": [],
"suggestions": [],
"auto_fixable": False,
}
try:
# 运行诊断脚本
env = os.environ.copy()
env["DIAGNOSIS_OUTPUT"] = "/tmp/ci_diagnosis_result.json"
proc = subprocess.run([sys.executable, diag_script], capture_output=True, text=True, timeout=30, env=env)
# 尝试读取结果文件
output_file = "/tmp/ci_diagnosis_result.json"
if os.path.exists(output_file):
with open(output_file) as f:
result = json.load(f)
elif proc.stdout:
# 从stdout解析
pass
except Exception as e:
print(f"诊断脚本执行失败: {e}", file=sys.stderr)
return result
def main() -> int:
webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "")
if not webhook:
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
return 0
failed_job = os.environ.get("FAILED_JOB", "Unknown Job")
@@ -21,6 +61,86 @@ def main() -> int:
run_id = os.environ.get("GITHUB_RUN_ID", "unknown")
repo = os.environ.get("GITHUB_REPOSITORY", "unknown")
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
pr_number = os.environ.get("PR_NUMBER", "")
# 运行诊断
diagnosis = run_diagnosis()
# 构建卡片内容
severity_color = {"high": "red", "medium": "orange", "low": "blue"}
card_status = severity_color.get(diagnosis.get("severity", "medium"), "red")
# 标题
title = f"❌ CI失败 - {diagnosis.get('category_cn', '未知')}"
# 诊断部分
diag_lines = []
diag_lines.append(f"**任务**: {failed_job}")
diag_lines.append(f"**分类**: {diagnosis.get('category_cn', '未知')}")
if diagnosis.get("summary"):
diag_lines.append(f"**问题**: {diagnosis['summary']}")
# 错误行
error_lines = diagnosis.get("error_lines", [])
if error_lines:
diag_lines.append("")
diag_lines.append("**关键错误**:")
for err in error_lines[:3]:
if len(err) > 100:
err = err[:97] + "..."
diag_lines.append(f"`{err}`")
# 修复建议
suggestions = diagnosis.get("suggestions", [])
if suggestions:
diag_lines.append("")
diag_lines.append("**修复建议**:")
for i, s in enumerate(suggestions[:3], 1):
diag_lines.append(f"{i}. {s}")
if diagnosis.get("auto_fixable"):
diag_lines.append("")
diag_lines.append("💡 *可自动修复的问题,试试Rerun*")
# 基本信息
info_lines = [
f"**分支**: {branch}",
f"**提交**: `{commit}`",
f"**提交者**: {actor}",
]
if pr_number:
info_lines.append(f"**PR**: #{pr_number}")
elements = [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "\n".join(diag_lines),
},
},
{
"tag": "hr",
},
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "\n".join(info_lines),
},
},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看失败日志"},
"url": run_url,
"type": "danger",
},
],
},
]
payload = {
"msg_type": "interactive",
@@ -28,36 +148,11 @@ def main() -> int:
"header": {
"title": {
"tag": "plain_text",
"content": "❌ CI 构建失败",
"content": title,
},
"status": "red",
"status": card_status,
},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": (
f"**任务**: {failed_job}\n"
f"**分支**: {branch}\n"
f"**提交**: {commit}\n"
f"**提交者**: {actor}\n"
f"**Run ID**: {run_id}"
),
},
},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看失败日志"},
"url": run_url,
"type": "danger",
}
],
},
],
"elements": elements,
},
}
@@ -71,7 +166,7 @@ def main() -> int:
try:
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()
print("通知已发送")
print("通知已发送(带诊断信息)")
except Exception as e:
print(f"通知发送失败: {e}", file=sys.stderr)
return 1
@@ -81,3 +176,5 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
# trigger CI - bypass [ci skip] bug
+151 -93
View File
@@ -1,22 +1,9 @@
#!/bin/sh
# ===========================================
# Staging 部署脚本(SSH 模式,支持自动回滚)
# Staging 部署脚本(SSH 模式,并行优化版)
# ===========================================
# 通过 SSH 在 staging 服务器上执行
#
# 环境变量:
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
# REGISTRY_TOKEN - Registry 访问令牌
# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji)
# REGISTRY_USER - Registry 用户名(默认 xiaoxia)
# ENV_FILE - 环境变量文件路径
# GENERATED_DIR - 生成文件目录
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false)
# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false)
set -eu
# ---- 重试工具函数 ----
retry_cmd() {
local max_attempts=$1
local backoff=$2
@@ -73,10 +60,9 @@ mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
echo "==========================================="
echo " Staging 部署 - $IMAGE_TAG"
echo " Staging 部署 - $IMAGE_TAG (并行优化版)"
echo "==========================================="
# ---- 记录当前运行的镜像版本(用于回滚) ----
echo "Recording current image versions for rollback..."
PREV_API_IMAGE=""
PREV_WORKER_IMAGE=""
@@ -95,7 +81,6 @@ for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
fi
done
# ---- 回滚函数 ----
rollback() {
echo ""
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
@@ -108,7 +93,6 @@ rollback() {
exit 1
fi
# 停止当前(失败的)新容器
echo "Stopping new containers..."
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
@@ -116,7 +100,6 @@ rollback() {
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# 恢复 API
if [ -n "$PREV_API_IMAGE" ]; then
echo "Rolling back API to: $PREV_API_IMAGE"
docker run -d \
@@ -137,12 +120,9 @@ rollback() {
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$PREV_API_IMAGE"
else
echo "No previous API image to roll back to"
"$PREV_API_IMAGE" &
fi
# 恢复 Worker
if [ -n "$PREV_WORKER_IMAGE" ]; then
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
docker run -d \
@@ -164,12 +144,9 @@ rollback() {
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$PREV_WORKER_IMAGE"
else
echo "No previous Worker image to roll back to"
"$PREV_WORKER_IMAGE" &
fi
# 恢复 Web
if [ -n "$PREV_WEB_IMAGE" ]; then
echo "Rolling back Web to: $PREV_WEB_IMAGE"
LEGACY_VOLUME=""
@@ -187,12 +164,11 @@ rollback() {
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$PREV_WEB_IMAGE"
else
echo "No previous Web image to roll back to"
"$PREV_WEB_IMAGE" &
fi
# 等待 API 回滚后恢复健康
wait
if [ -n "$PREV_API_IMAGE" ]; then
echo "Waiting for rolled-back API to become healthy..."
i=0
@@ -224,7 +200,6 @@ rollback() {
exit 1
}
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "=========================================="
echo " Login to Registry (with retries)"
@@ -233,28 +208,64 @@ if [ -n "$REGISTRY_TOKEN" ]; then
retry_docker_login
fi
# ---- Pull 新版本镜像 ----
# ---- 并行 Pull 三个镜像 ----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
echo "=========================================="
echo " Pull images (with retries)"
echo " Pull images (parallel, up to 3 retries each)"
echo "=========================================="
retry_docker_pull "$REGISTRY_API"
retry_docker_pull "$REGISTRY_WORKER"
retry_docker_pull "$REGISTRY_WEB"
PULL_LOG_DIR="/tmp/staging-pull-$$"
mkdir -p "$PULL_LOG_DIR"
retry_docker_pull "$REGISTRY_API" > "$PULL_LOG_DIR/api.log" 2>&1 &
PID_API=$!
retry_docker_pull "$REGISTRY_WORKER" > "$PULL_LOG_DIR/worker.log" 2>&1 &
PID_WORKER=$!
retry_docker_pull "$REGISTRY_WEB" > "$PULL_LOG_DIR/web.log" 2>&1 &
PID_WEB=$!
wait $PID_API $PID_WORKER $PID_WEB
echo ""
echo "Pull 结果:"
PULL_FAILED=0
for svc in api worker web; do
if tail -1 "$PULL_LOG_DIR/$svc.log" 2>/dev/null | grep -qE "Status:|Downloaded|already exists|is up to date"; then
echo " OK $svc"
elif grep -qE "Digest:|Status: Downloaded" "$PULL_LOG_DIR/$svc.log" 2>/dev/null; then
echo " OK $svc"
else
# 检查docker pull返回值不直接,用镜像是否存在来判断
img_var="REGISTRY_$(echo $svc | tr '[:lower:]' '[:upper:]')"
img_val=$(eval echo "\$$img_var")
if docker image inspect "$img_val" >/dev/null 2>&1; then
echo " OK $svc"
else
echo " FAIL $svc"
tail -5 "$PULL_LOG_DIR/$svc.log" 2>/dev/null || true
PULL_FAILED=$((PULL_FAILED + 1))
fi
fi
done
rm -rf "$PULL_LOG_DIR"
if [ "$PULL_FAILED" -gt 0 ]; then
echo ""
echo "ERROR: $PULL_FAILED 个镜像 pull 失败"
exit 1
fi
echo "All images pulled."
# ---- 备份 legacy assets ----
echo "Backing up legacy assets from current web container..."
if docker inspect xiaoxia-web-staging >/dev/null 2>&1; then
_tmpdir="/tmp/legacy-assets-$$"
rm -rf "$_tmpdir"
mkdir -p "$_tmpdir"
docker cp xiaoxia-web-staging:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
# 只有目录非空才拷贝,避免覆盖有内容的 legacy assets
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
@@ -264,13 +275,11 @@ else
echo "No existing web container, skipping legacy assets backup"
fi
# 清理 7 天前的 legacy assets
if [ -d "$LEGACY_ASSETS_DIR" ]; then
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
echo "Legacy assets cleanup done (retain 7 days)"
fi
# ---- 检查基础设施容器 ----
echo "Checking infrastructure containers..."
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
if ! docker inspect "$c" >/dev/null 2>&1; then
@@ -284,10 +293,8 @@ for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
fi
done
# ---- 创建网络(不存在则创建) ----
docker network create xiaoxia-net-staging 2>/dev/null || true
# ---- 数据库迁移 ----
if [ "$SKIP_MIGRATION" != "true" ]; then
echo "Running database migrations..."
docker run --rm \
@@ -296,8 +303,6 @@ if [ "$SKIP_MIGRATION" != "true" ]; then
-e APP_ENV=staging \
"$REGISTRY_API" sh -c "cd /app && alembic upgrade head" || {
echo "ERROR: Database migration failed"
echo "Note: Migration failures are NOT automatically rolled back (data safety)"
echo "Please manually check and fix the migration, then redeploy"
exit 1
}
echo "Migrations completed."
@@ -305,7 +310,6 @@ else
echo "Skipping migrations (SKIP_MIGRATION=true)"
fi
# ---- 停止旧容器 ----
echo "Stopping old containers..."
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
@@ -313,8 +317,14 @@ docker rm -f xiaoxia-web-staging 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# ---- 启动 API ----
echo "Starting API container..."
# ---- 并行启动三个容器 ----
echo "Starting all containers (parallel)..."
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
fi
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
@@ -333,10 +343,9 @@ docker run -d \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$REGISTRY_API" || rollback
"$REGISTRY_API" &
PID_API_START=$!
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
@@ -356,18 +365,9 @@ docker run -d \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$REGISTRY_WORKER" || rollback
"$REGISTRY_WORKER" &
PID_WORKER_START=$!
# ---- 启动 Web ----
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
echo "Web container: legacy assets mounted (fallback)"
else
echo "Web container: no legacy assets to mount"
fi
echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
@@ -379,53 +379,111 @@ docker run -d \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$REGISTRY_WEB" || rollback
"$REGISTRY_WEB" &
PID_WEB_START=$!
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API is healthy!"
break
wait $PID_API_START $PID_WORKER_START $PID_WEB_START
START_FAILED=0
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo " FAIL $c: not created"
START_FAILED=$((START_FAILED + 1))
else
state=$(docker inspect -f '{{.State.Status}}' "$c")
if [ "$state" = "running" ] || [ "$state" = "starting" ]; then
echo " OK $c: $state"
else
echo " FAIL $c: $state"
docker logs --tail 20 "$c" 2>/dev/null || true
START_FAILED=$((START_FAILED + 1))
fi
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
done
if [ "$i" -ge 40 ]; then
echo "ERROR: API did not become healthy within 120s"
if [ "$START_FAILED" -gt 0 ]; then
echo "ERROR: $START_FAILED 个容器启动失败"
rollback
fi
# ---- 并行等待 API 和 Web 健康 ----
echo ""
echo "Waiting for API + Web health (parallel)..."
HEALTH_LOG_DIR="/tmp/staging-health-$$"
mkdir -p "$HEALTH_LOG_DIR"
(
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API healthy after $((i * 3))s"
exit 0
fi
i=$((i + 1))
sleep 3
done
echo "API FAILED after 120s"
exit 1
) > "$HEALTH_LOG_DIR/api.log" 2>&1 &
PID_API_HEALTH=$!
(
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
echo "Web healthy after $((i * 2))s"
exit 0
fi
i=$((i + 1))
sleep 2
done
echo "Web FAILED after 30s"
exit 1
) > "$HEALTH_LOG_DIR/web.log" 2>&1 &
PID_WEB_HEALTH=$!
set +e
wait $PID_API_HEALTH
API_EXIT=$?
wait $PID_WEB_HEALTH
WEB_EXIT=$?
set -e
echo ""
echo "健康检查结果:"
API_OK=0
WEB_OK=0
if [ "$API_EXIT" -eq 0 ]; then
echo " OK API: $(cat "$HEALTH_LOG_DIR/api.log")"
API_OK=1
else
echo " FAIL API: 120s未就绪"
docker logs --tail 50 xiaoxia-api-staging
rollback
fi
# ---- 等待 Web 健康 ----
echo "Waiting for Web to become healthy..."
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
echo "Web is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/15)"
sleep 2
done
if [ "$i" -ge 15 ]; then
echo "ERROR: Web did not become healthy within 30s"
if [ "$WEB_EXIT" -eq 0 ]; then
echo " OK Web: $(cat "$HEALTH_LOG_DIR/web.log")"
WEB_OK=1
else
echo " FAIL Web: 30s未就绪"
docker logs --tail 30 xiaoxia-web-staging
fi
rm -rf "$HEALTH_LOG_DIR"
if [ "$API_OK" -eq 0 ] || [ "$WEB_OK" -eq 0 ]; then
echo ""
echo "ERROR: 健康检查失败"
rollback
fi
# ---- 清理旧镜像 ----
echo "Cleaning up old images..."
docker image prune -af --filter "until=168h" 2>/dev/null || true
docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=== Staging deployment complete ==="
echo "=== Staging deployment complete (并行优化版) ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "Version: $IMAGE_TAG"
+179 -2
View File
@@ -2,18 +2,167 @@
集成测试公共 fixtures
提供性能测试相关的工具、fixture 和 marker。
支持 pytest-xdist 并行执行:每个 worker 使用独立数据库,数据完全隔离。
"""
from __future__ import annotations
import os
import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Dict, List, Optional
import pytest
# ── xdist 并行数据库隔离 ──────────────────────────────────────────────────
# 每个 xdist worker 进程创建独立的数据库并执行迁移,确保测试数据完全隔离
# 通过 PYTEST_XDIST_WORKER 环境变量识别 worker(如 gw0, gw1, ...)
_WORKER_DB_NAME: Optional[str] = None
def _get_worker_id() -> Optional[str]:
"""获取当前 xdist worker ID,非 worker 模式返回 None"""
return os.environ.get("PYTEST_XDIST_WORKER")
def _parse_database_url(url: str) -> Dict[str, str]:
"""
解析 DATABASE_URL,返回各组件。
支持 postgresql+psycopg://user:pass@host:port/dbname 格式
"""
from urllib.parse import urlparse
parsed = urlparse(url)
return {
"driver": parsed.scheme,
"user": parsed.username or "",
"password": parsed.password or "",
"host": parsed.hostname or "",
"port": str(parsed.port or 5432),
"dbname": parsed.path.lstrip("/") or "",
}
def _create_worker_database(worker_id: str) -> str:
"""
为 xdist worker 创建独立数据库并执行迁移。
返回新的 DATABASE_URL。
"""
base_url = os.environ.get(
"DATABASE_URL",
"postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas",
)
db_info = _parse_database_url(base_url)
# 生成 worker 专属数据库名
base_db = db_info["dbname"]
worker_db = f"{base_db}_{worker_id}"
global _WORKER_DB_NAME
_WORKER_DB_NAME = worker_db
# 使用 psycopg 创建数据库(连接到 postgres 库)
try:
import psycopg
conn_str = (
f"host={db_info['host']} port={db_info['port']} "
f"user={db_info['user']} password={db_info['password']} "
f"dbname=postgres"
)
conn = psycopg.connect(conn_str, autocommit=True)
cur = conn.cursor()
# 先尝试删除(防止残留)
cur.execute(f'DROP DATABASE IF EXISTS "{worker_db}" WITH (FORCE)')
# 创建新数据库
cur.execute(f'CREATE DATABASE "{worker_db}"')
cur.close()
conn.close()
print(f"[xdist {worker_id}] ✅ 创建数据库: {worker_db}")
except ImportError:
print(f"[xdist {worker_id}] ⚠️ psycopg 未安装,跳过数据库创建")
return base_url
except Exception as e:
print(f"[xdist {worker_id}] ⚠️ 创建数据库失败: {e}")
return base_url
# 构建新的 DATABASE_URL
new_url = (
f"{db_info['driver']}://{db_info['user']}:{db_info['password']}"
f"@{db_info['host']}:{db_info['port']}/{worker_db}"
)
# 执行 alembic 迁移
print(f"[xdist {worker_id}] 🔄 执行 Alembic 迁移...")
try:
ROOT = Path(__file__).resolve().parents[2]
api_path = str(ROOT / "apps" / "api")
if api_path not in sys.path:
sys.path.insert(0, api_path)
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from alembic import command as alembic_command
from alembic.config import Config as AlembicConfig
alembic_cfg = AlembicConfig(str(ROOT / "alembic.ini"))
alembic_cfg.set_main_option("sqlalchemy.url", new_url)
# 兼容不同的脚本路径配置
alembic_cfg.set_main_option("script_location", str(ROOT / "alembic"))
# 临时设置环境变量供 alembic env.py 使用
os.environ["DATABASE_URL"] = new_url
alembic_command.upgrade(alembic_cfg, "head")
print(f"[xdist {worker_id}] ✅ 迁移完成")
except Exception as e:
print(f"[xdist {worker_id}] ❌ 迁移失败: {e}")
raise
return new_url
def _cleanup_worker_database(worker_id: str):
"""清理 xdist worker 的数据库"""
global _WORKER_DB_NAME
if not _WORKER_DB_NAME:
return
base_url = os.environ.get(
"DATABASE_URL",
"postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas",
)
db_info = _parse_database_url(base_url)
try:
import psycopg
conn_str = (
f"host={db_info['host']} port={db_info['port']} "
f"user={db_info['user']} password={db_info['password']} "
f"dbname=postgres"
)
conn = psycopg.connect(conn_str, autocommit=True)
cur = conn.cursor()
# 强制断开所有连接后删除
cur.execute(
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
f"WHERE datname = '{_WORKER_DB_NAME}' AND pid <> pg_backend_pid()"
)
cur.execute(f'DROP DATABASE IF EXISTS "{_WORKER_DB_NAME}" WITH (FORCE)')
cur.close()
conn.close()
print(f"[xdist {worker_id}] 🧹 已清理数据库: {_WORKER_DB_NAME}")
except Exception as e:
print(f"[xdist {worker_id}] ⚠️ 清理数据库失败: {e}")
finally:
_WORKER_DB_NAME = None
# ── 性能阈值配置 ──────────────────────────────────────────────────────────
PERF_THRESHOLDS: Dict[str, int] = {
"core": 500, # 核心接口:500ms
@@ -183,16 +332,41 @@ class PerfAssert:
return "\n".join(lines)
# ── pytest fixtures ──────────────────────────────────────────────────────
# ── pytest hooks ──────────────────────────────────────────────────────────
def pytest_configure(config):
"""注册自定义 marker"""
"""
pytest 配置钩子。
- 注册自定义 marker
- xdist worker 模式下:创建独立数据库 + 执行迁移
"""
# 注册自定义 marker
config.addinivalue_line("markers", "performance: 标记为性能测试(可通过 -m 'not performance' 跳过)")
config.addinivalue_line("markers", "perf_core: 核心接口性能测试(阈值 500ms)")
config.addinivalue_line("markers", "perf_normal: 普通接口性能测试(阈值 1000ms)")
config.addinivalue_line("markers", "perf_heavy: 重操作接口性能测试(阈值 3000ms)")
# xdist worker 模式:创建独立数据库并执行迁移
worker_id = _get_worker_id()
if worker_id:
# 只有当 USE_IN_MEMORY_DB 不为 true 时才创建独立数据库
use_in_memory = os.environ.get("USE_IN_MEMORY_DB", "true").lower() == "true"
if not use_in_memory:
print(f"[xdist {worker_id}] 🚀 worker 启动,准备独立数据库...")
new_db_url = _create_worker_database(worker_id)
os.environ["DATABASE_URL"] = new_db_url
else:
print(f"[xdist {worker_id}] ℹ️ USE_IN_MEMORY_DB=true,跳过 worker 数据库创建")
def pytest_unconfigure(config):
"""pytest 结束钩子:清理 xdist worker 数据库"""
worker_id = _get_worker_id()
if worker_id and _WORKER_DB_NAME:
_cleanup_worker_database(worker_id)
def pytest_collection_modifyitems(config, items):
"""根据环境变量自动跳过性能测试"""
@@ -203,6 +377,9 @@ def pytest_collection_modifyitems(config, items):
item.add_marker(skip_perf)
# ── pytest fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def perf_assert():
"""
+139
View File
@@ -0,0 +1,139 @@
"""classification 模块单元测试."""
import pytest
from domain.classification import (
AssetClassification,
AssetLibraryKind,
ClassificationJob,
ClassificationJobStatus,
IngestJobStatus,
)
class TestAssetLibraryKind:
"""AssetLibraryKind 枚举测试."""
def test_values(self):
assert AssetLibraryKind.VIDEO == "video"
assert AssetLibraryKind.VOICE == "voice"
class TestIngestJobStatus:
"""IngestJobStatus 枚举测试."""
def test_values(self):
assert IngestJobStatus.PENDING == "pending"
assert IngestJobStatus.PROCESSING == "processing"
assert IngestJobStatus.COMPLETED == "completed"
assert IngestJobStatus.FAILED == "failed"
class TestClassificationJobStatus:
"""ClassificationJobStatus 枚举测试."""
def test_values(self):
assert ClassificationJobStatus.PENDING == "pending"
assert ClassificationJobStatus.PROCESSING == "processing"
assert ClassificationJobStatus.COMPLETED == "completed"
assert ClassificationJobStatus.FAILED == "failed"
class TestAssetClassification:
"""AssetClassification 枚举测试."""
def test_values(self):
assert AssetClassification.SCENIC == "scenic"
assert AssetClassification.PRODUCT == "product"
assert AssetClassification.PERSON == "person"
assert AssetClassification.ANIMAL == "animal"
assert AssetClassification.FOOD == "food"
assert AssetClassification.TECH == "tech"
assert AssetClassification.SPORT == "sport"
assert AssetClassification.MUSIC == "music"
assert AssetClassification.OTHER == "other"
class TestClassificationJobCreate:
"""ClassificationJob.create 工厂方法测试."""
def test_create_with_valid_params(self):
job = ClassificationJob.create(project_id="proj_001", asset_id="asset_001")
assert job.id
assert len(job.id) == 32
assert job.project_id == "proj_001"
assert job.asset_id == "asset_001"
assert job.status == ClassificationJobStatus.PENDING
assert job.classification == ""
assert job.confidence == 0.0
assert job.error_message == ""
assert job.created_at is not None
assert job.updated_at is not None
def test_create_strips_strings(self):
job = ClassificationJob.create(
project_id=" proj_002 ",
asset_id=" asset_002 ",
)
assert job.project_id == "proj_002"
assert job.asset_id == "asset_002"
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
ClassificationJob.create(project_id="", asset_id="a")
def test_create_whitespace_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
ClassificationJob.create(project_id=" ", asset_id="a")
def test_create_empty_asset_id_raises(self):
with pytest.raises(ValueError, match="asset_id"):
ClassificationJob.create(project_id="p", asset_id="")
def test_create_whitespace_asset_id_raises(self):
with pytest.raises(ValueError, match="asset_id"):
ClassificationJob.create(project_id="p", asset_id=" ")
def test_create_ids_are_unique(self):
j1 = ClassificationJob.create(project_id="p", asset_id="a")
j2 = ClassificationJob.create(project_id="p", asset_id="b")
assert j1.id != j2.id
def test_create_timestamps_are_utc(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.created_at.tzinfo is not None
assert job.updated_at.tzinfo is not None
class TestClassificationJobState:
"""ClassificationJob 状态操作测试"""
def test_set_processing(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.status = ClassificationJobStatus.PROCESSING
assert job.status == ClassificationJobStatus.PROCESSING
def test_set_completed_with_result(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.status = ClassificationJobStatus.COMPLETED
job.classification = AssetClassification.SCENIC
job.confidence = 0.95
assert job.status == ClassificationJobStatus.COMPLETED
assert job.classification == "scenic"
assert job.confidence == pytest.approx(0.95)
def test_set_failed_with_error(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.status = ClassificationJobStatus.FAILED
job.error_message = "model timeout"
assert job.status == ClassificationJobStatus.FAILED
assert job.error_message == "model timeout"
def test_confidence_range_zero(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.confidence = 0.0
assert job.confidence == 0.0
def test_confidence_range_one(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.confidence = 1.0
assert job.confidence == 1.0
+262
View File
@@ -0,0 +1,262 @@
"""edit_plan_clip 领域模型单元测试."""
from datetime import datetime, timezone
import pytest
from domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
class TestEditPlanClipStatus:
"""EditPlanClipStatus 枚举测试."""
def test_values(self):
assert EditPlanClipStatus.PENDING == "pending"
assert EditPlanClipStatus.READY == "ready"
assert EditPlanClipStatus.RENDERED == "rendered"
assert EditPlanClipStatus.FAILED == "failed"
class TestEditPlanClipCreate:
"""EditPlanClip.create 工厂方法测试."""
def test_create_with_required_fields(self):
clip = EditPlanClip.create(plan_id="plan_001", clip_type="video", order=1)
assert clip.id # 自动生成的 UUID
assert len(clip.id) == 32 # hex 格式
assert clip.plan_id == "plan_001"
assert clip.clip_type == "video"
assert clip.order == 1
assert clip.status == EditPlanClipStatus.PENDING
assert clip.start_time == 0.0
assert clip.duration == 0.0
assert clip.transition_effect == "cut"
assert clip.playback_speed == 1.0
assert clip.config == {}
def test_create_with_all_fields(self):
clip = EditPlanClip.create(
plan_id="plan_002",
clip_type="audio",
order=2,
template_clip_config_id="tpl_001",
asset_id="asset_001",
text_content="测试文案",
start_time=5.0,
duration=10.0,
transition_effect="fade",
transition_duration=0.5,
playback_speed=1.5,
config={"key": "value"},
)
assert clip.plan_id == "plan_002"
assert clip.clip_type == "audio"
assert clip.order == 2
assert clip.template_clip_config_id == "tpl_001"
assert clip.asset_id == "asset_001"
assert clip.text_content == "测试文案"
assert clip.start_time == 5.0
assert clip.duration == 10.0
assert clip.transition_effect == "fade"
assert clip.transition_duration == 0.5
assert clip.playback_speed == 1.5
assert clip.config == {"key": "value"}
def test_create_strips_strings(self):
clip = EditPlanClip.create(
plan_id=" plan_003 ",
clip_type=" video ",
order=1,
asset_id=" asset_001 ",
template_clip_config_id=" tpl_001 ",
text_content=" 测试 ",
transition_effect=" fade ",
)
assert clip.plan_id == "plan_003"
assert clip.clip_type == "video"
assert clip.asset_id == "asset_001"
assert clip.template_clip_config_id == "tpl_001"
assert clip.text_content == "测试"
assert clip.transition_effect == "fade"
def test_create_empty_plan_id_raises(self):
with pytest.raises(ValueError, match="plan_id"):
EditPlanClip.create(plan_id="", clip_type="video", order=1)
def test_create_whitespace_plan_id_raises(self):
with pytest.raises(ValueError, match="plan_id"):
EditPlanClip.create(plan_id=" ", clip_type="video", order=1)
def test_create_empty_clip_type_raises(self):
with pytest.raises(ValueError, match="clip_type"):
EditPlanClip.create(plan_id="plan_001", clip_type="", order=1)
def test_create_negative_start_time_raises(self):
with pytest.raises(ValueError, match="start_time"):
EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=-1.0)
def test_create_negative_duration_raises(self):
with pytest.raises(ValueError, match="duration"):
EditPlanClip.create(plan_id="p", clip_type="v", order=1, duration=-5.0)
def test_create_zero_speed_clamps_to_1(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.0)
assert clip.playback_speed == 1.0
def test_create_negative_speed_clamps_to_1(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=-1.0)
assert clip.playback_speed == 1.0
def test_create_low_speed_clamps_to_min(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.1)
assert clip.playback_speed == 0.25
def test_create_high_speed_clamps_to_max(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=5.0)
assert clip.playback_speed == 4.0
def test_create_speed_at_boundary_values(self):
# 边界值应该保持不变
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.25)
assert clip.playback_speed == 0.25
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=4.0)
assert clip.playback_speed == 4.0
def test_create_negative_transition_duration_clamps_to_0(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, transition_duration=-1.0)
assert clip.transition_duration == 0.0
def test_create_empty_transition_effect_defaults_to_cut(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, transition_effect="")
assert clip.transition_effect == "cut"
def test_create_empty_asset_id_stays_empty(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="")
assert clip.asset_id == ""
def test_create_none_config_defaults_to_empty_dict(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, config=None)
assert clip.config == {}
def test_create_ids_are_unique(self):
c1 = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
c2 = EditPlanClip.create(plan_id="p", clip_type="v", order=2)
assert c1.id != c2.id
def test_create_timestamps_are_utc(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
assert clip.created_at.tzinfo is not None
assert clip.updated_at.tzinfo is not None
class TestEditPlanClipStateMachine:
"""状态机流转测试."""
@pytest.fixture
def pending_clip(self):
return EditPlanClip.create(plan_id="plan_001", clip_type="video", order=1)
def test_initial_status_is_pending(self, pending_clip):
assert pending_clip.status == EditPlanClipStatus.PENDING
def test_pending_to_ready(self, pending_clip):
pending_clip.mark_ready()
assert pending_clip.status == EditPlanClipStatus.READY
def test_pending_cannot_mark_rendered(self, pending_clip):
with pytest.raises(ValueError, match="只有 ready"):
pending_clip.mark_rendered()
def test_pending_cannot_mark_failed(self, pending_clip):
with pytest.raises(ValueError, match="只有 ready"):
pending_clip.mark_failed()
def test_ready_to_rendered(self, pending_clip):
pending_clip.mark_ready()
pending_clip.mark_rendered()
assert pending_clip.status == EditPlanClipStatus.RENDERED
def test_ready_to_failed(self, pending_clip):
pending_clip.mark_ready()
pending_clip.mark_failed()
assert pending_clip.status == EditPlanClipStatus.FAILED
def test_rendered_cannot_mark_ready_again(self, pending_clip):
pending_clip.mark_ready()
pending_clip.mark_rendered()
with pytest.raises(ValueError):
pending_clip.mark_ready()
def test_failed_cannot_mark_ready_again(self, pending_clip):
pending_clip.mark_ready()
pending_clip.mark_failed()
with pytest.raises(ValueError):
pending_clip.mark_ready()
def test_state_transition_updates_updated_at(self, pending_clip):
old_updated = pending_clip.updated_at
# 确保时间不同
import time
time.sleep(0.001)
pending_clip.mark_ready()
assert pending_clip.updated_at > old_updated
class TestEditPlanClipAssignAsset:
"""assign_asset 方法测试."""
def test_assign_asset(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
assert not clip.has_asset
clip.assign_asset("asset_001")
assert clip.asset_id == "asset_001"
assert clip.has_asset
def test_assign_asset_strips_whitespace(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
clip.assign_asset(" asset_001 ")
assert clip.asset_id == "asset_001"
def test_assign_empty_asset_raises(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
with pytest.raises(ValueError, match="asset_id"):
clip.assign_asset("")
def test_assign_whitespace_asset_raises(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
with pytest.raises(ValueError, match="asset_id"):
clip.assign_asset(" ")
def test_assign_updates_updated_at(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
old_updated = clip.updated_at
import time
time.sleep(0.001)
clip.assign_asset("asset_001")
assert clip.updated_at > old_updated
class TestEditPlanClipProperties:
"""属性方法测试."""
def test_end_time(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=5.0, duration=10.0)
assert clip.end_time == 15.0
def test_end_time_zero_duration(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=3.0, duration=0.0)
assert clip.end_time == 3.0
def test_has_asset_true(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="a001")
assert clip.has_asset is True
def test_has_asset_false(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
assert clip.has_asset is False
def test_has_asset_empty_string(self):
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="")
assert clip.has_asset is False
+237
View File
@@ -0,0 +1,237 @@
"""剪辑计划领域模型单元测试."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from packages.domain.edit_plan import EditPlan, EditPlanStatus
class TestEditPlanStatus:
"""EditPlanStatus 枚举测试."""
def test_status_values(self):
assert EditPlanStatus.DRAFT.value == "draft"
assert EditPlanStatus.EDITING.value == "editing"
assert EditPlanStatus.RENDERING.value == "rendering"
assert EditPlanStatus.COMPLETED.value == "completed"
assert EditPlanStatus.FAILED.value == "failed"
def test_status_is_str(self):
assert isinstance(EditPlanStatus.DRAFT, str)
assert EditPlanStatus.DRAFT == "draft"
class TestEditPlanCreate:
"""创建剪辑计划测试."""
def test_create_basic(self):
plan = EditPlan.create(template_id="tpl_001", name="测试计划")
assert plan.id
assert len(plan.id) == 32 # uuid4 hex
assert plan.template_id == "tpl_001"
assert plan.name == "测试计划"
assert plan.status == EditPlanStatus.DRAFT
assert plan.total_duration == 0.0
assert plan.config == {}
assert plan.source_edit_plan_id == ""
assert plan.project_id == ""
assert plan.created_by_user_id == ""
def test_create_with_all_fields(self):
plan = EditPlan.create(
template_id="tpl_001",
name="完整测试计划",
config={"key": "value"},
total_duration=60.5,
source_edit_plan_id="src_001",
project_id="proj_001",
created_by_user_id="user_001",
)
assert plan.name == "完整测试计划"
assert plan.total_duration == 60.5
assert plan.config == {"key": "value"}
assert plan.source_edit_plan_id == "src_001"
assert plan.project_id == "proj_001"
assert plan.created_by_user_id == "user_001"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="名称不能为空"):
EditPlan.create(template_id="tpl_001", name="")
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="名称不能为空"):
EditPlan.create(template_id="tpl_001", name=" ")
def test_create_empty_template_id_raises(self):
with pytest.raises(ValueError, match="template_id 不能为空"):
EditPlan.create(template_id="", name="测试")
def test_create_whitespace_template_id_raises(self):
with pytest.raises(ValueError, match="template_id 不能为空"):
EditPlan.create(template_id=" ", name="测试")
def test_create_name_stripped(self):
plan = EditPlan.create(template_id="tpl_001", name=" 我的计划 ")
assert plan.name == "我的计划"
def test_create_template_id_stripped(self):
plan = EditPlan.create(template_id=" tpl_001 ", name="测试")
assert plan.template_id == "tpl_001"
def test_create_timestamps_set(self):
before = datetime.now(timezone.utc)
plan = EditPlan.create(template_id="tpl_001", name="测试")
after = datetime.now(timezone.utc)
assert before <= plan.created_at <= after
assert before <= plan.updated_at <= after
def test_create_config_none_defaults_to_empty(self):
plan = EditPlan.create(template_id="tpl_001", name="测试", config=None)
assert plan.config == {}
class TestEditPlanStateMachine:
"""状态机流转测试."""
def _make_plan(self, status: EditPlanStatus) -> EditPlan:
return EditPlan(
id="test_id",
template_id="tpl_001",
name="测试计划",
status=status,
)
def test_draft_to_editing(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
plan.start_editing()
assert plan.status == EditPlanStatus.EDITING
assert plan.updated_at > plan.created_at
def test_editing_to_rendering(self):
plan = self._make_plan(EditPlanStatus.EDITING)
plan.start_rendering()
assert plan.status == EditPlanStatus.RENDERING
def test_rendering_to_completed(self):
plan = self._make_plan(EditPlanStatus.RENDERING)
plan.mark_completed()
assert plan.status == EditPlanStatus.COMPLETED
def test_rendering_to_failed(self):
plan = self._make_plan(EditPlanStatus.RENDERING)
plan.mark_failed()
assert plan.status == EditPlanStatus.FAILED
def test_completed_to_editing_resume(self):
plan = self._make_plan(EditPlanStatus.COMPLETED)
plan.resume_editing()
assert plan.status == EditPlanStatus.EDITING
def test_failed_to_editing_resume(self):
plan = self._make_plan(EditPlanStatus.FAILED)
plan.resume_editing()
assert plan.status == EditPlanStatus.EDITING
def test_failed_to_draft_reset(self):
plan = self._make_plan(EditPlanStatus.FAILED)
plan.reset_to_draft()
assert plan.status == EditPlanStatus.DRAFT
def test_invalid_start_editing_from_editing(self):
plan = self._make_plan(EditPlanStatus.EDITING)
with pytest.raises(ValueError, match="只有 draft 状态"):
plan.start_editing()
def test_invalid_start_editing_from_rendering(self):
plan = self._make_plan(EditPlanStatus.RENDERING)
with pytest.raises(ValueError):
plan.start_editing()
def test_invalid_start_rendering_from_draft(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
with pytest.raises(ValueError, match="只有 editing 状态"):
plan.start_rendering()
def test_invalid_start_rendering_from_completed(self):
plan = self._make_plan(EditPlanStatus.COMPLETED)
with pytest.raises(ValueError):
plan.start_rendering()
def test_invalid_mark_completed_from_draft(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
with pytest.raises(ValueError, match="只有 rendering 状态"):
plan.mark_completed()
def test_invalid_mark_failed_from_editing(self):
plan = self._make_plan(EditPlanStatus.EDITING)
with pytest.raises(ValueError):
plan.mark_failed()
def test_invalid_resume_editing_from_draft(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
with pytest.raises(ValueError, match="只有 completed/failed 状态"):
plan.resume_editing()
def test_invalid_resume_editing_from_rendering(self):
plan = self._make_plan(EditPlanStatus.RENDERING)
with pytest.raises(ValueError):
plan.resume_editing()
def test_invalid_reset_to_draft_from_draft(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
with pytest.raises(ValueError, match="只有 failed 状态"):
plan.reset_to_draft()
def test_invalid_reset_to_draft_from_completed(self):
plan = self._make_plan(EditPlanStatus.COMPLETED)
with pytest.raises(ValueError):
plan.reset_to_draft()
def test_state_transition_updates_updated_at(self):
plan = self._make_plan(EditPlanStatus.DRAFT)
old_updated = plan.updated_at
plan.start_editing()
assert plan.updated_at >= old_updated
class TestEditPlanDataclass:
"""数据类属性测试."""
def test_slots_prevents_dynamic_attributes(self):
plan = EditPlan(id="1", template_id="t1", name="test")
with pytest.raises(AttributeError):
plan.new_field = "value"
def test_full_flow_draft_editing_rendering_completed(self):
"""完整流程:草稿 → 编辑 → 渲染 → 完成."""
plan = EditPlan.create(template_id="tpl_001", name="完整流程")
assert plan.status == EditPlanStatus.DRAFT
plan.start_editing()
assert plan.status == EditPlanStatus.EDITING
plan.start_rendering()
assert plan.status == EditPlanStatus.RENDERING
plan.mark_completed()
assert plan.status == EditPlanStatus.COMPLETED
def test_full_flow_draft_editing_rendering_failed_reset(self):
"""完整流程:草稿 → 编辑 → 渲染 → 失败 → 重置 → 编辑 → 渲染 → 完成."""
plan = EditPlan.create(template_id="tpl_001", name="失败重试流程")
plan.start_editing()
plan.start_rendering()
plan.mark_failed()
assert plan.status == EditPlanStatus.FAILED
plan.reset_to_draft()
assert plan.status == EditPlanStatus.DRAFT
plan.start_editing()
plan.start_rendering()
plan.mark_completed()
assert plan.status == EditPlanStatus.COMPLETED
+40
View File
@@ -0,0 +1,40 @@
"""
EditingMode 剪辑模式枚举单元测试
"""
from packages.domain.editing_mode import EditingMode
class TestEditingMode:
"""EditingMode 枚举测试"""
def test_all_modes_exist(self):
assert EditingMode.ONE_TAKE == "one_take"
assert EditingMode.PIP == "pip"
assert EditingMode.VOICE_OVER == "voice_over"
assert EditingMode.VOICE_PIP == "voice_pip"
def test_total_count(self):
assert len(EditingMode) == 4
def test_is_string_type(self):
for mode in EditingMode:
assert isinstance(mode.value, str)
assert isinstance(mode, str)
def test_mode_descriptions(self):
"""验证模式值有意义"""
assert "one" in EditingMode.ONE_TAKE
assert "pip" in EditingMode.PIP
assert "voice" in EditingMode.VOICE_OVER
assert "voice" in EditingMode.VOICE_PIP
def test_usage_in_comparison(self):
mode = EditingMode.ONE_TAKE
assert mode == "one_take"
assert mode != "pip"
def test_iterable(self):
modes = list(EditingMode)
assert len(modes) == 4
assert EditingMode.ONE_TAKE in modes
+230
View File
@@ -0,0 +1,230 @@
"""filter_presets 模块单元测试."""
from dataclasses import FrozenInstanceError
import pytest
from domain.filter_presets import (
FILTER_PRESET_LIBRARY,
FilterPreset,
build_ffmpeg_filter,
get_filter_preset,
list_filter_presets,
)
class TestFilterPreset:
"""FilterPreset 数据类测试."""
def test_create_required_fields(self):
f = FilterPreset(id="test_001", name="测试滤镜", category="basic")
assert f.id == "test_001"
assert f.name == "测试滤镜"
assert f.category == "basic"
# 默认值
assert f.description == ""
assert f.tags == []
assert f.brightness == 0.0
assert f.contrast == 1.0
assert f.saturation == 1.0
assert f.gamma == 1.0
assert f.gamma_r == 1.0
assert f.gamma_g == 1.0
assert f.gamma_b == 1.0
assert f.hue == 0.0
assert f.lut_url == ""
def test_create_all_fields(self):
f = FilterPreset(
id="test_002",
name="完整滤镜",
category="cinematic",
description="测试描述",
tags=["标签1", "标签2"],
brightness=0.1,
contrast=1.2,
saturation=0.8,
gamma=1.1,
gamma_r=1.05,
gamma_g=0.95,
gamma_b=1.15,
hue=10.0,
lut_url="https://example.com/lut.png",
)
assert f.category == "cinematic"
assert f.brightness == 0.1
assert f.contrast == 1.2
assert f.saturation == 0.8
assert f.gamma == 1.1
assert f.gamma_r == 1.05
assert f.gamma_g == 0.95
assert f.gamma_b == 1.15
assert f.hue == 10.0
assert f.lut_url == "https://example.com/lut.png"
def test_frozen_immutable(self):
f = FilterPreset(id="test", name="测试", category="basic")
with pytest.raises(FrozenInstanceError):
f.name = "修改" # type: ignore[misc]
def test_tags_default_new_list(self):
f1 = FilterPreset(id="1", name="a", category="basic")
f2 = FilterPreset(id="2", name="b", category="basic")
assert f1.tags is not f2.tags
assert f1.tags == []
class TestFilterPresetLibrary:
"""FILTER_PRESET_LIBRARY 预设库测试."""
def test_not_empty(self):
assert len(FILTER_PRESET_LIBRARY) > 0
def test_all_unique_ids(self):
ids = [f.id for f in FILTER_PRESET_LIBRARY]
assert len(ids) == len(set(ids))
def test_all_are_filter_preset_instances(self):
for f in FILTER_PRESET_LIBRARY:
assert isinstance(f, FilterPreset)
def test_contains_basic_category(self):
cats = {f.category for f in FILTER_PRESET_LIBRARY}
assert "basic" in cats
def test_none_filter_is_identity(self):
"""filter_none 应该所有参数都是默认值(不改变画面)"""
f = get_filter_preset("filter_none")
assert f is not None
assert f.brightness == 0.0
assert f.contrast == 1.0
assert f.saturation == 1.0
assert f.gamma == 1.0
class TestGetFilterPreset:
"""get_filter_preset 函数测试."""
def test_existing_id(self):
f = get_filter_preset("filter_brighten")
assert f is not None
assert f.id == "filter_brighten"
assert f.name == "明亮"
def test_nonexistent_id(self):
assert get_filter_preset("nonexistent") is None
def test_empty_string(self):
assert get_filter_preset("") is None
class TestListFilterPresets:
"""list_filter_presets 函数测试."""
def test_no_filters_returns_all(self):
result = list_filter_presets()
assert len(result) == len(FILTER_PRESET_LIBRARY)
def test_filter_by_category_basic(self):
result = list_filter_presets(category="basic")
assert len(result) >= 4
for f in result:
assert f.category == "basic"
def test_filter_by_unknown_category_returns_empty(self):
result = list_filter_presets(category="nonexistent")
assert result == []
def test_filter_by_keyword_name(self):
result = list_filter_presets(keyword="明亮")
assert len(result) >= 1
assert any(f.name == "明亮" for f in result)
def test_filter_by_keyword_tag(self):
result = list_filter_presets(keyword="提亮")
assert len(result) >= 1
def test_filter_by_keyword_description(self):
result = list_filter_presets(keyword="偏暗")
assert len(result) >= 1
def test_filter_keyword_case_insensitive(self):
r1 = list_filter_presets(keyword="FILTER")
r2 = list_filter_presets(keyword="filter")
assert len(r1) == len(r2)
def test_filter_keyword_no_match(self):
result = list_filter_presets(keyword="xyz_nonexistent_12345")
assert result == []
def test_combined_category_and_keyword(self):
result = list_filter_presets(category="basic", keyword="明亮")
assert len(result) >= 1
for f in result:
assert f.category == "basic"
def test_combined_no_match(self):
result = list_filter_presets(category="basic", keyword="电影感")
# 基础分类里没有电影感关键词
pass # 不做强断言,看实际数据
class TestBuildFFmpegFilter:
"""build_ffmpeg_filter 函数测试."""
def test_none_preset_returns_empty(self):
result = build_ffmpeg_filter("nonexistent")
assert result == ""
def test_zero_intensity_returns_empty(self):
result = build_ffmpeg_filter("filter_brighten", intensity=0)
assert result == ""
def test_negative_intensity_returns_empty(self):
result = build_ffmpeg_filter("filter_brighten", intensity=-10)
assert result == ""
def test_full_intensity_brighten(self):
result = build_ffmpeg_filter("filter_brighten", intensity=100)
assert result.startswith("eq=")
assert "brightness=0.120" in result
assert "contrast=1.050" in result
assert "saturation=1.050" in result
assert "gamma=1.100" in result
def test_half_intensity(self):
"""强度 50% 时参数应该是全量的一半(向原值插值)"""
full = build_ffmpeg_filter("filter_brighten", intensity=100)
half = build_ffmpeg_filter("filter_brighten", intensity=50)
# 50% 强度的 brightness 应该是 0.060 (0.120 * 0.5)
assert "brightness=0.060" in half
# full 和 half 都应该有 eq= 前缀
assert full.startswith("eq=")
assert half.startswith("eq=")
def test_intensity_over_100_clamps_to_100(self):
result1 = build_ffmpeg_filter("filter_brighten", intensity=100)
result2 = build_ffmpeg_filter("filter_brighten", intensity=150)
assert result1 == result2
def test_filter_none_returns_empty(self):
"""原图滤镜所有参数都是默认值,应该返回空字符串"""
result = build_ffmpeg_filter("filter_none")
assert result == ""
def test_warm_filter_has_gamma_channels(self):
"""暖色滤镜应该调整 RGB 通道伽马"""
result = build_ffmpeg_filter("filter_warm", intensity=100)
assert "gamma_r=" in result
# 暖色红通道伽马 > 1.0
assert "gamma_r=1.100" in result
def test_result_format_is_eq_params(self):
"""结果格式应该是 eq=param1=val:param2=val..."""
result = build_ffmpeg_filter("filter_brighten", intensity=100)
assert result.startswith("eq=")
# 参数之间用冒号分隔
parts = result[3:].split(":")
assert len(parts) >= 4 # 至少 brightness/contrast/saturation/gamma
for part in parts:
assert "=" in part # 每个部分都是 key=value 格式
+196
View File
@@ -0,0 +1,196 @@
"""generated_video 领域模型单元测试."""
import pytest
from domain.generated_video import GeneratedVideo
class TestGeneratedVideoCreate:
"""GeneratedVideo.create 工厂方法测试."""
def test_create_with_required_fields(self):
video = GeneratedVideo.create(
project_id="proj_001",
generation_task_id="task_001",
name="测试视频",
file_url="https://example.com/out.mp4",
)
assert video.id
assert len(video.id) == 32
assert video.project_id == "proj_001"
assert video.generation_task_id == "task_001"
assert video.name == "测试视频"
assert video.file_url == "https://example.com/out.mp4"
# 默认值
assert video.user_id == ""
assert video.file_size == 0
assert video.duration == 0.0
assert video.width == 0
assert video.height == 0
assert video.fps == 0.0
assert video.thumbnail_url is None
assert video.status == "completed"
assert video.review_status == "pending_review"
assert video.generation_params == {}
assert video.video_fingerprint is None
assert video.is_duplicate is False
assert video.duplicate_of is None
assert video.generated_at is not None
assert video.created_at is not None
def test_create_with_all_fields(self):
video = GeneratedVideo.create(
project_id="proj_002",
generation_task_id="task_002",
name="完整视频",
file_url="https://example.com/full.mp4",
user_id="user_001",
file_size=1024000,
duration=30.5,
width=1920,
height=1080,
fps=30.0,
thumbnail_url="https://example.com/thumb.jpg",
generation_params={"quality": "high"},
)
assert video.user_id == "user_001"
assert video.file_size == 1024000
assert video.duration == 30.5
assert video.width == 1920
assert video.height == 1080
assert video.fps == 30.0
assert video.thumbnail_url == "https://example.com/thumb.jpg"
assert video.generation_params == {"quality": "high"}
def test_create_strips_strings(self):
video = GeneratedVideo.create(
project_id=" proj_003 ",
generation_task_id=" task_003 ",
name=" 测试视频 ",
file_url=" https://example.com/out.mp4 ",
user_id=" user_003 ",
)
assert video.project_id == "proj_003"
assert video.generation_task_id == "task_003"
assert video.name == "测试视频"
assert video.file_url == "https://example.com/out.mp4"
assert video.user_id == "user_003"
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
GeneratedVideo.create(
project_id="",
generation_task_id="t",
name="n",
file_url="u",
)
def test_create_whitespace_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
GeneratedVideo.create(
project_id=" ",
generation_task_id="t",
name="n",
file_url="u",
)
def test_create_empty_generation_task_id_raises(self):
with pytest.raises(ValueError, match="generation_task_id"):
GeneratedVideo.create(
project_id="p",
generation_task_id="",
name="n",
file_url="u",
)
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="name"):
GeneratedVideo.create(
project_id="p",
generation_task_id="t",
name="",
file_url="u",
)
def test_create_empty_file_url_raises(self):
with pytest.raises(ValueError, match="file_url"):
GeneratedVideo.create(
project_id="p",
generation_task_id="t",
name="n",
file_url="",
)
def test_create_none_generation_params_defaults_to_empty_dict(self):
video = GeneratedVideo.create(
project_id="p",
generation_task_id="t",
name="n",
file_url="u",
generation_params=None,
)
assert video.generation_params == {}
def test_create_ids_are_unique(self):
v1 = GeneratedVideo.create(project_id="p", generation_task_id="t1", name="n1", file_url="u1")
v2 = GeneratedVideo.create(project_id="p", generation_task_id="t2", name="n2", file_url="u2")
assert v1.id != v2.id
def test_create_timestamps_are_utc(self):
video = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
assert video.created_at.tzinfo is not None
assert video.generated_at.tzinfo is not None
class TestGeneratedVideoProperties:
"""GeneratedVideo 属性测试"""
def test_default_status_completed(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
assert gv.status == "completed"
def test_default_review_status(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
assert gv.review_status == "pending_review"
def test_set_status(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
gv.status = "failed"
assert gv.status == "failed"
def test_mark_as_duplicate(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
gv.is_duplicate = True
gv.duplicate_of = "video-original"
assert gv.is_duplicate is True
assert gv.duplicate_of == "video-original"
def test_set_fingerprint(self):
gv = GeneratedVideo.create(
project_id="proj-1",
generation_task_id="task-1",
name="测试视频",
file_url="https://example.com/video.mp4",
)
fingerprint = {"phash": "abc123", "md5": "def456"}
gv.video_fingerprint = fingerprint
assert gv.video_fingerprint == fingerprint
+142
View File
@@ -0,0 +1,142 @@
"""MemoryStateStore 单元测试 - 微信 OAuth state 存储
覆盖:正常存取、一次性消费、过期清理、并发安全、空 state 处理。
"""
from __future__ import annotations
import time
from threading import Thread
import pytest
class TestMemoryStateStore:
def test_put_and_verify_success(self):
"""正常存入并校验成功"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("test_state_123")
assert store.verify_and_consume("test_state_123") is True
def test_verify_nonexistent_state_fails(self):
"""不存在的 state 校验失败"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
assert store.verify_and_consume("nonexistent") is False
def test_state_single_use(self):
"""state 只能消费一次(防重放)"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("single_use_state")
assert store.verify_and_consume("single_use_state") is True
assert store.verify_and_consume("single_use_state") is False
def test_empty_state_rejected(self):
"""空字符串 state 校验失败"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("")
# 空字符串作为 key 技术上可以存,但业务层应该拒绝
# 这里验证 store 本身行为一致性
assert store.verify_and_consume("") is True # 存入了就能通过一次
assert store.verify_and_consume("") is False # 消费后就没了
def test_expired_state_cleaned(self):
"""过期 state 会被清理,校验失败"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
# TTL 设为 0.01 秒,快速过期
store = MemoryStateStore(ttl_seconds=0.01)
store.put("expire_me")
time.sleep(0.02)
assert store.verify_and_consume("expire_me") is False
def test_multiple_states_independent(self):
"""多个 state 互不影响"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("state_a")
store.put("state_b")
store.put("state_c")
# 消费 b
assert store.verify_and_consume("state_b") is True
assert store.verify_and_consume("state_b") is False
# a 和 c 仍然有效
assert store.verify_and_consume("state_a") is True
assert store.verify_and_consume("state_c") is True
def test_clean_expired_doesnt_touch_valid(self):
"""过期清理不影响未过期的 state"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore(ttl_seconds=10)
store.put("valid_state")
# 手动触发清理(通过 verify 触发内部 clean_expired)
# 由于所有 state 都没过期,清理不影响
assert store.verify_and_consume("valid_state") is True
def test_thread_safety_concurrent_put(self):
"""并发写入不丢数据"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore(ttl_seconds=60)
states = [f"state_{i}" for i in range(100)]
def put_states(states_list):
for s in states_list:
store.put(s)
threads = [Thread(target=put_states, args=(states[i * 20 : (i + 1) * 20],)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
# 每个 state 都能消费一次
for s in states:
assert store.verify_and_consume(s) is True
def test_thread_safety_concurrent_consume(self):
"""并发消费同一个 state 只有一个能成功"""
from packages.application.auth.wechat_oauth_service import MemoryStateStore
store = MemoryStateStore()
store.put("contested_state")
results = []
def try_consume():
results.append(store.verify_and_consume("contested_state"))
threads = [Thread(target=try_consume) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# 只有一个成功,其余失败
assert sum(1 for r in results if r) == 1
assert sum(1 for r in results if not r) == 9
def test_default_ttl_is_10_minutes(self):
"""默认 TTL 是 600 秒(10分钟)"""
from packages.application.auth.wechat_oauth_service import (
STATE_TTL_SECONDS,
MemoryStateStore,
)
assert STATE_TTL_SECONDS == 600
store = MemoryStateStore()
# 验证默认值生效:存入后立即验证应该通过
store.put("default_ttl_test")
assert store.verify_and_consume("default_ttl_test") is True
+190
View File
@@ -0,0 +1,190 @@
"""preset_bgm 模块单元测试."""
from dataclasses import FrozenInstanceError
import pytest
from domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
PresetBGM,
get_preset_bgm,
list_preset_bgm_by_style,
search_preset_bgm,
)
class TestPresetBGM:
"""PresetBGM 数据类测试."""
def test_create_required_fields(self):
bgm = PresetBGM(id="test_001", name="测试音乐", style="upbeat", duration=120.0)
assert bgm.id == "test_001"
assert bgm.name == "测试音乐"
assert bgm.style == "upbeat"
assert bgm.duration == 120.0
# 默认值
assert bgm.artist == ""
assert bgm.description == ""
assert bgm.tags == []
assert bgm.audio_url == ""
def test_create_all_fields(self):
bgm = PresetBGM(
id="test_002",
name="完整版",
style="relax",
duration=180.5,
artist="测试艺术家",
description="测试描述",
tags=["标签1", "标签2"],
audio_url="https://example.com/test.mp3",
)
assert bgm.artist == "测试艺术家"
assert bgm.description == "测试描述"
assert bgm.tags == ["标签1", "标签2"]
assert bgm.audio_url == "https://example.com/test.mp3"
def test_frozen_immutable(self):
"""frozen=True,实例不可变."""
bgm = PresetBGM(id="test", name="测试", style="upbeat", duration=60.0)
with pytest.raises(FrozenInstanceError):
bgm.name = "修改" # type: ignore[misc]
def test_tags_default_new_list(self):
"""每次创建都有独立的 tags 列表."""
b1 = PresetBGM(id="1", name="a", style="upbeat", duration=60.0)
b2 = PresetBGM(id="2", name="b", style="upbeat", duration=60.0)
assert b1.tags is not b2.tags
assert b1.tags == []
assert b2.tags == []
class TestPresetBGMLibrary:
"""PRESET_BGM_LIBRARY 预设库测试."""
def test_not_empty(self):
assert len(PRESET_BGM_LIBRARY) > 0
def test_all_unique_ids(self):
ids = [b.id for b in PRESET_BGM_LIBRARY]
assert len(ids) == len(set(ids)), "BGM ID 不能重复"
def test_all_are_preset_bgm_instances(self):
for bgm in PRESET_BGM_LIBRARY:
assert isinstance(bgm, PresetBGM)
def test_all_have_positive_duration(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.duration > 0, f"{bgm.id} duration 必须为正"
def test_styles_are_known(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.style in BGM_STYLES, f"{bgm.id} style {bgm.style} 不在 BGM_STYLES 中"
def test_style_distribution(self):
"""每种风格至少有 1 个 BGM."""
styles_found = {b.style for b in PRESET_BGM_LIBRARY}
for style in ["upbeat", "relax", "tech", "commerce"]:
assert style in styles_found
class TestBGMStyles:
"""BGM_STYLES 风格字典测试."""
def test_has_expected_styles(self):
assert "upbeat" in BGM_STYLES
assert "relax" in BGM_STYLES
assert "tech" in BGM_STYLES
assert "commerce" in BGM_STYLES
assert "emotional" in BGM_STYLES
assert "cinematic" in BGM_STYLES
def test_values_are_chinese_labels(self):
assert BGM_STYLES["upbeat"] == "轻快"
assert BGM_STYLES["relax"] == "治愈"
class TestGetPresetBGM:
"""get_preset_bgm 函数测试."""
def test_existing_id(self):
bgm = get_preset_bgm("bgm_upbeat_001")
assert bgm is not None
assert bgm.id == "bgm_upbeat_001"
assert bgm.name == "阳光清晨"
assert bgm.style == "upbeat"
def test_nonexistent_id(self):
assert get_preset_bgm("nonexistent") is None
def test_empty_string(self):
assert get_preset_bgm("") is None
def test_returns_preset_bgm_instance(self):
bgm = get_preset_bgm("bgm_relax_001")
assert isinstance(bgm, PresetBGM)
class TestListPresetBGMByStyle:
"""list_preset_bgm_by_style 函数测试."""
def test_upbeat_style(self):
result = list_preset_bgm_by_style("upbeat")
assert len(result) >= 3
for bgm in result:
assert bgm.style == "upbeat"
def test_relax_style(self):
result = list_preset_bgm_by_style("relax")
assert len(result) >= 3
for bgm in result:
assert bgm.style == "relax"
def test_tech_style(self):
result = list_preset_bgm_by_style("tech")
assert len(result) >= 2
def test_unknown_style_returns_empty(self):
result = list_preset_bgm_by_style("nonexistent_style")
assert result == []
def test_empty_style_returns_empty(self):
result = list_preset_bgm_by_style("")
assert result == []
class TestSearchPresetBGM:
"""search_preset_bgm 函数测试."""
def test_search_by_name(self):
result = search_preset_bgm("阳光")
assert len(result) >= 1
assert any(b.name == "阳光清晨" for b in result)
def test_search_by_tag(self):
result = search_preset_bgm("钢琴")
assert len(result) >= 1
for bgm in result:
assert any("钢琴" in tag for tag in bgm.tags) or "钢琴" in bgm.name or "钢琴" in bgm.description
def test_search_by_description(self):
result = search_preset_bgm("vlog")
assert len(result) >= 1
def test_search_case_insensitive(self):
r1 = search_preset_bgm("BGM")
r2 = search_preset_bgm("bgm")
assert len(r1) == len(r2)
def test_search_no_match(self):
result = search_preset_bgm("xyz_nonexistent_keyword_12345")
assert result == []
def test_search_empty_keyword_returns_all(self):
"""空关键词应该匹配所有(keyword in string 恒成立)."""
result = search_preset_bgm("")
assert len(result) == len(PRESET_BGM_LIBRARY)
def test_search_partial_match(self):
result = search_preset_bgm("科技")
assert len(result) >= 1
+166
View File
@@ -0,0 +1,166 @@
"""
PresetVoice 预置音色领域模型单元测试
"""
import pytest
from domain.preset_voices import (
PRESET_VOICES,
PresetVoice,
get_preset_voice_by_id,
get_preset_voices,
is_preset_voice,
)
class TestPresetVoice:
"""PresetVoice 数据类测试"""
def test_create_required_fields(self):
v = PresetVoice(
voice_id="test_v1",
name="测试音色",
description="测试描述",
gender="female",
)
assert v.voice_id == "test_v1"
assert v.name == "测试音色"
assert v.description == "测试描述"
assert v.gender == "female"
def test_default_language(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
assert v.language == "zh-CN"
def test_default_preview_url(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
assert v.preview_url == ""
def test_default_tags_none(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
assert v.tags is None
def test_custom_tags(self):
v = PresetVoice(
voice_id="v1",
name="n",
description="d",
gender="female",
tags=["温柔", "女声"],
)
assert v.tags == ["温柔", "女声"]
def test_is_frozen(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
with pytest.raises(AttributeError):
v.name = "改了"
class TestPresetVoiceToDict:
"""to_dict 序列化测试"""
def test_to_dict_basic(self):
v = PresetVoice(
voice_id="longxiaochun_v3",
name="龙小淳",
description="温柔女声",
gender="female",
language="zh-CN",
preview_url="https://example.com/audio.mp3",
tags=["温柔", "女声"],
)
d = v.to_dict()
assert d["voice_id"] == "longxiaochun_v3"
assert d["name"] == "龙小淳"
assert d["description"] == "温柔女声"
assert d["gender"] == "female"
assert d["language"] == "zh-CN"
assert d["preview_url"] == "https://example.com/audio.mp3"
assert d["tags"] == ["温柔", "女声"]
def test_to_dict_tags_none_becomes_empty_list(self):
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
d = v.to_dict()
assert d["tags"] == []
class TestPresetVoiceList:
"""预置音色列表测试"""
def test_list_not_empty(self):
voices = get_preset_voices()
assert len(voices) > 0
def test_all_are_preset_voice_instances(self):
for v in PRESET_VOICES:
assert isinstance(v, PresetVoice)
def test_voice_ids_unique(self):
ids = [v.voice_id for v in PRESET_VOICES]
assert len(ids) == len(set(ids))
def test_all_have_required_fields(self):
for v in PRESET_VOICES:
assert v.voice_id
assert v.name
assert v.description
assert v.gender in ("male", "female")
assert v.language
def test_total_count(self):
assert len(PRESET_VOICES) == 8
class TestGetPresetVoiceById:
"""按 ID 查询预置音色测试"""
def test_existing_voice(self):
v = get_preset_voice_by_id("longxiaochun_v3")
assert v is not None
assert v.name == "龙小淳"
assert v.gender == "female"
def test_nonexistent_voice(self):
v = get_preset_voice_by_id("nonexistent_voice")
assert v is None
def test_empty_string(self):
v = get_preset_voice_by_id("")
assert v is None
class TestIsPresetVoice:
"""判断是否预置音色测试"""
def test_existing_is_preset(self):
assert is_preset_voice("longxiaochen_v3") is True
def test_nonexistent_not_preset(self):
assert is_preset_voice("custom_voice_123") is False
def test_empty_not_preset(self):
assert is_preset_voice("") is False
class TestPresetVoiceSamples:
"""预置音色样本验证"""
@pytest.mark.parametrize(
"voice_id,expected_name,gender",
[
("longxiaochun_v3", "龙小淳", "female"),
("longxiaoxia_v3", "龙小夏", "female"),
("longxiaochen_v3", "龙小晨", "male"),
("longyue_v3", "龙悦", "female"),
("longshu_v3", "龙书", "male"),
("longjing_v3", "龙静", "female"),
("longbo_v3", "龙博", "male"),
("longtian_v3", "龙甜", "female"),
],
)
def test_all_preset_voices_sample(self, voice_id, expected_name, gender):
v = get_preset_voice_by_id(voice_id)
assert v is not None
assert v.name == expected_name
assert v.gender == gender
assert v.language == "zh-CN"
assert len(v.tags or []) >= 2
+84
View File
@@ -0,0 +1,84 @@
"""
Recipe 配方领域模型单元测试
"""
from packages.domain.recipe import Recipe, RecipeItem
class TestRecipeItem:
"""RecipeItem 测试"""
def test_create_item(self):
item = RecipeItem(
id="item-1",
recipe_id="recipe-1",
item_type="asset",
item_id="asset-123",
position=0,
)
assert item.id == "item-1"
assert item.recipe_id == "recipe-1"
assert item.item_type == "asset"
assert item.item_id == "asset-123"
assert item.position == 0
assert item.metadata_ == {}
def test_item_with_metadata(self):
item = RecipeItem(
id="item-1",
recipe_id="r1",
item_type="voice",
item_id="voice-1",
position=2,
metadata_={"speed": 1.0, "pitch": 0},
)
assert item.metadata_["speed"] == 1.0
assert item.metadata_["pitch"] == 0
class TestRecipe:
"""Recipe 测试"""
def test_create_minimal(self):
r = Recipe(id="r1", user_id="u1", name="我的配方")
assert r.id == "r1"
assert r.user_id == "u1"
assert r.name == "我的配方"
def test_default_values(self):
r = Recipe(id="r1", user_id="u1", name="n")
assert r.description == ""
assert r.template_id == ""
assert r.generation_params == {}
assert r.items == []
assert r.is_active is True
assert r.metadata_ == {}
def test_with_items(self):
items = [
RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=0),
RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1),
]
r = Recipe(id="r1", user_id="u1", name="n", items=items)
assert len(r.items) == 2
assert r.items[0].item_type == "asset"
assert r.items[1].item_type == "title"
def test_with_generation_params(self):
params = {"mode": "one_take", "duration": 30}
r = Recipe(id="r1", user_id="u1", name="n", generation_params=params)
assert r.generation_params["mode"] == "one_take"
def test_recipe_inactive(self):
r = Recipe(id="r1", user_id="u1", name="n", is_active=False)
assert r.is_active is False
def test_has_timestamps(self):
r = Recipe(id="r1", user_id="u1", name="n")
assert r.created_at is not None
assert r.updated_at is not None
def test_all_item_types(self):
for itype in ["asset", "title", "voice"]:
item = RecipeItem(id="i1", recipe_id="r1", item_type=itype, item_id="x", position=0)
assert item.item_type == itype
+182 -413
View File
@@ -1,73 +1,59 @@
"""
Subtitle 字幕领域模型单元测试
"""
"""字幕领域模型单元测试."""
import pytest
from __future__ import annotations
from packages.domain.subtitle import (
SubtitleSegment,
SubtitleTimeline,
SubtitleWord,
)
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
class TestSubtitleWord:
"""SubtitleWord 测试"""
"""SubtitleWord 测试."""
def test_duration_positive(self):
word = SubtitleWord(text="你好", start=1.0, end=2.5)
assert word.duration == pytest.approx(1.5)
def test_basic_properties(self):
word = SubtitleWord(text="你好", start=1.0, end=1.5)
assert word.text == "你好"
assert word.start == 1.0
assert word.end == 1.5
assert word.duration == 0.5
def test_duration_zero(self):
word = SubtitleWord(text="a", start=5.0, end=5.0)
def test_duration_zero_when_end_before_start(self):
word = SubtitleWord(text="test", start=2.0, end=1.0)
assert word.duration == 0.0
def test_duration_negative_returns_zero(self):
"""测试结束时间小于开始时间时返回 0"""
word = SubtitleWord(text="a", start=3.0, end=1.0)
def test_duration_zero_when_same_time(self):
word = SubtitleWord(text="test", start=1.0, end=1.0)
assert word.duration == 0.0
class TestSubtitleSegment:
"""SubtitleSegment 测试"""
"""SubtitleSegment 测试."""
def test_duration(self):
seg = SubtitleSegment(text="你好世界", start=0.0, end=3.0)
assert seg.duration == pytest.approx(3.0)
def test_duration_zero(self):
seg = SubtitleSegment(text="test", start=5.0, end=5.0)
assert seg.duration == 0.0
def test_duration_negative_returns_zero(self):
seg = SubtitleSegment(text="test", start=5.0, end=2.0)
assert seg.duration == 0.0
def test_char_count(self):
seg = SubtitleSegment(text="你好世界", start=0, end=1)
assert seg.char_count == 4
def test_char_count_empty(self):
seg = SubtitleSegment(text="", start=0, end=1)
assert seg.char_count == 0
def test_default_words_empty(self):
seg = SubtitleSegment(text="test", start=0, end=1)
def test_basic_properties(self):
seg = SubtitleSegment(text="大家好", start=0.0, end=2.0)
assert seg.text == "大家好"
assert seg.start == 0.0
assert seg.end == 2.0
assert seg.duration == 2.0
assert seg.char_count == 3
assert seg.words == []
def test_with_words(self):
def test_duration_with_words(self):
words = [
SubtitleWord(text="你好", start=0.0, end=1.0),
SubtitleWord(text="世界", start=1.0, end=2.0),
SubtitleWord(text="大", start=0.0, end=0.5),
SubtitleWord(text="家", start=0.5, end=1.0),
SubtitleWord(text="好", start=1.0, end=1.5),
]
seg = SubtitleSegment(text="你好世界", start=0.0, end=2.0, words=words)
assert len(seg.words) == 2
assert seg.words[0].text == "你好"
assert seg.words[1].text == "世界"
seg = SubtitleSegment(text="大家好", start=0.0, end=1.5, words=words)
assert seg.duration == 1.5
assert seg.char_count == 3
assert len(seg.words) == 3
def test_duration_zero_when_end_before_start(self):
seg = SubtitleSegment(text="test", start=3.0, end=1.0)
assert seg.duration == 0.0
class TestSubtitleTimelineBasics:
"""SubtitleTimeline 基础属性测试"""
"""SubtitleTimeline 基础属性测试."""
def test_empty_timeline(self):
tl = SubtitleTimeline()
@@ -76,428 +62,211 @@ class TestSubtitleTimelineBasics:
assert tl.language == "zh"
assert tl.total_duration == 0.0
def test_segment_count(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="a", start=0, end=1),
SubtitleSegment(text="b", start=1, end=2),
SubtitleSegment(text="c", start=2, end=3),
]
)
assert tl.segment_count == 3
def test_single_segment(self):
seg = SubtitleSegment(text="测试", start=0.0, end=1.0)
tl = SubtitleTimeline(segments=[seg])
assert tl.segment_count == 1
assert tl.total_chars == 2
def test_total_chars(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=0, end=1),
SubtitleSegment(text="世界", start=1, end=2),
SubtitleSegment(text="abcde", start=2, end=3),
]
)
def test_multiple_segments(self):
segs = [
SubtitleSegment(text="第一句", start=0.0, end=1.0),
SubtitleSegment(text="第二句", start=1.0, end=2.0),
SubtitleSegment(text="第三句", start=2.0, end=3.0),
]
tl = SubtitleTimeline(segments=segs, total_duration=3.0)
assert tl.segment_count == 3
assert tl.total_chars == 9
assert tl.total_duration == 3.0
def test_custom_language(self):
tl = SubtitleTimeline(language="en")
assert tl.language == "en"
def test_custom_total_duration(self):
tl = SubtitleTimeline(total_duration=60.0)
assert tl.total_duration == 60.0
class TestSubtitleTimelineMergeShort:
"""合并短字幕片段测试."""
class TestMergeShortSegments:
"""merge_short_segments 测试"""
def test_single_segment_no_merge(self):
"""单个片段不需要合并"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="a", start=0, end=1),
]
)
result = tl.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == "a"
def test_empty_timeline(self):
"""空时间轴"""
def test_empty_or_single_no_change(self):
tl = SubtitleTimeline()
result = tl.merge_short_segments(min_chars=8)
result = tl.merge_short_segments()
assert result.segment_count == 0
def test_all_short_segments_merge_into_one(self):
"""所有短片段合并成一个"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你", start=0, end=0.5),
SubtitleSegment(text="好", start=0.5, end=1.0),
SubtitleSegment(text="世", start=1.0, end=1.5),
SubtitleSegment(text="界", start=1.5, end=2.0),
]
)
result = tl.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == "你好世界"
assert result.segments[0].start == 0
assert result.segments[0].end == 2.0
seg = SubtitleSegment(text="短", start=0.0, end=0.5)
tl2 = SubtitleTimeline(segments=[seg])
result2 = tl2.merge_short_segments()
assert result2.segment_count == 1
def test_merge_short_segments_preserves_timing(self):
"""合并后时间轴正确"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=1.0, end=2.0),
SubtitleSegment(text="世界", start=2.0, end=3.5),
]
)
result = tl.merge_short_segments(min_chars=10)
assert result.segment_count == 1
assert result.segments[0].start == 1.0
assert result.segments[0].end == 3.5
def test_merge_short_segments_with_words(self):
"""合并后词级信息保留"""
w1 = SubtitleWord(text="你好", start=0.0, end=1.0)
w2 = SubtitleWord(text="世界", start=1.0, end=2.0)
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]),
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]),
]
)
result = tl.merge_short_segments(min_chars=10)
assert len(result.segments[0].words) == 2
assert result.segments[0].words[0].text == "你好"
assert result.segments[0].words[1].text == "世界"
def test_multiple_merged_groups(self):
"""多个合并组 — 短段会和后续段累积到够数才提交"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字,够数,提交
SubtitleSegment(text="九", start=2, end=2.5), # 1字,入buffer
SubtitleSegment(text="十", start=2.5, end=3), # 1字,入buffer(共2字)
SubtitleSegment(text="一二三四五六七八九十", start=3, end=5), # 10字,入buffer后共12字,够数提交
]
)
result = tl.merge_short_segments(min_chars=8)
# 第1段:"一二三四五六七八"(8字直接提交)
# 第2段:"九十" + "一二三四五六七八九十" 累积到12字一起提交
def test_merge_short_segments(self):
segs = [
SubtitleSegment(text="你好", start=0.0, end=0.5),
SubtitleSegment(text="世界", start=0.5, end=1.0),
SubtitleSegment(text="今天天气很好", start=1.0, end=2.0),
]
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
# "你好"+"世界"=4字,合并;"今天天气很好"=6字,保留
assert result.segment_count == 2
assert result.segments[0].text == "一二三四五六七八"
assert result.segments[1].text == "九十一二三四五六七八九十"
assert result.segments[0].text == "你好世界"
assert result.segments[0].start == 0.0
assert result.segments[0].end == 1.0
assert result.segments[1].text == "今天天气很好"
def test_remaining_short_merged_with_last(self):
"""剩余短片段合并到最后一段"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字
SubtitleSegment(text="一二三", start=2, end=3), # 3字,不够
]
)
result = tl.merge_short_segments(min_chars=8)
# 最后的3字会合并到上一段(因为 < min_chars)
def test_merge_trailing_short_to_last(self):
segs = [
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
SubtitleSegment(text="短", start=1.0, end=1.2),
SubtitleSegment(text="尾", start=1.2, end=1.4),
]
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
# "一二三四五六七八"=8字 → 保留
# "短"+"尾"=2字 < 4 → 合并到上一段
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八一二三"
assert result.segments[0].text == "一二三四五六七八短尾"
def test_custom_min_chars(self):
"""自定义最小字数 — 累积到够数就提交,剩余短的合并到最后"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二", start=0, end=1),
SubtitleSegment(text="三四", start=1, end=2),
SubtitleSegment(text="五六", start=2, end=3),
]
)
# min_chars=3:
# "一二"(2字) → 不够
# +"三四"(共4字) → 够了,提交"一二三四",buffer清空
# "五六"(2字) → 循环结束,剩余<min_chars且merged非空 → 合并到最后一段
# 结果:1段 "一二三四五六"
result = tl.merge_short_segments(min_chars=3)
def test_merge_with_words(self):
words1 = [SubtitleWord(text="你", start=0.0, end=0.25), SubtitleWord(text="好", start=0.25, end=0.5)]
words2 = [SubtitleWord(text="世", start=0.5, end=0.75), SubtitleWord(text="界", start=0.75, end=1.0)]
segs = [
SubtitleSegment(text="你好", start=0.0, end=0.5, words=words1),
SubtitleSegment(text="世界", start=0.5, end=1.0, words=words2),
]
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六"
assert len(result.segments[0].words) == 4
def test_preserves_language_and_duration(self):
"""合并后保留语言和总时长"""
tl = SubtitleTimeline(
segments=[SubtitleSegment(text="a", start=0, end=1)],
language="en",
total_duration=60.0,
)
result = tl.merge_short_segments(min_chars=8)
assert result.language == "en"
assert result.total_duration == 60.0
def test_does_not_modify_original(self):
"""不修改原时间轴"""
segments = [
SubtitleSegment(text="a", start=0, end=1),
SubtitleSegment(text="b", start=1, end=2),
]
tl = SubtitleTimeline(segments=segments)
result = tl.merge_short_segments(min_chars=5)
# 原时间轴不变
assert tl.segment_count == 2
assert result is not tl
segs = [SubtitleSegment(text="短", start=0.0, end=0.5)]
tl = SubtitleTimeline(segments=segs, language="ja", total_duration=0.5)
result = tl.merge_short_segments(min_chars=4)
assert result.language == "ja"
assert result.total_duration == 0.5
class TestSplitLongSegments:
"""split_long_segments 测试"""
class TestSubtitleTimelineSplitLong:
"""拆分长字幕片段测试."""
def test_short_segments_no_split(self):
"""短片段不需要拆分"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=0, end=1),
]
)
result = tl.split_long_segments(max_chars=20)
assert result.segment_count == 1
assert result.segments[0].text == "你好"
def test_single_long_segment_split_by_punctuation(self):
"""长片段按标点拆分"""
text = "你好世界。今天天气真好,我们出去玩吧!"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=10.0),
]
)
def test_short_segments_no_change(self):
segs = [SubtitleSegment(text="短句", start=0.0, end=1.0)]
tl = SubtitleTimeline(segments=segs)
result = tl.split_long_segments(max_chars=10)
# 应该被拆成多段
assert result.segment_count > 1
# 每段都不超过 max_chars(除了硬切的情况)
for seg in result.segments:
assert seg.char_count <= len(text) # 至少比原文短
assert result.segment_count == 1
def test_split_preserves_total_text(self):
"""拆分后总文本不变"""
text = "你好世界。今天天气真好,我们出去玩吧!明天再见。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=10.0),
]
)
def test_split_by_sentence_punctuation(self):
text = "今天天气很好。我们出去散步吧!"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=8)
merged_text = "".join(s.text for s in result.segments)
assert merged_text == text
assert result.segment_count >= 2
assert result.segments[0].text.endswith("。")
assert result.total_chars == len(text)
def test_split_long_text_no_punctuation_hard_cut(self):
text = "一二三四五六七八九十十一十二十三十四十五十六十七十八"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=8)
assert result.segment_count > 1
# 所有片段都不超过 max_chars
for s in result.segments:
assert s.char_count <= 8
def test_split_time_proportional(self):
"""拆分后时间按字数比例分配"""
text = "一二三四五六七八九十。" # 11字
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=5)
# 总时长不变
assert result.segments[0].start == 0.0
assert result.segments[-1].end == pytest.approx(10.0)
# 各段首尾相接
for i in range(len(result.segments) - 1):
assert result.segments[i].end == pytest.approx(result.segments[i + 1].start)
text = "一二三四。五六七八。"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=4)
assert result.segment_count >= 2
# 总时长保持一致
assert abs(result.segments[-1].end - 10.0) < 0.01
def test_split_with_words(self):
"""拆分时词级信息正确分配"""
words = [
SubtitleWord(text="你好", start=0.0, end=1.0),
SubtitleWord(text="世界", start=1.0, end=2.0),
SubtitleWord(text="你好吗", start=2.0, end=3.5),
SubtitleWord(text="一", start=0.0, end=0.5),
SubtitleWord(text="二", start=0.5, end=1.0),
SubtitleWord(text="三", start=1.0, end=1.5),
SubtitleWord(text="四", start=1.5, end=2.0),
]
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好世界。你好吗?", start=0.0, end=3.5, words=words),
]
)
text = "一二三四五六七八"
seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=4)
# 第一段应该有前几个词
assert len(result.segments) >= 2
assert result.segment_count >= 2
# 词的总数应该不变
total_words = sum(len(s.words) for s in result.segments)
assert total_words == 3 # 词的总数不变
assert total_words == 4
def test_multiple_mixed_segments(self):
"""混合长短片段"""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="短", start=0, end=1), # 短
SubtitleSegment(text="一二三四五六七八九十一二三四五六七八九十", start=1, end=5), # 长
SubtitleSegment(text="也短", start=5, end=6), # 短
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 3 # 至少3段(中间被拆成多段)
# 第一段还是原来的短的
assert result.segments[0].text == "短"
# 最后一段还是原来的短的
assert result.segments[-1].text == "也短"
def test_no_punctuation_hard_split(self):
"""没有标点时硬切"""
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 3
for seg in result.segments:
# 硬切的每段应该 <= max_chars
assert seg.char_count <= 10
def test_preserves_language_and_duration(self):
"""拆分后保留语言和总时长"""
tl = SubtitleTimeline(
segments=[SubtitleSegment(text="a", start=0, end=1)],
language="ja",
total_duration=30.0,
)
result = tl.split_long_segments(max_chars=20)
assert result.language == "ja"
assert result.total_duration == 30.0
def test_does_not_modify_original(self):
"""不修改原时间轴"""
original_text = "一二三四五六七八九十一二三四五六七八九十"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=original_text, start=0, end=5),
]
)
result = tl.split_long_segments(max_chars=8)
assert tl.segment_count == 1
assert tl.segments[0].text == original_text
assert result is not tl
def test_split_preserves_language(self):
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
tl = SubtitleTimeline(segments=[seg], language="en")
result = tl.split_long_segments(max_chars=2)
assert result.language == "en"
class TestSplitTextByPunctuation:
"""_split_text_by_punctuation 静态方法测试"""
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("你好世界", 10)
assert result == ["你好世界"]
def test_split_at_sentence_end(self):
"""在句末标点处断开"""
result = SubtitleTimeline._split_text_by_punctuation("你好。世界。", 5)
assert len(result) == 2
assert result[0] == "你好。"
assert result[1] == "世界。"
def test_split_at_comma(self):
"""在逗号处断开(超过最大长度时)"""
text = "一二三四五六七八,二二三四五六七八。"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert len(result) >= 2
def test_no_punctuation_hard_split(self):
"""没有标点时硬切"""
result = SubtitleTimeline._split_text_by_punctuation("一二三四五六七八九十", 5)
assert len(result) == 2
assert result[0] == "一二三四五"
assert result[1] == "六七八九十"
"""标点拆分静态方法测试."""
def test_empty_text(self):
# 空字符串循环不执行,current为空不append,返回空列表
result = SubtitleTimeline._split_text_by_punctuation("", 10)
assert result == []
def test_mixed_punctuation(self):
"""混合标点"""
text = "你好!吃饭了吗?是的,我吃过了。"
result = SubtitleTimeline._split_text_by_punctuation(text, 6)
# 验证所有段加起来等于原文
assert "".join(result) == text
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("短文本", 10)
assert len(result) == 1
def test_sentence_end_with_min_length(self):
"""句末标点断句的「半长门槛」只在未超max_chars时生效;
超过max_chars回溯找标点时,即使首段很短也会断开。"""
# "你好。" 3字 < max_chars//2(5),未超max_chars时不会主动断开
# 但加上后面的"世界很大很美好"后超过10字,回溯找标点找到"。",强制断开
text = "你好。世界很大很美好。"
def test_split_by_period(self):
result = SubtitleTimeline._split_text_by_punctuation("第一句。第二句。", 4)
assert len(result) >= 2
assert "。" in result[0]
def test_split_by_exclamation(self):
result = SubtitleTimeline._split_text_by_punctuation("你好!世界!", 3)
assert len(result) >= 2
def test_split_by_comma_when_long(self):
text = "这是一个很长的句子,中间有逗号分隔,后面还有内容"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) >= 2
def test_no_punctuation_hard_cut(self):
text = "一二三四五六七八九十十一十二十三十四十五"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) > 1
for part in result:
assert len(part) <= 8
def test_sentence_end_triggers_split_when_half_max(self):
# 句末标点在 max_chars//2 以上就拆分
text = "你好世界。abcdefghij"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
# 超过max_chars时回溯断开,首段可能很短
assert len(result) == 2
assert result[0] == "你好。"
assert result[1] == "世界很大很美好。"
# 总文本不变
assert "".join(result) == text
def test_exclamation_and_question_marks(self):
"""感叹号和问号也算句末标点"""
text = "你好吗!我很好!你呢?"
result = SubtitleTimeline._split_text_by_punctuation(text, 4)
assert len(result) >= 3
# "你好世界。"=5字 < 10但>=5(half),应该拆分
assert len(result) >= 2
class TestMergeSegments:
"""_merge_segments 静态方法测试"""
"""_merge_segments 静态方法测试."""
def test_merge_two_segments(self):
result = SubtitleTimeline._merge_segments(
[
SubtitleSegment(text="你好", start=0.0, end=1.0),
SubtitleSegment(text="世界", start=1.0, end=2.0),
]
)
assert result.text == "你好世界"
assert result.start == 0.0
assert result.end == 2.0
def test_merge_empty_list(self):
def test_merge_empty(self):
result = SubtitleTimeline._merge_segments([])
assert result.text == ""
assert result.start == 0
assert result.end == 0
def test_merge_single_segment(self):
def test_merge_single(self):
seg = SubtitleSegment(text="test", start=1.0, end=2.0)
result = SubtitleTimeline._merge_segments([seg])
assert result.text == "test"
assert result.start == 1.0
assert result.end == 2.0
def test_merge_preserves_words(self):
w1 = SubtitleWord(text="你好", start=0.0, end=1.0)
w2 = SubtitleWord(text="世界", start=1.0, end=2.0)
result = SubtitleTimeline._merge_segments(
[
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]),
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]),
]
)
assert len(result.words) == 2
assert result.words[0].text == "你好"
assert result.words[1].text == "世界"
def test_merge_non_contiguous_segments(self):
"""合并非连续片段(有间隙)"""
result = SubtitleTimeline._merge_segments(
[
SubtitleSegment(text="a", start=0.0, end=1.0),
SubtitleSegment(text="b", start=3.0, end=4.0),
]
)
def test_merge_multiple(self):
segs = [
SubtitleSegment(text="第一", start=0.0, end=1.0),
SubtitleSegment(text="第二", start=1.0, end=2.0),
]
result = SubtitleTimeline._merge_segments(segs)
assert result.text == "第一第二"
assert result.start == 0.0
assert result.end == 4.0
assert result.text == "ab"
class TestMergeAndSplitRoundtrip:
"""合并和拆分的组合测试"""
def test_split_then_merge_approximate(self):
"""拆分后再合并,总字数和总时长基本一致"""
original_text = "你好世界。今天天气真好,我们出去玩吧!明天见。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=original_text, start=0.0, end=10.0),
]
)
split = tl.split_long_segments(max_chars=5)
merged = split.merge_short_segments(min_chars=50) # 足够大的min_chars让它们都合并
assert merged.segment_count == 1
assert merged.segments[0].text == original_text
assert merged.segments[0].start == 0.0
assert merged.segments[0].end == pytest.approx(10.0)
assert result.end == 2.0
+34
View File
@@ -0,0 +1,34 @@
"""
Tag 标签领域模型单元测试
"""
import pytest
from packages.domain.tag import Tag
class TestTagCreate:
"""创建标签测试"""
def test_create_basic(self):
tag = Tag.create(user_id="user-1", name="风景")
assert tag.id is not None
assert len(tag.id) == 32
assert tag.user_id == "user-1"
assert tag.name == "风景"
def test_create_strips_name(self):
tag = Tag.create(user_id="user-1", name=" 风景 ")
assert tag.name == "风景"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="user-1", name="")
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="user-1", name=" ")
def test_create_has_created_at(self):
tag = Tag.create(user_id="user-1", name="美食")
assert tag.created_at is not None
+208
View File
@@ -0,0 +1,208 @@
"""template_clip_config 领域模型单元测试."""
import pytest
from domain.template_clip_config import (
ClipType,
TemplateClipConfig,
TransitionEffect,
)
class TestClipType:
"""ClipType 枚举测试."""
def test_values(self):
assert ClipType.INTRO == "intro"
assert ClipType.MAIN == "main"
assert ClipType.TRANSITION == "transition"
assert ClipType.OUTRO == "outro"
assert ClipType.TITLE == "title"
assert ClipType.SUBTITLE == "subtitle"
class TestTransitionEffect:
"""TransitionEffect 枚举测试."""
def test_values(self):
assert TransitionEffect.CUT == "cut"
assert TransitionEffect.FADE == "fade"
assert TransitionEffect.SLIDE_LEFT == "slide_left"
assert TransitionEffect.SLIDE_RIGHT == "slide_right"
assert TransitionEffect.DISSOLVE == "dissolve"
assert TransitionEffect.WIPE == "wipe"
class TestTemplateClipConfigCreate:
"""TemplateClipConfig.create 工厂方法测试."""
def test_create_with_required_fields(self):
clip = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=1)
assert clip.id
assert len(clip.id) == 32
assert clip.template_id == "tpl_001"
assert clip.clip_type == ClipType.MAIN
assert clip.order == 1
assert clip.min_duration == 0.0
assert clip.max_duration == 0.0
assert clip.text_template == ""
assert clip.material_requirements == {}
assert clip.transition_effect == TransitionEffect.CUT
assert clip.config == {}
def test_create_with_all_fields(self):
clip = TemplateClipConfig.create(
template_id="tpl_002",
clip_type=ClipType.INTRO,
order=2,
min_duration=3.0,
max_duration=10.0,
text_template="欢迎来到{channel}",
material_requirements={"type": "video", "min_count": 1},
transition_effect=TransitionEffect.FADE,
config={"key": "value"},
)
assert clip.clip_type == ClipType.INTRO
assert clip.min_duration == 3.0
assert clip.max_duration == 10.0
assert clip.text_template == "欢迎来到{channel}"
assert clip.material_requirements == {"type": "video", "min_count": 1}
assert clip.transition_effect == TransitionEffect.FADE
assert clip.config == {"key": "value"}
def test_create_with_string_clip_type(self):
clip = TemplateClipConfig.create(template_id="tpl_003", clip_type="title", order=1)
assert clip.clip_type == ClipType.TITLE
def test_create_with_string_transition_effect(self):
clip = TemplateClipConfig.create(
template_id="tpl_004",
clip_type=ClipType.MAIN,
order=1,
transition_effect="dissolve",
)
assert clip.transition_effect == TransitionEffect.DISSOLVE
def test_create_strips_strings(self):
clip = TemplateClipConfig.create(
template_id=" tpl_005 ",
clip_type=ClipType.MAIN,
order=1,
text_template=" 测试模板 ",
)
assert clip.template_id == "tpl_005"
assert clip.text_template == "测试模板"
def test_create_empty_template_id_raises(self):
with pytest.raises(ValueError, match="template_id"):
TemplateClipConfig.create(template_id="", clip_type=ClipType.MAIN, order=1)
def test_create_whitespace_template_id_raises(self):
with pytest.raises(ValueError, match="template_id"):
TemplateClipConfig.create(template_id=" ", clip_type=ClipType.MAIN, order=1)
def test_create_invalid_clip_type_raises(self):
with pytest.raises(ValueError):
TemplateClipConfig.create(template_id="tpl", clip_type="invalid_type", order=1)
def test_create_invalid_transition_effect_raises(self):
with pytest.raises(ValueError):
TemplateClipConfig.create(
template_id="tpl",
clip_type=ClipType.MAIN,
order=1,
transition_effect="invalid_effect",
)
def test_create_negative_min_duration_raises(self):
with pytest.raises(ValueError, match="min_duration"):
TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=-1.0)
def test_create_negative_max_duration_raises(self):
with pytest.raises(ValueError, match="max_duration"):
TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=-1.0)
def test_create_min_greater_than_max_raises(self):
with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"):
TemplateClipConfig.create(
template_id="tpl",
clip_type=ClipType.MAIN,
order=1,
min_duration=10.0,
max_duration=5.0,
)
def test_create_min_equals_max_ok(self):
clip = TemplateClipConfig.create(
template_id="tpl",
clip_type=ClipType.MAIN,
order=1,
min_duration=5.0,
max_duration=5.0,
)
assert clip.min_duration == 5.0
assert clip.max_duration == 5.0
def test_create_zero_duration_range_ok(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
assert clip.min_duration == 0.0
assert clip.max_duration == 0.0
def test_create_none_material_requirements_defaults_to_empty_dict(self):
clip = TemplateClipConfig.create(
template_id="tpl", clip_type=ClipType.MAIN, order=1, material_requirements=None
)
assert clip.material_requirements == {}
def test_create_none_config_defaults_to_empty_dict(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, config=None)
assert clip.config == {}
def test_create_ids_are_unique(self):
c1 = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
c2 = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=2)
assert c1.id != c2.id
def test_create_timestamps_are_utc(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
assert clip.created_at.tzinfo is not None
assert clip.updated_at.tzinfo is not None
class TestTemplateClipConfigProperties:
"""属性方法测试."""
def test_has_duration_range_false_when_both_zero(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
assert clip.has_duration_range is False
def test_has_duration_range_true_when_min_set(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=2.0)
assert clip.has_duration_range is True
def test_has_duration_range_true_when_max_set(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=10.0)
assert clip.has_duration_range is True
def test_default_duration_both_zero(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
assert clip.default_duration == 0.0
def test_default_duration_only_min(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0)
assert clip.default_duration == 5.0
def test_default_duration_only_max(self):
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=10.0)
assert clip.default_duration == 10.0
def test_default_duration_both_set_is_midpoint(self):
clip = TemplateClipConfig.create(
template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0, max_duration=15.0
)
assert clip.default_duration == 10.0
def test_default_duration_min_equals_max(self):
clip = TemplateClipConfig.create(
template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0, max_duration=5.0
)
assert clip.default_duration == 5.0
+153
View File
@@ -0,0 +1,153 @@
"""
Template 模板领域模型单元测试
"""
from domain.template import Template, TemplateCategory, TemplateSegment
class TestTemplateSegment:
"""TemplateSegment 测试"""
def test_create_segment(self):
seg = TemplateSegment(
id="seg-1",
template_id="tpl-1",
segment_order=1,
duration_min=3.0,
duration_max=5.0,
)
assert seg.id == "seg-1"
assert seg.template_id == "tpl-1"
assert seg.segment_order == 1
assert seg.duration_min == 3.0
assert seg.duration_max == 5.0
assert seg.material_type is None
def test_segment_with_material_type(self):
seg = TemplateSegment(
id="seg-1",
template_id="tpl-1",
segment_order=0,
duration_min=2.0,
duration_max=4.0,
material_type="人物",
)
assert seg.material_type == "人物"
def test_segment_has_timestamps(self):
seg = TemplateSegment(
id="seg-1",
template_id="tpl-1",
segment_order=1,
duration_min=1.0,
duration_max=2.0,
)
assert seg.created_at is not None
assert seg.updated_at is not None
class TestTemplate:
"""Template 测试"""
def test_create_template_minimal(self):
t = Template(
id="tpl-1",
user_id="user-1",
name="测试模板",
mode="one_take",
)
assert t.id == "tpl-1"
assert t.user_id == "user-1"
assert t.name == "测试模板"
assert t.mode == "one_take"
def test_default_values(self):
t = Template(id="tpl-1", user_id="u1", name="n", mode="one_take")
assert t.category == ""
assert t.tags == []
assert t.title_config == {}
assert t.subtitle_config == {}
assert t.bgm_config == {}
assert t.estimated_duration == 0.0
assert t.segments == []
assert t.is_active is True
def test_with_segments(self):
segs = [
TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=2, duration_max=4),
TemplateSegment(id="s2", template_id="t1", segment_order=1, duration_min=3, duration_max=5),
]
t = Template(
id="tpl-1",
user_id="u1",
name="n",
mode="voice_over",
segments=segs,
)
assert len(t.segments) == 2
assert t.segments[0].segment_order == 0
assert t.segments[1].segment_order == 1
def test_all_modes(self):
for mode in ["pip", "voice_pip", "one_take", "voice_over"]:
t = Template(id="t1", user_id="u1", name="n", mode=mode)
assert t.mode == mode
def test_with_configs(self):
t = Template(
id="t1",
user_id="u1",
name="n",
mode="one_take",
title_config={"font_size": 24, "color": "#ffffff"},
subtitle_config={"style": "bottom"},
bgm_config={"volume": 0.5},
)
assert t.title_config["font_size"] == 24
assert t.subtitle_config["style"] == "bottom"
assert t.bgm_config["volume"] == 0.5
def test_estimated_duration(self):
t = Template(
id="t1",
user_id="u1",
name="n",
mode="one_take",
estimated_duration=30.5,
)
assert t.estimated_duration == 30.5
def test_is_active_false(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", is_active=False)
assert t.is_active is False
def test_has_timestamps(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take")
assert t.created_at is not None
assert t.updated_at is not None
def test_tags_list(self):
t = Template(
id="t1",
user_id="u1",
name="n",
mode="one_take",
tags=["风景", "vlog"],
)
assert "风景" in t.tags
assert "vlog" in t.tags
assert len(t.tags) == 2
class TestTemplateCategory:
"""TemplateCategory 测试"""
def test_create_category(self):
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
assert cat.id == "cat-1"
assert cat.user_id == "u1"
assert cat.name == "风景"
def test_category_has_timestamp(self):
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
assert cat.created_at is not None
+127
View File
@@ -0,0 +1,127 @@
"""
EditTemplateVersion 模板版本领域模型单元测试
"""
import pytest
from domain.template_version import EditTemplateVersion
class TestEditTemplateVersionCreate:
"""创建模板版本测试"""
def test_create_required_fields(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
assert v.id is not None
assert len(v.id) == 32
assert v.template_id == "tpl-1"
assert v.version == 1
def test_default_values(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
def test_with_name_and_mode(self):
v = EditTemplateVersion.create(
template_id="tpl-1",
version=2,
name="风景Vlog模板",
editing_mode="voice_over",
)
assert v.name == "风景Vlog模板"
assert v.editing_mode == "voice_over"
def test_with_config(self):
config = {
"title": {"font_size": 24},
"subtitle": {"style": "bottom"},
"bgm": {"volume": 0.5},
}
v = EditTemplateVersion.create(
template_id="tpl-1",
version=1,
config=config,
)
assert v.config == config
assert v.config["title"]["font_size"] == 24
def test_with_clip_configs(self):
clips = [
{"clip_id": 1, "duration": 3.0, "transition": "fade"},
{"clip_id": 2, "duration": 5.0, "transition": "slide"},
]
v = EditTemplateVersion.create(
template_id="tpl-1",
version=1,
clip_configs=clips,
)
assert len(v.clip_configs) == 2
assert v.clip_configs[0]["clip_id"] == 1
def test_config_none_defaults_to_empty_dict(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, config=None)
assert v.config == {}
def test_clip_configs_none_defaults_to_empty_list(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, clip_configs=None)
assert v.clip_configs == []
def test_with_change_note(self):
v = EditTemplateVersion.create(
template_id="tpl-1",
version=3,
change_note="优化转场效果,新增滤镜",
)
assert v.change_note == "优化转场效果,新增滤镜"
def test_with_published_by(self):
v = EditTemplateVersion.create(
template_id="tpl-1",
version=1,
published_by="user-123",
)
assert v.published_by == "user-123"
def test_full_version(self):
config = {"bgm": {"volume": 0.3}}
clips = [{"clip_id": 1, "duration": 2.5}]
v = EditTemplateVersion.create(
template_id="tpl-abc",
version=5,
name="正式版v5",
editing_mode="one_take",
config=config,
clip_configs=clips,
change_note="第五次发布",
published_by="admin",
)
assert v.template_id == "tpl-abc"
assert v.version == 5
assert v.name == "正式版v5"
assert v.editing_mode == "one_take"
assert v.config == config
assert v.clip_configs == clips
assert v.change_note == "第五次发布"
assert v.published_by == "admin"
def test_version_number(self):
for ver in [1, 2, 5, 10, 99]:
v = EditTemplateVersion.create(template_id="t1", version=ver)
assert v.version == ver
def test_has_created_at(self):
v = EditTemplateVersion.create(template_id="t1", version=1)
assert v.created_at is not None
class TestEditTemplateVersionSlots:
"""slots 模式属性测试"""
def test_cannot_add_new_attribute(self):
v = EditTemplateVersion.create(template_id="t1", version=1)
with pytest.raises(AttributeError):
v.nonexistent_field = "value" # type: ignore[attr-defined]
+78
View File
@@ -0,0 +1,78 @@
"""
TitleLibraryItem 标题库领域模型单元测试
"""
from packages.domain.title_library import TitleLibraryItem
class TestTitleLibraryItem:
"""TitleLibraryItem 测试"""
def test_create_minimal(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="标题1", text="这是标题文本")
assert item.id == "t1"
assert item.user_id == "u1"
assert item.name == "标题1"
assert item.text == "这是标题文本"
def test_default_values(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
assert item.category == "default"
assert item.description == ""
assert item.tags == []
assert item.usage_count == 0
assert item.is_active is True
assert item.metadata_ == {}
def test_with_category(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="n",
text="t",
category="美食",
)
assert item.category == "美食"
def test_with_tags(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="n",
text="t",
tags=["爆款", "美食"],
)
assert len(item.tags) == 2
assert "爆款" in item.tags
def test_usage_count(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
assert item.usage_count == 0
item.usage_count = 10
assert item.usage_count == 10
def test_inactive(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="n",
text="t",
is_active=False,
)
assert item.is_active is False
def test_with_metadata(self):
meta = {"source": "import", "quality": "high"}
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="n",
text="t",
metadata_=meta,
)
assert item.metadata_["source"] == "import"
def test_has_timestamps(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
assert item.created_at is not None
assert item.updated_at is not None
+181
View File
@@ -0,0 +1,181 @@
"""transition_presets 模块单元测试."""
from dataclasses import FrozenInstanceError
import pytest
from domain.transition_presets import (
TRANSITION_PRESET_LIBRARY,
TransitionPreset,
get_default_transition,
get_transition_preset,
list_transition_presets,
)
class TestTransitionPreset:
"""TransitionPreset 数据类测试."""
def test_create_required_fields(self):
t = TransitionPreset(id="test_001", name="测试转场", category="basic")
assert t.id == "test_001"
assert t.name == "测试转场"
assert t.category == "basic"
# 默认值
assert t.description == ""
assert t.tags == []
assert t.transition == "fade"
assert t.default_duration == 0.5
assert t.min_duration == 0.1
assert t.max_duration == 3.0
assert t.has_custom_params is False
def test_create_all_fields(self):
t = TransitionPreset(
id="test_002",
name="完整转场",
category="slide",
description="测试描述",
tags=["标签1", "标签2"],
transition="slideleft",
default_duration=1.0,
min_duration=0.3,
max_duration=2.5,
has_custom_params=True,
)
assert t.category == "slide"
assert t.description == "测试描述"
assert t.tags == ["标签1", "标签2"]
assert t.transition == "slideleft"
assert t.default_duration == 1.0
assert t.min_duration == 0.3
assert t.max_duration == 2.5
assert t.has_custom_params is True
def test_frozen_immutable(self):
t = TransitionPreset(id="test", name="测试", category="basic")
with pytest.raises(FrozenInstanceError):
t.name = "修改" # type: ignore[misc]
def test_tags_default_new_list(self):
t1 = TransitionPreset(id="1", name="a", category="basic")
t2 = TransitionPreset(id="2", name="b", category="basic")
assert t1.tags is not t2.tags
assert t1.tags == []
class TestTransitionPresetLibrary:
"""TRANSITION_PRESET_LIBRARY 预设库测试."""
def test_not_empty(self):
assert len(TRANSITION_PRESET_LIBRARY) > 0
def test_all_unique_ids(self):
ids = [t.id for t in TRANSITION_PRESET_LIBRARY]
assert len(ids) == len(set(ids)), "转场 ID 不能重复"
def test_all_are_transition_preset_instances(self):
for t in TRANSITION_PRESET_LIBRARY:
assert isinstance(t, TransitionPreset)
def test_contains_basic_categories(self):
cats = {t.category for t in TRANSITION_PRESET_LIBRARY}
assert "basic" in cats
assert "fade" in cats
def test_duration_constraints_valid(self):
"""每个预设的 min <= default <= max."""
for t in TRANSITION_PRESET_LIBRARY:
assert t.min_duration <= t.default_duration, f"{t.id}: min > default"
assert t.default_duration <= t.max_duration, f"{t.id}: default > max"
def test_none_transition_zero_duration(self):
t = get_transition_preset("transition_none")
assert t is not None
assert t.default_duration == 0.0
assert t.min_duration == 0.0
assert t.max_duration == 0.0
class TestGetTransitionPreset:
"""get_transition_preset 函数测试."""
def test_existing_id(self):
t = get_transition_preset("transition_fade")
assert t is not None
assert t.id == "transition_fade"
assert t.name == "淡入淡出"
assert t.category == "fade"
def test_nonexistent_id(self):
assert get_transition_preset("nonexistent") is None
def test_empty_string(self):
assert get_transition_preset("") is None
class TestListTransitionPresets:
"""list_transition_presets 函数测试."""
def test_no_filters_returns_all(self):
result = list_transition_presets()
assert len(result) == len(TRANSITION_PRESET_LIBRARY)
def test_filter_by_category_basic(self):
result = list_transition_presets(category="basic")
assert len(result) >= 2
for t in result:
assert t.category == "basic"
def test_filter_by_category_fade(self):
result = list_transition_presets(category="fade")
assert len(result) >= 3
for t in result:
assert t.category == "fade"
def test_filter_by_unknown_category_returns_empty(self):
result = list_transition_presets(category="nonexistent")
assert result == []
def test_filter_by_keyword_name(self):
result = list_transition_presets(keyword="淡入")
assert len(result) >= 1
assert any(t.name == "淡入淡出" for t in result)
def test_filter_by_keyword_description(self):
result = list_transition_presets(keyword="经典")
assert len(result) >= 1
def test_filter_by_keyword_tag(self):
result = list_transition_presets(keyword="电影感")
assert len(result) >= 1
def test_filter_keyword_case_insensitive(self):
r1 = list_transition_presets(keyword="FADE")
r2 = list_transition_presets(keyword="fade")
assert len(r1) == len(r2)
def test_filter_keyword_no_match(self):
result = list_transition_presets(keyword="xyz_nonexistent_12345")
assert result == []
def test_combined_category_and_keyword(self):
result = list_transition_presets(category="fade", keyword="黑场")
assert len(result) >= 1
for t in result:
assert t.category == "fade"
def test_combined_no_match(self):
result = list_transition_presets(category="basic", keyword="黑场")
assert result == []
class TestGetDefaultTransition:
"""get_default_transition 函数测试."""
def test_returns_none_transition(self):
t = get_default_transition()
assert t.id == "transition_none"
assert t.name == "无转场"
def test_returns_transition_preset_instance(self):
assert isinstance(get_default_transition(), TransitionPreset)
+135 -133
View File
@@ -1,14 +1,12 @@
"""
TTS 配音配置模型单元测试
"""
"""TTS 配音配置领域模型单元测试."""
import pytest
from __future__ import annotations
from packages.domain.tts_config import TtsConfig
class TestTtsConfigDefaults:
"""默认值测试"""
"""默认值测试."""
def test_default_values(self):
config = TtsConfig()
@@ -23,180 +21,184 @@ class TestTtsConfigDefaults:
class TestTtsConfigParse:
"""parse 方法测试"""
"""parse 方法测试."""
def test_parse_none(self):
def test_parse_none_returns_default(self):
config = TtsConfig.parse(None)
assert config.enabled is False
assert config.speed == 1.0
def test_parse_empty_dict(self):
def test_parse_empty_dict_returns_default(self):
config = TtsConfig.parse({})
assert config.enabled is False
def test_parse_not_dict(self):
config = TtsConfig.parse("not a dict")
def test_parse_not_dict_returns_default(self):
config = TtsConfig.parse("invalid")
assert config.enabled is False
config2 = TtsConfig.parse(123)
assert config2.enabled is False
config3 = TtsConfig.parse([])
assert config3.enabled is False
def test_parse_disabled_returns_minimal(self):
"""disabled 时直接返回 enabled=False,忽略其他字段"""
config = TtsConfig.parse(
{
"enabled": False,
"voice_id": "v123",
"speed": 1.5,
}
)
def test_parse_enabled_false_ignores_other_fields(self):
data = {
"enabled": False,
"voice_id": "test_voice",
"speed": 2.0,
"text": "hello",
}
config = TtsConfig.parse(data)
assert config.enabled is False
assert config.voice_id == "" # 不保留
def test_parse_enabled_true(self):
config = TtsConfig.parse(
{
"enabled": True,
"voice_id": "voice_001",
"speed": 1.2,
"pitch": 2.5,
"volume": 0.5,
"text": "你好世界",
"align_mode": "subtitle",
"overlap_mode": "mix",
}
)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.2
assert config.pitch == 2.5
assert config.volume == 0.5
assert config.text == "你好世界"
assert config.align_mode == "subtitle"
assert config.overlap_mode == "mix"
def test_parse_enabled_not_bool_false(self):
"""enabled 不是 bool 时视为 False"""
config = TtsConfig.parse({"enabled": "true"})
assert config.enabled is False
def test_parse_enabled_not_bool_zero(self):
config = TtsConfig.parse({"enabled": 0})
assert config.enabled is False
def test_parse_voice_id_not_string(self):
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
assert config.voice_id == ""
def test_parse_speed_not_number(self):
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
assert config.speed == 1.0
def test_parse_pitch_not_number(self):
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
def test_parse_basic_enabled(self):
data = {"enabled": True, "voice_id": "voice_001"}
config = TtsConfig.parse(data)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.0
assert config.pitch == 0.0
def test_parse_volume_not_number(self):
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
assert config.volume == 0.8
def test_parse_text_not_string(self):
config = TtsConfig.parse({"enabled": True, "text": 12345})
assert config.text == ""
def test_parse_align_mode_invalid(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
assert config.align_mode == "full"
def test_parse_align_mode_subtitle(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
def test_parse_full_config(self):
data = {
"enabled": True,
"voice_id": "voice_001",
"speed": 1.5,
"pitch": 2.0,
"volume": 0.9,
"text": "测试配音文本",
"align_mode": "subtitle",
"overlap_mode": "mix",
}
config = TtsConfig.parse(data)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.5
assert config.pitch == 2.0
assert config.volume == 0.9
assert config.text == "测试配音文本"
assert config.align_mode == "subtitle"
def test_parse_align_mode_full(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "full"})
assert config.align_mode == "full"
def test_parse_overlap_mode_invalid(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
assert config.overlap_mode == "replace"
def test_parse_overlap_mode_replace(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
assert config.overlap_mode == "replace"
def test_parse_overlap_mode_mix(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
assert config.overlap_mode == "mix"
def test_parse_integer_speed(self):
"""int 类型的 speed 应该被转成 float"""
config = TtsConfig.parse({"enabled": True, "speed": 2})
assert config.speed == 2.0
assert isinstance(config.speed, float)
def test_parse_enabled_non_bool_fallback(self):
data = {"enabled": "true", "voice_id": "v1"}
config = TtsConfig.parse(data)
assert config.enabled is False
def test_parse_integer_pitch(self):
config = TtsConfig.parse({"enabled": True, "pitch": -5})
assert config.pitch == -5.0
assert isinstance(config.pitch, float)
def test_parse_voice_id_non_string_fallback(self):
data = {"enabled": True, "voice_id": 123}
config = TtsConfig.parse(data)
assert config.voice_id == ""
def test_parse_integer_volume(self):
config = TtsConfig.parse({"enabled": True, "volume": 1})
assert config.volume == 1.0
assert isinstance(config.volume, float)
def test_parse_speed_non_numeric_fallback(self):
data = {"enabled": True, "speed": "fast"}
config = TtsConfig.parse(data)
assert config.speed == 1.0
def test_parse_pitch_non_numeric_fallback(self):
data = {"enabled": True, "pitch": "high"}
config = TtsConfig.parse(data)
assert config.pitch == 0.0
def test_parse_volume_non_numeric_fallback(self):
data = {"enabled": True, "volume": "loud"}
config = TtsConfig.parse(data)
assert config.volume == 0.8
def test_parse_text_non_string_fallback(self):
data = {"enabled": True, "text": 12345}
config = TtsConfig.parse(data)
assert config.text == ""
def test_parse_align_mode_invalid_fallback(self):
data = {"enabled": True, "align_mode": "invalid"}
config = TtsConfig.parse(data)
assert config.align_mode == "full"
def test_parse_overlap_mode_invalid_fallback(self):
data = {"enabled": True, "overlap_mode": "invalid"}
config = TtsConfig.parse(data)
assert config.overlap_mode == "replace"
class TestTtsConfigClamp:
"""边界钳制测试"""
"""边界钳制测试."""
def test_speed_too_low(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
def test_speed_below_min_clamped(self):
data = {"enabled": True, "speed": 0.1}
config = TtsConfig.parse(data)
assert config.speed == 0.5
def test_speed_too_high(self):
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
def test_speed_above_max_clamped(self):
data = {"enabled": True, "speed": 3.0}
config = TtsConfig.parse(data)
assert config.speed == 2.0
def test_speed_lower_boundary(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
def test_speed_at_min_ok(self):
data = {"enabled": True, "speed": 0.5}
config = TtsConfig.parse(data)
assert config.speed == 0.5
def test_speed_upper_boundary(self):
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
def test_speed_at_max_ok(self):
data = {"enabled": True, "speed": 2.0}
config = TtsConfig.parse(data)
assert config.speed == 2.0
def test_pitch_too_low(self):
config = TtsConfig.parse({"enabled": True, "pitch": -20})
def test_pitch_below_min_clamped(self):
data = {"enabled": True, "pitch": -20}
config = TtsConfig.parse(data)
assert config.pitch == -12
def test_pitch_too_high(self):
config = TtsConfig.parse({"enabled": True, "pitch": 20})
def test_pitch_above_max_clamped(self):
data = {"enabled": True, "pitch": 20}
config = TtsConfig.parse(data)
assert config.pitch == 12
def test_pitch_lower_boundary(self):
config = TtsConfig.parse({"enabled": True, "pitch": -12})
def test_pitch_at_min_ok(self):
data = {"enabled": True, "pitch": -12}
config = TtsConfig.parse(data)
assert config.pitch == -12
def test_pitch_upper_boundary(self):
config = TtsConfig.parse({"enabled": True, "pitch": 12})
def test_pitch_at_max_ok(self):
data = {"enabled": True, "pitch": 12}
config = TtsConfig.parse(data)
assert config.pitch == 12
def test_volume_negative(self):
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
def test_volume_below_min_clamped(self):
data = {"enabled": True, "volume": -0.5}
config = TtsConfig.parse(data)
assert config.volume == 0.0
def test_volume_over_one(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.5})
def test_volume_above_max_clamped(self):
data = {"enabled": True, "volume": 2.0}
config = TtsConfig.parse(data)
assert config.volume == 1.0
def test_volume_zero(self):
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
def test_volume_at_min_ok(self):
data = {"enabled": True, "volume": 0.0}
config = TtsConfig.parse(data)
assert config.volume == 0.0
def test_volume_one(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
def test_volume_at_max_ok(self):
data = {"enabled": True, "volume": 1.0}
config = TtsConfig.parse(data)
assert config.volume == 1.0
def test_clamp_via_direct_construction(self):
"""直接构造也应该钳制(通过 _clamp 方法)"""
config = TtsConfig(enabled=True, speed=5.0, pitch=100, volume=-1)
config._clamp()
assert config.speed == 2.0
assert config.pitch == 12
assert config.volume == 0.0
def test_int_speed_converted_to_float(self):
data = {"enabled": True, "speed": 1}
config = TtsConfig.parse(data)
assert isinstance(config.speed, float)
assert config.speed == 1.0
def test_int_pitch_converted_to_float(self):
data = {"enabled": True, "pitch": 5}
config = TtsConfig.parse(data)
assert isinstance(config.pitch, float)
assert config.pitch == 5.0
def test_int_volume_converted_to_float(self):
data = {"enabled": True, "volume": 1}
config = TtsConfig.parse(data)
assert isinstance(config.volume, float)
assert config.volume == 1.0
+393
View File
@@ -0,0 +1,393 @@
"""tts_job 领域模型单元测试."""
import pytest
from domain.tts_job import TERMINAL_STATUSES, TTSJob, TTSJobStatus
class TestTTSJobStatus:
"""TTSJobStatus 枚举测试."""
def test_values(self):
assert TTSJobStatus.PENDING == "pending"
assert TTSJobStatus.PROCESSING == "processing"
assert TTSJobStatus.COMPLETED == "completed"
assert TTSJobStatus.FAILED == "failed"
assert TTSJobStatus.CANCELLED == "cancelled"
def test_terminal_statuses(self):
assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES
assert TTSJobStatus.FAILED in TERMINAL_STATUSES
assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES
assert TTSJobStatus.PENDING not in TERMINAL_STATUSES
assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES
class TestTTSJobCreate:
"""TTSJob.create 工厂方法测试."""
def test_create_with_required_fields(self):
job = TTSJob.create(user_id="user_001", input_text="你好世界")
assert job.id
assert len(job.id) == 32
assert job.user_id == "user_001"
assert job.input_text == "你好世界"
assert job.status == TTSJobStatus.PENDING
assert job.voice_id == ""
assert job.sample_rate == 22050
assert job.format == "mp3"
assert job.retry_count == 0
assert job.max_retries == 3
assert job.metadata == {}
assert job.created_at is not None
assert job.updated_at is not None
def test_create_with_all_fields(self):
job = TTSJob.create(
user_id="user_002",
input_text="测试文本",
voice_id="voice_001",
voice_model="cosyvoice",
project_id="proj_001",
voice_clone_profile_id="clone_001",
sample_rate=16000,
format="wav",
max_retries=5,
metadata={"key": "value"},
)
assert job.voice_id == "voice_001"
assert job.voice_model == "cosyvoice"
assert job.project_id == "proj_001"
assert job.voice_clone_profile_id == "clone_001"
assert job.sample_rate == 16000
assert job.format == "wav"
assert job.max_retries == 5
assert job.metadata == {"key": "value"}
def test_create_strips_strings(self):
job = TTSJob.create(
user_id=" user_003 ",
input_text=" 测试文本 ",
voice_id=" voice_001 ",
voice_model=" cosyvoice ",
project_id=" proj_001 ",
voice_clone_profile_id=" clone_001 ",
format="wav",
)
assert job.user_id == "user_003"
assert job.input_text == "测试文本"
assert job.voice_id == "voice_001"
assert job.voice_model == "cosyvoice"
assert job.project_id == "proj_001"
assert job.voice_clone_profile_id == "clone_001"
assert job.format == "wav"
def test_create_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
TTSJob.create(user_id="", input_text="test")
def test_create_whitespace_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
TTSJob.create(user_id=" ", input_text="test")
def test_create_empty_input_text_raises(self):
with pytest.raises(ValueError, match="input_text"):
TTSJob.create(user_id="u", input_text="")
def test_create_input_text_too_long_raises(self):
long_text = "a" * 10001
with pytest.raises(ValueError, match="10000"):
TTSJob.create(user_id="u", input_text=long_text)
def test_create_input_text_at_limit_ok(self):
text = "a" * 10000
job = TTSJob.create(user_id="u", input_text=text)
assert job.input_text == text
def test_create_invalid_format_raises(self):
with pytest.raises(ValueError, match="不支持的输出格式"):
TTSJob.create(user_id="u", input_text="t", format="flac")
def test_create_supported_formats(self):
for fmt in ["mp3", "wav", "pcm"]:
job = TTSJob.create(user_id="u", input_text="t", format=fmt)
assert job.format == fmt
def test_create_none_metadata_defaults_to_empty_dict(self):
job = TTSJob.create(user_id="u", input_text="t", metadata=None)
assert job.metadata == {}
def test_create_ids_are_unique(self):
j1 = TTSJob.create(user_id="u", input_text="t")
j2 = TTSJob.create(user_id="u", input_text="t")
assert j1.id != j2.id
class TestTTSJobStateMachine:
"""TTSJob 状态机测试."""
@pytest.fixture
def pending_job(self):
return TTSJob.create(user_id="user_001", input_text="测试")
def test_initial_status_is_pending(self, pending_job):
assert pending_job.status == TTSJobStatus.PENDING
assert not pending_job.is_terminal
def test_pending_to_processing(self, pending_job):
pending_job.mark_processing()
assert pending_job.status == TTSJobStatus.PROCESSING
assert pending_job.started_at is not None
assert pending_job.error_message == ""
def test_pending_can_fail_directly(self, pending_job):
"""pending 可以直接到 failed(比如入参校验失败)"""
pending_job.mark_failed("校验失败")
assert pending_job.status == TTSJobStatus.FAILED
assert pending_job.error_message == "校验失败"
def test_pending_can_be_cancelled(self, pending_job):
pending_job.mark_cancelled()
assert pending_job.status == TTSJobStatus.CANCELLED
def test_processing_to_completed(self, pending_job):
pending_job.mark_processing()
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
assert pending_job.status == TTSJobStatus.COMPLETED
assert pending_job.output_audio_url == "https://example.com/out.mp3"
assert pending_job.completed_at is not None
assert pending_job.error_message == ""
def test_processing_to_failed(self, pending_job):
pending_job.mark_processing()
pending_job.mark_failed("API 超时")
assert pending_job.status == TTSJobStatus.FAILED
assert pending_job.error_message == "API 超时"
def test_processing_can_be_cancelled(self, pending_job):
pending_job.mark_processing()
pending_job.mark_cancelled()
assert pending_job.status == TTSJobStatus.CANCELLED
def test_completed_is_terminal(self, pending_job):
pending_job.mark_processing()
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
assert pending_job.is_terminal
assert pending_job.is_completed
def test_failed_is_terminal_but_retryable(self, pending_job):
pending_job.mark_processing()
pending_job.mark_failed("error")
assert pending_job.is_terminal
assert pending_job.is_retryable
def test_cancelled_is_terminal_and_not_retryable(self, pending_job):
pending_job.mark_cancelled()
assert pending_job.is_terminal
assert not pending_job.is_retryable
def test_invalid_transition_completed_to_processing_raises(self, pending_job):
pending_job.mark_processing()
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
with pytest.raises(ValueError, match="非法状态转换"):
pending_job.mark_processing()
def test_invalid_transition_completed_to_failed_raises(self, pending_job):
pending_job.mark_processing()
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
with pytest.raises(ValueError, match="非法状态转换"):
pending_job.mark_failed("test")
def test_cancelled_cannot_transition(self, pending_job):
pending_job.mark_cancelled()
with pytest.raises(ValueError):
pending_job.mark_processing()
with pytest.raises(ValueError):
pending_job.mark_failed("test")
def test_transition_to_with_string(self, pending_job):
"""transition_to 支持字符串参数"""
pending_job.transition_to("processing")
assert pending_job.status == TTSJobStatus.PROCESSING
def test_transition_to_invalid_string_raises(self, pending_job):
with pytest.raises(ValueError, match="无效状态"):
pending_job.transition_to("invalid_status")
def test_state_transition_updates_updated_at(self, pending_job):
old_updated = pending_job.updated_at
import time
time.sleep(0.001)
pending_job.mark_processing()
assert pending_job.updated_at > old_updated
class TestTTSJobRetry:
"""TTSJob 重试逻辑测试."""
def test_failed_can_retry(self):
job = TTSJob.create(user_id="u", input_text="t", max_retries=3)
job.mark_processing()
job.mark_failed("error")
assert job.is_retryable
assert job.retry_count == 0
def test_prepare_retry_resets_to_pending(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_failed("error")
job.prepare_retry()
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 1
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
def test_retry_up_to_max_retries(self):
job = TTSJob.create(user_id="u", input_text="t", max_retries=2)
# 第 1 次失败 + 重试 → retry_count=1,还可以重试
job.mark_processing()
job.mark_failed("e1")
assert job.is_retryable
job.prepare_retry()
assert job.retry_count == 1
# 第 2 次失败 → retry_count=1,还是 failed 状态,还可以重试(max_retries=2)
job.mark_processing()
job.mark_failed("e2")
assert job.is_retryable # retry_count=1 < max_retries=2
job.prepare_retry()
assert job.retry_count == 2
# 第 3 次失败 → retry_count=2,达到上限,不可重试
job.mark_processing()
job.mark_failed("e3")
assert not job.is_retryable # retry_count=2 == max_retries=2
def test_retry_exceed_max_raises(self):
job = TTSJob.create(user_id="u", input_text="t", max_retries=1)
job.mark_processing()
job.mark_failed("e")
job.prepare_retry() # 第 1 次重试,用完了
job.mark_processing()
job.mark_failed("e2")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_pending_not_retryable(self):
job = TTSJob.create(user_id="u", input_text="t")
assert not job.is_retryable
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_completed_not_retryable(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/out.mp3")
assert not job.is_retryable
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_cancelled_not_retryable(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_cancelled()
assert not job.is_retryable
class TestTTSJobMarkCompleted:
"""mark_completed 方法测试."""
def test_requires_output_url(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
with pytest.raises(ValueError, match="output_audio_url"):
job.mark_completed(output_audio_url="")
def test_sets_all_fields(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(
output_audio_url="https://example.com/out.mp3",
output_audio_key="audio/001.mp3",
duration=30.5,
file_size=102400,
)
assert job.output_audio_url == "https://example.com/out.mp3"
assert job.output_audio_key == "audio/001.mp3"
assert job.duration == 30.5
assert job.file_size == 102400
assert job.completed_at is not None
def test_strips_whitespace(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(
output_audio_url=" https://example.com/out.mp3 ",
output_audio_key=" audio/001.mp3 ",
)
assert job.output_audio_url == "https://example.com/out.mp3"
assert job.output_audio_key == "audio/001.mp3"
class TestTTSJobIsCompleted:
"""is_completed 属性测试."""
def test_completed_with_url_is_completed(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/out.mp3")
assert job.is_completed
def test_completed_without_url_not_completed(self):
"""极端情况:completed 状态但没有 URL(理论不会发生)"""
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.transition_to(TTSJobStatus.COMPLETED) # 直接转,不设 URL
assert not job.is_completed
def test_pending_not_completed(self):
job = TTSJob.create(user_id="u", input_text="t")
assert not job.is_completed
class TestTTSJobToDict:
"""to_dict 序列化测试."""
def test_pending_job_to_dict(self):
job = TTSJob.create(user_id="user_001", input_text="测试文本", voice_id="v001")
d = job.to_dict()
assert d["id"] == job.id
assert d["user_id"] == "user_001"
assert d["status"] == "pending"
assert d["input_text"] == "测试文本"
assert d["voice_id"] == "v001"
assert d["retry_count"] == 0
assert d["is_retryable"] is False
assert d["is_completed"] is False
assert d["metadata"] == {}
assert d["started_at"] is None
assert d["completed_at"] is None
assert d["created_at"] is not None
assert d["updated_at"] is not None
def test_completed_job_to_dict(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/out.mp3", duration=10.0)
d = job.to_dict()
assert d["status"] == "completed"
assert d["output_audio_url"] == "https://example.com/out.mp3"
assert d["duration"] == 10.0
assert d["is_completed"] is True
assert d["started_at"] is not None
assert d["completed_at"] is not None
def test_failed_job_to_dict(self):
job = TTSJob.create(user_id="u", input_text="t")
job.mark_failed("出错了")
d = job.to_dict()
assert d["status"] == "failed"
assert d["error_message"] == "出错了"
assert d["is_retryable"] is True
+168
View File
@@ -0,0 +1,168 @@
"""
VerificationCode 验证码领域模型单元测试
"""
from datetime import datetime, timedelta, timezone
import pytest
from domain.verification_code import VerificationCode
class TestVerificationCodeCreate:
"""创建验证码测试"""
def test_create_default_6digit_code(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.id is not None
assert len(vc.id) == 32 # uuid4 hex
assert vc.recipient == "test@example.com"
assert vc.code_type == "email_bind"
assert len(vc.code) == 6
assert vc.code.isdigit()
assert vc.used_at is None
assert vc.attempts == 0
def test_create_custom_code(self):
vc = VerificationCode.create("13800138000", "phone_bind", custom_code="123456")
assert vc.code == "123456"
def test_create_default_ttl_300s(self):
before = datetime.now(timezone.utc)
vc = VerificationCode.create("test@example.com", "email_login")
after = datetime.now(timezone.utc)
expected_expiry_min = before + timedelta(seconds=300)
expected_expiry_max = after + timedelta(seconds=300)
assert expected_expiry_min <= vc.expires_at <= expected_expiry_max
def test_create_custom_ttl(self):
vc = VerificationCode.create("test@example.com", "reset_password", ttl_seconds=60)
expected = datetime.now(timezone.utc) + timedelta(seconds=60)
diff = abs((vc.expires_at - expected).total_seconds())
assert diff < 2
def test_create_recipient_stripped(self):
vc = VerificationCode.create(" test@example.com ", "email_bind")
assert vc.recipient == "test@example.com"
def test_create_phone_recipient(self):
vc = VerificationCode.create("13800138000", "phone_login")
assert vc.recipient == "13800138000"
assert vc.code_type == "phone_login"
def test_create_sets_created_at(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.created_at is not None
assert isinstance(vc.created_at, datetime)
class TestVerificationCodeExpiry:
"""过期状态测试"""
def test_fresh_code_not_expired(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.is_expired is False
def test_expired_code_is_expired(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-60)
assert vc.is_expired is True
def test_boundary_not_expired_at_expiry_time(self):
now = datetime.now(timezone.utc)
vc = VerificationCode.create("test@example.com", "email_bind")
vc.expires_at = now + timedelta(seconds=1)
assert vc.is_expired is False
def test_boundary_expired_right_after(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.expires_at = datetime.now(timezone.utc) - timedelta(microseconds=1)
assert vc.is_expired is True
class TestVerificationCodeUsed:
"""使用状态测试"""
def test_fresh_code_not_used(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.is_used is False
def test_mark_used(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.mark_used()
assert vc.is_used is True
assert vc.used_at is not None
assert isinstance(vc.used_at, datetime)
def test_mark_used_sets_recent_time(self):
vc = VerificationCode.create("test@example.com", "email_bind")
before = datetime.now(timezone.utc)
vc.mark_used()
after = datetime.now(timezone.utc)
assert before <= vc.used_at <= after
def test_mark_used_idempotent(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.mark_used()
first_used_at = vc.used_at
vc.mark_used()
# 第二次会更新时间
assert vc.used_at >= first_used_at
class TestVerificationCodeValidity:
"""有效性(未过期+未使用)测试"""
def test_fresh_code_is_valid(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.is_valid is True
def test_expired_code_not_valid(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-10)
assert vc.is_valid is False
def test_used_code_not_valid(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.mark_used()
assert vc.is_valid is False
def test_expired_and_used_not_valid(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-10)
vc.mark_used()
assert vc.is_valid is False
class TestVerificationCodeAttempts:
"""尝试次数测试"""
def test_initial_attempts_zero(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.attempts == 0
def test_increment_attempts(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.increment_attempts()
assert vc.attempts == 1
def test_increment_attempts_multiple(self):
vc = VerificationCode.create("test@example.com", "email_bind")
for _ in range(5):
vc.increment_attempts()
assert vc.attempts == 5
class TestVerificationCodeTypes:
"""不同验证码类型测试"""
@pytest.mark.parametrize(
"code_type",
[
"email_bind",
"phone_bind",
"email_login",
"phone_login",
"reset_password",
],
)
def test_all_supported_types(self, code_type):
vc = VerificationCode.create("test@example.com", code_type)
assert vc.code_type == code_type
assert vc.is_valid is True
+95
View File
@@ -0,0 +1,95 @@
"""
VoiceLibraryItem 配音库领域模型单元测试
"""
from packages.domain.voice_library import VoiceLibraryItem
class TestVoiceLibraryItem:
"""VoiceLibraryItem 测试"""
def test_create_minimal(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="我的配音")
assert item.id == "v1"
assert item.user_id == "u1"
assert item.name == "我的配音"
def test_default_values(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.text == ""
assert item.voice_provider == ""
assert item.voice_id == ""
assert item.voice_name == ""
assert item.audio_url == ""
assert item.duration == 0
assert item.file_size == 0
assert item.status == "completed"
assert item.project_id is None
assert item.tags == []
assert item.metadata_ == {}
def test_with_voice_info(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="温柔女声",
text="大家好",
voice_provider="cosyvoice",
voice_id="longxiaochun_v3",
voice_name="龙小淳",
)
assert item.voice_provider == "cosyvoice"
assert item.voice_id == "longxiaochun_v3"
assert item.voice_name == "龙小淳"
def test_with_audio_info(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="n",
audio_url="https://example.com/audio.wav",
duration=15.5,
file_size=102400,
)
assert item.audio_url == "https://example.com/audio.wav"
assert item.duration == 15.5
assert item.file_size == 102400
def test_with_project_id(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="n",
project_id="proj-123",
)
assert item.project_id == "proj-123"
def test_status_values(self):
for status in ["pending", "processing", "completed", "failed"]:
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status=status)
assert item.status == status
def test_with_tags(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="n",
tags=["温柔", "女声", "解说"],
)
assert len(item.tags) == 3
assert "温柔" in item.tags
def test_with_metadata(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="n",
metadata_={"speed": 1.0, "pitch": 0.5},
)
assert item.metadata_["speed"] == 1.0
assert item.metadata_["pitch"] == 0.5
def test_has_timestamps(self):
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.created_at is not None
assert item.updated_at is not None
+231
View File
@@ -0,0 +1,231 @@
"""voice_presets 模块单元测试."""
import pytest
from domain.voice_presets import (
MOCK_VOICES,
VoiceGender,
VoicePreset,
VoiceStyle,
get_default_voice,
get_voice,
list_voices,
)
class TestVoiceGender:
"""VoiceGender 枚举测试."""
def test_values(self):
assert VoiceGender.MALE == "male"
assert VoiceGender.FEMALE == "female"
assert VoiceGender.CHILD == "child"
def test_is_str_enum(self):
# StrEnum 在不同 Python 版本 str() 行为可能不同(3.11+ 返回值,旧版自定义 StrEnum 可能返回类名)
# 用 value 比较更稳妥
assert isinstance(VoiceGender.FEMALE, str)
assert VoiceGender.MALE.value == "male"
assert VoiceGender.FEMALE.value == "female"
class TestVoiceStyle:
"""VoiceStyle 枚举测试."""
def test_values(self):
assert VoiceStyle.STABLE == "stable"
assert VoiceStyle.LIVELY == "lively"
assert VoiceStyle.CUSTOMER_SERVICE == "customer_service"
assert VoiceStyle.NARRATION == "narration"
assert VoiceStyle.NEWS == "news"
assert VoiceStyle.STORY == "story"
class TestVoicePreset:
"""VoicePreset 数据类测试."""
def test_create_with_required_fields(self):
v = VoicePreset(voice_id="test_001", name="测试音色")
assert v.voice_id == "test_001"
assert v.name == "测试音色"
# 默认值
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.provider == "mock"
assert v.default_speed == 1.0
assert v.default_pitch == 0.0
assert v.sample_rate == 22050
assert v.language == "zh-CN"
def test_create_with_all_fields(self):
v = VoicePreset(
voice_id="male_news",
name="新闻男声",
gender=VoiceGender.MALE,
style=VoiceStyle.NEWS,
description="字正腔圆",
provider="aliyun",
provider_voice_id="zhiqiang",
default_speed=0.9,
default_pitch=1.0,
sample_rate=16000,
language="zh-CN",
)
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.NEWS
assert v.provider == "aliyun"
assert v.default_speed == 0.9
assert v.sample_rate == 16000
def test_slots(self):
"""dataclass slots=True,不能添加新属性."""
v = VoicePreset(voice_id="test", name="测试")
with pytest.raises(AttributeError):
v.new_field = "value" # type: ignore[attr-defined]
class TestMockVoices:
"""MOCK_VOICES 预设列表测试."""
def test_not_empty(self):
assert len(MOCK_VOICES) > 0
def test_all_have_unique_voice_id(self):
ids = [v.voice_id for v in MOCK_VOICES]
assert len(ids) == len(set(ids)), "voice_id 不能重复"
def test_all_are_voice_preset_instances(self):
for v in MOCK_VOICES:
assert isinstance(v, VoicePreset)
assert v.provider == "mock"
def test_contains_expected_voices(self):
ids = {v.voice_id for v in MOCK_VOICES}
assert "female_warm" in ids
assert "male_stable" in ids
assert "female_lively" in ids
assert "child_cute" in ids
def test_voice_genders_coverage(self):
genders = {v.gender for v in MOCK_VOICES}
assert VoiceGender.FEMALE in genders
assert VoiceGender.MALE in genders
assert VoiceGender.CHILD in genders
class TestGetVoice:
"""get_voice 函数测试."""
def test_existing_mock_voice(self):
v = get_voice("female_warm")
assert v is not None
assert v.voice_id == "female_warm"
assert v.name == "温暖女声"
def test_nonexistent_voice(self):
assert get_voice("nonexistent") is None
def test_provider_mock(self):
v = get_voice("male_stable", provider="mock")
assert v is not None
assert v.voice_id == "male_stable"
def test_unknown_provider_returns_none(self):
assert get_voice("female_warm", provider="aliyun") is None
def test_empty_string_returns_none(self):
assert get_voice("") is None
class TestListVoices:
"""list_voices 函数测试."""
def test_no_filters_returns_all(self):
result = list_voices()
assert len(result) == len(MOCK_VOICES)
def test_filter_by_gender_female(self):
result = list_voices(gender="female")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.FEMALE
def test_filter_by_gender_male(self):
result = list_voices(gender="male")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.MALE
def test_filter_by_gender_child(self):
result = list_voices(gender="child")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.CHILD
def test_filter_by_style(self):
result = list_voices(style="story")
assert len(result) > 0
for v in result:
assert v.style == VoiceStyle.STORY
def test_filter_by_style_narration(self):
result = list_voices(style="narration")
assert len(result) >= 1
def test_filter_by_unknown_provider_returns_empty(self):
result = list_voices(provider="aliyun")
assert result == []
def test_filter_by_keyword_name(self):
result = list_voices(keyword="男声")
assert len(result) > 0
for v in result:
assert "男声" in v.name or "男声" in v.description
def test_filter_by_keyword_description(self):
result = list_voices(keyword="vlog")
assert len(result) > 0
def test_filter_by_keyword_voice_id(self):
result = list_voices(keyword="female")
assert len(result) > 0
for v in result:
assert "female" in v.voice_id.lower()
def test_filter_by_keyword_case_insensitive(self):
r1 = list_voices(keyword="FEMALE")
r2 = list_voices(keyword="female")
assert len(r1) == len(r2)
def test_filter_keyword_no_match(self):
result = list_voices(keyword="xyz_nonexistent_keyword")
assert result == []
def test_combined_filters_gender_and_style(self):
result = list_voices(gender="female", style="story")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.STORY
def test_combined_filters_gender_and_keyword(self):
result = list_voices(gender="male", keyword="新闻")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.MALE
def test_combined_no_match(self):
result = list_voices(gender="child", style="news")
# 童声没有新闻风格
assert result == []
class TestGetDefaultVoice:
"""get_default_voice 函数测试."""
def test_returns_first_mock_voice(self):
v = get_default_voice()
assert v == MOCK_VOICES[0]
assert v.voice_id == "female_warm"
def test_returns_voice_preset(self):
assert isinstance(get_default_voice(), VoicePreset)
@@ -271,8 +271,49 @@ class TestWechatOAuthService:
from packages.application.auth.wechat_oauth_service import WechatOAuthService
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
user_info, err = service.handle_callback("test_code", "test_state")
# 先生成授权 URL 获得有效 state(state 会被存入 store)
_, valid_state = service.generate_auth_url()
user_info, err = service.handle_callback("test_code", valid_state)
assert err is None
assert user_info is not None
assert "mock" in user_info.openid
assert user_info.nickname == "微信测试用户"
def test_callback_invalid_state_rejected(self):
"""无效 state 应被拒绝(CSRF 防护)"""
from packages.application.auth.wechat_oauth_service import WechatOAuthService
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
# 直接用随机 state 调用,未经过 generate_auth_url
user_info, err = service.handle_callback("test_code", "random_fake_state")
assert err is not None
assert "state" in err
assert user_info is None
def test_callback_state_single_use(self):
"""state 只能使用一次(防重放)"""
from packages.application.auth.wechat_oauth_service import WechatOAuthService
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
_, valid_state = service.generate_auth_url()
# 第一次使用:成功
user_info, err = service.handle_callback("test_code", valid_state)
assert err is None
assert user_info is not None
# 第二次使用相同 state:失败(已被消费)
user_info2, err2 = service.handle_callback("test_code", valid_state)
assert err2 is not None
assert "state" in err2
assert user_info2 is None
def test_callback_empty_state_rejected(self):
"""空 state 应被拒绝"""
from packages.application.auth.wechat_oauth_service import WechatOAuthService
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
user_info, err = service.handle_callback("test_code", "")
assert err is not None
assert "state" in err
assert user_info is None