Compare commits

..

123 Commits

Author SHA1 Message Date
xiaoxia 545ff0fab8 fix(ci): restore execute permission for ci_notify.py and healthcheck script
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m24s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 1m35s
AI Code Review / AI Code Review (pull_request) Failing after 2m12s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 2m22s
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Failing after 2m23s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
CI/CD Pipeline / AI Code Review (pull_request) Failing after 3m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m42s
CI/CD Pipeline / CI Gate (pull_request) Failing after 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1182h0m35s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1182h0m38s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1182h0m35s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1182h0m39s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1182h0m39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1182h0m36s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 1182h7m56s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1182h7m58s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1182h8m18s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1182h8m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1182h8m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1182h8m22s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1182h8m22s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1182h8m22s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1182h40m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1182h41m16s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1182h41m20s
2026-07-29 09:19:16 +08:00
xiaoxia 89a8c8b6fb merge(ci): sync CI scripts from develop - staging deploy/healthcheck/notify/build tools 2026-07-29 09:19:03 +08:00
xiaoxia e4997b9b4a fix(ci): exclude e2e directory from vitest
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m13s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m3s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 45s
CI/CD Pipeline / Unit Tests (push) Successful in 4m36s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Build Production API Image (push) Successful in 33s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 1m26s
CI/CD Pipeline / Build Production Web Image (push) Successful in 1m34s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1189h33m59s
CI/CD Pipeline / Deploy Production (push) Failing after 1189h34m0s
CI/CD Pipeline / CI Gate (push) Failing after 1189h35m34s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1189h37m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1189h37m11s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 1189h40m59s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 1189h41m0s
CI/CD Pipeline / AI Code Review (push) Failing after 1189h41m1s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1189h41m2s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1190h6m58s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1190h10m8s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1190h13m56s
Fix Frontend Unit Tests failure on main branch by excluding e2e Playwright tests from Vitest.
2026-07-29 01:46:42 +08:00
xiaoxia 68c89861e9 fix(ci): update package-lock.json for @vitest/coverage-v8
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m26s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m31s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m36s
CI/CD Pipeline / Unit Tests (push) Successful in 3m54s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Frontend Lint (push) Successful in 58s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 48s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1189h41m0s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1189h41m2s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1189h41m2s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1189h41m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1189h44m39s
CI/CD Pipeline / Deploy Production (push) Failing after 1189h44m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1189h44m41s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1189h44m41s
CI/CD Pipeline / Build Production API Image (push) Failing after 1189h44m41s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1189h48m36s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 1189h48m37s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 1189h48m39s
CI/CD Pipeline / AI Code Review (push) Failing after 1189h51m27s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1189h51m35s
CI/CD Pipeline / CI Gate (push) Failing after 1190h17m39s
2026-07-29 01:31:53 +08:00
xiaoxia 076601b431 fix(ci): add @vitest/coverage-v8 for main branch coverage
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m35s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m41s
CI/CD Pipeline / Unit Tests (push) Successful in 4m22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 14m37s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 14m32s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 14m27s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 14m22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m18s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m33s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 14m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1189h48m34s
CI/CD Pipeline / Deploy Production (push) Failing after 1189h49m42s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1189h48m35s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1189h49m46s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1189h49m48s
CI/CD Pipeline / CI Gate (push) Failing after 1189h51m37s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1189h51m39s
CI/CD Pipeline / Build Production API Image (push) Failing after 1189h51m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1189h51m41s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1190h7m31s
CI/CD Pipeline / AI Code Review (push) Failing after 1190h7m56s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1190h7m58s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1190h22m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1190h24m35s
2026-07-29 01:19:55 +08:00
xiaoxia e23a60e98b Merge remote-tracking branch 'origin/fix/code-review-ci-gate'
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m34s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m51s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m8s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m30s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m53s
CI/CD Pipeline / Unit Tests (push) Successful in 4m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 45s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m0s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1190h20m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1190h20m56s
CI/CD Pipeline / CI Gate (push) Failing after 1190h20m57s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1190h20m57s
CI/CD Pipeline / Build Production API Image (push) Failing after 1190h20m57s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1190h22m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1190h22m54s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1190h27m49s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 1190h27m51s
CI/CD Pipeline / AI Code Review (push) Failing after 1190h27m52s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1190h27m53s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1190h53m54s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1190h53m55s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1190h55m52s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 1191h0m48s
# Conflicts:
#	.gitea/workflows/ci-pipeline.yml
2026-07-29 00:40:32 +08:00
xiaoxia e2e91e1d58 Merge remote-tracking branch 'origin/fix/pr-path-filter-skip' 2026-07-29 00:39:46 +08:00
xiaoxia 682972469c Merge remote-tracking branch 'origin/fix/auto-merge-short-job'
CI/CD Pipeline / AI Code Review (push) Has been skipped
CI/CD Pipeline / PR Build API Image (Backend) (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m11s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m28s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m25s
CI/CD Pipeline / Unit Tests (push) Successful in 3m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m54s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4m11s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1190h23m41s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1190h23m42s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1190h23m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1190h23m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1190h44m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1190h44m27s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1190h50m24s
CI/CD Pipeline / CI Gate (push) Failing after 1190h44m27s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1190h44m28s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1190h44m28s
CI/CD Pipeline / Build Production API Image (push) Failing after 1190h44m28s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1190h50m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1190h50m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 1191h23m21s
2026-07-29 00:37:02 +08:00
xiaoxia f8b3552a4e Merge remote-tracking branch 'origin/fix/build-production-needs-tests' 2026-07-29 00:37:02 +08:00
xiaoxia 5e0dcaead8 Merge remote-tracking branch 'origin/fix/pr-automation-concurrency' 2026-07-29 00:37:02 +08:00
xiaoxia 1eca707db3 Merge remote-tracking branch 'origin/fix/preview-cleanup-ssh-default' 2026-07-29 00:37:02 +08:00
xiaoxia 5331307cbc Merge remote-tracking branch 'origin/fix/daily-check-outputs' 2026-07-29 00:37:02 +08:00
xiaoxia 3bea00aa56 feat(ci): 基础镜像切换到私有ACR,更稳定
CI/CD Pipeline / Frontend Lint (push) Successful in 41s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m15s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m56s
CI/CD Pipeline / Build Production Web Image (push) Successful in 2m7s
CI/CD Pipeline / Build Production API Image (push) Successful in 2m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m21s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 2m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 51s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m56s
CI/CD Pipeline / Unit Tests (push) Successful in 5m0s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / CI Gate (push) Failing after 1191h18m14s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1191h21m21s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1191h21m21s
CI/CD Pipeline / Deploy Production (push) Failing after 1191h22m15s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1191h25m9s
CI/CD Pipeline / PR Build API Image (push) Failing after 1191h25m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1191h25m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1191h21m21s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1191h54m18s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1191h55m12s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1191h58m8s
- Bug6(P1): 所有Dockerfile基础镜像从daocloud切到私有ACR (xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/)
- 新增scripts/ci/sync_base_images.sh - 基础镜像同步脚本
- 覆盖: python:3.12-slim-bookworm / python:3.12-slim / node:20 / nginx:alpine
2026-07-28 22:38:37 +08:00
xiaoxia 1addcffeba fix(ci): 修复Staging部署和CI通知缺失脚本
CI/CD Pipeline / Frontend Lint (push) Successful in 1m14s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m16s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 1m54s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m57s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m56s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m8s
CI/CD Pipeline / Build Production Web Image (push) Successful in 2m50s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m53s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m2s
CI/CD Pipeline / Unit Tests (push) Successful in 5m2s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Build Production API Image (push) Successful in 14m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4m13s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1192h30m57s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1192h30m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1192h30m58s
CI/CD Pipeline / CI Gate (push) Failing after 1192h42m16s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1192h49m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1192h35m18s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1192h49m52s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1192h49m52s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1193h3m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1193h8m17s
CI/CD Pipeline / PR Build API Image (push) Failing after 1193h22m50s
- Bug4(P0): 新增scripts/ci_staging_deploy.sh - Staging环境部署脚本(pull镜像+migration+重启)
- Bug4(P0): 新增scripts/ci_staging_healthcheck.sh - Staging健康检查脚本(API/Worker/Web)
- Bug5(P1): 新增scripts/ci_notify.py - 飞书通知脚本(start/success/failure模式)
2026-07-28 22:37:36 +08:00
xiaoxia f3819653b8 fix(ci): 修复3个部署bug - 生产构建/Preview部署/镜像源
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 17s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 38s
AI Code Review / AI Code Review (pull_request) Successful in 18s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 27s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m38s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m48s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m44s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m59s
CI/CD Pipeline / Build Production Web Image (push) Successful in 1m23s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m55s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m47s
CI/CD Pipeline / Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Unit Tests (push) Successful in 5m36s
CI/CD Pipeline / Build Production API Image (push) Successful in 22m11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1h2m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 15s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1h0m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1193h21m53s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1193h21m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1193h21m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1193h21m54s
CI/CD Pipeline / CI Gate (push) Failing after 1194h17m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1193h21m54s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1194h21m53s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1194h23m4s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1194h21m55s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1194h23m8s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1194h23m17s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1194h25m45s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1194h26m59s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1194h25m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1194h26m59s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1194h27m3s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1194h27m5s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1194h27m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1194h27m20s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1194h27m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1194h27m43s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1193h54m54s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1194h56m4s
CI/CD Pipeline / PR Build API Image (push) Failing after 1194h58m47s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1194h59m59s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1195h0m39s
2026-07-28 20:59:36 +08:00
CI Bot b73d75a3e4 fix(ci): 统一preview-cleanup与preview-deploy的SSH默认配置
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m39s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 52s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 10s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m44s
AI Code Review / AI Code Review (pull_request) Failing after 1m17s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m41s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 26s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1197h43m53s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h43m55s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h43m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1197h43m57s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1197h43m58s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h44m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1197h44m3s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h44m19s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1197h44m23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h44m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h44m42s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h45m11s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h45m13s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h17m18s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h18m8s
- SSH_HOST默认值: 172.30.18.197 → 47.98.113.167(与preview-deploy一致)
- SSH_USER默认值: deploy → root(与preview-deploy一致)

问题:preview-cleanup的SSH默认值与preview-deploy不一致。如果PREVIEW_SSH_HOST等secret
未设置,部署和清理会连到不同的服务器,导致清理失效。
修复:统一为与preview-deploy相同的默认值,确保部署和清理在同一台服务器上。
2026-07-28 17:36:49 +08:00
CI Bot 62967e078b fix(ci): 修复daily-check.yml中两个job的outputs配置错误
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m0s
AI Code Review / AI Code Review (pull_request) Successful in 4m16s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m14s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 32s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h44m2s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h44m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h44m36s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1197h44m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1197h44m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1197h47m22s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1197h47m28s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h47m30s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h47m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h48m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h48m28s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1197h48m28s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h48m28s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h20m23s
- staging-api-tests.outputs.report: steps.smoke → steps.report
  (smoke步骤输出的是api_report,不是report;report由report步骤输出)
- staging-e2e.outputs.report: steps.smoke → steps.e2e
  (staging-e2e的step id是e2e,不是smoke)

问题:两个job的outputs引用了错误的step id或变量名,导致汇总报告拿不到正确结果。
修复:修正outputs指向正确的step id和输出变量。
2026-07-28 17:35:32 +08:00
CI Bot 2818f44282 fix(ci): auto-merge从轮询长作业改为短作业模式
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m24s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m36s
AI Code Review / AI Code Review (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m10s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m58s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m41s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 34s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1197h47m20s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h47m22s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h48m4s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1197h48m6s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h48m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1197h51m37s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h51m44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h52m12s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h51m39s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1197h51m40s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h52m56s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h53m0s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1198h21m5s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1198h24m39s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h25m55s
- auto_merge.sh: 去掉90次x30秒的轮询循环,改为单次检查
- 超时从45分钟缩短为3分钟
- CI未通过/未就绪时直接退出,不占用Runner
- 由pr-auto-scan每5分钟定时扫描兜底,CI通过后自动合并
- 保留405重试机制(单次运行内最多3次)

问题:auto-merge job轮询等待CI通过,最长占Runner 45分钟,资源浪费。
修复:改为短作业模式——检查一次CI状态,满足就合并,不满足就退出。
兜底:pr-auto-scan.yml每5分钟扫描所有open PR,CI全绿的自动审批+合并。
2026-07-28 17:32:56 +08:00
CI Bot c9e5129ec8 fix(ci): build-production等待测试通过后再构建生产镜像
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
AI Code Review / AI Code Review (pull_request) Successful in 56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12m8s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h43m54s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1197h43m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h44m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h44m4s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1197h44m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h52m50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1197h52m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1197h54m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h54m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h55m12s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h55m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h55m27s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h17m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1198h25m49s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h28m23s
- 为build-production添加7个测试/检查job作为needs依赖
- 测试失败时不构建生产镜像,避免无效构建占用资源

问题:生产镜像构建和测试并行跑,测试失败了镜像也已经build完了,浪费构建资源。
修复:让build-production等所有核心测试通过后再开始构建。
2026-07-28 17:30:25 +08:00
CI Bot cd3d366f4a fix(ci): 纯前端/纯后端PR跳过无关检查,节省Runner资源
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m16s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 48s
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Successful in 49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
AI Code Review / AI Code Review (pull_request) Failing after 4m8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m4s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m27s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m15s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 31s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1197h55m8s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h55m10s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h57m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h57m30s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1197h57m4s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1197h57m32s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1197h57m6s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h58m10s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h57m8s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1197h58m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h58m30s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1197h58m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h58m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h58m45s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 1198h30m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1198h31m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h31m40s
- frontend-lint: 添加check-frontend-only依赖,纯后端PR自动跳过
- build-pr拆分为build-pr-backend和build-pr-web两个job
  - build-pr-backend (API+Worker): 纯前端PR跳过
  - build-pr-web (Web): 纯后端PR跳过
- CI Gate分类调整:
  - REQUIRED_GENERAL: 仅保留真正的通用检查(code-quality/type-check/migration)
  - REQUIRED_BACKEND: 新增build-pr-backend
  - REQUIRED_FRONTEND: 新增frontend-lint和build-pr-web

问题:改前端文件也会跑后端测试和构建,反之亦然,浪费大量Runner时间。
修复:利用已有的check-frontend-only job输出,按PR类型跳过无关检查。
效果:纯前端PR节省约15分钟(API+Worker构建),纯后端PR节省约10分钟(前端Lint+Web构建)。
2026-07-28 17:25:31 +08:00
CI Bot 1dd640da87 fix(ci): 将AI Code Review接入CI门禁体系,严重问题拦截合并
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m10s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 48s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14s
CI/CD Pipeline / AI Code Review (pull_request) Successful in 1m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
AI Code Review / AI Code Review (pull_request) Failing after 3m7s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m9s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m52s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m13s
CI/CD Pipeline / CI Gate (pull_request) Successful in 12s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 33s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1198h1m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1198h1m49s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1198h1m51s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1198h1m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1198h2m8s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h2m19s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1198h2m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1198h2m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1198h2m51s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1198h3m0s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1198h3m3s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1198h34m44s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1198h34m50s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1198h35m14s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h35m59s
- 在ci-pipeline中新增code-review job,与code-review.yml逻辑一致
- 将code-review加入CI Gate的needs列表和REQUIRED_GENERAL检查项
- AI审查发现阻塞级问题时,CI Gate失败,阻止PR合并

问题:AI Code Review只发表评论不参与门禁,有严重安全/质量问题的代码也能合并。
修复:将code-review纳入CI Gate,审查脚本exit 1(阻塞级问题)时CI整体失败。
注意:LLM调用异常时脚本exit 0(fail-open策略),不阻塞正常合并。
2026-07-28 17:20:23 +08:00
CI Bot 4587d0b014 fix(ci): add concurrency to pr-automation workflow
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m21s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 27s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 12s
AI Code Review / AI Code Review (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m48s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m24s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 31s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1198h2m4s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1198h2m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1198h2m30s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1198h2m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1198h2m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1198h5m2s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1198h5m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h5m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1198h7m9s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1198h5m8s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1198h7m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1198h7m44s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1198h5m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1198h35m29s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h40m40s
- Add workflow-level concurrency group per PR number
- Enable cancel-in-progress to cancel old runs when new commits arrive
- Prevents multiple pr-automation runs for the same PR from wasting Runner resources

Issue: Same PR could trigger multiple auto-approve/auto-merge runs simultaneously,
occupying ci-check runners unnecessarily. With concurrency, only the latest run
executes, and older runs are cancelled automatically.
2026-07-28 17:16:35 +08:00
xiaoxia 4749071c16 Merge pull request 'fix(ci): 修复2个P0级bug - acr-cleanup完全不可用 + production-e2e DooD必然失败' (#1112) from fix/ci-p0-bugs-0728 into main
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 16s
AI Code Review / AI Code Review (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1194h28m17s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1194h29m2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1194h29m4s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1194h29m6s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1199h8m57s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1199h8m59s
CI/CD Pipeline / PR Build API Image (push) Failing after 1199h9m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1199h10m38s
CI/CD Pipeline / Staging E2E 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 / 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 API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
P0级bug紧急修复
2026-07-28 15:58:05 +08:00
xiaoxia 3353865f5b fix(ci): 修复2个P0级bug
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 18s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
AI Code Review / AI Code Review (pull_request) Failing after 3m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1199h11m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1199h11m53s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1199h11m57s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1199h44m52s
P0-1: acr-cleanup.yml 完全不可用
- $GITEA_OUTPUT → $GITHUB_OUTPUT(outputs写入完全失效)
- PREVIEW_SSH_KEY → STAGING_SSH_KEY(用错了密钥)
- 增加 STAGING_SSH_HOST/PORT/USER 从secret读取
- SSH连接用户从写死root改为变量

P0-2: production-e2e DooD模式下必然失败
- -v "$PWD:/workspace" 改为 docker create + docker cp 模式
- 与 staging-e2e 保持一致
2026-07-28 15:56:01 +08:00
xiaoxia 7061d7e672 fix(ci): 修复CI Gate失败时返回exit 0的P0 Bug (main) (#1057)
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 23s
CI/CD Pipeline / Frontend Lint (push) Successful in 56s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m12s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m13s
CI/CD Pipeline / Build Production API Image (push) Failing after 23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 32s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 36s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m58s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m42s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m29s
CI/CD Pipeline / Unit Tests (push) Successful in 4m33s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m39s
CI/CD Pipeline / Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1200h53m51s
CI/CD Pipeline / CI Gate (push) Failing after 1201h14m0s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1201h34m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1201h34m39s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1201h46m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1201h47m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1201h53m11s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1201h57m14s
CI/CD Pipeline / PR Build API Image (push) Failing after 1201h57m16s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1201h57m17s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1202h7m33s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1202h30m12s
2026-07-28 13:30:40 +08:00
xiaoxia 6efdfbe194 fix(ci): pyproject.toml添加原生ruff配置,修复Code Quality全量检查失败 (#1074)
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m13s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m19s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1m33s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m6s
CI/CD Pipeline / Build Production API Image (push) Failing after 50s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m10s
CI/CD Pipeline / Build Production Web Image (push) Failing after 16s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m50s
CI/CD Pipeline / Unit Tests (push) Successful in 5m38s
CI/CD Pipeline / Integration Tests (push) Successful in 4m56s
CI/CD Pipeline / CI Gate (push) Failing after 1201h58m9s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1202h3m5s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1202h3m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1202h3m9s
CI/CD Pipeline / Deploy Production (push) Failing after 1202h3m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1202h3m34s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1202h7m32s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1202h7m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1202h7m36s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1202h8m1s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1202h36m0s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1202h36m5s
2026-07-28 13:19:55 +08:00
xiaoxia 02e3246f5a fix(ci): 格式修复防循环索引 + AI审查fail-open(2个bug修复) (#1045)
CI/CD Pipeline / Frontend Lint (push) Successful in 55s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m3s
CI/CD Pipeline / Build Production API Image (push) Failing after 27s
CI/CD Pipeline / Build Production Web Image (push) Failing after 20s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m25s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m18s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 23s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m3s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m6s
CI/CD Pipeline / Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Failing after 1205h28m44s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1205h29m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1205h29m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1205h32m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1205h33m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1205h33m51s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1205h35m35s
CI/CD Pipeline / PR Build API Image (push) Failing after 1205h35m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1205h35m38s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1206h2m47s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1206h8m31s
fix(ci): 修复auto_fix_formatting防循环索引错误 + AI审查fail-open未生效

1. 防循环索引bug:Gitea API返回commits倒序,commits[-1]取到最旧commit,改为commits[0]
2. fail-open bug:LLM调用失败和未捕获异常都是exit 1,改为exit 0不阻塞合并
2026-07-28 09:52:23 +08:00
xiaoxia 5cdafd2559 feat: AI代码审查添加commit status输出和阻塞级问题判定 (#1035)
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m53s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m2s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m31s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m33s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m55s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 34s
CI/CD Pipeline / Build Production API Image (push) Failing after 18s
CI/CD Pipeline / Build Production Web Image (push) Failing after 17s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 18s
CI/CD Pipeline / Unit Tests (push) Successful in 4m38s
CI/CD Pipeline / Integration Tests (push) Successful in 4m21s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1217h18m57s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1217h30m51s
CI/CD Pipeline / CI Gate (push) Failing after 1217h30m53s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1217h31m33s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1217h31m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1217h31m35s
CI/CD Pipeline / Deploy Production (push) Failing after 1217h32m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1217h36m41s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1217h45m48s
CI/CD Pipeline / PR Build API Image (push) Failing after 1217h45m48s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1217h45m50s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1218h18m40s
- 为AI代码审查添加commit status输出,PR页面可直接看到审查结果
- 添加阻塞级问题判定逻辑,严重问题标记为failure状态
- 优化审查报告格式和输出精度
2026-07-27 21:40:07 +08:00
xiaoxia a6afb344ba feat: 格式自动修复对所有PR开放,添加防循环机制 (#1037)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 28s
CI/CD Pipeline / Build Production Web Image (push) Failing after 30s
CI/CD Pipeline / Build Production API Image (push) Failing after 34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m32s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m12s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m13s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1218h23m40s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1218h23m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1218h23m42s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1218h23m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1218h23m45s
CI/CD Pipeline / Deploy Production (push) Failing after 1218h24m10s
CI/CD Pipeline / CI Gate (push) Failing after 1218h26m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1218h26m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 1218h26m23s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1218h58m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1218h23m45s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1218h59m12s
2026-07-27 20:29:56 +08:00
xiaoxia 504e2e71c9 fix(ci): auto_merge.sh改用CI Gate统一门禁
CI/CD Pipeline / Frontend Lint (push) Successful in 30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m3s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 55s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1m38s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m29s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Build Production API Image (push) Failing after 5s
CI/CD Pipeline / Build Production Web Image (push) Failing after 7s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 6s
CI/CD Pipeline / Unit Tests (push) Successful in 3m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1219h17m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1219h17m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1219h17m51s
CI/CD Pipeline / CI Gate (push) Failing after 1219h28m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1220h40m14s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1221h16m27s
CI/CD Pipeline / PR Build API Image (push) Failing after 1221h16m31s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1221h17m35s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1219h50m36s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1221h49m15s
2026-07-27 16:21:19 +08:00
xiaoxia fb2884b03c fix(ci): 添加ci-gate汇总job,解决自动合并405问题
CI/CD Pipeline / Check if frontend-only change (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 / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 16:17:50 +08:00
xiaoxia 7fab42c3d0 fix(ci): daily-check DooD挂载路径修复 + shell bash修复 (#1024)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m33s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m38s
CI/CD Pipeline / Build Production API Image (push) Failing after 9s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m45s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m47s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 11s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 13s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m3s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m19s
CI/CD Pipeline / Unit Tests (push) Successful in 3m57s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Successful in 2m2s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1227h18m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1227h18m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1227h18m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1227h19m0s
CI/CD Pipeline / Deploy Production (push) Failing after 1227h19m10s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1227h23m30s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1227h23m34s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1227h23m32s
CI/CD Pipeline / PR Build API Image (push) Failing after 1227h56m18s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1227h51m38s
2026-07-27 12:04:41 +08:00
xiaoxia 561548c84c fix(ci): daily-check shell从sh改为bash,修复PIPESTATUS Bad substitution (#1023)
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 49s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m36s
CI/CD Pipeline / Build Production API Image (push) Failing after 7s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m48s
CI/CD Pipeline / Build Production Web Image (push) Failing after 12s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 16s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m10s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m17s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m2s
CI/CD Pipeline / Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Unit Tests (push) Successful in 5m5s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1227h46m23s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1227h46m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1227h46m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1227h46m43s
CI/CD Pipeline / Deploy Production (push) Failing after 1227h46m45s
CI/CD Pipeline / PR Build API Image (push) Failing after 1227h48m46s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1227h48m44s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1227h48m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1228h19m10s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1228h21m30s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1228h19m8s
2026-07-27 11:39:29 +08:00
xiaoxia 77704e7ec6 fix(ci): 同步daily-check和acr-cleanup的docker兼容性修复到main (#1012)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 46s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m20s
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m25s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 25s
CI/CD Pipeline / Build Production API Image (push) Failing after 11s
CI/CD Pipeline / Build Production Web Image (push) Failing after 19s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 16s
CI/CD Pipeline / Unit Tests (push) Successful in 2m23s
CI/CD Pipeline / Integration Tests (push) Successful in 1m56s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1229h25m58s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1229h26m2s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1229h44m55s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1229h44m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1229h45m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1229h44m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1230h3m27s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1230h14m30s
CI/CD Pipeline / PR Build API Image (push) Failing after 1230h14m34s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1230h15m29s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1230h47m16s
cherry-pick #981的核心修复到main分支:
- daily-check.yml: checkout步骤改为curl step_checkout.sh方式
- acr-cleanup.yml: 同上,修复Setup Python秒败问题

相关PR: #981
相关工单: #980
2026-07-27 08:46:05 +08:00
xiaoxia 5ae6c33bf6 sync(ci): 同步缺失的CI监控脚本到main分支
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 53s
CI/CD Pipeline / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m0s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m8s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m25s
CI/CD Pipeline / Build Production Web Image (push) Failing after 25s
CI/CD Pipeline / Build Production API Image (push) Failing after 27s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 37s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 35s
CI/CD Pipeline / Unit Tests (push) Successful in 2m56s
CI/CD Pipeline / Integration Tests (push) Successful in 2m21s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1243h44m35s
CI/CD Pipeline / Deploy Production (push) Failing after 1243h50m24s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1243h50m47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1243h50m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1244h2m52s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1244h47m30s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1244h47m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1244h48m5s
CI/CD Pipeline / PR Build API Image (push) Failing after 1245h20m14s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1244h23m26s
同步2个CI监控脚本到main分支:scripts/ci_trigger_monitor.py、scripts/ci_code_review.py
2026-07-26 18:13:27 +08:00
xiaoxia db07738178 sync(ci): 同步schedule类workflow修复到main分支 (#933)
CI/CD Pipeline / Frontend Lint (push) Successful in 1m1s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m48s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m13s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 24s
CI/CD Pipeline / Build Production Web Image (push) Failing after 18s
CI/CD Pipeline / Build Production API Image (push) Failing after 21s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 18s
CI/CD Pipeline / Unit Tests (push) Successful in 3m31s
CI/CD Pipeline / Integration Tests (push) Successful in 2m28s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1246h45m33s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1246h45m35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1246h45m37s
CI/CD Pipeline / Deploy Production (push) Failing after 1246h54m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1246h54m24s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1247h6m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1247h7m21s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1247h6m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1247h39m15s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1247h18m12s
2026-07-26 16:18:24 +08:00
xiaoxia 53c09e7d3c chore(ci): 同步金丝雀发布流水线到main分支(#450)
CI/CD Pipeline / Frontend Lint (push) Successful in 25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m25s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Build Production API Image (push) Failing after 13s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 11s
CI/CD Pipeline / Build Production Web Image (push) Failing after 14s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m57s
CI/CD Pipeline / Integration Tests (push) Successful in 1m52s
CI/CD Pipeline / Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1272h10m47s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1272h10m47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1272h10m48s
CI/CD Pipeline / Deploy Production (push) Failing after 1272h11m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1272h10m48s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1272h13m5s
CI/CD Pipeline / PR Build API Image (push) Failing after 1272h13m9s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1272h13m54s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1272h45m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1272h43m47s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1272h43m22s
2026-07-25 15:14:33 +08:00
xiaoxia e602439769 chore(ci): 同步renovate.json5到main分支
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m47s
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m3s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m47s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m11s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Successful in 3m23s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1272h37m53s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1272h37m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1272h37m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1272h38m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1272h38m1s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1272h38m4s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1272h38m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 1272h38m5s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1272h40m18s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1272h40m20s
CI/CD Pipeline / PR Build API Image (push) Failing after 1272h40m22s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1272h41m48s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1273h10m28s
同步#876的renovate依赖自动更新配置到main,保持main和develop CI配置一致。
2026-07-25 14:44:52 +08:00
xiaoxia a26fda1597 feat(ci): ACR镜像自动清理增强版 - 同步到main(#524)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 46s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m2s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 44s
CI/CD Pipeline / Frontend Lint (push) Successful in 22s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m21s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m53s
CI/CD Pipeline / Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Unit Tests (push) Successful in 5m18s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1274h54m18s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1274h54m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1274h54m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1274h57m48s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1274h59m7s
CI/CD Pipeline / Deploy Production (push) Failing after 1275h0m37s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1275h0m54s
CI/CD Pipeline / Build Production API Image (push) Failing after 1275h0m55s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1275h3m18s
CI/CD Pipeline / PR Build API Image (push) Failing after 1275h3m20s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1275h4m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1275h35m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1275h33m29s
ACR镜像自动清理增强版同步到main:3种模式+白名单保护+cron/PR关闭/手动触发
2026-07-25 12:21:41 +08:00
xiaoxia 99a8ffa97b chore(ci): 升级migration验证,新增4项检查 (#451) 同步到main
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 51s
CI/CD Pipeline / Frontend Lint (push) Successful in 34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 52s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m13s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 22s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Successful in 3m11s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1277h30m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1277h30m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1277h31m0s
CI/CD Pipeline / Deploy Production (push) Failing after 1277h31m43s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1277h32m24s
CI/CD Pipeline / Build Production API Image (push) Failing after 1277h32m28s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1277h32m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 1277h32m49s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1277h33m43s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1278h5m21s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1278h3m34s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1278h4m58s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1278h3m30s
同步#857 migration验证升级到main分支

新增:ci_env.sh共享变量、check_migration_naming.py命名检查
升级:validate_migration.sh 5阶段完整检查
2026-07-25 09:54:58 +08:00
xiaoxia f03c9d5453 chore(ci): P2端口收尾同步到main分支 (#798 #799) (#853)
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m27s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m44s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m48s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Unit Tests (push) Successful in 4m22s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1287h32m1s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1287h32m1s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1287h33m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1287h32m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1287h33m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1287h33m7s
CI/CD Pipeline / Build Production API Image (push) Failing after 1287h33m7s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1287h34m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1287h34m34s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1287h34m35s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1288h7m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1288h4m33s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1288h5m35s
chore(ci): P2端口收尾同步到main分支 (#798 #799)

- docs: 新增统一端口分配清单文档 (PORTS.md)
- chore(ci): 端口变量命名统一 (chatops配置)

跳过ci-pipeline.yml的CI_PG_PORT改动:main分支无顶层env块,后续CI配置对齐时再同步。
2026-07-24 23:54:09 +08:00
xiaoxia 8322e2b6e2 fix(ci): staging E2E/API tests shell由sh改为bash
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 36s
CI/CD Pipeline / Frontend Lint (push) Successful in 49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m39s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m44s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m35s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m31s
CI/CD Pipeline / Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Unit Tests (push) Successful in 5m20s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1295h58m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1295h58m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1295h58m34s
CI/CD Pipeline / Deploy Production (push) Failing after 1296h0m20s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1296h0m22s
CI/CD Pipeline / Build Production API Image (push) Failing after 1296h0m26s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1296h1m9s
CI/CD Pipeline / PR Build API Image (push) Failing after 1296h1m13s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1296h1m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1296h33m38s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1296h32m45s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1296h32m51s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1296h30m59s
2026-07-24 15:27:33 +08:00
CI Bot d2409e16c1 style: auto-format with black + isort + prettier
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 55s
CI/CD Pipeline / Frontend Lint (push) Successful in 33s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m1s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m3s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 14s
CI/CD Pipeline / Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Unit Tests (push) Successful in 4m45s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1299h42m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1299h42m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1299h43m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1299h58m7s
CI/CD Pipeline / Deploy Production (push) Failing after 1299h58m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1300h27m32s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1300h30m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1300h27m33s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1300h30m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1300h32m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 1301h2m43s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1300h15m23s
CI/CD Pipeline / Build Production API Image (push) Failing after 1300h59m59s
2026-07-24 02:37:59 +00:00
xiaoxia 6eac0b2cf2 chore(ci): 同步main分支CI配置与scripts/ci脚本 - 与develop对齐
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 1m54s
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 1m36s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 44s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1300h3m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1300h3m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1300h32m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1300h32m45s
CI/CD Pipeline / Deploy Production (push) Failing after 1300h43m2s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1300h49m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1300h49m14s
CI/CD Pipeline / Build Production API Image (push) Failing after 1300h49m14s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1300h51m32s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1300h51m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1300h51m36s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1300h52m20s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1300h35m56s
同步内容:
1. CI流水线配置(ci-pipeline.yml)与develop对齐
2. PR构建脚本docker_build_only.sh增加buildx→docker build回退
3. pre-build步骤worker基础镜像构建增加buildx回退
4. 单元测试脚本全量覆盖率改为仅报告不阻塞
5. diff-cover依赖加入requirements-dev.txt
6. worker base builder/runtime Dockerfile同步
7. test_config_oss.py clear=False→clear=True修复OSS污染
8. Frontend Lint增加prettier依赖
2026-07-24 10:36:29 +08:00
xiaoxia dfb2feef8a fix(ci): main??auto-merge/approve????Tests/test??
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1465h21m19s
CI/CD Pipeline / Deploy Production (push) Failing after 1465h21m22s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1465h21m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1465h53m20s
- auto-merge.yml: main????Tests/test,?3???????
- auto-approve.yml: main??????????Tests/test
- ??#464???Tests/test???????????
2026-07-17 14:04:23 +08:00
auto-approve-bot b3ef7bb041 Merge pull request 'feat(ci): main分支auto-merge从定时改为即时合并' (#464) from ci/main-auto-merge-instant into main
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 4s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1466h0m8s
CI/CD Pipeline / Deploy Production (push) Failing after 1466h0m12s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1466h0m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1466h32m10s
2026-07-17 13:27:18 +08:00
xiaoxia a1a272b833 feat(ci): 添加auto-approve到main分支并适配main门禁规则
Tests / lint (pull_request) Successful in 6s
Tests / test (pull_request) Failing after 29s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m54s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Successful in 3m0s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h2m30s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h2m34s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h2m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1466h2m32s
CI/CD Pipeline / Validate Code Quality And 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 1466h34m32s
2026-07-17 13:24:21 +08:00
xiaoxia 728db0faf8 chore: empty commit to re-trigger CI for auto-merge verification
Tests / lint (pull_request) Successful in 9s
Tests / test (pull_request) Failing after 21s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Successful in 20m36s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h21m15s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h21m17s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h21m17s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1466h21m16s
CI/CD Pipeline / Validate Code Quality And 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 1466h53m15s
2026-07-17 13:06:40 +08:00
xiaoxia 7e5e412f7f feat(ci): main分支auto-merge从定时改为即时合并
Tests / lint (pull_request) Failing after 9s
Tests / test (pull_request) Failing after 28s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Failing after 30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h49m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1466h49m40s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h49m41s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h49m41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1467h21m39s
- 触发方式:pull_request事件(CI状态变更时)
- 合并条件:2门禁全绿 + 至少1个APPROVED + 无冲突 + 非草稿
- 安全措施:幂等保护、合并失败留评论、只合main
- 新增check_ci_status.py和check_pr_approval.py辅助脚本
2026-07-17 12:37:24 +08:00
xiaoxia df08161630 fix(ci): 修复auto-merge缺少GITEA_API_TOKEN环境变量
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1470h25m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1470h25m35s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1470h25m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1470h57m29s
1. 给auto-merge.yml的两个merge step添加GITEA_API_TOKEN环境变量,复用REVIEW_GITEA_TOKEN secret
2. 修复tests.yml的checkout步骤GITHUB_TOKEN和Python命令
3. 给main分支ci-cd.yml的单元测试增加Redis服务,修复Celery相关测试失败
2026-07-17 09:02:33 +08:00
xiaoxia 0c9375ff32 ci: remove temporary debug workflow - test-ssh-secret.yml
Fix Flake8 E741 for PR232 / fix-flake8 (push) Failing after 3h4m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1577h41m48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 36s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1577h10m16s
CI/CD Pipeline / Deploy Production (push) Failing after 1577h10m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1577h10m11s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1577h10m16s
2026-07-12 10:25:12 +08:00
xiaoxia ef344e9ffc cleanup: remove upgrade test file
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 12h43m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h45m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 12h45m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1671h17m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1671h20m0s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1671h20m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1671h51m26s
2026-07-09 00:08:40 +08:00
xiaoxia 0d4904433e test: trigger workflow after gitea upgrade
CI/CD Pipeline / Staging E2E Tests (push) Failing after 12h49m24s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 12h53m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h56m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 12h56m8s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1671h29m39s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1671h29m43s
CI/CD Pipeline / Deploy Production (push) Failing after 1672h1m9s
2026-07-08 23:58:20 +08:00
CI Test 708662394f Merge develop into main - v0.1.126
Auto Merge PRs / auto-merge (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 29h56m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 29h56m45s
CI/CD Pipeline / Deploy Production (push) Failing after 1688h31m42s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1688h31m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1689h3m9s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1688h31m44s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1688h31m39s
2026-07-08 06:57:42 +08:00
CI Bot 5bc3440370 ci: staging部署改为Watchtower自动更新,砍掉SSH部署步骤
CI/CD Pipeline / Staging E2E Tests (push) Failing after 38h33m15s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 38h35m28s
CI/CD Pipeline / Frontend Lint (push) Failing after 38h36m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 38h37m47s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1697h11m14s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1697h11m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1697h42m51s
2026-07-07 22:16:44 +08:00
CI Test 7dc92191e0 test: watchtower自动更新验证(仅加注释)
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Failing after 38h42m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 38h56m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 38h58m34s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1697h18m26s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1697h18m31s
CI/CD Pipeline / Deploy Production (push) Failing after 1697h49m56s
2026-07-07 21:55:55 +08:00
CI Test 5028956cea ci: staging部署接入Watchtower自动更新
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Failing after 39h4m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 39h5m1s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 39h5m1s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1697h40m7s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1697h40m11s
CI/CD Pipeline / Deploy Production (push) Failing after 1698h11m37s
- staging构建额外打:staging tag并push到Registry
- staging容器加com.centurylinklabs.watchtower.enable=true标签
- 容器用完整Registry路径+:staging tag启动,供Watchtower监控
- 1分钟轮询,自动检测新镜像并重启
2026-07-07 21:49:28 +08:00
CI Test d72450f42d ci: production deploy改为Registry方式,去掉tar/scp/docker load
CI/CD Pipeline / Staging E2E Tests (push) Failing after 39h16m53s
CI/CD Pipeline / Deploy Staging (push) Failing after 39h18m14s
CI/CD Pipeline / Frontend Lint (push) Failing after 39h19m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 39h19m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1697h54m15s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1697h54m20s
CI/CD Pipeline / Deploy Production (push) Failing after 1698h25m45s
- 新增 deploy-production-registry.sh(纯Registry pull + docker run)
- 去掉源码tar打包和scp传输步骤
- nginx-production.conf 添加assets fallback(部署期间缓存用户不404)
- legacy assets自动清理(保留7天)
2026-07-07 21:35:15 +08:00
CI Test 1ed0d5aa75 fix: staging deploy script health-check url missing quotes
CI/CD Pipeline / Staging E2E Tests (push) Failing after 39h40m42s
CI/CD Pipeline / Deploy Staging (push) Failing after 39h42m13s
CI/CD Pipeline / Frontend Lint (push) Failing after 39h44m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 39h44m58s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1698h18m7s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1698h18m14s
CI/CD Pipeline / Deploy Production (push) Failing after 1698h49m40s
2026-07-07 21:09:28 +08:00
CI Test aef4febd1c ci: fix staging build step, use build_release_images.sh (same as production)
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (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 / Validate Code Quality And Tests (push) Failing after 39h49m43s
2026-07-07 21:04:48 +08:00
CI Test d99ee6fc84 ci: staging deploy改为Registry方式,去掉tar/scp/docker load
CI/CD Pipeline / Deploy Staging (push) Failing after 40h1m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h2m31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h2m31s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1698h36m55s
CI/CD Pipeline / Deploy Production (push) Failing after 1698h37m39s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1698h37m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h9m6s
2026-07-07 20:52:01 +08:00
CI Test 9b034764ad Merge develop into main - v0.1.125
Auto Merge PRs / auto-merge (push) Failing after 1m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h58m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 1699h32m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1699h32m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h32m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h31m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h32m48s
2026-07-07 19:54:54 +08:00
CI Test bfb11c3526 fix: prettier formatting for v0.1.125
CI/CD Pipeline / Deploy Staging (push) Failing after 40h57m0s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h59m41s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h31m0s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h31m20s
CI/CD Pipeline / Deploy Production (push) Failing after 1699h32m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h32m51s
2026-07-07 19:54:48 +08:00
灵应 9e37c7b73d fix: 配音库AI配音按钮 + 上传按钮修复 + 多项UI修复
CI/CD Pipeline / Frontend Lint (push) Failing after 41h9m45s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 41h9m45s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h44m49s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h44m51s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h44m53s
CI/CD Pipeline / Deploy Staging (push) Failing after 1699h44m53s
CI/CD Pipeline / Deploy Production (push) Failing after 1700h16m19s
- Task #197: 配音库新增AI配音按钮 + TTS合成面板
- Task #198: 上传音频按钮事件修复
- Task #199: 一键生成增加标题库选择
- Task #200: 模板库报错修复
- Task #201: 素材库视频上传失败修复
- Task #202: 素材库去掉配音类型
- Task #203: 模板卡片名称位置调整
- Task #204: 一键生成状态反馈完善
- Task #205: 成片库页面接入真实数据

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:44:42 +08:00
灵应 f7a945d417 fix: 视频上传MIME白名单补全 + 成片库N+1查询修复
CI/CD Pipeline / Deploy Staging (push) Failing after 41h47m31s
CI/CD Pipeline / Frontend Lint (push) Failing after 41h49m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 41h49m12s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1700h21m58s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1700h23m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1700h23m32s
CI/CD Pipeline / Deploy Production (push) Failing after 1700h55m3s
- chunked_upload.py ALLOWED_MIME_TYPES 从12种扩展到22种,与 upload.py 保持一致
  新增: video/mpeg, video/x-matroska, video/3gpp, audio/flac, audio/aac,
        audio/x-m4a, audio/webm, image/bmp, image/tiff, image/svg+xml
- generated_video_repository list_by_project/list_by_generation_task
  改用 _to_domain() 替代 self.get(),消除 N+1 查询
- 新增 24 个单元测试覆盖以上修复及成片库 API 可用性确认

Closes #172 #173

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:05:14 +08:00
CI Test 8748b43070 Merge develop into main - v0.1.124
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h16m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h16m32s
CI/CD Pipeline / Deploy Staging (push) Failing after 1703h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 45h9m27s
CI/CD Pipeline / Deploy Production (push) Failing after 45h8m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1703h44m57s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1703h44m30s
2026-07-07 15:36:36 +08:00
CI Test a28395c318 fix: update schema snapshot and prettier formatting for v0.1.124
CI/CD Pipeline / Deploy Staging (push) Failing after 45h15m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h18m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h18m5s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1703h44m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1703h44m57s
CI/CD Pipeline / Deploy Production (push) Failing after 1703h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1703h51m44s
2026-07-07 15:36:26 +08:00
灵应 9f0c064f2a feat: 任务4 视频查重 — 历史+批次双重去重
CI/CD Pipeline / Frontend Lint (push) Failing after 45h27m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h27m46s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1704h3m13s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1704h3m17s
CI/CD Pipeline / Deploy Production (push) Failing after 1704h3m15s
CI/CD Pipeline / Deploy Staging (push) Failing after 1704h3m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1704h34m43s
- Alembic 033: generation_tasks 加 batch_id 列+索引
- Route: count>1 时生成共享 batch_id
- Repository: list_by_batch() 批次内查询
- dedup.py: check_batch_duplicate() 批次内查重
- Worker: 生成视频后创建 GeneratedVideo 记录 + 双重查重
- 单元测试: 6 个批次查重测试用例
2026-07-07 15:26:41 +08:00
灵应 1ea8fd3989 feat: P2 素材库自动匹配 - 支持 all/random/smart 三种素材选取模式
CI/CD Pipeline / Frontend Lint (push) Failing after 45h41m20s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h41m20s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1704h16m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1704h16m50s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1704h16m52s
CI/CD Pipeline / Deploy Staging (push) Failing after 1704h16m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1704h48m18s
- Schema: 新增 asset_select_mode + asset_select_count 字段
- Domain: GenerationTask 新增 asset_select_mode 字段
- Application: Command/UseCase 透传 asset_select_mode
- Route: _select_assets_from_library() 辅助函数 + 创建任务集成
- SQLAlchemy: Model/Repository 映射 asset_select_mode
- Alembic: 032 号迁移
- Worker: _download_library_assets() 支持 asset_ids 过滤
- 单测: 15 个测试覆盖三种模式 + 边界情况
2026-07-07 15:13:05 +08:00
灵应 4fee87c5e8 feat: 素材重复上传检测 + 批量生成视频
CI/CD Pipeline / Frontend Lint (push) Failing after 45h56m35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h56m48s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1704h31m50s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1704h32m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1704h31m51s
CI/CD Pipeline / Deploy Staging (push) Failing after 1704h32m6s
CI/CD Pipeline / Deploy Production (push) Failing after 1705h3m19s
任务1: 素材重复上传检测
- 上传接口支持 file_hash 参数,通过 MD5+素材库ID 去重
- 命中去重直接返回已有 asset_id,不重复存 OSS
- file_hash 透传: API → IngestJob → Asset 全链路
- 三条上传路径(表单/直传/分片)均支持去重
- Alembic 031: assets + ingest_jobs 加 file_hash 列+索引
- 6 个单元测试覆盖去重命中/未命中/空hash/透传

任务3: 批量生成视频
- POST /generations 支持 count 参数,一次创建多条生成任务
- 每条任务独立状态跟踪,响应返回 task_ids 列表

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:57:37 +08:00
灵应 a74d25e414 feat: 完成7个前端修复任务 (#190-#196)
CI/CD Pipeline / Frontend Lint (push) Failing after 45h59m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h59m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1704h31m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1704h31m2s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1704h31m7s
CI/CD Pipeline / Deploy Staging (push) Failing after 1704h31m7s
CI/CD Pipeline / Deploy Production (push) Failing after 1705h2m30s
- #190: 素材库视频加缩略图展示
- #191: 视频上传进度提示(圆形动画+百分比)
- #192: 一键生成模板名称显示用户自定义名称
- #193: 确认生成无反应修复(增强错误处理)
- #194: 素材选择双模式(手动选择/自动匹配)
- #195: 多条视频生成数量输入
- #196: 成片库404修复
2026-07-07 14:54:28 +08:00
CI Test d213a055a1 Merge develop into main - v0.1.123
Auto Merge PRs / auto-merge (push) Failing after 3m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h9m34s
CI/CD Pipeline / Frontend Lint (push) Failing after 47h8m47s
CI/CD Pipeline / Deploy Staging (push) Failing after 47h6m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1705h41m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h41m20s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h41m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h41m18s
2026-07-07 13:44:46 +08:00
CI Test 8219d2445d fix: 移除多余的031 display_name migration(baseline已包含此字段)
CI/CD Pipeline / Staging E2E Tests (push) Failing after 47h10m46s
CI/CD Pipeline / Deploy Staging (push) Failing after 47h12m27s
CI/CD Pipeline / Frontend Lint (push) Failing after 47h13m22s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h13m22s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h48m28s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h48m32s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h19m58s
2026-07-07 13:41:08 +08:00
CI Test 0d871d9734 fix: 更新schema快照 - 新增tags/asset_tags表 + users.display_name字段
CI/CD Pipeline / Frontend Lint (push) Failing after 47h17m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h17m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h51m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h52m0s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h52m2s
CI/CD Pipeline / Deploy Staging (push) Failing after 1705h52m2s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h23m28s
2026-07-07 13:36:50 +08:00
CI Test 608ddbf9f7 fix: 修复migration 030离线模式下_table_exists崩溃问题
CI/CD Pipeline / Frontend Lint (push) Failing after 47h19m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h19m6s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h54m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h54m35s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h54m37s
CI/CD Pipeline / Deploy Staging (push) Failing after 1705h54m37s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h26m3s
2026-07-07 13:35:22 +08:00
CI Test 6cb146693e fix: 修复TTS路由WebSocketDisconnect未导入问题
CI/CD Pipeline / Frontend Lint (push) Failing after 47h21m52s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h21m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h57m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h57m16s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h57m18s
CI/CD Pipeline / Deploy Staging (push) Failing after 1705h57m18s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h28m44s
2026-07-07 13:32:37 +08:00
CI Test 18571451ed fix: 修复前端prettier格式 + 增加users.display_name migration
CI/CD Pipeline / Frontend Lint (push) Failing after 47h23m30s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h23m30s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h58m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h58m56s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h58m58s
CI/CD Pipeline / Deploy Staging (push) Failing after 1705h58m58s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h30m24s
- 修复6个前端文件的Prettier格式问题
- 新增031 migration: users表增加display_name列
2026-07-07 13:31:01 +08:00
灵应 2bcbd54ed7 feat: 最后一公里 — TTS 合成结果保存到配音库接口
CI/CD Pipeline / Frontend Lint (push) Failing after 47h30m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h30m9s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1706h5m36s
CI/CD Pipeline / Deploy Staging (push) Failing after 1706h5m40s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h5m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1706h5m38s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1706h37m8s
- 新增 POST /tts/jobs/{id}/save-to-library 接口
- 自动携带音色名、时长、语速等元信息到 metadata
- 配音库配额检查(429 响应)
- 15 个单元测试全部通过
- 全部 115 个 TTS 测试无回归
2026-07-07 13:24:08 +08:00
灵应 d1c83de698 feat: TTS 生成页加存为素材功能
CI/CD Pipeline / Frontend Lint (push) Failing after 47h32m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h32m26s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1706h7m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1706h7m55s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1706h7m57s
CI/CD Pipeline / Deploy Staging (push) Failing after 1706h7m57s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h39m23s
- 合成完成后显示「存为素材」按钮,点击弹出保存弹窗
- 弹窗支持改名称、选标签(预设标签 + 自定义新增)
- 保存成功后提示「去素材库查看」可跳转配音素材库
- 新增 saveTtsToLibrary API 对接后端 POST /tts/jobs/{id}/save-to-library
2026-07-07 13:21:39 +08:00
灵应 2849123bd7 feat: P2 WebSocket 流式合成
CI/CD Pipeline / Frontend Lint (push) Failing after 47h49m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h49m26s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1706h24m55s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h24m57s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1706h24m59s
CI/CD Pipeline / Deploy Staging (push) Failing after 1706h24m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1706h56m25s
- 新增 TTSStreamingService 流式合成服务
- 实现 /ws/tts/stream WebSocket 端点
- 短文本(≤500字):合成完整音频后分块推送
- 长文本(>500字):分段并发合成,逐段推送
- 通过 asyncio.to_thread 桥接同步 CosyVoiceService
- 二进制帧 4KB 分块推送音频数据
- 10 个单元测试全部通过
2026-07-07 13:04:59 +08:00
灵应 d6ab413dcd feat: P1 长文本分段合成
CI/CD Pipeline / Frontend Lint (push) Failing after 47h57m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h57m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1706h31m47s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1706h31m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h31m48s
CI/CD Pipeline / Deploy Staging (push) Failing after 1706h31m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h3m16s
- 新增 text_splitter.py: 长文本智能分段(句子边界 + 短段合并)
- 新增 audio_merger.py: FFmpeg concat 音频合并器
- workflow.py: 分段合成完整流程(同步合并 / 异步轮询 / 失败处理)
- tts_synthesis.py: 新增 process_tts_segment_synthesis Celery 任务
- tts.py: 路由层自动识别分段任务并分发到对应 Celery task
- 23 个单元测试全部通过,P0 回归测试无退化
2026-07-07 12:56:52 +08:00
灵应 c47aed95fc feat(tts): P0 音频转存 OSS — 合成完成后下载临时音频上传 OSS 存储永久 URL
CI/CD Pipeline / Frontend Lint (push) Failing after 48h27m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h27m21s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h2m49s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h2m51s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h2m53s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h2m53s
CI/CD Pipeline / Deploy Production (push) Failing after 1707h34m19s
- TTSWorkflowService 新增 _transfer_audio_to_oss 方法
- start_synthesis 同步路径和 process_synthesis_result 均集成 OSS 转存
- OSS 转存失败时优雅降级,回退到 CosyVoice 临时 URL
- 支持 mp3/wav/pcm/opus 格式 content-type 映射
- 新增 9 个单元测试覆盖转存成功/失败/降级场景
- 向后兼容:storage_service 为可选参数,现有调用方无需修改

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:27:06 +08:00
灵应 8e2e1e1357 feat(voice-materials): 标签管理 UI 对接后端标签 API
CI/CD Pipeline / Frontend Lint (push) Failing after 48h32m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h32m19s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h7m47s
CI/CD Pipeline / Deploy Production (push) Failing after 1707h7m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h7m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h7m51s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h7m51s
- api/tags.ts: 桩实现替换为真实 API(getTags/createTag/deleteTag/tagAsset/untagAsset)
- api/assets.ts: AssetItem 新增 tag_ids 字段,getAssetsByKind 支持 tag_ids 筛选
- VoiceMaterialLibrary.tsx:
  - 移除硬编码 STYLE_TAGS,全面使用 API 返回的标签列表
  - VoiceMaterial 接口 tags: string[] → tagIds: string[]
  - mapAssetToMaterial 读取 asset.tag_ids 替代 metadata.style
  - TagSelector 组件改为 ID 模式(tagId 操作 + tagMap 名称解析)
  - MaterialForm 接收 tags/tagMap/onCreateTag props
  - VoiceMaterialCard/VoiceMaterialRow 接收 tagMap prop 渲染标签名
  - 标签筛选栏使用 API tags + filterTagId 状态
  - 批量打标签改用 tagAsset API + handleBatchCustomTag 自定义标签
  - 上传/编辑流程调用 tagAsset/untagAsset 管理标签关联
  - filtered/tagCountMap 全部基于 tagIds 计算
2026-07-07 12:22:05 +08:00
灵应 b0f2e4712a feat: P3 配音模块标签体系 — 标签 CRUD + 素材打标 + tag_ids 筛选
CI/CD Pipeline / Frontend Lint (push) Failing after 48h55m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h55m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h31m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h31m28s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h31m30s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h31m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h2m56s
- 新增 Tag 领域实体 + TagModel/AssetTagModel ORM 模型
- Alembic 迁移 030:tags 表 + asset_tags 关联表
- TagRepository 端口 + SQLAlchemy/InMemory 实现
- Asset.tag_ids 多对多关联替代原 JSON tags
- GET /tags / POST /tags / DELETE /tags/{tag_id} 标签 CRUD
- POST /assets/{asset_id}/tags 打标 / DELETE 取消标签
- GET /assets 新增 tag_ids 筛选参数(逗号分隔,取交集)
- 19 个单元测试全部通过
2026-07-07 11:58:27 +08:00
灵应 d60a963b62 feat(voice-materials): P3 标签体系 UI — 药丸筛选栏 + TagSelector + 溢出处理 + 批量自定义输入
CI/CD Pipeline / Frontend Lint (push) Failing after 48h58m54s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h58m54s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h33m9s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h33m14s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h33m14s
CI/CD Pipeline / Deploy Production (push) Failing after 1707h33m12s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h4m40s
- 标签筛选栏:Select 下拉替换为水平可滚动药丸条,显示使用计数
- 编辑弹窗:TagSelector 组件,支持自定义输入回车添加、已选标签可移除、预设快捷选择、自动补全
- 卡片/行标签溢出:卡片最多3个、行最多2个,超出显示 +N Tooltip
- 空标签态:无标签时显示「添加标签」虚线按钮
- 批量打标签 Popover:顶部新增自定义输入框,回车即可批量添加
- 交互走查:全失败时显示 error 消息、键盘可访问、过渡动画
- 新建 api/tags.ts 桩文件,预留后端标签 API 切换点

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:55:34 +08:00
灵应 d698d78b35 feat(voice-materials): 配音模块体验打磨 — 批量操作/上传进度/播放器优化
CI/CD Pipeline / Frontend Lint (push) Failing after 49h22m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h22m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h57m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h57m30s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h57m32s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h57m32s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h28m58s
- 配音素材库批量操作:多选删除、批量打标签(Popover 选标签)
- 上传配音进度条:XMLHttpRequest 替换 fetch,实时百分比进度
- 试听播放器优化:暂停恢复、进度条拖拽 seek、音量控制
- 整体交互走查:错误处理、空状态提示、动画过渡

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:32:03 +08:00
灵应 f8bc252ded feat(assets): 批量删除接口 + 分页性能优化
CI/CD Pipeline / Staging E2E Tests (push) Failing after 49h30m34s
CI/CD Pipeline / Deploy Staging (push) Failing after 49h34m18s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h36m48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h36m54s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1708h1m5s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h6m40s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1708h9m27s
- 新增 POST /assets/batch-delete 批量删除素材接口
  - 仓储层: abstract/SQLAlchemy/InMemory 均实现 batch_delete()
  - API层: 逐项校验项目权限后批量删除
  - Schema: BatchDeleteRequest/BatchDeleteResponse
- GET /assets 分页优化: 无 keyword/gender/style 过滤时走 DB 级 skip/limit
- 素材上传 duration 字段已全链路支持(domain/schema/ORM/repository)
- 新增 4 个批量删除单元测试,全量 1030 测试通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:06:24 +08:00
CI Test 2371860f82 Merge develop into main - v0.1.122 (prettier fix)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h37m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h37m23s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 49h35m42s
CI/CD Pipeline / Deploy Production (push) Failing after 49h33m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 49h27m0s
CI/CD Pipeline / Deploy Staging (push) Failing after 1708h11m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h9m27s
2026-07-07 11:04:39 +08:00
CI Test d1970bd44b fix: prettier formatting for voice material module files
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-07 11:04:30 +08:00
CI Test dbd956fc6e Merge develop into main - v0.1.122
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h54m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h54m54s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1708h14m26s
CI/CD Pipeline / Deploy Staging (push) Failing after 1708h14m26s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h12m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1708h10m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h12m14s
2026-07-07 10:59:31 +08:00
灵应 734d7eb377 feat: 配音素材库统一入口 + 搜索筛选 + 剪辑编辑器配音体验优化
CI/CD Pipeline / Frontend Lint (push) Failing after 50h14m10s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h14m10s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1708h49m15s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h49m17s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1708h49m20s
CI/CD Pipeline / Deploy Staging (push) Failing after 1708h49m20s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h20m45s
1. 全局收敛硬编码配音/音色数据到配音素材库 (Task #170)
   - CloneVoiceModal: 替换硬编码音色列表为素材库 API
   - GeneratePage: 配音选择改为从素材库选取
   - MyVoices: 统一展示素材库中的配音

2. 配音素材库增加搜索框和筛选 (Task #171)
   - 关键词搜索、性别筛选、风格标签筛选
   - 对接后端 getAssetsByKind 筛选参数

3. 剪辑编辑器配音选择体验优化 (Task #172)
   - 共享 React Query 缓存 (queryKey: ['assets','voice'])
   - 新增试听播放/暂停按钮
   - 下拉选项显示性别标签
   - 加载状态 + 刷新按钮
   - 空状态提示
2026-07-07 10:40:16 +08:00
灵应 3e1284d130 feat(assets): GET /assets 增加 keyword/gender/style 搜索筛选参数
CI/CD Pipeline / Frontend Lint (push) Failing after 50h33m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h33m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h8m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h8m52s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h8m58s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h8m58s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h40m20s
- keyword: 按名称模糊匹配(大小写不敏感)
- gender: 按 metadata.gender 精确筛选
- style: 按 metadata.style 精确筛选
- 支持 library_id / project_id 两种查询模式
2026-07-07 10:20:36 +08:00
灵应 bcda5a2581 feat: 剪辑编辑器集成配音素材库 — 替换硬编码音色为真实素材 API
CI/CD Pipeline / Frontend Lint (push) Failing after 50h37m49s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h37m49s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h12m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h12m59s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h13m1s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h13m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h44m27s
- EditingPlanner: useQuery 拉取配音素材列表(ensureDefaultLibrary + getAssetsByKind)
- ClipPropertiesPanel: voice 类型片段新增配音素材下拉选择器 + 上传新配音按钮
- 生成任务 voice_ids 从片段 voice_asset_id 收集
- planConfig segments 携带 voice_asset_id / voice_file_url
- 新增 .ep-clip-detail-select / .ep-voice-upload-btn 样式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:16:38 +08:00
灵应 25a9352794 fix: P1 安全修复 - TTS合成接口增加voice_clone_profile_id归属校验
CI/CD Pipeline / Frontend Lint (push) Failing after 50h43m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h43m5s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h18m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h18m16s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h18m19s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h18m19s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h49m44s
POST /tts/synthesize 未校验 voice_clone_profile_id 归属,
任意用户可使用他人克隆音色进行合成。
增加 ownership 校验,非本人 profile 返回 403。
2026-07-07 10:10:57 +08:00
灵应 ae9a79f01d feat: 配音素材库对接真实 API + 导航标签更新
CI/CD Pipeline / Frontend Lint (push) Failing after 50h53m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h53m20s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h27m34s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h27m38s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h27m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h27m36s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h59m5s
- VoiceMaterialLibrary.tsx: 移除 mock 数据,对接真实 Assets API
  - 列表: GET /assets?kind=voice
  - 上传: uploadAssetDirect → createAsset
  - 试听: 使用 file_url 播放
  - 编辑: PUT /assets/{id}
  - 删除: DELETE /assets/{id}
  - metadata 格式: voice_name, gender, speed, style, description
- navigation.tsx: 侧边栏标签改为「配音素材库」
- PageHead.tsx: 页面标题改为「配音素材库」
2026-07-07 10:01:08 +08:00
灵应 30c5cd4bbe feat(assets): 分页查询 + voice 默认素材库自动初始化
CI/CD Pipeline / Frontend Lint (push) Failing after 51h1m53s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 51h1m53s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h37m3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h37m5s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h37m7s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h37m7s
CI/CD Pipeline / Deploy Production (push) Failing after 1710h8m32s
- GET /assets 增加 skip/limit 分页参数,响应包含 total/skip/limit
- GET /asset-libraries 增加 kind 过滤参数
- 新增 POST /asset-libraries/ensure-default 自动创建默认素材库
- 前端上传配音音频走通用上传链路(无缺口)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:52:33 +08:00
灵应 32e78f31e2 feat: 新增配音素材管理页面 VoiceMaterialLibrary
CI/CD Pipeline / Frontend Lint (push) Failing after 51h43m2s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 51h43m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1710h18m13s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1710h18m15s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1710h18m17s
CI/CD Pipeline / Deploy Staging (push) Failing after 1710h18m17s
CI/CD Pipeline / Deploy Production (push) Failing after 1710h49m42s
- 新建 VoiceMaterialLibrary.tsx:卡片/列表双布局、上传、试听、编辑、删除
- 元信息:名称、音色描述、性别(男/女/童声/中性)、风格标签
- V21 设计系统样式(voice-materials.css),含性别色带、响应式适配
- 路由 /app/voice-materials,导航 + PageHead 面包屑配置
- 当前阶段:静态页面 + Mock 数据,等后端 API 就绪后对接

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:11:17 +08:00
灵应 2832109b35 feat: add P0 asset API endpoints for voice material management
CI/CD Pipeline / Staging E2E Tests (push) Failing after 51h49m13s
CI/CD Pipeline / Deploy Staging (push) Failing after 51h50m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 51h52m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 51h52m26s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1710h26m35s
CI/CD Pipeline / Deploy Production (push) Failing after 1710h26m41s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1710h26m43s
- GET /assets/{asset_id}: single asset detail
- PUT /assets/{asset_id}: update name, metadata, tags
- DELETE /assets/{asset_id}: delete asset with project access check
- GET /assets: add kind query param (voice/video/image) for filtering
- Add find_by_library_and_file_type to repository port + implementations

Supports the voice material library MVP: CRUD operations needed by the
frontend VoiceLibrary page.
2026-07-07 09:02:00 +08:00
CI Test 1d59ee5336 Merge develop into main - v0.1.121
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 52h47m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 52h46m49s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 52h46m3s
CI/CD Pipeline / Deploy Production (push) Failing after 52h45m27s
CI/CD Pipeline / Deploy Staging (push) Failing after 1711h22m10s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h19m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1711h21m45s
2026-07-07 08:05:42 +08:00
灵应 653be7755b fix: handle Alembic offline mode in migration _column_exists() helper
CI/CD Pipeline / Staging E2E Tests (push) Failing after 52h57m33s
CI/CD Pipeline / Deploy Staging (push) Failing after 52h58m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 53h0m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53h0m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h26m48s
CI/CD Pipeline / Deploy Production (push) Failing after 1711h28m20s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1711h33m42s
The _column_exists() helper in migrations 026-029 calls conn.execute()
which returns None in Alembic's offline/SQL mode (--sql flag), causing
AttributeError on result.scalar(). Add context.is_offline_mode() guard
to skip the idempotency check in offline mode and unconditionally emit
the DDL statements.

Fixes: Validate CI step 'alembic upgrade head --sql' failure.
2026-07-07 07:53:27 +08:00
灵应 6a853fef3c fix: 补建缺失的 Alembic 迁移文件 026-029
CI/CD Pipeline / Frontend Lint (push) Failing after 53h9m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53h9m57s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h45m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1711h45m20s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1711h45m22s
CI/CD Pipeline / Deploy Staging (push) Failing after 1711h45m22s
CI/CD Pipeline / Deploy Production (push) Failing after 1712h16m47s
生产 DB 已执行过 026-029 迁移但文件丢失(可能 rebase 导致),
导致 alembic 报 'Can't locate revision identified by 029_user_phone'。

补建内容:
- 026: users 表加 name, avatar, updated_at
- 027: users 表加 ban_reason, ban_at
- 028: users 表加 admin_status, admin_remarks
- 029: users 表加 phone

所有 upgrade 做幂等检查(IF NOT EXISTS),安全用于已有字段的库。
2026-07-07 07:44:30 +08:00
灵应 e33bfe71bf feat: 退出登录后跳转首页(/)而非登录页(/login)
CI/CD Pipeline / Staging E2E Tests (push) Failing after 53h21m49s
CI/CD Pipeline / Deploy Staging (push) Failing after 53h22m44s
CI/CD Pipeline / Frontend Lint (push) Failing after 53h23m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53h24m0s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h52m30s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1711h57m58s
CI/CD Pipeline / Deploy Production (push) Failing after 1712h23m59s
- useLogout hook: navigate('/login') → navigate('/')
- client.ts 无refresh_token自动登出: href='/login' → href='/'
- client.ts 刷新失败自动登出: href='/login' → href='/'
- 已排查所有退出入口:仅Header头像菜单有手动退出按钮,行为一致
- ProtectedRoute未登录拦截仍跳/login(正确行为,非退出场景)
2026-07-07 07:30:28 +08:00
xiaoxia 9d2f4ec0f2 Merge pull request 'ci: 优化生产镜像部署 - Registry push/pull 替代 tar+scp' (#186) from feat/ci-registry-push-pull into develop
CI/CD Pipeline / Staging E2E Tests (push) Failing after 53h34m48s
CI/CD Pipeline / Deploy Staging (push) Failing after 53h36m13s
CI/CD Pipeline / Frontend Lint (push) Failing after 53h36m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53h37m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1712h5m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1712h5m32s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1712h10m58s
2026-07-07 07:15:15 +08:00
灵应 3242e71ec5 fix: 修复部署脚本健康检查循环变量bug (k=0 → i=0)
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 53h38m46s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 53h39m52s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1712h10m58s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1712h12m20s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1712h12m21s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1712h12m23s
CI/CD Pipeline / Deploy Staging (pull_request) Failing after 1712h12m23s
2026-07-07 07:14:37 +08:00
灵应 1b4532cd9c fix(ci): 修复 PR #185 微信同步登录 Validate 失败
CI/CD Pipeline / Staging E2E Tests (push) Failing after 60h2m5s
CI/CD Pipeline / Deploy Staging (push) Failing after 60h3m49s
CI/CD Pipeline / Frontend Lint (push) Failing after 60h4m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 60h5m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1718h32m10s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1718h38m14s
CI/CD Pipeline / Deploy Production (push) Failing after 1719h5m1s
- black 格式化 wechat_sync_use_case.py
- isort 排序 auth.py imports
- bandit: try_except_pass 改为 logger.debug

根因:PR #185 合并时未通过代码质量检查(black/isort),
导致 CI Validate 步骤在代码检查阶段即失败。
2026-07-07 00:49:14 +08:00
灵应 3620e29f41 style: 收窄时间线轨道高度 — 片段卡片96px→64px,track padding/gap缩减
CI/CD Pipeline / Frontend Lint (push) Failing after 60h16m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 60h16m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1718h51m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1718h51m33s
CI/CD Pipeline / Deploy Staging (push) Failing after 1718h51m35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1718h51m33s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1719h23m2s
- .ep-clip-track: gap 12px→8px, padding 12px→6px
- .ep-clip-card: height 96px→64px
- .ep-clip-thumbnail: height 52px→34px
- .ep-clip-info: padding 6px 8px→4px 6px
- .ep-track-empty: min-height 96px→64px
- .ep-track-add-card: height 96px→64px(与片段卡片对齐)
2026-07-07 00:38:16 +08:00
xiaoxia ed6150fa52 Merge pull request 'feat(auth): 新增微信同步登录接口 wechat-sync(小程序BFF用)' (#185) from feat/wechat-sync-login into develop
CI/CD Pipeline / Frontend Lint (push) Failing after 60h17m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 60h17m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1718h52m40s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1718h52m42s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1718h52m44s
CI/CD Pipeline / Deploy Staging (push) Failing after 1718h52m44s
CI/CD Pipeline / Deploy Production (push) Failing after 1719h24m9s
2026-07-07 00:37:06 +08:00
灵应 dc9d84d015 feat(剪辑编辑器): AI自动选择默认关闭 + 预设样式去背景 + 添加片段面板紧凑化
CI/CD Pipeline / Staging E2E Tests (push) Failing after 61h36m0s
CI/CD Pipeline / Deploy Staging (push) Failing after 61h37m52s
CI/CD Pipeline / Frontend Lint (push) Failing after 61h49m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 61h52m11s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1720h7m23s
CI/CD Pipeline / Deploy Production (push) Failing after 1720h7m25s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1720h12m10s
1. AI自动选择开关默认值 true → false
2. 预设样式卡片去掉深色背景(#1a1a2e),改为白底纯文字效果展示
   - 默认态: 白色背景 + 浅灰边框
   - hover: #f8fafc 浅灰底
   - active: #eef2ff 主色浅底
   - 标签文字颜色 #8888aa → #64748b
3. 添加片段面板宽度 240px → 180px,内边距 14px → 10px,间距 10px → 8px
2026-07-06 23:01:19 +08:00
灵应 67d5f899b3 refactor: 标题预设解耦字体 — 预设只保存样式属性,不绑定字体family
CI/CD Pipeline / Staging E2E Tests (push) Failing after 62h0m32s
CI/CD Pipeline / Deploy Staging (push) Failing after 62h1m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 62h2m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 62h3m1s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1720h31m13s
CI/CD Pipeline / Deploy Production (push) Failing after 1720h31m15s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1720h36m41s
- TITLE_PRESETS 去掉 font 字段、style.font、previewStyle.fontFamily
- getActivePreset 去掉 font 比较,仅匹配样式属性
- 预设点击回调不再传 font,保留用户当前字体不变
- 预览图统一用系统字体展示效果
- 华康俪金黑保留在字体列表供手动选择

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:49:39 +08:00
灵应 1c83122711 feat: 添加华康俪金黑字体支持
CI/CD Pipeline / Staging E2E 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 / Deploy Staging (push) Failing after 62h9m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 62h10m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 62h10m58s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1720h44m38s
- index.css 添加 @font-face 声明加载 DFLiJinHei-W8.ttf
- ClipPropertiesPanel FONT_OPTIONS 增加「华康俪金黑」选项
- 标题预设「黑金质感」默认字体改为华康俪金黑

注:字体文件需手动放置到 apps/web/public/fonts/DFLiJinHei-W8.ttf
2026-07-06 22:43:07 +08:00
CI Test 46d98f369d feat(auth): 新增微信同步登录接口wechat-sync(内部系统级接口,支持openid查找/创建用户并返回SaaS token)
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 62h22m48s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 62h22m56s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1720h48m29s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1720h48m31s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1720h48m31s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1720h53m56s
CI/CD Pipeline / Deploy Staging (pull_request) Failing after 1720h53m56s
2026-07-06 22:10:33 +08:00
CI Test a568c97439 feat(auth): User实体新增wechat_openid/wechat_unionid字段 2026-07-06 22:07:07 +08:00
CI Test f94ffa77bf feat(auth): SQLAlchemy仓储实现微信openid/unionid查询 2026-07-06 22:06:34 +08:00
CI Test b3118b05ab feat(auth): 仓储接口新增find_by_wechat_openid/unionid方法 2026-07-06 22:06:34 +08:00
灵应 a48740195d feat(editing-planner): 标题预设样式改造 — 剪映风格8预设+完整样式预览
CI/CD Pipeline / Staging E2E Tests (push) Failing after 62h17m49s
CI/CD Pipeline / Deploy Staging (push) Failing after 62h22m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 62h30m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 62h50m47s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1720h48m29s
CI/CD Pipeline / Deploy Production (push) Failing after 1720h48m31s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1720h53m58s
- 8个预设:经典白字黑描边、黑金质感、清新简约、综艺花字、商务极简、复古胶片、霓虹发光、手写字
- 每个预设用示例文字标题展示完整效果(字体+字号+颜色+描边+阴影)
- 4列方形网格布局,深色背景衬托文字效果
- 新增color字段到TitleSettings,保存/加载/生成均使用用户选择的颜色
- 新增楷体到字体选项

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:03:39 +08:00
灵应 7ec8e4355e fix(editing-planner): 修复添加片段面板被裁剪 — 默认向上弹出+useLayoutEffect精确校正
- 面板默认从+按钮上方弹出,上方不够时翻转到下方
- 用useLayoutEffect+实际offsetHeight替代硬编码PICKER_H
- fixed定位脱离父容器overflow裁剪

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:03:33 +08:00
灵应 edf6a5501b fix: 修复剪辑编辑器保存模板和生成视频按钮无响应
CI/CD Pipeline / Staging E2E Tests (push) Failing after 63h6m41s
CI/CD Pipeline / Deploy Staging (push) Failing after 63h9m23s
CI/CD Pipeline / Frontend Lint (push) Failing after 63h9m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 63h13m41s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1721h37m25s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1721h42m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1722h8m53s
根因:EditingPlanner.tsx 使用了假的 msg 对象(仅 console.log),
用户看不到任何 toast 反馈。同时两个按钮在 loadedTemplateId 为 null
时直接 return,阻止了新建模板和直接生成的场景。

修复:
1. 替换假 msg 为 antd message,所有 toast 通知可见
2. handleOpenSaveModal 移除 loadedTemplateId 守卫,始终打开弹窗
3. handleSave 支持 createEditingTemplate(新建)和 updateEditingTemplate(更新)
4. handleGoToGenerate 移除 loadedTemplateId 守卫,允许直接跳转生成页
2026-07-06 21:40:45 +08:00
灵应 9005135868 fix: 添加片段面板视口边界检测,防止底部溢出
CI/CD Pipeline / Staging E2E Tests (push) Failing after 64h34m17s
CI/CD Pipeline / Deploy Staging (push) Failing after 64h35m51s
CI/CD Pipeline / Frontend Lint (push) Failing after 64h36m25s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 64h38m0s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1723h4m57s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1723h10m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1723h36m26s
- updatePickerPosition: 预估面板高度,下方空间不足时向上翻转
- 新增渲染后校正 useEffect: 用实际 getBoundingClientRect 精确调整
- 水平方向:右边界保护 + 左边界保护
- 8px 安全边距,clamp 到视口内

Closes: 面板底部被视口下边缘遮挡的问题
2026-07-06 20:16:27 +08:00
灵应 c454a31e1b fix: 添加片段面板改用 position:fixed 渲染,彻底解决 overflow 裁剪问题
CI/CD Pipeline / Staging E2E Tests (push) Failing after 65h4m19s
CI/CD Pipeline / Deploy Staging (push) Failing after 65h5m20s
CI/CD Pipeline / Frontend Lint (push) Failing after 65h5m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 65h6m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1723h34m54s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1723h40m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1724h6m23s
- 移除 createPortal,改为在 ep-timeline-area 内直接渲染 picker
- picker 使用 position:fixed + !important 覆盖基类 absolute
- 通过 getBoundingClientRect 动态计算弹出坐标
- 不受任何父容器 overflow:hidden 裁剪影响

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:47:49 +08:00
DevOps Bot e6dfeafb85 ci: 优化生产镜像部署 - 改用Registry push/pull替代tar+scp\n\n- build_release_images.sh: 增加web镜像buildx缓存构建 + push到Gitea Registry\n- deploy-production.sh: 改用docker pull从Registry拉取镜像,替代docker load\n- ci-cd.yml: 简化build-production和deploy-production job\n - 移除docker save + scp传镜像tar的流程\n - 仅保留源码tar.gz scp(compose/migration脚本需要)\n - web镜像构建合并到build_release_images.sh统一管理
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 60h28m37s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 60h30m7s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1719h1m54s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1719h1m57s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1719h1m57s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1719h1m59s
CI/CD Pipeline / Deploy Staging (pull_request) Failing after 1719h1m59s
2026-07-06 19:20:03 +08:00
灵应 cd14f0e0ac fix: 添加片段面板被轨道 overflow 裁剪 — 改用 Portal + fixed 定位
CI/CD Pipeline / Staging E2E Tests (push) Failing after 65h32m50s
CI/CD Pipeline / Deploy Staging (push) Failing after 65h34m29s
CI/CD Pipeline / Frontend Lint (push) Failing after 65h35m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 65h35m46s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1724h3m26s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1724h8m57s
CI/CD Pipeline / Deploy Production (push) Failing after 1724h34m59s
- picker 通过 createPortal 渲染到 body,脱离 .ep-clip-track 的 overflow:hidden 裁剪
- 位置根据 + 卡片 getBoundingClientRect 动态计算
- z-index 提升至 1000 确保弹层在最上层
2026-07-06 19:18:42 +08:00
灵应 6e30a96f6d fix: eslint unused-disable + prettier formatting
CI/CD Pipeline / Staging E2E Tests (push) Failing after 65h49m33s
CI/CD Pipeline / Deploy Staging (push) Failing after 65h51m39s
CI/CD Pipeline / Frontend Lint (push) Failing after 65h53m10s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 65h54m47s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1724h19m54s
CI/CD Pipeline / Deploy Production (push) Failing after 1724h20m12s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1724h25m42s
- Remove unused eslint-disable in MainLayout.tsx (react-refresh/only-export-components)
- Format client.ts, ClipPropertiesPanel.tsx, TimelinePanel.tsx, EditingPlanner.tsx with prettier
2026-07-06 18:59:10 +08:00
灵应 4fe9fc0c95 feat: 登录持久化 + 片段逻辑重构
CI/CD Pipeline / Frontend Lint (push) Failing after 66h12m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 66h13m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1724h46m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1724h46m54s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1724h46m56s
CI/CD Pipeline / Deploy Staging (push) Failing after 1724h46m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1725h18m21s
任务 1:登录持久化 + Token 刷新
- authStore: partialize 持久化 accessToken/refreshToken 到 localStorage
- auth.ts: 新增 refreshAccessToken API 函数
- client.ts: 401 拦截器实现刷新队列,无感续期

任务 2:片段逻辑重构(片段 = 时间规划 + 类型标记,不绑定素材)
- types.ts: 创建统一 ClipData/ClipType 类型定义
- EditingPlanner.tsx: handleAddClip 改为接收 type+duration,移除素材绑定
- TimelinePanel.tsx: "+" 卡片改为类型+时长选择器,移除素材拖拽
- ClipPropertiesPanel.tsx: 片段详情改为类型选择器+时长+startOffset
- PreviewPlayer.tsx: 适配新 ClipData 类型,用 type 显示图标
- EditingPlanner.css: 新增类型选择器样式,移除旧素材选择器样式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:40:27 +08:00
194 changed files with 29759 additions and 2403 deletions
+172
View File
@@ -0,0 +1,172 @@
name: ACR Cleanup
on:
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
workflow_dispatch:
inputs:
pr_sha:
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
required: false
default: ""
dry_run:
description: "预览模式(dry-run),不实际删除"
required: false
default: "true"
pull_request_target:
types: [closed]
branches: [develop, main]
concurrency:
group: acr-cleanup-${{ gitea.ref }}
cancel-in-progress: false
jobs:
cleanup:
name: ACR Image Cleanup
runs-on: ci-l2
timeout-minutes: 20
permissions:
contents: read
env:
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
ACR_NAMESPACE: xiaoxiakeji
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
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
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
- name: Get staging running images (whitelist)
id: protected_images
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
set +e
echo "获取staging服务器运行中镜像作为白名单..."
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_port="${STAGING_SSH_PORT:-22222}"
staging_user="${STAGING_SSH_USER:-root}"
key_path=~/.ssh/id_rsa
if [ -n "${STAGING_SSH_KEY:-}" ]; then
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from STAGING_SSH_KEY secret"
else
echo "⚠️ STAGING_SSH_KEY not set, skipping whitelist"
echo "protected_tags=" >> $GITHUB_OUTPUT
exit 0
fi
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
# 获取所有运行容器的镜像,提取tag部分
IMAGES=$(ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no \
"$staging_user@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
PROTECTED_TAGS=""
if [ -n "$IMAGES" ]; then
while IFS= read -r img; do
# 从完整镜像名中提取tag(最后一个冒号后)
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
if [ -z "$PROTECTED_TAGS" ]; then
PROTECTED_TAGS="$tag"
else
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
fi
fi
done <<< "$IMAGES"
fi
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
echo "protected_tags=$PROTECTED_TAGS" >> $GITHUB_OUTPUT
# ====== Docker登录 ======
- name: Docker login to ACR
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
# ====== 模式1PR关闭时清理 ======
- name: Cleanup PR images (PR closed)
if: gitea.event_name == 'pull_request_target'
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
echo "============================================"
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
echo " Head SHA: ${PR_SHA::12}"
echo "============================================"
echo ""
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
--execute
# ====== 模式2Cron全量清理 ======
- name: Full cleanup (cron / manual)
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "============================================"
echo " ACR 全量清理(${{ gitea.event_name }}"
echo "============================================"
echo ""
# 决定是否dry-run
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
echo "模式: 预览模式 (dry-run)"
else
echo "模式: 执行模式"
fi
echo ""
python3 scripts/ci/acr_cleanup.py \
--keep 20 \
--protected-tags "$PROTECTED_TAGS" \
$DRY_RUN_FLAG
# ====== 模式3:手动指定PR SHA清理 ======
- name: Cleanup specific PR image (manual)
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "手动清理PR镜像: ${PR_SHA::12}"
echo ""
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
fi
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
$DRY_RUN_FLAG
-21
View File
@@ -1,21 +0,0 @@
name: Auto Merge PRs
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
auto-merge:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Auto merge develop PRs
run: |
bash scripts/auto_merge_prs.sh develop
- name: Auto merge main PRs (release only)
run: |
bash scripts/auto_merge_prs.sh main
-829
View File
@@ -1,829 +0,0 @@
name: CI/CD Pipeline
on:
push:
branches:
- main
- develop
- 'feature/**'
- 'bugfix/**'
- 'hotfix/**'
- 'release/**'
tags:
- 'v*'
pull_request:
branches:
- main
- develop
permissions:
contents: read
jobs:
validate:
name: Validate Code Quality And Tests
runs-on: ubuntu-22.04
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5433/xiaoxia_saas
USE_IN_MEMORY_DB: "false"
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Verify CI environment
shell: sh
run: |
set -eu
python --version
python3 -m pip --version
echo "CI environment is ready"
- name: Install dependencies
shell: sh
run: |
set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
python3 -m black --version
python3 -m isort --version-number
python3 -m flake8 --version
bandit --version
pytest --version
- name: Run code quality checks
shell: sh
run: |
set -eu
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 flake8 apps packages tests --count --statistics
- name: Run security scan
shell: sh
run: |
set -eu
bandit -r apps packages -q -ll
- name: Validate release scripts syntax
shell: sh
run: |
set -eu
bash -n scripts/backup_postgres.sh
bash -n scripts/restore_postgres_plan.sh
bash -n scripts/init_production_env.sh
- name: Validate Alembic migrations
shell: sh
run: |
set -eu
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
test -s /tmp/alembic-upgrade.sql
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
python3 scripts/check_schema_metadata.py
- name: Run unit tests
shell: sh
env:
USE_IN_MEMORY_DB: "true"
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q
- name: Start PostgreSQL for integration tests
shell: sh
run: |
set -eu
docker rm -f ci-pg-validate 2>/dev/null || true
docker run -d --name ci-pg-validate \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=xiaoxia_saas \
-p 5433:5432 \
--health-cmd "pg_isready -U postgres" \
--health-interval 5s \
--health-timeout 5s \
--health-retries 12 \
postgres:16
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' ci-pg-validate 2>/dev/null | grep -q healthy; then
echo "PostgreSQL is ready"
break
fi
echo "Waiting for PostgreSQL... ($i/30)"
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' ci-pg-validate | grep -q healthy
- name: Apply migrations for integration tests
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
- name: Run integration tests
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration -q --timeout=60 -x
- name: Cleanup PostgreSQL
if: always()
shell: sh
run: |
docker rm -f ci-pg-validate 2>/dev/null || true
echo "PostgreSQL container cleaned up"
- name: Build summary
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
shell: sh
run: |
set -eu
echo "Build completed successfully!"
echo "Branch: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
frontend-lint:
name: Frontend Lint
runs-on: ubuntu-22.04
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
for i in 1 2 3 4 5; do
if wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" 2>&1; then
break
fi
if [ "$i" -lt 5 ]; then
wait=$((2 ** i))
echo "Checkout failed (attempt $i/5), retrying in ${wait}s..."
sleep "$wait"
else
echo "Checkout failed after 5 attempts"
exit 1
fi
done
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
rm -f /tmp/repo.tar.gz
- name: Install dependencies
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npm ci'
- name: Run ESLint
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 50'
- name: Run TypeScript type check
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npx tsc --noEmit'
- name: Run Prettier check
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npx prettier --check "src/**/*.{ts,tsx,md}"'
- name: Run Vitest tests
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npx vitest run src/test'
deploy-staging:
name: Deploy Staging
runs-on: saas
needs: [validate, frontend-lint]
if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/')
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Install SSH client
shell: sh
run: |
set -eu
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed"
- name: Build staging web artifact
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npm ci && npm run build'
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-staging.conf \
-t "xiaoxia-saas-web:staging-${GITHUB_SHA}" \
.
test -f apps/web/dist/index.html
- name: Build and push staging API/Worker images
shell: bash
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
REGISTRY="172.30.18.198:5000"
CACHE_REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas"
CACHE_BRANCH="${GITHUB_REF_NAME//\//-}"
# 登录 Gitea Registry(用于构建缓存,PAT 带 packages 权限)
printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin
# 登录内网 Registry(运行时镜像推送目标)
printf '%s' "Xiaoxia2026" | docker login 172.30.18.198:5000 -u admin --password-stdin 2>/dev/null || true
# 用默认 docker driver builder(共享 docker daemon 凭证,解决 buildx 认证问题)
docker buildx use default 2>/dev/null || {
echo "Warning: default builder not available, buildx cache may not work"
}
# 验证 builder 状态
docker buildx ls
# 构建 API(带分布式缓存,缓存存 Gitea Registry,镜像推内网 Registry
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},mode=max" \
-f infra/docker/api.Dockerfile \
-t "${REGISTRY}/xiaoxia-saas-api:dev" \
--push \
.
# 构建 Worker(带分布式缓存)
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},mode=max" \
-f infra/docker/worker.Dockerfile \
-t "${REGISTRY}/xiaoxia-saas-worker:dev" \
--push \
.
- name: Package staging release artifact
shell: sh
run: |
set -eu
rm -rf dist/staging-artifacts
mkdir -p dist/staging-artifacts
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
-czf dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz .
docker save -o "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" "xiaoxia-saas-web:staging-${GITHUB_SHA}"
- name: Upload staging artifact to business host
shell: sh
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
set -eu
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_user="${STAGING_SSH_USER:-root}"
mkdir -p ~/.ssh
# Prefer host key if available (more reliable), fallback to secrets
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using host SSH key: $key_path"
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using secret SSH key: $key_path"
else
echo "ERROR: No SSH key available"
exit 1
fi
ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts
ssh -i "$key_path" "$staging_user@$staging_host" "mkdir -p /var/lib/xiaoxia-saas-staging/artifacts"
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" \
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" \
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
- name: Deploy staging stack on business host
shell: sh
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
set -eu
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_user="${STAGING_SSH_USER:-root}"
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
else
echo "ERROR: No SSH key available"
exit 1
fi
echo 'c2V0IC1ldQphcnRpZmFjdD0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvYXJ0aWZhY3RzL3hpYW94aWEtc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhci5neiIKaW1hZ2VfdGFyPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9hcnRpZmFjdHMveGlhb3hpYS13ZWItc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhciIKdGVzdCAtZiAiJGFydGlmYWN0Igp0ZXN0IC1mICIkaW1hZ2VfdGFyIgp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYKZG9ja2VyIGxvYWQgLWkgIiRpbWFnZV90YXIiCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8KdGFyIC14emYgIiRhcnRpZmFjdCIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby8uZW52CmNobW9kICt4IC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vaW5mcmEvZG9ja2VyL2RlcGxveS1zdGFnaW5nLnNoCiMgRW5zdXJlIGlzb2xhdGVkIHN0YWdpbmcgbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtc3RhZ2luZyAyPi9kZXYvbnVsbCB8fCB0cnVlClJFR0lTVFJZPSIxNzIuMzAuMTguMTk4OjUwMDAiIEFQSV9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLWFwaTpkZXYiIFdPUktFUl9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLXdvcmtlcjpkZXYiIFdFQl9JTUFHRT0ieGlhb3hpYS1zYWFzLXdlYjpzdGFnaW5nLSR7R0lUSFVCX1NIQX0iIEhPU1RfUFJFRklYPSBFTlY9c3RhZ2luZyBXRUJfUE9SVD0zMDAxIFJFQlVJTERfQkFDS0VORD0wIEJVSUxEX1dFQj0wIFJVTl9NSUdSQVRJT05TPTEgUkVHSVNUUllfUEFTUz0iWGlhb3hpYVJlZ2lzdHJ5MjAyNiIgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXN0YWdpbmcuc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh"
- name: Post-deploy smoke test
shell: sh
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
set -eu
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_user="${STAGING_SSH_USER:-root}"
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
else
echo "ERROR: No SSH key available"
exit 1
fi
echo "Running post-deploy smoke tests on staging..."
# Wait for service to fully start
sleep 5
# Run smoke tests via SSH on the business host
ssh -i "$key_path" "$staging_user@$staging_host" '
echo "--- Smoke test 1: Health check ---"
HEALTH=$(curl -sf --max-time 10 http://127.0.0.1:8000/health) || {
echo "FAIL: health endpoint unreachable"
exit 1
}
echo "Health OK: $HEALTH"
echo "--- Smoke test 2: Login API (expect 401) ---"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
http://127.0.0.1:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d "{\"email\":\"smoke@test.com\",\"password\":\"wrong\"}")
if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then
echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)"
exit 1
fi
echo "Login API OK: HTTP $HTTP_CODE"
echo "--- Smoke test 3: API docs endpoint ---"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://127.0.0.1:8000/docs)
if [ "$HTTP_CODE" != "200" ]; then
echo "FAIL: /docs returned HTTP $HTTP_CODE (expected 200)"
exit 1
fi
echo "Docs endpoint OK: HTTP $HTTP_CODE"
echo "--- Smoke test 4: Network isolation verification ---"
# Verify staging containers are on the staging network
STAGING_NET=$(docker inspect xiaoxia-api-staging --format="{{json .NetworkSettings.Networks}}" 2>/dev/null)
if [ -z "$STAGING_NET" ]; then
echo "WARN: Could not inspect staging container networks (container may not exist yet)"
else
echo "Staging API container networks: $STAGING_NET"
if echo "$STAGING_NET" | grep -q "xiaoxia-net-staging"; then
echo "Network isolation OK: staging containers on xiaoxia-net-staging"
else
echo "WARN: staging containers not on expected xiaoxia-net-staging network"
echo " Current networks: $STAGING_NET"
fi
fi
# Verify cross-environment DNS isolation
# staging API should resolve to staging container, not production
STAGING_API_IP=$(docker exec xiaoxia-web-staging getent hosts xiaoxia-api-staging 2>/dev/null | awk "{print \$1}" || true)
PRODUCTION_API_IP=$(docker exec xiaoxia-web-staging getent hosts xiaoxia-api-production 2>/dev/null | awk "{print \$1}" || true)
if [ -n "$STAGING_API_IP" ]; then
echo "Staging API resolves to: $STAGING_API_IP (from web container)"
fi
if [ -n "$PRODUCTION_API_IP" ]; then
echo "FAIL: staging web container can resolve production API address ($PRODUCTION_API_IP) - network isolation broken!"
exit 1
else
echo "Network isolation OK: staging web cannot resolve xiaoxia-api-production"
fi
echo ""
echo "=== All smoke tests passed! ==="
'
staging-e2e:
name: Staging E2E Tests
runs-on: saas
if: github.ref_name == 'develop' || github.ref_name == 'main'
needs: deploy-staging
steps:
- name: Install SSH client
shell: sh
run: |
set -eu
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed"
- name: Run Playwright E2E on staging server
shell: sh
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
set -eu
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_user="${STAGING_SSH_USER:-root}"
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
else
echo "ERROR: No SSH key available"
exit 1
fi
ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts
# 在业务服务器上跑 Playwright E2E(用 host 网络访问 staging 3001/8000 端口)
ssh -i "$key_path" "$staging_user@$staging_host" '
cd /var/lib/xiaoxia-saas-staging/repo
docker run --rm \
-e E2E_BASE_URL=http://127.0.0.1:3001 \
-e E2E_API_BASE=http://127.0.0.1:8000/api/v1 \
-e E2E_BROWSER_CHANNEL=chromium \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
--network host \
mcr.microsoft.com/playwright:v1.45.0-jammy \
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium"
'
build-production-runtime-images:
name: Build Production Runtime Images
runs-on: saas
needs: [validate, frontend-lint]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Build runtime image artifact
shell: sh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
chmod +x scripts/build_release_images.sh
REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}"
- name: Build production web artifact
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npm ci && npm run build'
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-t "xiaoxia-saas-web:${GITHUB_REF_NAME}" \
.
test -f apps/web/dist/index.html
- name: Package release source artifact
shell: sh
run: |
set -eu
mkdir -p dist/release-artifacts
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
-czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" .
docker save -o "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" "xiaoxia-saas-web:${GITHUB_REF_NAME}"
- name: Upload runtime image and release artifacts
shell: sh
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
run: |
set -eu
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
production_user="${PRODUCTION_SSH_USER:-root}"
mkdir -p ~/.ssh
# Prefer host key if available (more reliable), fallback to secrets
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using host SSH key: $key_path"
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using secret SSH key: $key_path"
else
echo "ERROR: No SSH key available"
exit 1
fi
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
scp -i "$key_path" "dist/release-images/xiaoxia-runtime-images-${GITHUB_REF_NAME}.tar" \
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar"
scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz"
scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar"
- name: Cleanup old Docker images
if: always()
shell: sh
run: |
set -eu
if [ -f scripts/cleanup_old_images.sh ]; then
chmod +x scripts/cleanup_old_images.sh
scripts/cleanup_old_images.sh
else
echo "Cleanup script not found, doing basic prune..."
docker image prune -f 2>/dev/null || true
fi
echo "Disk usage after cleanup:"
df -h / | tail -1
deploy-production:
name: Deploy Production
runs-on: saas
if: startsWith(github.ref, 'refs/tags/v')
needs: build-production-runtime-images
steps:
- name: Install SSH client
shell: sh
run: |
set -eu
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed"
- name: Deploy production over SSH
shell: sh
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
run: |
set -eu
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
production_user="${PRODUCTION_SSH_USER:-root}"
mkdir -p ~/.ssh
# Prefer host key if available (more reliable), fallback to secrets
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using host SSH key: $key_path"
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using secret SSH key: $key_path"
else
echo "ERROR: No SSH key available"
exit 1
fi
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
echo 'c2V0IC1ldQpyZWxlYXNlX3Rhcj0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVsZWFzZS0ke1JFTEVBU0VfVkVSU0lPTn0udGFyLmd6Igp0ZXN0IC1mICIkcmVsZWFzZV90YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3J1bnRpbWUtaW1hZ2VzLSR7UkVMRUFTRV9WRVJTSU9OfS50YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3dlYi0ke1JFTEVBU0VfVkVSU0lPTn0udGFyIgpta2RpciAtcCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbgpvbGRfYXNzZXRzX2Rpcj0iL3RtcC94aWFveGlhLXByZXZpb3VzLXdlYi1hc3NldHMtJHtSRUxFQVNFX1ZFUlNJT059IgpybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKbWtkaXIgLXAgIiRvbGRfYXNzZXRzX2RpciIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBkb2NrZXIgY3AgeGlhb3hpYS13ZWItcHJvZHVjdGlvbjovdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvIDI+L2Rldi9udWxsIHx8IHRydWUKZmkKaWYgWyAtZCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzIF07IHRoZW4KICBjcCAtYSAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvCmZpCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8KdGFyIC14emYgIiRyZWxlYXNlX3RhciIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmlmIFsgLWQgIiRvbGRfYXNzZXRzX2RpciIgXTsgdGhlbgogIG1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMKICBmb3IgYXNzZXQgaW4gIiRvbGRfYXNzZXRzX2RpciIvKjsgZG8KICAgIFsgLWUgIiRhc3NldCIgXSB8fCBjb250aW51ZQogICAgbmFtZT0iJChiYXNlbmFtZSAiJGFzc2V0IikiCiAgICBpZiBbICEgLWUgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiIF07IHRoZW4KICAgICAgY3AgLWEgIiRhc3NldCIgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiCiAgICBmaQogIGRvbmUKICBybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKZmkKdGVzdCAtZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi8uZW52CmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby8uZW52CiMgRW5zdXJlIGlzb2xhdGVkIHByb2R1Y3Rpb24gbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCkhPU1RfUFJFRklYPSBFTlY9cHJvZHVjdGlvbiBXRUJfSU1BR0U9InhpYW94aWEtc2Fhcy13ZWI6JHtSRUxFQVNFX1ZFUlNJT059IiBXRUJfSU1BR0VfVEFSPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi93ZWItJHtSRUxFQVNFX1ZFUlNJT059LnRhciIgc2ggL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXByb2R1Y3Rpb24uc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "RELEASE_VERSION='${GITHUB_REF_NAME}' sh"
production-e2e:
name: Production Browser E2E
runs-on: saas
if: startsWith(github.ref, 'refs/tags/v')
needs: deploy-production
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run production browser E2E
shell: sh
run: |
set -eu
docker run --rm \
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
-e E2E_BROWSER_CHANNEL=chromium \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
mcr.microsoft.com/playwright:v1.45.0-jammy \
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
+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
+103
View File
@@ -0,0 +1,103 @@
name: CI Health Daily Report
on:
schedule:
- cron: '0 1 * * *' # UTC 01:00 = 北京时间 09:00
workflow_dispatch:
permissions:
contents: read
jobs:
ci-health-report:
name: CI健康度每日巡检
runs-on: ci-l2
timeout-minutes: 15
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Generate CI Dashboard HTML
shell: sh
env:
GITEA_TOKEN: ${{ github.token }}
run: |
set +e
echo "=== 生成 CI 健康度 HTML 看板 ==="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output ci_dashboard.html
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ] && [ -f ci_dashboard.html ]; then
HTML_SIZE=$(wc -c < ci_dashboard.html)
echo ""
echo "✅ HTML 看板生成成功 (${HTML_SIZE} bytes)"
echo "路径: $(pwd)/ci_dashboard.html"
# 输出文件内容前几行,方便在 Actions 日志中确认
echo ""
echo "--- 看板预览 (前 5 行) ---"
head -5 ci_dashboard.html
echo "...(完整内容见产物文件)"
else
echo "❌ HTML 看板生成失败 (exit code: $EXIT_CODE)"
fi
echo ""
# 永远成功,看板生成失败不影响主流程
exit 0
- name: Run CI health check and report
shell: sh
env:
GITEA_TOKEN: ${{ github.token }}
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
echo "=== CI健康度每日巡检 ==="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
python3 scripts/ci/ci_health_report.py --limit 30
EXIT_CODE=$?
echo ""
echo "巡检完成 (exit code: $EXIT_CODE)"
# 永远成功,不影响CI状态(通知失败不应该标红)
exit 0
+2083
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
name: CI Trigger Monitor
on:
schedule:
- cron: '*/5 * * * *' # 每5分钟检查一次
workflow_dispatch:
inputs:
stale_threshold:
description: 'CI未触发告警阈值(分钟)'
required: false
default: '5'
permissions:
contents: read
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ci-l2
timeout-minutes: 5
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: Check CI trigger status for all open PRs
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 }}
STALE_THRESHOLD_MIN: ${{ inputs.stale_threshold || 5 }}
run: |
set +e
python3 scripts/ci_trigger_monitor.py
# 监控脚本永远不fail,避免告警风暴
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+79
View File
@@ -0,0 +1,79 @@
name: AI Code Review
on:
pull_request:
types:
- opened
- synchronize
- reopened
# 同一个 PR 只跑一个 review,新的取消旧的
concurrency:
group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
code-review:
name: AI Code Review
runs-on: ci-l2
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
- 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: Install dependencies
run: |
# 确保 python3-pip 可用(兼容不同基础镜像)
if ! python3 -m pip --version >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq python3-pip python3-venv >/dev/null 2>&1
fi
# 部分镜像 ensurepip 方式兜底
if ! python3 -m pip --version >/dev/null 2>&1; then
python3 -m ensurepip --upgrade 2>/dev/null || curl -sS https://bootstrap.pypa.io/get-pip.py | python3
fi
python3 -m pip install --upgrade pip
python3 -m pip install requests
- name: Run AI Code Review
env:
# Gitea 配置(自动从运行环境获取)
GITEA_API_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REPO_NAME: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
LLM_PROVIDER: "coze"
# 扣子模式配置(默认国内站 api.coze.cn
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
# 可选参数
MAX_DIFF_CHARS: "30000"
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 注意:脚本退出码决定job状态
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open
- 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
+624
View File
@@ -0,0 +1,624 @@
name: Daily Health Check
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
on:
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨 3:00
workflow_dispatch:
permissions:
contents: read
jobs:
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke:
name: Production Smoke Test
runs-on: ci-l2
timeout-minutes: 8
outputs:
report: ${{ steps.smoke.outputs.report }}
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: Production health check & smoke test
id: smoke
shell: bash
env:
SMOKE_ENV: production
EXISTING_TOKEN: ${{ secrets.PROD_E2E_TOKEN }}
MODULES: health,assets,generation,subscription,nginx
run: |
set +e
START_TIME=$(date +%s)
chmod +x tests/e2e/api_smoke_test.sh
BASE_URL="https://api.xiaoxiajianji.com" \
WEB_URL="https://saas.xiaoxiajianji.com" \
SMOKE_ENV="${SMOKE_ENV}" \
EXISTING_TOKEN="${EXISTING_TOKEN}" \
MODULES="${MODULES}" \
CLEANUP_ENABLED=0 \
PERF_CHECK_ENABLED=1 \
PERF_WARN_THRESHOLD_MS=500 \
PERF_FAIL_THRESHOLD_MS=5000 \
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/prod-smoke.log
SMOKE_EXIT=${PIPESTATUS[0]}
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== 生产冒烟测试报告 =========="
echo "环境: https://api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
# 提取通过/失败数
grep "测试完成:" /tmp/prod-smoke.log || true
if [ "$SMOKE_EXIT" -eq 0 ]; then
echo "结果: PASS"
echo "report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
grep "失败用例:" /tmp/prod-smoke.log || true
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "======================================"
exit $SMOKE_EXIT
- 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
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests:
name: Staging API Integration Tests
runs-on: ci-l2
timeout-minutes: 10
outputs:
report: ${{ steps.report.outputs.report }}
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: Run API smoke test on staging
id: smoke
shell: bash
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
chmod +x tests/e2e/api_smoke_test.sh
CONTAINER_NAME="ci-test-$$"
docker create --name "$CONTAINER_NAME" \
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
-e WEB_URL=https://staging.xiaoxiajianji.com \
-e TEST_USER="$STAGING_TEST_USER" \
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
-e CLEANUP_ENABLED=1 \
-e PERF_CHECK_ENABLED=1 \
-e PERF_WARN_THRESHOLD_MS=500 \
-e PERF_FAIL_THRESHOLD_MS=3000 \
-w /workspace \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
bash tests/e2e/api_smoke_test.sh 2>&1
docker cp . "$CONTAINER_NAME:/workspace"
docker start -a "$CONTAINER_NAME" 2>&1 | tee /tmp/staging-api-smoke.log
SMOKE_EXIT=${PIPESTATUS[0]}
docker rm "$CONTAINER_NAME" > /dev/null 2>&1 || true
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== Staging API 冒烟测试报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
grep "测试完成:" /tmp/staging-api-smoke.log || true
if [ "$SMOKE_EXIT" -eq 0 ]; then
echo "结果: PASS"
echo "api_report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
grep "失败用例:" /tmp/staging-api-smoke.log || true
echo "api_report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "=============================================="
exit $SMOKE_EXIT
- name: Run Staging API Integration Tests (Playwright)
id: e2e_api
shell: bash
run: |
set +e
START_TIME=$(date +%s)
CONTAINER_NAME="ci-test-$$"
docker create --name "$CONTAINER_NAME" \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc "npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts" 2>&1
docker cp . "$CONTAINER_NAME:/workspace"
docker start -a "$CONTAINER_NAME" 2>&1 | tee /tmp/staging-api-e2e.log
EXIT_CODE=${PIPESTATUS[0]}
docker rm "$CONTAINER_NAME" > /dev/null 2>&1 || true
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== Staging API 集成测试报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
grep -E "passed|failed|timed out" /tmp/staging-api-e2e.log || true
if [ "$EXIT_CODE" -eq 0 ]; then
echo "结果: PASS"
echo "int_report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
echo "int_report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "=============================================="
exit $EXIT_CODE
- name: Set report output
id: report
shell: sh
run: |
if [ "${{ steps.smoke.outputs.api_report }}" = "PASS" ] && [ "${{ steps.e2e_api.outputs.int_report }}" = "PASS" ]; then
echo "report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi
- 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
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e:
name: Staging Browser E2E
runs-on: ci-l2
timeout-minutes: 15
outputs:
report: ${{ steps.e2e.outputs.report }}
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: Run Playwright E2E on staging
id: e2e
shell: bash
run: |
set +e
START_TIME=$(date +%s)
CONTAINER_NAME="ci-test-$$"
docker create --name "$CONTAINER_NAME" --ipc=host \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
-e E2E_BROWSER_CHANNEL=chromium \
-e PLAYWRIGHT_HEADLESS=1 \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1
docker cp . "$CONTAINER_NAME:/workspace"
docker start -a "$CONTAINER_NAME" 2>&1 | tee /tmp/staging-e2e.log
EXIT_CODE=${PIPESTATUS[0]}
docker rm "$CONTAINER_NAME" > /dev/null 2>&1 || true
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== Staging E2E 测试报告 =========="
echo "环境: https://staging.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
grep -E "passed|failed|timed out" /tmp/staging-e2e.log || true
if [ "$EXIT_CODE" -eq 0 ]; then
echo "结果: PASS"
echo "report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "=========================================="
exit $EXIT_CODE
- 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
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: ci-l2
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
steps:
- name: Run performance baseline checks
id: perf
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
echo "=========================================="
echo " 性能基线巡检 - Staging API"
echo " 目标: https://staging-api.xiaoxiajianji.com"
echo "=========================================="
echo ""
TOTAL=0
PASS=0
FAIL=0
WARN=0
WARN_LIST=""
FAIL_LIST=""
# 核心接口配置: 名称|路径|方法|阈值(ms)|失败阈值(ms)
# 核心接口(core): 500ms
# 普通接口(normal): 1000ms
# 重操作接口(heavy): 3000ms
ENDPOINTS="
登录|/api/v1/auth/login|POST|500|3000
获取当前用户|/api/v1/auth/me|GET|500|3000
项目列表|/api/v1/projects|GET|500|3000
素材列表|/api/v1/assets|GET|500|3000
模板列表|/api/v1/templates|GET|500|3000
剪辑计划列表|/api/v1/edit-plans|GET|500|3000
生成任务列表|/api/v1/generation/tasks|GET|500|3000
订阅信息|/api/v1/subscription/current|GET|500|3000
音色列表|/api/v1/voices|GET|1000|5000
健康检查|/health|GET|200|1000
"
# 先登录获取 token
echo "--- 准备: 获取测试 Token ---"
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$LOGIN_BODY" \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
if [ "$AUTH_CODE" = "200" ]; then
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null)
if [ -n "$TOKEN" ]; then
echo "Token 获取成功"
else
echo "Token 解析失败,部分接口可能无法测试"
TOKEN=""
fi
else
echo "登录失败 (HTTP $AUTH_CODE),部分接口将跳过鉴权测试"
TOKEN=""
fi
echo ""
echo "--- 开始性能测试 ---"
echo ""
echo "$ENDPOINTS" | while IFS='|' read -r name path method warn_ms fail_ms; do
[ -z "$name" ] && continue
TOTAL=$((TOTAL + 1))
# 构建 curl 命令
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
fi
# 执行请求
RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
if [ "$HTTP_CODE" -ge 500 ] 2>/dev/null; then
FAIL=$((FAIL + 1))
FAIL_LIST="$FAIL_LIST\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms (FAIL)"
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
FAIL=$((FAIL + 1))
FAIL_LIST="$FAIL_LIST\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms"
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms (FAIL)"
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
WARN=$((WARN + 1))
WARN_LIST="$WARN_LIST\n ⚠️ $name - ${ELAPSED_MS}ms > ${warn_ms}ms"
echo "⚠️ $name - ${ELAPSED_MS}ms (WARN, threshold: ${warn_ms}ms)"
PASS=$((PASS + 1))
else
PASS=$((PASS + 1))
echo "✅ $name - ${ELAPSED_MS}ms (OK, threshold: ${warn_ms}ms)"
fi
done
# 由于 while 在子 shell 中执行,用文件传递结果
# 重新跑一次用文件计数方式
echo ""
echo "--- 汇总性能数据 ---"
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== 性能基线巡检报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
echo "======================================"
- name: Generate performance report
id: report
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
echo ""
echo "=========================================="
echo " 性能基线巡检 - 详细报告"
echo "=========================================="
TOTAL=0
PASS=0
FAIL=0
WARN=0
RESULTS=""
START_TIME=$(date +%s)
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
# 先登录获取 token
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$LOGIN_BODY" \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
TOKEN=""
if [ "$AUTH_CODE" = "200" ]; then
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || echo "")
fi
run_perf_test() {
local name="$1" path="$2" method="$3" warn_ms="$4" fail_ms="$5"
TOTAL=$((TOTAL + 1))
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
fi
local RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
local HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
local TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
local ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
if echo "$HTTP_CODE" | grep -q "^[5]"; then
FAIL=$((FAIL + 1))
RESULTS="$RESULTS\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms [FAIL]"
return 1
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
FAIL=$((FAIL + 1))
RESULTS="$RESULTS\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
return 1
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
WARN=$((WARN + 1))
PASS=$((PASS + 1))
RESULTS="$RESULTS\n ⚠️ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [WARN]"
echo "⚠️ $name - ${ELAPSED_MS}ms > 阈值 ${warn_ms}ms [WARN]"
return 0
else
PASS=$((PASS + 1))
RESULTS="$RESULTS\n ✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
echo "✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
return 0
fi
}
echo ""
echo "=== 核心接口 (阈值: 500ms / 3000ms) ==="
run_perf_test "登录" "/api/v1/auth/login" "POST" 500 3000 || true
run_perf_test "获取当前用户" "/api/v1/auth/me" "GET" 500 3000 || true
run_perf_test "项目列表" "/api/v1/projects" "GET" 500 3000 || true
run_perf_test "素材列表" "/api/v1/assets" "GET" 500 3000 || true
run_perf_test "模板列表" "/api/v1/templates" "GET" 500 3000 || true
run_perf_test "剪辑计划列表" "/api/v1/edit-plans" "GET" 500 3000 || true
run_perf_test "生成任务列表" "/api/v1/generation/tasks" "GET" 500 3000 || true
run_perf_test "订阅信息" "/api/v1/subscription/current" "GET" 500 3000 || true
echo ""
echo "=== 普通接口 (阈值: 1000ms / 5000ms) ==="
run_perf_test "音色列表" "/api/v1/voices" "GET" 1000 5000 || true
echo ""
echo "=== 基础接口 (阈值: 200ms / 1000ms) ==="
run_perf_test "健康检查" "/health" "GET" 200 1000 || true
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== 性能基线巡检报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "总接口: ${TOTAL}"
echo "通过: ${PASS}"
echo "失败: ${FAIL}"
echo "警告: ${WARN}"
echo "耗时: ${ELAPSED}s"
echo "======================================"
# 写入结果文件供 report job 使用
echo "${TOTAL}" > /tmp/perf_total
echo "${PASS}" > /tmp/perf_pass
echo "${FAIL}" > /tmp/perf_fail
echo "${WARN}" > /tmp/perf_warn
echo "${ELAPSED}" > /tmp/perf_elapsed
if [ "$FAIL" -gt 0 ]; then
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
echo "perf_detail=fail:${FAIL}:warn:${WARN}" >> "${GITHUB_OUTPUT}"
exit 1
else
echo "report=PASS" >> "${GITHUB_OUTPUT}"
if [ "$WARN" -gt 0 ]; then
echo "perf_detail=pass:warn:${WARN}" >> "${GITHUB_OUTPUT}"
else
echo "perf_detail=pass" >> "${GITHUB_OUTPUT}"
fi
exit 0
fi
- 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
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: ci-l2
timeout-minutes: 2
if: always()
needs:
- production-smoke
- staging-api-tests
- staging-e2e
- performance-check
steps:
- name: Print summary report
shell: sh
run: |
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo "║ 每日巡检报告 ║"
echo "╠══════════════════════════════════════════════════════╣"
# 获取各 job 状态
PROD_STATUS="${{ needs.production-smoke.result }}"
STAGING_API_STATUS="${{ needs.staging-api-tests.result }}"
STAGING_E2E_STATUS="${{ needs.staging-e2e.result }}"
PERF_STATUS="${{ needs.performance-check.result }}"
format_result() {
if [ "$1" = "success" ]; then
echo "✅ PASS"
elif [ "$1" = "failure" ]; then
echo "❌ FAIL"
elif [ "$1" = "skipped" ]; then
echo "⏭️ SKIP"
else
echo "❓ UNKNOWN ($1)"
fi
}
echo "║"
echo "║ 生产冒烟测试: $(format_result "$PROD_STATUS")"
echo "║ Staging API: $(format_result "$STAGING_API_STATUS")"
echo "║ Staging E2E: $(format_result "$STAGING_E2E_STATUS")"
echo "║ 性能基线巡检: $(format_result "$PERF_STATUS")"
echo "║"
echo "║ 巡检时间: $(date '+%Y-%m-%d %H:%M:%S UTC')"
echo "║"
# 判断整体状态
ALL_PASS=true
FAILED_ITEMS=""
for status_name in "$PROD_STATUS:生产冒烟" "$STAGING_API_STATUS:Staging API" "$STAGING_E2E_STATUS:Staging E2E" "$PERF_STATUS:性能基线"; do
STATUS=$(echo "$status_name" | cut -d: -f1)
NAME=$(echo "$status_name" | cut -d: -f2)
if [ "$STATUS" != "success" ] && [ "$STATUS" != "skipped" ]; then
ALL_PASS=false
FAILED_ITEMS="$FAILED_ITEMS $NAME"
fi
done
echo "╠══════════════════════════════════════════════════════╣"
if [ "$ALL_PASS" = "true" ]; then
echo "║ 整体状态: ✅ 全部通过 ║"
else
echo "║ 整体状态: ❌ 存在失败 ║"
echo "║ 失败项: ${FAILED_ITEMS} ║"
fi
echo "╚══════════════════════════════════════════════════════╝"
echo ""
# 如果有失败项,以非零退出码结束(方便 Gitea 标记流水线失败)
if [ "$ALL_PASS" = "false" ]; then
echo "⚠️ 部分巡检项失败,请检查上方日志获取详细信息。"
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
# 但其他失败的 job 已经让整体流水线标记为失败
fi
- 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
+56
View File
@@ -0,0 +1,56 @@
name: PR Auto Scan
# 定时扫描所有open PR,对CI全绿的触发审批/合并
# 作为短作业模式的兜底,防止事件驱动遗漏
on:
schedule:
- cron: "*/5 * * * *" # 每5分钟扫描一次
workflow_dispatch:
permissions:
contents: read
jobs:
auto-scan:
name: Auto Scan Open PRs
runs-on: ci-check
timeout-minutes: 5
if: github.repository == 'xiaoxia/xiaoxia-saas'
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/pr_auto_scan.py?ref=develop" -o /tmp/pr_auto_scan.py
python3 /tmp/pr_auto_scan.py --help > /dev/null 2>&1 || {
# fallback: checkout
echo "使用checkout方式"
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=develop" | bash
}
- name: Scan and auto process PRs
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
run: |
set -eu
echo "=== 扫描所有open PR并自动处理 ==="
echo "时间: $(date)"
echo
python3 /tmp/pr_auto_scan.py --token "$REVIEW_TOKEN" --repo "$GITHUB_REPOSITORY" --base develop --approve --merge --dry-run false
echo ""
echo "✅ 扫描完成"
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "" || true
+117
View File
@@ -0,0 +1,117 @@
name: PR Automation
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pr-automation-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 3 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
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: "🔍 脚本语法自检"
shell: bash
run: |
ERROR=0
for f in scripts/ci/*.sh; do [ -f "$f" ] && bash -n "$f" 2>&1 || ERROR=$((ERROR+1)); done
for f in scripts/ci/*.py; do [ -f "$f" ] && python3 -m py_compile "$f" 2>&1 || ERROR=$((ERROR+1)); done
if [ "$ERROR" -ne 0 ]; then echo "❌ 语法自检失败 ($ERROR个)"; exit 1; fi
echo "✅ 脚本语法自检通过"
- name: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
bash scripts/ci/auto_approve.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
auto-merge:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
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: "🔍 脚本语法自检(防止脚本bug导致所有PR挂掉)"
shell: bash
run: |
echo "=== CI脚本语法自检 ==="
ERROR=0
for f in scripts/ci/*.sh; do
[ -f "$f" ] || continue
if ! bash -n "$f" 2>&1; then
echo "FAIL: $f"
ERROR=1
fi
done
for f in scripts/ci/*.py; do
[ -f "$f" ] || continue
if ! python3 -m py_compile "$f" 2>&1; then
echo "FAIL: $f"
ERROR=1
fi
done
if [ "$ERROR" -ne 0 ]; then
echo "❌ 脚本语法自检失败"
exit 1
fi
echo "✅ 所有CI脚本语法自检通过"
- name: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
bash scripts/ci/auto_merge.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
+207
View File
@@ -0,0 +1,207 @@
name: Preview Cleanup
on:
pull_request:
types:
- closed
branches:
- main
- develop
permissions:
contents: read
pull-requests: write
jobs:
cleanup-preview:
name: Cleanup Preview Environment
runs-on: runtime-builder
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Extract PR number
shell: sh
run: |
set -eu
# 优先从event payload中读取(兼容所有PR事件类型)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
fi
# fallback: 从GITHUB_REF中提取
if [ -z "${PR_NUMBER:-}" ]; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
# 再fallback: 兼容纯数字ref
if [ -z "${PR_NUMBER:-}" ] || ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
echo "WARNING: Could not extract PR number cleanly, using raw ref suffix"
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
fi
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "Preview dir: /var/www/preview/pr-${PR_NUMBER}"
- name: Install SSH client
shell: sh
run: |
set -eu
# 先检查是否已存在ssh
if command -v ssh >/dev/null 2>&1 && command -v ssh-keyscan >/dev/null 2>&1; then
echo "SSH client already available: $(ssh -V 2>&1)"
exit 0
fi
# 尝试多种包管理器安装
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client >/dev/null 2>&1
echo "openssh-client installed via apk"
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed via apt-get"
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via yum"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via dnf"
else
echo "ERROR: No package manager found and ssh not pre-installed"
which ssh 2>/dev/null || echo " ssh: not found"
which ssh-keyscan 2>/dev/null || echo " ssh-keyscan: not found"
exit 1
fi
- name: Remove preview directory from server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
preview_user="${PREVIEW_SSH_USER:-root}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (builder key)"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (home key)"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
ls -la /root/.ssh/ 2>/dev/null || true
exit 1
fi
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 检查目录是否存在
DIR_EXISTS=$(ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi")
if [ "$DIR_EXISTS" = "yes" ]; then
echo "Removing preview directory: ${preview_dir}"
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"rm -rf ${preview_dir} && echo 'Preview directory removed successfully'"
echo "Cleanup completed: ${preview_dir}"
else
echo "Preview directory does not exist: ${preview_dir}, nothing to clean up"
fi
- name: Comment cleanup notice on PR
if: success()
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
# 从event payload读取PR号(最可靠)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
else
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
export PR_NUMBER
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py cleanup)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Cleanup comment posted"
- 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
+296
View File
@@ -0,0 +1,296 @@
name: Preview Deploy
on:
pull_request:
types:
- opened
- synchronize
- reopened
branches:
- main
- develop
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - 预览环境补跑"
permissions:
contents: read
pull-requests: write
concurrency:
group: preview-deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
name: Deploy Preview Environment
runs-on: runtime-builder
timeout-minutes: 20
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Record job start time
shell: sh
run: |
set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
- name: Extract PR number
shell: sh
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Build frontend
shell: sh
run: |
set -eu
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm install"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm install..."
if ! npm install --include=dev; then
echo "npm install failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm install --include=dev
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
- name: Install SSH client and rsync
shell: sh
run: |
set -eu
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client rsync >/dev/null 2>&1
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients rsync >/dev/null 2>&1
else
echo "ERROR: No package manager found"
exit 1
fi
echo "openssh-client and rsync installed"
- name: Deploy preview to server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
preview_user="${PREVIEW_SSH_USER:-root}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (builder key)"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (home key)"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
ls -la /root/.ssh/ 2>/dev/null || true
exit 1
fi
# SSH密钥完整性自检
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
echo "ERROR: SSH密钥损坏(private key contents do not match public"
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
head -2 "$key_path"
exit 1
fi
echo "SSH key integrity check passed"
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 创建预览目录并上传文件
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'"
# 使用rsync上传dist目录内容
rsync -avz --delete -e "ssh -p ${preview_port} -i ${key_path} -o StrictHostKeyChecking=no" \
apps/web/dist/ \
"${preview_user}@${preview_host}:${preview_dir}/"
echo "Preview deployed to: ${preview_dir}"
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Comment preview link on PR
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
export PR_NUMBER PREVIEW_URL
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py deploy)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
EXISTING_COMMENT_ID=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
import sys, json
try:
for c in json.load(sys.stdin):
if '预览环境已部署' in c.get('body', ''):
print(c['id'])
break
except Exception:
pass
")
if [ -n "$EXISTING_COMMENT_ID" ]; then
curl -s -X PATCH \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \
> /dev/null
echo "Comment updated"
else
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Comment posted"
fi
- name: Job duration summary
if: always()
shell: sh
run: |
set +eu
if [ -n "$JOB_START_TIME" ]; then
END_TIME=$(date +%s)
DURATION=$((END_TIME - JOB_START_TIME))
MINS=$((DURATION / 60))
SECS=$((DURATION % 60))
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
echo "=== Job Duration: ${MINS}m${SECS}s ==="
else
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
echo "=== Job Duration: unknown ==="
fi
- 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="Deploy Preview Environment" 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
-69
View File
@@ -1,69 +0,0 @@
name: Test SSH Secret
on:
push:
branches: [develop]
paths:
- '.gitea/workflows/test-ssh-secret.yml'
jobs:
test-ssh:
runs-on: ubuntu-22.04
steps:
- name: Install SSH client
run: |
which ssh || (apt-get update && apt-get install -y openssh-client)
ssh -V
- name: Debug environment
run: |
echo "=== Environment ==="
echo "Runner hostname: $(hostname)"
echo "Runner IP: $(hostname -i || echo 'unknown')"
echo "Current user: $(whoami)"
echo "=== Secrets check ==="
if [ -n "$STAGING_SSH_HOST" ]; then
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
else
echo "STAGING_SSH_HOST: [EMPTY]"
fi
if [ -n "$STAGING_SSH_USER" ]; then
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
else
echo "STAGING_SSH_USER: [EMPTY]"
fi
if [ -n "$STAGING_SSH_KEY" ]; then
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
else
echo "STAGING_SSH_KEY: [EMPTY]"
fi
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
echo "=== SSH Key fingerprint ==="
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
env:
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
- name: Test SSH connection
run: |
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 \
-o BatchMode=yes \
-v \
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
echo "=== SSH Test Complete ==="
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
-163
View File
@@ -1,163 +0,0 @@
name: Tests
on:
pull_request:
branches: [ main ]
jobs:
test:
runs-on: runtime-builder
steps:
- name: Checkout code
shell: sh
run: |
set -eu
python - <<'PY'
import io
import os
import tarfile
import time
import urllib.error
import urllib.request
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Show Python version
shell: sh
run: |
set -eu
python --version
python -m pip --version
- name: Install dependencies
shell: sh
run: |
set -eu
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
- name: Run unit tests
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
- name: Run integration tests
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/integration -q --timeout=60 -x
lint:
runs-on: runtime-builder
steps:
- name: Checkout code
shell: sh
run: |
set -eu
python - <<'PY'
import io
import os
import tarfile
import time
import urllib.error
import urllib.request
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Install dependencies
shell: sh
run: |
set -eu
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
- name: Run Black (check only)
shell: sh
run: |
set -eu
python -m black --check alembic apps packages tests scripts
- name: Run Flake8
shell: sh
run: |
set -eu
python -m flake8 apps packages tests --count --statistics
+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"
@@ -0,0 +1,56 @@
"""Add user profile fields (name, avatar, updated_at)
Revision ID: 026
Revises: 025
Create Date: 2026-07-05
补录用户资料字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查,避免在已有字段的库上执行报错。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "026"
down_revision = "025"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "name"):
op.add_column("users", sa.Column("name", sa.String(100), nullable=True))
if not _column_exists("users", "avatar"):
op.add_column("users", sa.Column("avatar", sa.String(500), nullable=True))
if not _column_exists("users", "updated_at"):
op.add_column(
"users",
sa.Column(
"updated_at",
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_column("users", "updated_at")
op.drop_column("users", "avatar")
op.drop_column("users", "name")
@@ -0,0 +1,44 @@
"""Add user ban fields (ban_reason, ban_at)
Revision ID: 027
Revises: 026
Create Date: 2026-07-05
补录用户封禁字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "027"
down_revision = "026"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "ban_reason"):
op.add_column("users", sa.Column("ban_reason", sa.Text(), nullable=True))
if not _column_exists("users", "ban_at"):
op.add_column("users", sa.Column("ban_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "ban_at")
op.drop_column("users", "ban_reason")
@@ -0,0 +1,44 @@
"""Add user admin fields (admin_status, admin_remarks)
Revision ID: 028
Revises: 027
Create Date: 2026-07-05
补录管理员备注字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "admin_status"):
op.add_column("users", sa.Column("admin_status", sa.String(50), nullable=True))
if not _column_exists("users", "admin_remarks"):
op.add_column("users", sa.Column("admin_remarks", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "admin_remarks")
op.drop_column("users", "admin_status")
+40
View File
@@ -0,0 +1,40 @@
"""Add user phone field
Revision ID: 029
Revises: 028
Create Date: 2026-07-05
补录用户手机号字段。生产数据库已手动添加过该字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "029"
down_revision = "028"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "phone"):
op.add_column("users", sa.Column("phone", sa.String(20), nullable=True))
def downgrade() -> None:
op.drop_column("users", "phone")
@@ -0,0 +1,68 @@
"""Add tags and asset_tags tables
Revision ID: 030
Revises: 029
Create Date: 2026-07-07
新增标签表和素材-标签关联表,支持规范化多对多标签管理。
"""
import sqlalchemy as sa
from alembic import op
revision = "030"
down_revision = "029"
branch_labels = None
depends_on = None
def _table_exists(table: str) -> bool:
ctx = op.get_context()
if ctx.as_sql:
return False
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = :table"),
{"table": table},
)
return (result.scalar() or 0) > 0
def upgrade() -> None:
if not _table_exists("tags"):
op.create_table(
"tags",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("user_id", "name", name="uq_tags_user_name"),
)
op.create_index("ix_tags_user_id", "tags", ["user_id"])
if not _table_exists("asset_tags"):
op.create_table(
"asset_tags",
sa.Column("asset_id", sa.String(36), primary_key=True),
sa.Column("tag_id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_asset_tags_tag_id", "asset_tags", ["tag_id"])
def downgrade() -> None:
op.drop_index("ix_asset_tags_tag_id", table_name="asset_tags")
op.drop_table("asset_tags")
op.drop_index("ix_tags_user_id", table_name="tags")
op.drop_table("tags")
@@ -0,0 +1,33 @@
"""Add file_hash to assets and ingest_jobs
Revision ID: 031
Revises: 030
Create Date: 2026-07-07
为素材去重检测功能添加 file_hash 字段。
"""
import sqlalchemy as sa
from alembic import op
revision = "031"
down_revision = "030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("assets", sa.Column("file_hash", sa.String(64), nullable=True))
op.create_index(op.f("ix_assets_file_hash"), "assets", ["file_hash"])
op.add_column("ingest_jobs", sa.Column("file_hash", sa.String(64), nullable=True))
op.create_index(op.f("ix_ingest_jobs_file_hash"), "ingest_jobs", ["file_hash"])
def downgrade() -> None:
op.drop_index(op.f("ix_ingest_jobs_file_hash"), table_name="ingest_jobs")
op.drop_column("ingest_jobs", "file_hash")
op.drop_index(op.f("ix_assets_file_hash"), table_name="assets")
op.drop_column("assets", "file_hash")
@@ -0,0 +1,28 @@
"""Add asset_select_mode to generation_tasks
Revision ID: 032
Revises: 031
Create Date: 2026-07-07
素材库自动匹配功能:为 generation_tasks 表添加 asset_select_mode 字段。
"""
import sqlalchemy as sa
from alembic import op
revision = "032"
down_revision = "031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("asset_select_mode", sa.String(20), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "asset_select_mode")
@@ -0,0 +1,31 @@
"""Add batch_id to generation_tasks
Revision ID: 033
Revises: 032
Create Date: 2026-07-07
视频查重功能:为 generation_tasks 表添加 batch_id 字段,
用于关联同一次批量生成请求中的多个任务。
"""
import sqlalchemy as sa
from alembic import op
revision = "033"
down_revision = "032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("batch_id", sa.String(32), nullable=False, server_default=""),
)
op.create_index(op.f("ix_generation_tasks_batch_id"), "generation_tasks", ["batch_id"])
def downgrade() -> None:
op.drop_index(op.f("ix_generation_tasks_batch_id"), table_name="generation_tasks")
op.drop_column("generation_tasks", "batch_id")
+6
View File
@@ -16,6 +16,7 @@ from app.api.routes.jobs import router as jobs_router
from app.api.routes.projects import router as projects_router
from app.api.routes.recipes import router as recipes_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
from app.api.routes.templates import router as templates_router
from app.api.routes.titles import router as titles_router
@@ -38,6 +39,11 @@ api_router.include_router(
prefix="/projects",
tags=["Project"],
)
api_router.include_router(
tags_router,
prefix="/tags",
tags=["Tag"],
)
api_router.include_router(
task_center_router,
tags=["TaskCenter"],
+58 -1
View File
@@ -5,6 +5,7 @@ from app.dependencies import get_asset_library_repository, get_project_repositor
from app.schemas.asset_library import (
AssetLibraryResponse,
CreateAssetLibraryRequest,
EnsureDefaultLibraryRequest,
ListAssetLibrariesResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -15,7 +16,7 @@ from packages.application import (
GetProjectUseCase,
ListAssetLibrariesUseCase,
)
from packages.domain import AssetLibraryKind
from packages.domain import AssetLibrary, AssetLibraryKind
router = APIRouter()
@@ -43,6 +44,7 @@ def _to_asset_library_response(item) -> AssetLibraryResponse:
@router.get("", response_model=ListAssetLibrariesResponse)
def list_asset_libraries(
project_id: str | None = Query(None),
kind: str | None = Query(None, pattern="^(video|voice|image)$"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repository: Any = Depends(get_asset_library_repository),
project_repository: Any = Depends(get_project_repository),
@@ -66,6 +68,11 @@ def list_asset_libraries(
all_items.extend(use_case.execute(proj.id))
items = all_items
# 按 kind 过滤(可选)
if kind:
kind_enum = AssetLibraryKind(kind)
items = [item for item in items if item.kind == kind_enum]
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
@@ -90,3 +97,53 @@ def create_asset_library(
)
)
return _to_asset_library_response(item)
# 默认素材库名称映射
_DEFAULT_LIBRARY_NAMES = {
"video": "视频素材库",
"voice": "配音素材库",
"image": "图片素材库",
}
@router.post("/ensure-default", response_model=AssetLibraryResponse)
def ensure_default_library(
request: EnsureDefaultLibraryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repository: Any = Depends(get_asset_library_repository),
project_repository: Any = Depends(get_project_repository),
) -> AssetLibraryResponse:
"""确保项目下指定 kind 的默认素材库存在,已存在则直接返回,不存在则自动创建。"""
project = project_repository.find_by_id(request.project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
kind = AssetLibraryKind(request.kind)
# 查找该项目下同 kind 的素材库,返回第一个
existing = asset_library_repository.find_by_project(request.project_id)
for lib in existing:
if lib.kind == kind:
return _to_asset_library_response(lib)
# 不存在 → 自动创建
import uuid
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
library = AssetLibrary(
id=str(uuid.uuid4()),
project_id=request.project_id,
name=default_name,
kind=kind,
asset_count=0,
total_size=0,
created_at=now,
updated_at=now,
)
created = asset_library_repository.create(library)
return _to_asset_library_response(created)
+278 -21
View File
@@ -7,14 +7,23 @@ from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_project_repository,
get_tag_repository,
)
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest
from app.schemas.asset import (
AssetResponse,
BatchDeleteRequest,
BatchDeleteResponse,
CreateAssetRequest,
ListAssetsResponse,
UpdateAssetRequest,
UpdateAssetReviewRequest,
)
from app.schemas.tag import TagAssetsRequest
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
CreateAssetCommand,
CreateAssetUseCase,
ListAssetsUseCase,
)
from packages.domain import AssetStatus, ClassificationStatus
@@ -59,6 +68,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
classification_status=item.classification_status.value,
quality_score=item.quality_score,
uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []),
)
@@ -75,6 +85,13 @@ def _check_project_access(project_id: str, user_id: str, project_repository) ->
def list_assets(
library_id: Optional[str] = Query(None),
project_id: Optional[str] = Query(None),
kind: Optional[str] = Query(None, pattern="^(video|voice|image)$"),
keyword: Optional[str] = Query(None, description="按名称模糊匹配"),
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
@@ -82,32 +99,148 @@ def list_assets(
) -> ListAssetsResponse:
user_id = authenticated_user.user.id
# 模式1:指定 library_id → 返回该库的素材
# kind → file_type 映射(voice 对应 audio
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
# 解析 tag_ids 参数(逗号分隔)
filter_tag_ids: list[str] | None = None
if tag_ids:
filter_tag_ids = [t.strip() for t in tag_ids.split(",") if t.strip()]
if not filter_tag_ids:
filter_tag_ids = None
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
def _apply_memory_filters(items):
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
result = items
if keyword:
kw = keyword.lower()
result = [i for i in result if kw in (i.name or "").lower()]
if gender:
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
if style:
result = [i for i in result if (i.metadata or {}).get("style") == style]
if filter_tag_ids:
tag_set = set(filter_tag_ids)
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
return result
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
if not needs_memory_filter:
ft = kind_to_file_type.get(kind) if kind else None
# 模式1:指定 library_id
if library_id:
library = asset_library_repository.get(library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
_check_project_access(library.project_id, user_id, project_repository)
if ft:
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
else:
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id)
return ListAssetsResponse(
items=[_to_asset_response(item) for item in items],
total=total,
skip=skip,
limit=limit,
)
# 模式2:指定 project_id
if project_id:
_check_project_access(project_id, user_id, project_repository)
if ft:
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
all_items = asset_repository.find_by_project(project_id)
items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
total = len(items)
paged = items[skip : skip + limit]
else:
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(project_id)
paged = items
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged],
total=total,
skip=skip,
limit=limit,
)
# 模式3:跨项目(无 library_id/project_id
try:
projects = project_repository.find_accessible_projects(user_id)
except Exception:
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
project_ids = [p.id for p in projects]
if not project_ids:
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
total = asset_repository.count_by_project_ids(project_ids)
# 跨项目分页:逐项目累积直到凑够一页
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project(pid)
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
paged_items.extend(proj_items)
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged_items],
total=total,
skip=skip,
limit=limit,
)
# ── 内存过滤路径:有 keyword/gender/style 时,加载全量后内存过滤 ──
if library_id:
library = asset_library_repository.get(library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
_check_project_access(library.project_id, user_id, project_repository)
items = asset_repository.find_by_library(library_id)
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
# 模式2:指定 project_id → 返回该项目所有素材
if project_id:
if kind:
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
else:
all_items = asset_repository.find_by_library(library_id)
elif project_id:
_check_project_access(project_id, user_id, project_repository)
items = asset_repository.find_by_project(project_id)
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
all_items = asset_repository.find_by_project(project_id)
else:
try:
projects = project_repository.find_accessible_projects(user_id)
except Exception:
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
all_items = []
for proj in projects:
all_items.extend(asset_repository.find_by_project(proj.id))
# 模式3:都不传 → 返回用户可访问的所有项目的所有素材
try:
projects = project_repository.find_accessible_projects(user_id)
except Exception:
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
return ListAssetsResponse(items=[])
all_items = []
for proj in projects:
all_items.extend(asset_repository.find_by_project(proj.id))
return ListAssetsResponse(items=[_to_asset_response(item) for item in all_items])
# 应用 kind 过滤(如果有)+ keyword/gender/style
if kind:
ft = kind_to_file_type.get(kind)
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
filtered = _apply_memory_filters(all_items)
total = len(filtered)
paged = filtered[skip : skip + limit]
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged],
total=total,
skip=skip,
limit=limit,
)
def _apply_asset_review_status(item, review_status: str):
@@ -135,6 +268,130 @@ def update_asset_review_status(
return _to_asset_response(updated)
@router.post("/batch-delete", response_model=BatchDeleteResponse)
def batch_delete_assets(
request: BatchDeleteRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> BatchDeleteResponse:
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
user_id = authenticated_user.user.id
deleted_ids: list[str] = []
failed_ids: list[str] = []
for asset_id in request.ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_ids.append(asset_id)
continue
try:
_check_project_access(item.project_id, user_id, project_repository)
deleted_ids.append(asset_id)
except HTTPException:
failed_ids.append(asset_id)
if deleted_ids:
asset_repository.batch_delete(deleted_ids)
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
@router.get("/{asset_id}", response_model=AssetResponse)
def get_asset(
asset_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> AssetResponse:
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
return _to_asset_response(item)
@router.put("/{asset_id}", response_model=AssetResponse)
def update_asset(
asset_id: str,
request: UpdateAssetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> AssetResponse:
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
# 合并可修改字段
if request.name is not None:
item.name = request.name
if request.metadata is not None:
item.metadata = {**item.metadata, **request.metadata}
if request.tags is not None:
item.metadata = {**item.metadata, "tags": request.tags}
updated = asset_repository.update(item)
return _to_asset_response(updated)
@router.delete("/{asset_id}", status_code=204)
def delete_asset(
asset_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> None:
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
asset_repository.delete(asset_id)
@router.post("/{asset_id}/tags", response_model=AssetResponse)
def tag_asset(
asset_id: str,
request: TagAssetsRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
tag_repository: Any = Depends(get_tag_repository),
) -> AssetResponse:
"""给素材打标签。"""
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
for tag_id in request.tag_ids:
tag = tag_repository.get(tag_id)
if tag is None:
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found")
if tag.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail=f"无权使用标签 {tag_id}")
item.add_tag(tag_id)
updated = asset_repository.update(item)
return _to_asset_response(updated)
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
def untag_asset(
asset_id: str,
tag_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""取消素材的标签。"""
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
item.remove_tag(tag_id)
asset_repository.update(item)
@router.post("", response_model=AssetResponse)
def create_asset(
request: CreateAssetRequest,
+98 -1
View File
@@ -6,13 +6,14 @@ app.dependencies and authentication behavior lives in application use cases.
"""
import logging
import os
from typing import Optional
import jwt
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
from app.config import settings
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, EmailStr
@@ -284,3 +285,99 @@ def _translate_auth_error(error: str | None) -> str:
"Display name is required": "显示名称不能为空",
}
return translations.get(error or "", error or "注册失败")
class WechatSyncRequest(BaseModel):
openid: str
unionid: Optional[str] = None
nickname: Optional[str] = None
avatar_url: Optional[str] = None
source: str = "miniapp"
class WechatSyncResponse(BaseModel):
access_token: str
token: str
refresh_token: str
user_id: str
user: dict
user_info: dict
is_new_user: bool
expires_in: int
def _get_internal_api_keys() -> list[str]:
"""获取内部 API Key 列表
优先级:
1. INTERNAL_API_KEYS 环境变量
2. /app/generated/internal_api_keys.txt 文件 (volume 持久化)
"""
env_keys = os.environ.get("INTERNAL_API_KEYS", "")
if env_keys:
return [k.strip() for k in env_keys.split(",") if k.strip()]
# 从持久化文件读取
try:
with open("/app/generated/internal_api_keys.txt", "r") as f:
content = f.read().strip()
if content:
return [k.strip() for k in content.split(",") if k.strip()]
except Exception:
logger.debug("Failed to read internal API keys from file", exc_info=True)
return []
def _verify_internal_api_key(x_api_key: str | None = Header(None)) -> bool:
"""验证内部 API Key
- 已配置时:必须匹配 INTERNAL_API_KEYS 中的 key
- 未配置且非生产环境:放行(方便开发)
- 未配置且生产环境:拒绝
"""
env = os.environ.get("APP_ENV", os.environ.get("ENV", "development")).lower()
key_list = _get_internal_api_keys()
if not key_list:
if env in ("production", "prod"):
raise HTTPException(status_code=401, detail="内部接口未配置 API Key")
return True
if x_api_key and x_api_key.strip() in key_list:
return True
raise HTTPException(status_code=401, detail="无效的 API Key")
@router.post("/wechat-sync", response_model=WechatSyncResponse, include_in_schema=False)
async def wechat_sync(
request: WechatSyncRequest,
user_repository: UserRepository = Depends(get_user_repository),
_: bool = Depends(_verify_internal_api_key),
):
"""
微信同步登录/注册(系统级内部接口)
由 BFF 层通过 API Key 调用,不直接面向终端用户。
根据 openid 查找或创建用户,返回 SaaS token。
"""
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as UseCaseRequest
from packages.application.auth.wechat_sync_use_case import (
WechatSyncUseCase,
)
use_case = WechatSyncUseCase(user_repository=user_repository)
use_case_request = UseCaseRequest(
openid=request.openid,
unionid=request.unionid,
nickname=request.nickname,
avatar_url=request.avatar_url,
source=request.source,
)
response, error = use_case.execute(use_case_request)
if error:
raise HTTPException(status_code=400, detail=error)
return WechatSyncResponse(**response.to_dict())
+40 -1
View File
@@ -19,6 +19,7 @@ from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
@@ -42,20 +43,33 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
CHUNK_EXPIRY_HOURS = 24
# Allowed file types (consistent with existing upload.py)
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
ALLOWED_MIME_TYPES = {
# Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/svg+xml",
# Video
"video/mp4",
"video/quicktime",
"video/mpeg",
"video/x-msvideo",
"video/webm",
"video/x-matroska",
"video/3gpp",
# Audio
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/mp3",
"audio/flac",
"audio/aac",
"audio/x-m4a",
"audio/webm",
}
# Chunk storage root directory
@@ -360,6 +374,7 @@ async def complete_chunked_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ChunkedUploadCompleteResponse:
@@ -423,6 +438,29 @@ async def complete_chunked_upload(
content_type=meta["content_type"],
)
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
if request.file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=request.library_id,
file_hash=request.file_hash,
)
if existing is not None:
logger.info(
"素材去重命中(chunked): library=%s hash=%s existing_asset=%s",
request.library_id,
request.file_hash,
existing.id,
)
meta["status"] = "completed"
_save_upload_meta(upload_id, meta)
return ChunkedUploadCompleteResponse(
storage_key=storage_key,
ingest_job_id="",
url=file_url,
duplicated=True,
asset_id=existing.id,
)
# Create ingest job
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -430,6 +468,7 @@ async def complete_chunked_upload(
project_id=meta["project_id"],
library_id=meta["library_id"],
storage_key=storage_key,
file_hash=request.file_hash,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
+86 -17
View File
@@ -1,3 +1,5 @@
import random
import uuid
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
@@ -14,6 +16,7 @@ from app.schemas.generated_video import (
ListGeneratedVideosResponse,
)
from app.schemas.generation_task import (
BatchGenerationTaskResponse,
CreateGenerationTaskRequest,
GenerationTaskResponse,
ListGenerationTasksResponse,
@@ -51,6 +54,8 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
title_ids=task.title_ids,
voice_ids=task.voice_ids,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -85,6 +90,49 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
)
def _select_assets_from_library(
assets: list,
mode: str,
count: int,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
Args:
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
Returns:
选中的素材 ID 列表
"""
ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
if not ready_video_assets:
return []
if mode == "random":
selected = (
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
)
return [a.id for a in selected]
if mode == "smart":
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
sorted_assets = sorted(
ready_video_assets,
key=lambda a: (
a.quality_score if a.quality_score is not None else 0.0,
a.duration if a.duration is not None else 0.0,
),
reverse=True,
)
selected = sorted_assets if count <= 0 else sorted_assets[:count]
return [a.id for a in selected]
# 默认 all 模式:返回全部 ready 视频素材
return [a.id for a in ready_video_assets]
def _resolve_project_and_library(
request: CreateGenerationTaskRequest,
project_repository: Any,
@@ -122,7 +170,7 @@ def _resolve_project_and_library(
return project_id, asset_library_id
@router.post("/tasks", response_model=GenerationTaskResponse)
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -130,12 +178,13 @@ def create_generation_task(
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> GenerationTaskResponse:
) -> BatchGenerationTaskResponse:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
resolved_asset_ids: list[str] = list(request.asset_ids)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
@@ -144,23 +193,42 @@ def create_generation_task(
assets = asset_repository.find_by_library(asset_library_id)
_ensure_library_has_ready_video_assets(assets)
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
if not resolved_asset_ids:
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=request.asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
count = request.count
created_tasks = []
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
for _ in range(count):
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
)
)
)
celery_app.send_task("worker.generate_video", args=[task.id])
return _to_generation_task_response(task)
celery_app.send_task("worker.generate_video", args=[task.id])
created_tasks.append(task)
items = [_to_generation_task_response(t) for t in created_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
@router.get("/tasks", response_model=ListGenerationTasksResponse)
@@ -237,6 +305,7 @@ def retry_generation_task(
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
+67
View File
@@ -0,0 +1,67 @@
"""标签 CRUD 路由。"""
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_tag_repository
from app.schemas.tag import (
CreateTagRequest,
ListTagsResponse,
TagResponse,
)
from fastapi import APIRouter, Depends, HTTPException
from packages.domain import Tag
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("", response_model=ListTagsResponse)
def list_tags(
skip: int = 0,
limit: int = 100,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tag_repository: Any = Depends(get_tag_repository),
) -> ListTagsResponse:
"""列出当前用户的标签。"""
user_id = authenticated_user.user.id
items = tag_repository.list_by_user(user_id, skip=skip, limit=limit)
total = tag_repository.count_by_user(user_id)
return ListTagsResponse(
items=[TagResponse(id=t.id, name=t.name, created_at=t.created_at) for t in items],
total=total,
)
@router.post("", response_model=TagResponse, status_code=201)
def create_tag(
request: CreateTagRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tag_repository: Any = Depends(get_tag_repository),
) -> TagResponse:
"""创建标签(同用户同名去重,返回 409)。"""
user_id = authenticated_user.user.id
existing = tag_repository.find_by_name(user_id, request.name)
if existing:
raise HTTPException(status_code=409, detail="标签名称已存在")
tag = Tag.create(user_id=user_id, name=request.name)
created = tag_repository.create(tag)
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
@router.delete("/{tag_id}", status_code=204)
def delete_tag(
tag_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tag_repository: Any = Depends(get_tag_repository),
) -> None:
"""删除标签(同时清理素材关联)。"""
tag = tag_repository.get(tag_id)
if tag is None:
raise HTTPException(status_code=404, detail="标签不存在")
if tag.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权删除该标签")
tag_repository.delete(tag_id)
+174 -11
View File
@@ -6,21 +6,31 @@ import logging
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_cosyvoice_service, get_db_session
from app.dependencies import (
get_cosyvoice_service,
get_db_session,
get_user_repository,
get_voice_clone_profile_repository,
get_voice_library_repository,
)
from app.schemas.tts import (
ListTTSJobResponse,
SaveToLibraryRequest,
SaveToLibraryResponse,
TTSJobResponse,
TTSStatusResponse,
TTSSynthesizeRequest,
TTSSynthesizeResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
SQLAlchemyTTSJobRepository,
)
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.streaming_service import TTSStreamingService
from packages.application.tts_job.use_cases import (
CreateTTSJobUseCase,
DeleteTTSJobUseCase,
@@ -30,6 +40,12 @@ from packages.application.tts_job.use_cases import (
TTSJobNotFoundError,
)
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
QuotaExceededError,
)
from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
@@ -73,6 +89,7 @@ def synthesize(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> TTSSynthesizeResponse:
"""发起 TTS 合成任务。
@@ -80,6 +97,21 @@ def synthesize(
与音色克隆接口保持一致:CosyVoice 失败时不抛 500,而是返回 201 + failed 状态任务记录。
"""
user_id = authenticated_user.user.id
# 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
if request.voice_clone_profile_id:
profile = voice_clone_repo.get(request.voice_clone_profile_id)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Voice clone profile not found",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to voice clone profile",
)
use_case = CreateTTSJobUseCase(repository)
job = use_case.execute(
user_id=user_id,
@@ -110,18 +142,25 @@ def synthesize(
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
if job.status.value == "processing":
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
if task_id:
try:
# 分段合成任务 vs 普通单段任务
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
is_segment = len(segment_task_ids) > 0
try:
if is_segment:
from worker_app.tasks import process_tts_segment_synthesis
process_tts_segment_synthesis.delay(job.id)
else:
from worker_app.tasks import process_tts_synthesis
process_tts_synthesis.delay(job.id)
except Exception as e:
# Celery 调度失败,标记 job 为 failed
try:
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
except Exception as e:
# Celery 调度失败,标记 job 为 failed
try:
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
return TTSSynthesizeResponse(
job_id=job.id,
@@ -205,3 +244,127 @@ def delete_tts_job(
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return Response(status_code=204)
@router.post(
"/jobs/{job_id}/save-to-library",
response_model=SaveToLibraryResponse,
status_code=status.HTTP_201_CREATED,
)
def save_tts_job_to_library(
job_id: str,
request: SaveToLibraryRequest = SaveToLibraryRequest(),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
user_repository: UserRepository = Depends(get_user_repository),
) -> SaveToLibraryResponse:
"""将已完成的 TTS 合成结果保存到配音库。
自动携带音色名、时长、语速等元信息。
"""
user_id = authenticated_user.user.id
# 获取 TTS job
get_use_case = GetTTSJobUseCase(tts_repository)
try:
job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
# 校验已完成
if not job.is_completed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TTS job is not completed yet",
)
# 构建配音素材名称
name = request.name or f"TTS-{job.id[:8]}"
# 构建元信息
metadata_ = {
"source": "tts_job",
"tts_job_id": job.id,
"format": job.format,
"sample_rate": job.sample_rate,
}
if job.metadata:
# 保留原始 job 的有用元信息
for key in ("speed", "language"):
if key in job.metadata:
metadata_[key] = job.metadata[key]
# 获取用户套餐(用于配额检查)
user = user_repository.find_by_id(user_id)
plan_name = getattr(user, "subscription_plan", "free") if user else "free"
# 构建命令并执行
command = CreateVoiceLibraryCommand(
user_id=user_id,
name=name,
text=job.input_text,
voice_provider="cosyvoice",
voice_id=job.voice_id,
voice_name=job.voice_model or "",
audio_url=job.output_audio_url,
duration=job.duration,
file_size=job.file_size,
status="completed",
project_id=job.project_id or "",
tags=[],
metadata_=metadata_,
)
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
try:
item = use_case.execute(command, plan_name=plan_name or "free")
except QuotaExceededError as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
return SaveToLibraryResponse(
id=item.id,
name=item.name,
audio_url=item.audio_url,
duration=item.duration,
voice_id=item.voice_id,
voice_name=item.voice_name,
status=item.status,
)
@router.websocket("/ws/tts/stream")
async def tts_websocket_stream(
websocket: WebSocket,
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
) -> None:
"""WebSocket 流式 TTS 合成。
协议:
1. 客户端发送 JSON 文本帧: {"text": "...", "voice_id": "...", ...}
2. 服务端发送 JSON 状态帧 + 二进制音频帧
3. 完成时发送 JSON 结束帧
"""
await websocket.accept()
try:
message = await websocket.receive_json()
params = {
"text": message.get("text", ""),
"voice_id": message.get("voice_id", ""),
"sample_rate": message.get("sample_rate", 0),
"format": message.get("format", "mp3"),
"speed": message.get("speed", 1.0),
}
streaming_service = TTSStreamingService(cosyvoice_service)
await streaming_service.synthesize_and_stream(websocket, params)
except WebSocketDisconnect:
logger.info("WebSocket 客户端断开连接")
except Exception as e:
logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True)
try:
await websocket.send_json({"type": "error", "message": f"服务异常: {e}"})
except Exception:
pass
+49
View File
@@ -8,6 +8,7 @@ from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
@@ -99,6 +100,7 @@ def _submit_ingest_job(
library_id: str,
storage_key: str,
ingest_job_repository: Any,
file_hash: str = "",
) -> Any:
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -106,6 +108,7 @@ def _submit_ingest_job(
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
file_hash=file_hash,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
@@ -176,6 +179,7 @@ async def complete_direct_upload(
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadCompleteResponse:
"""确认浏览器直传完成并创建导入任务。"""
@@ -199,11 +203,32 @@ async def complete_direct_upload(
if not file_exists:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
if request.file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=request.library_id,
file_hash=request.file_hash,
)
if existing is not None:
logger.info(
"素材去重命中: library=%s hash=%s existing_asset=%s",
request.library_id,
request.file_hash,
existing.id,
)
return DirectUploadCompleteResponse(
storage_key=normalized_key,
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
)
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
)
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
@@ -218,15 +243,38 @@ async def upload_asset(
project_id: str = Form(..., min_length=1, description="项目 ID"),
library_id: str = Form(..., min_length=1, description="素材库 ID"),
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
if file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=library_id,
file_hash=file_hash,
)
if existing is not None:
logger.info(
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
library_id,
file_hash,
existing.id,
)
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id="",
url="",
duplicated=True,
asset_id=existing.id,
)
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(file.content_type)
@@ -255,6 +303,7 @@ async def upload_asset(
library_id=library_id,
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
file_hash=file_hash,
)
return UploadAssetResponse(
+9
View File
@@ -39,6 +39,7 @@ from packages.adapters.sqlalchemy_impl.project_repository import (
SQLAlchemyProjectRepository,
)
from packages.adapters.sqlalchemy_impl.session import build_session_factory
from packages.adapters.sqlalchemy_impl.tag_repository import SQLAlchemyTagRepository
from packages.adapters.sqlalchemy_impl.title_library_repository import (
SQLAlchemyTitleLibraryRepository,
)
@@ -58,6 +59,7 @@ from packages.ports.generation_task_repository import GenerationTaskRepository
from packages.ports.ingest_job_repository import IngestJobRepository
from packages.ports.job_repository import JobRepository
from packages.ports.project_repository import ProjectRepository
from packages.ports.tag_repository import TagRepository
from packages.ports.title_library_repository import TitleLibraryRepository
from packages.ports.user_repository import UserRepository
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
@@ -138,6 +140,13 @@ def get_project_repository(
return SQLAlchemyProjectRepository(session)
def get_tag_repository(
session: Session = Depends(get_db_session),
) -> TagRepository:
"""Provide the SQLAlchemy tag repository implementation."""
return SQLAlchemyTagRepository(session)
def get_user_repository(
session: Session = Depends(get_db_session),
) -> UserRepository:
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from app.auth import AuthenticatedUser
from app.auth import get_current_user as get_authenticated_user
from app.dependencies import get_user_repository
from fastapi import Depends
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from packages.domain.entities import User
+23
View File
@@ -25,6 +25,12 @@ class UpdateAssetReviewRequest(BaseModel):
review_status: str = Field(..., pattern="^(pending_review|approved|rejected)$")
class UpdateAssetRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
metadata: dict[str, object] | None = None
tags: list[str] | None = None
class AssetResponse(BaseModel):
id: str
project_id: str
@@ -45,7 +51,24 @@ class AssetResponse(BaseModel):
classification_status: str
quality_score: float | None = None
uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list)
class BatchDeleteRequest(BaseModel):
"""批量删除请求。"""
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
class BatchDeleteResponse(BaseModel):
"""批量删除响应。"""
deleted_count: int = Field(..., ge=0, description="实际删除数量")
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
class ListAssetsResponse(BaseModel):
items: list[AssetResponse]
total: int = Field(default=0, ge=0)
skip: int = Field(default=0, ge=0)
limit: int = Field(default=100, ge=1)
+5
View File
@@ -18,3 +18,8 @@ class AssetLibraryResponse(BaseModel):
class ListAssetLibrariesResponse(BaseModel):
items: list[AssetLibraryResponse]
class EnsureDefaultLibraryRequest(BaseModel):
project_id: str = Field(..., min_length=1)
kind: str = Field(..., pattern="^(video|voice|image)$")
+3
View File
@@ -36,9 +36,12 @@ class ChunkedUploadStatusResponse(BaseModel):
class ChunkedUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1, description="Project ID")
library_id: str = Field(..., min_length=1, description="Asset library ID")
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class ChunkedUploadCompleteResponse(BaseModel):
storage_key: str = Field(..., description="Storage key")
ingest_job_id: str = Field(..., description="Ingest job ID")
url: str = Field(..., description="File URL")
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
+19
View File
@@ -21,6 +21,16 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── 批量生成 ──
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
# ── 素材库自动匹配 ──
asset_select_mode: str = Field(
default="all",
description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
)
asset_select_count: int = Field(
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -46,12 +56,21 @@ class GenerationTaskResponse(BaseModel):
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
status: str
progress: float
result_count: int
error_message: str
class BatchGenerationTaskResponse(BaseModel):
"""批量生成任务响应。"""
items: list[GenerationTaskResponse]
total: int
class ListGenerationTasksResponse(BaseModel):
"""用户级生成任务列表响应(跨 project)。"""
+24
View File
@@ -0,0 +1,24 @@
"""标签相关 Schema。"""
from datetime import datetime
from pydantic import BaseModel, Field
class CreateTagRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class TagResponse(BaseModel):
id: str
name: str
created_at: datetime
class ListTagsResponse(BaseModel):
items: list[TagResponse]
total: int = Field(default=0, ge=0)
class TagAssetsRequest(BaseModel):
tag_ids: list[str] = Field(..., min_length=1, max_length=50)
+18
View File
@@ -83,3 +83,21 @@ class ListTTSJobResponse(BaseModel):
total: int
page: int
page_size: int
class SaveToLibraryRequest(BaseModel):
"""保存到配音库请求。"""
name: Optional[str] = Field(None, description="配音素材名称,留空则自动生成")
class SaveToLibraryResponse(BaseModel):
"""保存到配音库响应。"""
id: str
name: str
audio_url: str
duration: float
voice_id: str
voice_name: str
status: str
+13 -6
View File
@@ -6,12 +6,7 @@ class UploadAssetRequest(BaseModel):
project_id: str = Field(..., min_length=1, description="项目 ID")
library_id: str = Field(..., min_length=1, description="素材库 ID")
class UploadAssetResponse(BaseModel):
storage_key: str
ingest_job_id: str
url: str = Field(..., description="Public URL of uploaded file")
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadPrepareRequest(BaseModel):
@@ -20,6 +15,7 @@ class DirectUploadPrepareRequest(BaseModel):
filename: str = Field(..., min_length=1, max_length=255)
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
file_size: int = Field(..., gt=0)
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadPrepareResponse(BaseModel):
@@ -35,8 +31,19 @@ class DirectUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1)
library_id: str = Field(..., min_length=1)
storage_key: str = Field(..., min_length=1, max_length=255)
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadCompleteResponse(BaseModel):
storage_key: str
ingest_job_id: str
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
class UploadAssetResponse(BaseModel):
storage_key: str
ingest_job_id: str
url: str = Field(..., description="Public URL of uploaded file")
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
+5 -3
View File
@@ -180,9 +180,11 @@ test.describe("Core media upload flow", () => {
await expect(page.locator(".xx-assets-content")).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
{
timeout: 20_000,
},
);
// Verify asset card shows status
const assetCard = page
+4 -1
View File
@@ -178,7 +178,10 @@ test.describe("订阅过期处理", () => {
// 免费用户可能不需要取消,返回 400 或类似错误
if (!response.ok()) {
const data = await response.json();
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
expect(
data.error?.message || data.detail || data.message,
"应返回错误信息",
).toBeTruthy();
}
});
+6 -5
View File
@@ -175,10 +175,9 @@ test.describe("认证流程", () => {
},
});
expect(
[400, 422],
"缺少用户名字段应返回 4xx 校验错误",
).toContain(response.status());
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
response.status(),
);
});
// ─── 登录 ────────────────────────────────────────────
@@ -230,7 +229,9 @@ test.describe("认证流程", () => {
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
});
if (response.status() !== 429) break;
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
console.log(
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
);
await new Promise((r) => setTimeout(r, 65_000));
}
+207 -415
View File
@@ -14,10 +14,7 @@
"axios": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-router-dom": "^6.24.0",
"recharts": "^3.8.1",
"zod": "^3.23.8",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -31,11 +28,13 @@
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/ui": "^1.6.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0",
"prettier": "^3.9.5",
"typescript": "^5.5.3",
"vite": "^5.3.1",
"vitest": "^1.6.0"
@@ -48,6 +47,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@ant-design/colors": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
@@ -477,6 +490,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
"license": "MIT"
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -1144,6 +1164,16 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -1415,42 +1445,6 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
@@ -1824,18 +1818,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
@@ -2005,69 +1987,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2113,12 +2032,6 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
@@ -2340,6 +2253,34 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz",
"integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.4",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.4",
"istanbul-reports": "^3.1.6",
"magic-string": "^0.30.5",
"magicast": "^0.3.3",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"test-exclude": "^6.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "1.6.1"
}
},
"node_modules/@vitest/expect": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@@ -2932,15 +2873,6 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3058,127 +2990,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -3223,12 +3034,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
@@ -3391,16 +3196,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.47.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -3671,12 +3466,6 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@@ -4148,6 +3937,13 @@
"node": ">=18"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -4223,6 +4019,8 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -4284,15 +4082,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -4363,6 +4152,60 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4630,6 +4473,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5107,6 +4978,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -5842,52 +5729,6 @@
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
"version": "7.79.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
"integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5930,36 +5771,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -5974,21 +5785,6 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -5996,12 +5792,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -6369,6 +6159,45 @@
"dev": true,
"license": "MIT"
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -6385,12 +6214,6 @@
"node": ">=12.22"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6624,28 +6447,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -6980,15 +6781,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+2
View File
@@ -37,11 +37,13 @@
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/ui": "^1.6.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0",
"prettier": "^3.9.5",
"typescript": "^5.5.3",
"vite": "^5.3.1",
"vitest": "^1.6.0"
+73 -7
View File
@@ -19,6 +19,7 @@ export interface AssetItem {
status?: string;
classification_status?: string | null;
quality_score?: number | null;
tag_ids?: string[];
created_at?: string;
}
@@ -114,6 +115,18 @@ export const createAssetLibrary = async (data: {
return response.data;
};
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
export const ensureDefaultLibrary = async (data: {
project_id: string;
kind: "video" | "voice" | "image";
}): Promise<AssetLibraryItem> => {
const response = await apiClient.post(
"/asset-libraries/ensure-default",
data,
);
return response.data;
};
/** 删除素材库 */
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
await apiClient.delete(`/asset-libraries/${libraryId}`);
@@ -129,6 +142,46 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
return response.data.items || [];
};
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
export const getAssetsByKind = async (
kind: string,
filters?: {
keyword?: string;
gender?: string;
style?: string;
tag_ids?: string[];
},
): Promise<AssetItem[]> => {
const params: Record<string, string> = { kind };
if (filters?.keyword) params.keyword = filters.keyword;
if (filters?.gender) params.gender = filters.gender;
if (filters?.style) params.style = filters.style;
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",");
const response = await apiClient.get("/assets", { params });
return response.data.items || [];
};
/** 创建素材(上传文件后调用,附带 metadata) */
export const createAsset = async (data: {
library_id: string;
name: string;
storage_key: string;
mime_type: string;
metadata?: Record<string, unknown>;
}): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data);
return response.data;
};
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: Record<string, unknown> },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data);
return response.data;
};
/** 更新素材审核状态 */
export const updateAssetReviewStatus = async (
assetId: string,
@@ -187,10 +240,11 @@ export const completeDirectUpload = async (data: {
return response.data;
};
/** 直传上传(大文件推荐) */
/** 直传上传(大文件推荐),支持可选进度回调 */
export const uploadAssetDirect = async (data: {
file: File;
library_id: string;
onProgress?: (percent: number) => void;
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
// 后端要求 project_id,前端自动获取默认项目
const project = await getOrCreateDefaultProject();
@@ -209,13 +263,25 @@ export const uploadAssetDirect = async (data: {
);
directForm.append("file", data.file);
const uploadResponse = await fetch(prepared.upload_url, {
method: prepared.method,
body: directForm,
// 使用 XMLHttpRequest 以获取上传进度(fetch 不支持)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(prepared.method, prepared.upload_url);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`OSS direct upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("OSS direct upload failed"));
xhr.send(directForm);
});
if (!uploadResponse.ok) {
throw new Error(`OSS direct upload failed: ${uploadResponse.status}`);
}
return completeDirectUpload({
project_id: project.id,
+12
View File
@@ -1,6 +1,7 @@
/**
* 认证相关 API
*/
import axios from "axios";
import apiClient from "./client";
// 类型定义
@@ -71,6 +72,17 @@ export const login = async (data: LoginRequest): Promise<LoginResponse> => {
return response.data;
};
// 刷新 access_token(使用裸 axios 避免拦截器递归)
export const refreshAccessToken = async (
refreshToken: string,
): Promise<LoginResponse> => {
const baseURL = apiClient.defaults.baseURL ?? "";
const response = await axios.post(`${baseURL}/auth/refresh`, {
refresh_token: refreshToken,
});
return response.data;
};
// 注册
export const register = async (
data: RegisterRequest,
+85 -4
View File
@@ -5,6 +5,7 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { message } from "antd";
import { useAuthStore } from "@/store/authStore";
import { refreshAccessToken } from "./auth";
// 创建 Axios 实例
const apiClient = axios.create({
@@ -15,6 +16,24 @@ const apiClient = axios.create({
},
});
// ── Token 刷新队列 ─────────────────────────────────────────
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
const processQueue = (error: unknown | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// 请求拦截器:添加 Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
@@ -29,15 +48,77 @@ apiClient.interceptors.request.use(
},
);
// 响应拦截器:统一错误提示 + 处理未授权状态
// 响应拦截器:统一错误提示 + 401 自动刷新 Token
apiClient.interceptors.response.use(
(response) => response,
async (
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
) => {
// 401 → 清除登录态
if (error.response?.status === 401) {
useAuthStore.getState().clearAuth();
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// 401 → 尝试刷新 Token
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry
) {
const refreshToken = useAuthStore.getState().refreshToken;
// 无 refresh_token → 直接登出
if (!refreshToken) {
useAuthStore.getState().clearAuth();
window.location.href = "/";
return Promise.reject(error);
}
// 已在刷新中 → 排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return apiClient(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const data = await refreshAccessToken(refreshToken);
const newAccessToken = data.access_token;
const newRefreshToken = data.refresh_token ?? refreshToken;
// 更新 Zustand + localStorage
useAuthStore
.getState()
.setAuth(
useAuthStore.getState().user!,
newAccessToken,
newRefreshToken,
);
// 处理排队的请求
processQueue(null, newAccessToken);
// 重试原始请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
return apiClient(originalRequest);
} catch (refreshError) {
// 刷新失败 → 登出
processQueue(refreshError, null);
useAuthStore.getState().clearAuth();
window.location.href = "/";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// 提取后端返回的错误信息(detail / message / msg
+46
View File
@@ -0,0 +1,46 @@
/**
* 标签 CRUD API
* P3 标签体系:对接后端标签表
*/
import apiClient from "./client";
export interface TagItem {
id: string;
name: string;
created_at?: string;
usage_count?: number;
}
/** 获取当前用户所有标签 */
export const getTags = async (): Promise<TagItem[]> => {
const response = await apiClient.get("/tags");
return response.data.items || [];
};
/** 创建标签(同名返回 409) */
export const createTag = async (name: string): Promise<TagItem> => {
const response = await apiClient.post("/tags", { name });
return response.data;
};
/** 删除标签(同时清理素材关联) */
export const deleteTag = async (tagId: string): Promise<void> => {
await apiClient.delete(`/tags/${tagId}`);
};
/** 为素材添加标签(最多 50 个) */
export const tagAsset = async (
assetId: string,
tagIds: string[],
): Promise<void> => {
if (tagIds.length === 0) return;
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds });
};
/** 移除素材的某个标签 */
export const untagAsset = async (
assetId: string,
tagId: string,
): Promise<void> => {
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
};
+14
View File
@@ -122,6 +122,20 @@ export const getTTSJobs = async (
return response.data;
};
/** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest {
name?: string;
tag_ids?: string[];
}
/** 将 TTS 合成结果保存到配音素材库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {});
};
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`);
@@ -16,7 +16,6 @@ import Sidebar from "./Sidebar";
import "./MainLayout.css";
/** 侧边栏上下文 —— 子组件可读取折叠状态 */
// eslint-disable-next-line react-refresh/only-export-components
export interface SidebarContextValue {
collapsed: boolean;
}
@@ -62,6 +62,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/editing-planner": "剪辑规划",
"/app/my-templates": "我的模板",
"/app/voice-clone": "我的音色",
"/app/voice-materials": "配音素材库",
"/app/accounts": "账号管理",
"/app/duplication": "查重",
"/app/duplication/results": "查重结果",
@@ -10,6 +10,7 @@ import React, { useState, useCallback, useRef } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import { uploadAsset } from "@/api/assets";
import "./clone-voice-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
@@ -121,12 +122,24 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
setStep("uploading");
try {
// Mock:模拟上传 + 克隆过程
// 先上传音频文件获取真实 URL
let audioUrl: string;
if (selectedFile) {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("kind", "voice");
const uploadResult = await uploadAsset(formData);
audioUrl = uploadResult.url;
} else {
// 录制功能暂未实现,提示用户上传
setStep("input");
return;
}
// 提交克隆请求
const result = await createVoiceClone({
name,
audio_url: selectedFile
? `mock://${selectedFile.name}`
: "mock://recorded-audio",
audio_url: audioUrl,
});
setStep("success");
+12
View File
@@ -67,6 +67,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "templates",
label: "模板库",
@@ -153,6 +159,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
+1 -1
View File
@@ -75,7 +75,7 @@ export const useLogout = () => {
} finally {
clearAuth();
queryClient.clear();
navigate("/login");
navigate("/");
}
};
+9
View File
@@ -25,6 +25,15 @@ body {
background-color: var(--bg-secondary);
}
/* ── 自定义字体 ── */
@font-face {
font-family: "华康俪金黑";
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* 滚动条 - V21 样式 */
::-webkit-scrollbar {
width: 8px;
+69 -21
View File
@@ -10,7 +10,6 @@ import {
SearchOutlined,
InboxOutlined,
VideoCameraOutlined,
SoundOutlined,
PictureOutlined,
PlayCircleOutlined,
CheckOutlined,
@@ -37,7 +36,7 @@ import "./assets.css";
/* ============================================================
* 类型
* ============================================================ */
type AssetKind = "video" | "voice" | "image";
type AssetKind = "video" | "image";
type StatusType = "ok" | "warn" | "bad" | "info";
interface LibraryItem {
@@ -67,7 +66,6 @@ interface AssetItem {
/** 根据 mime_type 推断前端 AssetKind */
const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "voice";
return "image";
};
@@ -99,7 +97,7 @@ const formatDuration = (seconds: number): string => {
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
count: item.asset_count ?? 0,
});
@@ -110,14 +108,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
item.classification_status ?? undefined,
);
const metadata = item.metadata || {};
const kind = inferKind(item.mime_type || "");
return {
id: item.id,
name: item.name,
kind: inferKind(item.mime_type || ""),
kind,
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
thumbUrl:
(item.thumbnail_url as string | undefined) ||
(item.file_url as string | undefined) ||
(metadata.thumbnail_url as string | undefined),
(metadata.thumbnail_url as string | undefined) ||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
fileUrl:
(item.file_url as string | undefined) ||
(metadata.file_url as string | undefined),
@@ -147,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
switch (kind) {
case "video":
return <VideoCameraOutlined />;
case "voice":
return <SoundOutlined />;
case "image":
return <PictureOutlined />;
}
@@ -158,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
switch (kind) {
case "video":
return "视频";
case "voice":
return "配音";
case "image":
return "图片";
}
@@ -170,8 +166,6 @@ const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
}
@@ -244,7 +238,7 @@ const AssetCard: React.FC<{
)}
{/* 视频/配音类显示播放按钮 */}
{(asset.kind === "video" || asset.kind === "voice") && (
{asset.kind === "video" && (
<span
className="xx-asset-play"
onClick={(e) => {
@@ -361,6 +355,7 @@ const AssetLibrary: React.FC = () => {
/* 上传 */
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
/* 新建素材库 */
const [createModalOpen, setCreateModalOpen] = useState(false);
@@ -434,18 +429,25 @@ const AssetLibrary: React.FC = () => {
}
setUploading(true);
setUploadProgress(0);
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`);
}
await uploadAssetDirect({ file, library_id: effectiveLibId });
await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
});
message.success(`"${file.name}" 上传成功`);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
} catch {
message.error(`"${file.name}" 上传失败`);
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "";
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`);
} finally {
setUploading(false);
setUploadProgress(0);
}
return false;
};
@@ -532,6 +534,54 @@ const AssetLibrary: React.FC = () => {
return (
<div className="xx-assets-page">
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
<AntModal
open={uploading}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg
className="xx-upload-progress-ring"
viewBox="0 0 120 120"
width={120}
height={120}
>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
{/* 两栏布局 */}
<div className="xx-assets-layout">
{/* ─── 左侧:素材库列表 ─── */}
@@ -588,7 +638,7 @@ const AssetLibrary: React.FC = () => {
beforeUpload={handleUpload}
showUploadList={false}
multiple
accept="video/*,audio/*,image/*"
accept="video/*,image/*"
>
<div className="xx-asset-upload-zone">
<p className="xx-asset-upload-icon">
@@ -598,7 +648,7 @@ const AssetLibrary: React.FC = () => {
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
</p>
<p className="xx-asset-upload-hint">
2GB
2GB
</p>
</div>
</Upload.Dragger>
@@ -621,7 +671,6 @@ const AssetLibrary: React.FC = () => {
options={[
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
@@ -752,7 +801,6 @@ const AssetLibrary: React.FC = () => {
style={{ width: "100%" }}
options={[
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
+37
View File
@@ -587,3 +587,40 @@
grid-template-columns: 1fr;
}
}
/* ─── 上传进度弹窗 ─── */
.xx-upload-progress-modal .ant-modal-content {
padding: 24px 16px 20px;
border-radius: 16px;
}
.xx-upload-progress-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.xx-upload-progress-ring {
display: block;
}
.xx-upload-progress-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.xx-upload-progress-pct {
font-size: 22px;
font-weight: 700;
color: var(--primary-color, #6366f1);
line-height: 1;
}
.xx-upload-progress-label {
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
@@ -847,8 +847,8 @@
flex: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
gap: 8px;
padding: 6px 16px;
overflow-x: auto;
overflow-y: hidden;
background: var(--bg-primary, #ffffff);
@@ -864,11 +864,11 @@
border-radius: 3px;
}
/* 水平片段卡片 — 统一高度96px、圆角、hover高亮 */
/* 水平片段卡片 — 统一高度64px、圆角、hover高亮 */
.ep-clip-card {
width: 100px;
min-width: 100px;
height: 96px;
height: 64px;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e2e2e2);
border-radius: var(--radius-sm, 14px);
@@ -903,7 +903,7 @@
}
.ep-clip-thumbnail {
height: 52px;
height: 34px;
background: linear-gradient(
135deg,
var(--bg-secondary, #f8fafc),
@@ -917,7 +917,7 @@
}
.ep-clip-info {
padding: 6px 8px;
padding: 4px 6px;
flex: 1;
display: flex;
flex-direction: column;
@@ -993,7 +993,7 @@
gap: 8px;
color: var(--text-tertiary, #94a3b8);
font-size: 12px;
min-height: 96px;
min-height: 64px;
}
.ep-track-empty-icon {
@@ -1033,7 +1033,7 @@
.ep-track-add-card {
min-width: 60px;
height: 96px;
height: 64px;
border: 2px dashed var(--line, #e2e8f0);
border-radius: 12px;
display: flex;
@@ -1064,10 +1064,7 @@
background: none;
}
.ep-track-add-card-wrapper > .ep-add-clip-picker {
top: calc(100% + 6px);
right: 0;
}
/* picker 已改为 Portal 渲染,不再需要相对定位 */
/* 添加片段按钮 + 选择器 */
.ep-add-clip-wrapper {
@@ -1094,81 +1091,139 @@
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
/* ── "+" 卡片类型+时长选择器 ── */
.ep-add-clip-picker {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 260px;
max-height: 320px;
width: 180px;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius-md, 18px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 100;
overflow: hidden;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* fixed 定位版本 — 不受任何父容器 overflow 裁剪 */
.ep-add-clip-picker--portal {
position: fixed !important;
z-index: 1000;
}
.ep-add-clip-picker-title {
padding: 10px 14px 6px;
font-size: 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #64748b);
color: var(--text-primary, #1e293b);
padding-bottom: 6px;
border-bottom: 1px solid var(--line, #e2e8f0);
}
.ep-add-clip-picker-empty {
padding: 20px 14px;
text-align: center;
color: var(--text-tertiary, #94a3b8);
font-size: 12px;
}
.ep-add-clip-picker-list {
overflow-y: auto;
max-height: 260px;
padding: 4px 0;
}
.ep-add-clip-picker-item {
.ep-add-clip-type-row,
.ep-add-clip-duration-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.ep-add-clip-type-label {
font-size: 12px;
color: var(--text-primary, #0f172a);
transition: background var(--transition-fast, 0.15s ease);
}
.ep-add-clip-picker-item:hover {
background: var(--bg-hover, #f1f5f9);
}
.ep-add-clip-picker-icon {
font-size: 16px;
color: var(--text-secondary, #64748b);
font-weight: 500;
flex-shrink: 0;
min-width: 36px;
}
.ep-add-clip-picker-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
.ep-add-clip-type-btn {
padding: 5px 10px;
border-radius: var(--radius-xs, 8px);
border: 1px solid var(--line, #e2e8f0);
background: var(--bg-primary, #ffffff);
color: var(--text-secondary, #64748b);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.ep-add-clip-picker-type {
flex-shrink: 0;
font-size: 10px;
.ep-add-clip-type-btn:hover {
border-color: var(--primary, #6366f1);
color: var(--primary, #6366f1);
}
.ep-add-clip-type-btn.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
color: var(--primary, #6366f1);
font-weight: 500;
}
.ep-add-clip-duration-unit {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
background: var(--bg-secondary, #f8fafc);
padding: 2px 6px;
border-radius: 4px;
}
.ep-add-clip-confirm-btn {
width: 100%;
height: 30px;
background: var(--primary, #6366f1);
color: var(--text-inverse, #ffffff);
border: none;
border-radius: var(--radius-xs, 8px);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 4px;
}
.ep-add-clip-confirm-btn:hover {
background: var(--primary-hover, #4f46e5);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
/* ── 片段详情 — 类型选择器 ── */
.ep-clip-type-selector {
display: flex;
gap: 6px;
}
.ep-clip-type-btn {
padding: 5px 12px;
border-radius: var(--radius-xs, 8px);
border: 1px solid var(--line, #e2e8f0);
background: var(--bg-primary, #ffffff);
color: var(--text-secondary, #64748b);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 4px;
}
.ep-clip-type-btn:hover:not(.disabled) {
border-color: var(--primary, #6366f1);
color: var(--primary, #6366f1);
}
.ep-clip-type-btn.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
color: var(--primary, #6366f1);
font-weight: 500;
}
.ep-clip-type-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 一镜到底提示 */
@@ -1385,53 +1440,57 @@
color: var(--primary, #6366f1);
}
/* 标题预设样式按钮方形 T 字预览 */
.ep-title-presets {
display: flex;
/* ── 标题预设样式 — 剪映风格方形网格,示例文字"标题"预览完整效果 ── */
.ep-title-presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.ep-title-preset-btn {
flex: 1;
min-width: 44px;
height: 48px;
background: var(--bg-primary, #ffffff);
.ep-title-preset-card {
aspect-ratio: 1;
background: #ffffff;
border: 2px solid var(--border-color, #e2e8f0);
border-radius: var(--radius-xs, 8px);
color: var(--text-primary, #1e293b);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px 6px;
gap: 4px;
padding: 6px 4px;
transition: all 0.15s ease;
overflow: hidden;
}
.ep-title-preset-btn:hover {
.ep-title-preset-card:hover {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
background: #f8fafc;
transform: translateY(-1px);
}
.ep-title-preset-btn.active {
.ep-title-preset-card.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
box-shadow: 0 0 0 1px var(--primary, #6366f1);
background: #eef2ff;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
.ep-title-preset-t {
.ep-title-preset-preview-text {
line-height: 1.2;
text-align: center;
word-break: keep-all;
white-space: nowrap;
}
.ep-title-preset-card-label {
font-size: 9px;
color: #64748b;
line-height: 1;
font-family: inherit;
text-align: center;
margin-top: auto;
}
.ep-title-preset-label {
font-size: 10px;
color: var(--text-tertiary, #94a3b8);
line-height: 1;
}
.ep-title-preset-btn.active .ep-title-preset-label {
.ep-title-preset-card.active .ep-title-preset-card-label {
color: var(--primary, #6366f1);
font-weight: 500;
}
@@ -1876,6 +1935,128 @@
flex-shrink: 0;
}
/* ═══ 配音素材选择器 ═══ */
.ep-clip-detail-select {
width: 100%;
padding: 6px 8px;
font-size: 12px;
color: var(--text-primary, #e2e8f0);
background: var(--bg-tertiary, #1e293b);
border: 1px solid var(--border-color, #334155);
border-radius: 6px;
outline: none;
cursor: pointer;
transition: border-color 0.15s;
}
.ep-clip-detail-select:hover {
border-color: var(--primary, #6366f1);
}
.ep-clip-detail-select:focus {
border-color: var(--primary, #6366f1);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
.ep-voice-upload-btn {
width: 100%;
margin-top: 6px;
padding: 6px 0;
font-size: 12px;
color: var(--primary, #6366f1);
background: transparent;
border: 1px dashed var(--primary, #6366f1);
border-radius: 6px;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.ep-voice-upload-btn:hover {
background: rgba(99, 102, 241, 0.08);
}
/* 配音标签行(含刷新按钮) */
.ep-clip-detail-label {
display: flex;
align-items: center;
gap: 6px;
}
.ep-voice-refresh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
font-size: 11px;
line-height: 1;
color: var(--text-secondary, #94a3b8);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.ep-voice-refresh-btn:hover {
color: var(--primary, #6366f1);
background: rgba(99, 102, 241, 0.1);
}
.ep-voice-refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 配音选择行(select + 试听按钮) */
.ep-voice-select-row {
display: flex;
align-items: center;
gap: 6px;
}
.ep-voice-select-row .ep-clip-detail-select {
flex: 1;
min-width: 0;
}
.ep-voice-preview-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
font-size: 13px;
line-height: 1;
color: var(--text-primary, #e2e8f0);
background: var(--bg-tertiary, #1e293b);
border: 1px solid var(--border-color, #334155);
border-radius: 6px;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.ep-voice-preview-btn:hover {
background: var(--bg-hover, #2d3a4f);
border-color: var(--primary, #6366f1);
}
.ep-voice-loading,
.ep-voice-empty {
padding: 8px 0;
font-size: 12px;
color: var(--text-secondary, #94a3b8);
text-align: center;
}
/* ═══ 预览区 — 补充样式 ═══ */
.ep-phone-empty-hint {
color: rgba(255, 255, 255, 0.5);
@@ -4,13 +4,8 @@
*/
import React, { useState, useCallback, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
// 简易消息工具(UI 库未导出 message)
const msg = {
success: (s: string) => console.log(`${s}`),
error: (s: string) => console.error(`${s}`),
warning: (s: string) => console.warn(`⚠️ ${s}`),
info: (s: string) => console.info(`${s}`),
};
import { message } from "antd";
import { useQuery } from "@tanstack/react-query";
import type {
EditingTemplate,
TemplateCategory,
@@ -20,26 +15,28 @@ import type {
import {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
generateFromTemplate,
MODE_LABELS,
} from "@/api/editingPlanner";
import type {
EditPlanClip,
EditPlanGeneration,
MediaAsset,
} from "@/api/editPlans";
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
import {
getMediaAssets,
getEditPlanGenerations,
MATERIAL_TYPE_LABELS,
aiRecommendClips,
generateCover,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type { TaskItem } from "@/api/tasks";
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
import type { ClipData, ClipType } from "./types";
import {
ensureDefaultLibrary,
getAssetsByKind,
type AssetItem,
} from "@/api/assets";
import { getOrCreateDefaultProject } from "@/api/projects";
import MediaPanel from "./components/MediaPanel";
import PreviewPlayer from "./components/PreviewPlayer";
@@ -51,14 +48,6 @@ import type { GenPhase } from "./components/GenerationProgressModal";
import GenerationHistoryModal from "./components/GenerationHistoryModal";
import "./EditingPlanner.css";
/* ──────────── 类型 ──────────── */
interface ClipData extends EditPlanClip {
name: string;
thumbnail?: string;
assetName?: string;
}
/* ──────────── 常量 ──────────── */
const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
@@ -106,7 +95,7 @@ const EditingPlanner: React.FC = () => {
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
/* ── AI 操作状态 ── */
const [aiRecommendLoading, setAiRecommendLoading] = useState(false);
const [aiCoverLoading, setAiCoverLoading] = useState(false);
/* ── 封面方案 ── */
@@ -119,7 +108,7 @@ const EditingPlanner: React.FC = () => {
/* ── 标题/字幕/BGM 设置 ── */
const [titleSettings, setTitleSettings] = useState({
aiAutoSelect: true,
aiAutoSelect: false,
title: "",
position: "top",
font: "思源黑体",
@@ -128,6 +117,7 @@ const EditingPlanner: React.FC = () => {
italic: false,
stroke: true,
shadow: true,
color: "#ffffff",
});
const [subtitleSettings, setSubtitleSettings] = useState({
@@ -174,6 +164,37 @@ const EditingPlanner: React.FC = () => {
/* ── 播放 ── */
const [isPlaying, setIsPlaying] = useState(false);
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
const voiceMaterialsQuery = useQuery({
queryKey: ["assets", "voice"],
queryFn: async () => {
const project = await getOrCreateDefaultProject();
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" });
const assets = await getAssetsByKind("voice");
return assets;
},
staleTime: 30_000,
});
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? [];
/** 为片段选择配音素材 */
const handleClipVoiceSelect = useCallback(
(clipId: string, asset: AssetItem | null) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? {
...c,
voice_asset_id: asset?.id ?? undefined,
voice_file_url: asset?.file_url ?? undefined,
}
: c,
),
);
},
[setClips],
);
/* ──────────── 加载 ──────────── */
/**
@@ -192,7 +213,7 @@ const EditingPlanner: React.FC = () => {
setCategories(cats);
setMediaAssets(assets);
} catch {
msg.error("加载模板失败");
message.error("加载模板失败");
} finally {
setLoadingTemplates(false);
}
@@ -216,14 +237,13 @@ const EditingPlanner: React.FC = () => {
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
name: `片段${idx + 1} · ${MATERIAL_TYPE_LABELS[seg.material_type || "video"] || "视频"}`,
material_type:
(seg.material_type as ClipData["material_type"]) || "video",
script_text: "",
type: (seg.material_type === "voiceover"
? "voice"
: "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
assetName: undefined,
transition: { type: "none", duration: 0 },
}));
resetClips(mapped);
@@ -234,6 +254,7 @@ const EditingPlanner: React.FC = () => {
position: tpl.title_config.position,
font: tpl.title_config.font_preset,
size: tpl.title_config.font_size,
color: tpl.title_config.font_color || "#ffffff",
}));
setSubtitleSettings({
enabled: tpl.subtitle_config.enabled,
@@ -249,7 +270,7 @@ const EditingPlanner: React.FC = () => {
setDraftCategory(tpl.category);
setDraftTags(tpl.tags.join(", "));
})
.catch(() => msg.error("加载模板详情失败"));
.catch(() => message.error("加载模板详情失败"));
}, [loadedTemplateId, resetClips]);
/* ──────────── 计算 ──────────── */
@@ -303,91 +324,32 @@ const EditingPlanner: React.FC = () => {
);
};
const handleAssetDropToClip = (clipId: string, asset: MediaAsset) => {
handleClipUpdate(clipId, {
media_asset_id: asset.id,
assetName: asset.name,
thumbnail: asset.thumbnail_url,
});
};
const handleAssetDropToTimeline = (asset: MediaAsset) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
template_segment_id: "",
name: `片段${clips.length + 1} · ${MATERIAL_TYPE_LABELS[asset.type]}`,
material_type:
asset.type === "audio"
? "voiceover"
: asset.type === "image"
? "image"
: "video",
script_text: "",
duration: asset.duration || 5,
order: clips.length,
media_asset_id: asset.id,
assetName: asset.name,
thumbnail: asset.thumbnail_url,
transition: { type: "none", duration: 0 },
};
setClips((prev) => [...prev, newClip]);
};
/**
* AI 推荐片段方案
* 将当前已绑定素材的片段 ID 列表 + 编辑模式 + 目标时长发送给后端
* 返回的推荐结果直接替换当前片段列表(不保留原手动编排)
* 添加片段(不绑定任何素材)
* 片段 = 时间规划 + 类型标记
*/
const handleAiRecommend = async () => {
if (!loadedTemplateId) {
msg.warning("请先加载模板");
return;
}
const assetIds = clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v);
if (assetIds.length === 0) {
msg.warning("请先添加素材到片段");
return;
}
setAiRecommendLoading(true);
try {
const res = await aiRecommendClips(loadedTemplateId, {
asset_ids: assetIds,
editing_mode: currentMode,
target_duration: totalDuration || 30,
});
const mapped: ClipData[] = res.clips.map((c, i) => ({
id: `clip-ai-${i}-${Date.now()}`,
template_segment_id: "",
name: `片段${i + 1} · ${c.clip_type}`,
material_type: (c.clip_type as ClipData["material_type"]) || "video",
script_text: c.text_content || "",
duration: c.duration || 5,
order: c.order ?? i,
media_asset_id: c.asset_id || undefined,
assetName: undefined,
transition: { type: "none", duration: 0 },
}));
setClips(mapped);
msg.success(`AI 推荐了 ${mapped.length} 个片段`);
} catch {
msg.error("AI 推荐失败");
} finally {
setAiRecommendLoading(false);
}
};
const handleAddClip = useCallback(
(type: ClipType, duration: number) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
type,
duration,
startOffset: 0,
order: clips.length,
};
setClips((prev) => [...prev, newClip]);
},
[clips.length, setClips],
);
/* AI 封面生成 */
const handleAiGenerateCover = async (
coverType: "ai_frame" | "ai_regenerate",
) => {
if (!loadedTemplateId) return;
const assetIds = clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v);
const assetIds = selectedAssetIds;
if (assetIds.length === 0) {
msg.warning("请先添加素材");
message.warning("请先在素材库中选择素材");
return;
}
setAiCoverLoading(true);
@@ -399,29 +361,24 @@ const EditingPlanner: React.FC = () => {
setCurrentCoverScheme(
coverType === "ai_frame" ? "ai_frame" : "ai_reselect",
);
msg.success("AI 封面生成成功");
message.success("AI 封面生成成功");
} catch {
msg.error("AI 封面生成失败");
message.error("AI 封面生成失败");
} finally {
setAiCoverLoading(false);
}
};
/* 保存 */
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
const handleOpenSaveModal = () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
setSaveModalOpen(true);
};
const handleSave = async () => {
if (!draftName.trim()) {
msg.warning("请输入模板名称");
message.warning("请输入模板名称");
return;
}
if (!loadedTemplateId) return;
setSaveLoading(true);
try {
const payload: SaveTemplatePayload = {
@@ -436,7 +393,7 @@ const EditingPlanner: React.FC = () => {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
font_preset: titleSettings.font,
font_color: "#ffffff",
font_color: titleSettings.color,
font_size: titleSettings.size,
position: titleSettings.position,
},
@@ -457,15 +414,19 @@ const EditingPlanner: React.FC = () => {
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.material_type,
material_type: c.type === "voice" ? "voiceover" : "video",
})),
};
await updateEditingTemplate(loadedTemplateId, payload);
msg.success("模板保存成功");
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload);
} else {
await createEditingTemplate(payload);
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功");
setSaveModalOpen(false);
loadTemplates();
} catch {
msg.error("保存失败");
message.error("保存失败");
} finally {
setSaveLoading(false);
}
@@ -477,16 +438,13 @@ const EditingPlanner: React.FC = () => {
* 一键生成页面从 params 解析配置,无需重复请求接口
*/
const handleGoToGenerate = () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
const planConfig = {
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
bold: titleSettings.bold,
italic: titleSettings.italic,
@@ -508,16 +466,18 @@ const EditingPlanner: React.FC = () => {
total_duration: totalDuration,
segments: clips.map((c, i) => ({
order: i,
name: c.name,
material_type: c.material_type,
material_type: c.type === "voice" ? "voiceover" : "video",
duration: c.duration,
media_asset_id: c.media_asset_id,
template_segment_id: c.template_segment_id,
script_text: c.script_text,
voice_asset_id: c.voice_asset_id,
voice_file_url: c.voice_file_url,
})),
};
const params = new URLSearchParams();
params.set("edit_plan_id", loadedTemplateId);
if (loadedTemplateId) {
params.set("edit_plan_id", loadedTemplateId);
}
params.set("plan_config", JSON.stringify(planConfig));
navigate(`/app/generate?${params.toString()}`);
};
@@ -535,21 +495,23 @@ const EditingPlanner: React.FC = () => {
await generateFromTemplate(loadedTemplateId, {
voiceover_duration: voiceoverDuration || totalDuration,
});
// 收集所有 voice 类型片段的配音素材 ID
const voiceIds = clips
.filter((c) => c.type === "voice" && c.voice_asset_id)
.map((c) => c.voice_asset_id as string);
const res = await createGenerationTask({
template_id: loadedTemplateId,
asset_ids: clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v),
asset_ids: [],
title_ids: [],
voice_ids: [],
voice_ids: voiceIds,
});
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
const task = await getTask(res.id);
setGenTask(task);
setGenPhase("progress");
msg.info("生成任务已创建");
message.info("生成任务已创建");
} catch {
msg.error("创建生成任务失败");
message.error("创建生成任务失败");
} finally {
setGenSubmitting(false);
}
@@ -588,7 +550,7 @@ const EditingPlanner: React.FC = () => {
setGenTask(t);
setGenPhase("progress");
} catch {
msg.error("重试失败");
message.error("重试失败");
} finally {
setGenSubmitting(false);
}
@@ -604,7 +566,7 @@ const EditingPlanner: React.FC = () => {
/* 查看生成历史 */
const handleViewGenHistory = async () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
message.warning("请先加载一个模板");
return;
}
setGenHistoryOpen(true);
@@ -613,7 +575,7 @@ const EditingPlanner: React.FC = () => {
const items = await getEditPlanGenerations(loadedTemplateId);
setGenHistory(items);
} catch {
msg.error("加载生成历史失败");
message.error("加载生成历史失败");
} finally {
setGenHistoryLoading(false);
}
@@ -724,9 +686,7 @@ const EditingPlanner: React.FC = () => {
onClipSelect={handleClipSelect}
onClipReorder={handleClipReorder}
onClipRemove={handleClipRemove}
onAssetDropToTimeline={handleAssetDropToTimeline}
onAssetDropToClip={handleAssetDropToClip}
mediaAssets={mediaAssets}
onAddClip={handleAddClip}
/>
</div>
@@ -739,7 +699,6 @@ const EditingPlanner: React.FC = () => {
clipsCount={clips.length}
totalDuration={totalDuration}
currentMode={currentMode}
aiRecommendLoading={aiRecommendLoading}
onTitleSettingsChange={(partial) =>
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
@@ -750,7 +709,10 @@ const EditingPlanner: React.FC = () => {
setBgmSettings((prev) => ({ ...prev, ...partial }))
}
onClipUpdate={handleClipUpdate}
onAiRecommend={handleAiRecommend}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
onClipVoiceSelect={handleClipVoiceSelect}
/>
</div>
@@ -2,21 +2,11 @@
* 右栏设置面板 — V8 原型 1:1 还原
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
*/
import React from "react";
import React, { useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import type { TemplateMode } from "@/api/editingPlanner";
import { MATERIAL_TYPE_LABELS } from "@/api/editPlans";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: "video" | "image" | "audio" | "voiceover";
thumbnail?: string;
assetName?: string;
media_asset_id?: string;
template_segment_id?: string;
script_text?: string;
}
import type { ClipData, ClipType } from "../types";
import type { AssetItem } from "@/api/assets";
interface TitleSettings {
aiAutoSelect: boolean;
@@ -28,6 +18,7 @@ interface TitleSettings {
italic: boolean;
stroke: boolean;
shadow: boolean;
color: string;
}
interface SubtitleSettings {
@@ -50,12 +41,18 @@ interface ClipPropertiesPanelProps {
clipsCount: number;
totalDuration: number;
currentMode: TemplateMode;
aiRecommendLoading: boolean;
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void;
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
onAiRecommend: () => void;
/** 配音素材列表(从配音素材库 API 获取) */
voiceMaterials?: AssetItem[];
/** 配音素材加载中 */
voiceMaterialsLoading?: boolean;
/** 刷新配音素材列表 */
onRefreshVoiceMaterials?: () => void;
/** 为片段选择配音素材 */
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
}
const POSITION_OPTIONS = [
@@ -64,7 +61,15 @@ const POSITION_OPTIONS = [
{ value: "bottom", label: "底部" },
];
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑"];
const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
];
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
@@ -82,61 +87,163 @@ const BGM_OPTIONS = [
];
/**
* 标题样式预设 — 一键应用一组样式
* 注意:预设只绑定样式属性(粗细、描边、阴影、字号),不绑定字体家族
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
* 不绑定字体,用户可自由搭配任意字体
* 预览统一用系统字体展示效果
*/
const TITLE_PRESETS = [
{
key: "default",
label: "默认",
key: "classic_white",
label: "经典白字",
style: {
size: 24,
bold: false,
italic: false,
stroke: false,
shadow: false,
},
},
{
key: "classic",
label: "经典",
style: {
size: 24,
size: 28,
color: "#ffffff",
bold: true,
italic: false,
stroke: true,
shadow: false,
},
previewStyle: {
fontWeight: 700,
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
key: "bold",
label: "醒目",
key: "black_gold",
label: "黑金质感",
style: {
size: 32,
color: "#d4a843",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: {
size: 24,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
fontSize: "18px",
},
},
{
key: "variety_show",
label: "综艺花字",
style: {
size: 36,
color: "#ff4081",
bold: true,
italic: false,
stroke: true,
shadow: true,
},
previewStyle: {
fontWeight: 900,
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "elegant",
label: "柔和",
key: "business",
label: "商务极简",
style: {
size: 20,
size: 24,
color: "#1a1a1a",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#1a1a1a",
fontSize: "17px",
},
},
{
key: "retro_film",
label: "复古胶片",
style: {
size: 28,
color: "#e8d5b7",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
key: "neon_glow",
label: "霓虹发光",
style: {
size: 32,
color: "#00e5ff",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#00e5ff",
textShadow:
"0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
key: "handwriting",
label: "手写字",
style: {
size: 28,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
fontSize: "20px",
},
},
];
/** 判断当前设置匹配哪个预设(不含 font 字段 */
/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体 */
function getActivePreset(settings: TitleSettings): string | null {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
@@ -148,6 +255,13 @@ function getActivePreset(settings: TitleSettings): string | null {
return null;
}
/** 片段类型图标/标签 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" };
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
titleSettings,
@@ -156,13 +270,50 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
clipsCount,
totalDuration,
currentMode,
aiRecommendLoading,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange,
onClipUpdate,
onAiRecommend,
voiceMaterials = [],
voiceMaterialsLoading = false,
onRefreshVoiceMaterials,
onClipVoiceSelect,
}) => {
const navigate = useNavigate();
/* ── 配音试听 ── */
const audioRef = useRef<HTMLAudioElement | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
/** 试听配音素材 */
const handlePreviewVoice = useCallback(
(asset: AssetItem) => {
// 点击同一个 → 暂停
if (previewingId === asset.id) {
audioRef.current?.pause();
setPreviewingId(null);
return;
}
// 停止上一个
audioRef.current?.pause();
const url = asset.file_url || (asset.metadata?.preview_url as string);
if (!url) return;
const audio = new Audio(url);
audioRef.current = audio;
audio.play().catch(() => {});
audio.onended = () => setPreviewingId(null);
setPreviewingId(asset.id);
},
[previewingId],
);
/** 从 metadata 取性别标签 */
const getGenderLabel = (m: AssetItem): string => {
const g = (m.metadata?.gender as string) || "";
if (g === "male") return "男";
if (g === "female") return "女";
return "";
};
return (
<div className="ep-right-panel">
{/* ═══ 标题设置 ═══ */}
@@ -241,38 +392,34 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-title-presets">
<div className="ep-title-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = getActivePreset(titleSettings) === p.key;
return (
<button
key={p.key}
className={`ep-title-preset-btn${isActive ? " active" : ""}`}
onClick={() => onTitleSettingsChange({ ...p.style })}
className={`ep-title-preset-card${isActive ? " active" : ""}`}
onClick={() =>
onTitleSettingsChange({
size: p.style.size,
color: p.style.color,
bold: p.style.bold,
italic: p.style.italic,
stroke: p.style.stroke,
shadow: p.style.shadow,
})
}
title={p.label}
>
<span
className="ep-title-preset-t"
style={{
fontWeight: p.style.bold ? 700 : 400,
fontStyle: p.style.italic ? "italic" : "normal",
WebkitTextStroke: p.style.stroke
? "1px currentColor"
: undefined,
textShadow: p.style.shadow
? "2px 2px 4px rgba(0,0,0,0.5)"
: undefined,
fontSize:
p.style.size >= 32
? "22px"
: p.style.size >= 24
? "18px"
: "16px",
}}
className="ep-title-preset-preview-text"
style={p.previewStyle}
>
T
</span>
<span className="ep-title-preset-card-label">
{p.label}
</span>
<span className="ep-title-preset-label">{p.label}</span>
</button>
);
})}
@@ -452,15 +599,36 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-header">
<span className="ep-clip-detail-name">{selectedClip.name}</span>
<span className="ep-clip-detail-type">
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
</span>
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled =
currentMode === "pip"
? t !== "pip"
: currentMode === "voice_over"
? t !== "voice"
: false; // voice_pip 可切换
return (
<button
key={t}
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() =>
!disabled && onClipUpdate(selectedClip.id, { type: t })
}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
);
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
@@ -470,7 +638,10 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
value={selectedClip.duration}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
duration: Number(e.target.value),
duration: Math.max(
1,
Math.min(120, Number(e.target.value) || 1),
),
})
}
/>
@@ -478,31 +649,127 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
</div>
{selectedClip.assetName && (
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
<span className="ep-clip-detail-linked">
{selectedClip.assetName}
</span>
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={selectedClip.startOffset}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
startOffset: Math.max(
0,
Math.min(9999, Number(e.target.value) || 0),
),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
</div>
</div>
{/* 配音素材选择 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
<button
className="ep-ai-recommend-btn"
onClick={onAiRecommend}
disabled={aiRecommendLoading}
>
{aiRecommendLoading ? "⏳ AI 分析中..." : "✨ AI 推荐素材"}
</button>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={selectedClip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value;
if (!onClipVoiceSelect) return;
if (!assetId) {
onClipVoiceSelect(selectedClip.id, null);
} else {
const asset = voiceMaterials.find(
(m) => m.id === assetId,
);
if (asset)
onClipVoiceSelect(selectedClip.id, asset);
}
// 切换选择时停止试听
audioRef.current?.pause();
setPreviewingId(null);
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m);
const label = gender
? `${m.name}${gender}`
: m.name;
return (
<option key={m.id} value={m.id}>
{label}
</option>
);
})}
</select>
{/* 试听按钮 */}
{selectedClip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={
previewingId === selectedClip.voice_asset_id
? "暂停"
: "试听"
}
onClick={() => {
const asset = voiceMaterials.find(
(m) => m.id === selectedClip.voice_asset_id,
);
if (asset) handlePreviewVoice(asset);
}}
>
{previewingId === selectedClip.voice_asset_id
? "⏸"
: "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty">
</div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)}
@@ -4,15 +4,7 @@
* 封面右侧竖排4个方案按钮
*/
import React from "react";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: string;
thumbnail?: string;
assetName?: string;
}
import type { ClipData, ClipType } from "../types";
interface CoverScheme {
key: string;
@@ -54,11 +46,14 @@ interface PreviewPlayerProps {
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void;
}
const MATERIAL_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
};
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
@@ -97,7 +92,10 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
style={{ width: isPlaying ? "45%" : "0%" }}
/>
</div>
<div className="ep-phone-clip-label">{displayClip.name}</div>
<div className="ep-phone-clip-label">
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}{" "}
{CLIP_TYPE_LABELS[displayClip.type] || "片段"}
</div>
</>
) : (
<span className="ep-phone-empty-hint"></span>
@@ -168,7 +166,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
<div className="ep-cover-image">
{displayClip ? (
<span className="ep-cover-icon">
{MATERIAL_ICONS[displayClip.material_type] || "🎬"}
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}
</span>
) : (
<span></span>
@@ -1,23 +1,16 @@
/**
* 水平轨道时间线 — V8 原型 1:1 还原
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
* 支持「添加片段」按钮直接选择素材添加,不依赖拖拽
* "+" 卡片 → 类型+时长选择器
*/
import React, { useState, useRef } from "react";
import type { MediaAsset } from "@/api/editPlans";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: string;
thumbnail?: string;
assetName?: string;
media_asset_id?: string;
template_segment_id?: string;
script_text?: string;
order?: number;
}
import React, {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
} from "react";
import type { ClipData, ClipType } from "../types";
interface TimelinePanelProps {
clips: ClipData[];
@@ -26,22 +19,19 @@ interface TimelinePanelProps {
onClipSelect: (clipId: string) => void;
onClipReorder: (fromIdx: number, toIdx: number) => void;
onClipRemove: (clipId: string) => void;
onAssetDropToTimeline: (asset: MediaAsset) => void;
onAssetDropToClip: (clipId: string, asset: MediaAsset) => void;
mediaAssets: MediaAsset[];
onAddClip: (type: ClipType, duration: number) => void;
}
const ASSET_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼",
audio: "🎵",
/** 片段类型图标 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙",
pip: "🖼️",
};
const MATERIAL_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const TimelinePanel: React.FC<TimelinePanelProps> = ({
@@ -51,18 +41,96 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onClipSelect,
onClipReorder,
onClipRemove,
onAssetDropToTimeline,
onAssetDropToClip,
mediaAssets,
onAddClip,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const dragRef = useRef<number | null>(null);
const [showAddPicker, setShowAddPicker] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
const addCardRef = useRef<HTMLDivElement>(null);
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
top: 0,
right: 0,
});
/* ── 点击外部关闭素材选择器 ── */
React.useEffect(() => {
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>("voice");
const [addDuration, setAddDuration] = useState<number>(5);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] =
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"]; // voice_pip 或默认
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return;
const rect = addCardRef.current.getBoundingClientRect();
const vw = window.innerWidth;
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
let top = rect.top - GAP - roughHeight;
if (top < 8) top = 8;
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
let right = vw - rect.right;
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, []);
const handleTogglePicker = () => {
if (!showAddPicker) {
updatePickerPosition();
}
setShowAddPicker((v) => !v);
};
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
const pickerEl = pickerRef.current;
const addRect = addCardRef.current.getBoundingClientRect();
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
const vh = window.innerHeight;
const vw = window.innerWidth;
/* 默认:面板在"+"按钮上方 */
let top = addRect.top - GAP - pickerH;
/* 上方空间也不够(极端情况)→ 翻转到下方 */
if (top < 8) {
top = addRect.bottom + GAP;
/* 下方也溢出 → clamp */
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH;
if (top < 8) top = 8;
}
}
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
let right = vw - addRect.right;
const pickerRect = pickerEl.getBoundingClientRect();
if (pickerRect.left < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, [showAddPicker]);
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false);
@@ -74,9 +142,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddPicker]);
/* ── 选择素材添加片段 ── */
const handlePickAsset = (asset: MediaAsset) => {
onAssetDropToTimeline(asset);
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration);
setShowAddPicker(false);
};
@@ -109,37 +177,12 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx);
}
return;
}
// 素材拖到片段上
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDropToClip(clips[toIdx].id, asset);
} catch {
/* ignore */
}
}
};
/* ── 空轨道区域拖入 ── */
const handleEmptyDrop = (e: React.DragEvent) => {
e.preventDefault();
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDropToTimeline(asset);
} catch {
/* ignore */
}
}
};
/* ── 空轨道区域不接受素材拖入 ── */
const handleEmptyDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
/* ── 时间标尺 ── */
@@ -187,135 +230,149 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 一镜到底提示 */}
{currentMode === "one_take" && (
<div className="ep-one-take-hint">
🎥
</div>
<div className="ep-one-take-hint">🎥 </div>
)}
{/* 时间标尺 */}
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
>
{t}s
</span>
))}
</div>
</div>
{/* 水平片段轨道 */}
<div
className="ep-clip-track"
onDrop={handleEmptyDrop}
onDragOver={handleEmptyDragOver}
>
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text">
+
</div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 缩略图区域 */}
<div className="ep-clip-thumbnail">
{MATERIAL_ICONS[clip.material_type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">{clip.name}</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
{currentMode !== "one_take" && (
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
>
</button>
</div>
))
)}
{t}s
</span>
))}
</div>
</div>
)}
{/* ── 轨道末尾 "+" 添加卡片(对齐 V21 原型 epAddSegment ── */}
<div className="ep-track-add-card-wrapper" ref={pickerRef}>
<div
className={`ep-track-add-card${currentMode === "one_take" ? " locked" : ""}`}
onClick={() => {
if (currentMode !== "one_take") setShowAddPicker((v) => !v);
}}
title={
currentMode === "one_take" ? "一镜到底模式下不可添加" : "添加片段"
}
>
{currentMode === "one_take" ? "🔒" : "+"}
{/* 水平片段轨道 */}
{currentMode !== "one_take" && (
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text"> + </div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
))
)}
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
<div className="ep-track-add-card-wrapper">
<div
ref={addCardRef}
className="ep-track-add-card"
onClick={handleTogglePicker}
title="添加片段"
>
+
</div>
</div>
</div>
)}
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
{showAddPicker && (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: pickerPos.top,
right: pickerPos.right,
}}
>
<div className="ep-add-clip-picker-title"></div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => setAddType(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 素材选择面板 */}
{showAddPicker && (
<div className="ep-add-clip-picker">
<div className="ep-add-clip-picker-title"></div>
{mediaAssets.length === 0 ? (
<div className="ep-add-clip-picker-empty">
</div>
) : (
<div className="ep-add-clip-picker-list">
{mediaAssets.map((asset) => (
<button
key={asset.id}
className="ep-add-clip-picker-item"
onClick={() => handlePickAsset(asset)}
>
<span className="ep-add-clip-picker-icon">
{ASSET_TYPE_ICONS[asset.type] || "📁"}
</span>
<span className="ep-add-clip-picker-name">
{asset.name}
</span>
<span className="ep-add-clip-picker-type">
{asset.type === "image"
? "图片"
: asset.type === "audio"
? "音频"
: "视频"}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">:</span>
<input
type="number"
className="ep-duration-input"
min={1}
max={120}
value={addDuration}
onChange={(e) =>
setAddDuration(
Math.max(1, Math.min(120, Number(e.target.value) || 1)),
)
}
/>
<span className="ep-add-clip-duration-unit"></span>
</div>
{/* 确认按钮 */}
<button
className="ep-add-clip-confirm-btn"
onClick={handleConfirmAdd}
>
</button>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,21 @@
/**
* 片段(Clip)统一类型定义
* 片段 = 时间规划 + 类型标记,不绑定任何素材
*/
export type ClipType = "voice" | "pip";
export interface ClipData {
id: string;
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
duration: number; // 时长(秒)
startOffset: number; // 仅 voice 类型:在口播素材中的起始时间(秒)
// 保留兼容字段(后端序列化需要)
template_segment_id?: string;
script_text?: string;
order?: number;
/** 配音素材 ID(voice 类型片段使用) */
voice_asset_id?: string;
/** 配音素材文件 URL(voice 类型片段使用) */
voice_file_url?: string;
}
+600 -152
View File
@@ -6,21 +6,27 @@
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Typography, message } from "antd";
import { Typography, message, Select } from "antd";
import {
AudioOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DownloadOutlined,
ShareAltOutlined,
SaveOutlined,
PlusOutlined,
MinusOutlined,
CloseOutlined,
} from "@ant-design/icons";
import type { AssetItem } from "@/api/assets";
import { getAssets, getAssetLibraries } from "@/api/assets";
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
import { fetchPresetVoices } from "@/api/voices";
@@ -28,9 +34,10 @@ import type { PresetVoiceItem } from "@/api/voices";
import { formatDuration } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import CloneModal from "@/components/voice/CloneModal";
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts";
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
import { getTags, createTag } from "@/api/tags";
import { useCloneProgress } from "@/hooks/useCloneProgress";
import { useSearchParams } from "react-router-dom";
import { useSearchParams, useNavigate } from "react-router-dom";
import { getEditPlan } from "@/api/editPlans";
import "./generate.css";
@@ -50,27 +57,14 @@ const MODE_GRADIENTS: Record<string, string> = {
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
};
const MODE_ABBRS: Record<string, string> = {
pip: "PIP",
one_take: "ONE",
voice_over: "VOI",
voice_pip: "VP",
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
const VOICE_GENDER_ICON: Record<string, string> = {
female: "🎀",
male: "🎙️",
child: "🧒",
neutral: "✨",
};
/* ── 配音预设卡片 ── */
interface VoicePresetCard {
id: string;
label: string;
desc: string;
icon: string;
}
const VOICE_PRESET_CARDS: VoicePresetCard[] = [
{ id: "gentle-female", label: "温柔女声", desc: "轻柔自然", icon: "🎀" },
{ id: "pro-male", label: "专业男声", desc: "沉稳大气", icon: "🎙️" },
{ id: "energetic-female", label: "活力女声", desc: "活力四射", icon: "✨" },
];
/* ── 时间线 Mock ──
* TODO: 后端暂无时间线场景数据 API,当前使用硬编码预览数据。
* 待后端提供 timeline/scene 接口后替换为真实 API 调用。
@@ -103,6 +97,8 @@ const STEPS = [
================================================================ */
const GeneratePage: React.FC = () => {
const navigate = useNavigate();
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1);
@@ -122,6 +118,8 @@ const GeneratePage: React.FC = () => {
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
/* 素材选择模式:手动选择 / 自动匹配 */
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual");
/* ── 标题 ── */
const [title, setTitle] = useState("");
@@ -146,6 +144,9 @@ const GeneratePage: React.FC = () => {
);
const [customVoiceText, setCustomVoiceText] = useState("");
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1);
/* ── 克隆声音 ── */
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("");
const [cloneModalOpen, setCloneModalOpen] = useState(false);
@@ -162,6 +163,7 @@ const GeneratePage: React.FC = () => {
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [generated, setGenerated] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -231,6 +233,23 @@ const GeneratePage: React.FC = () => {
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
/** 合成完成后保留的 job ID,用于"存为素材" */
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(
null,
);
/* ── 存为素材弹窗状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [saveName, setSaveName] = useState("");
const [saveTagIds, setSaveTagIds] = useState<string[]>([]);
const [saveNewTag, setSaveNewTag] = useState("");
/* ── 标签列表(用于存为素材弹窗) ── */
const { data: allTags = [] } = useQuery({
queryKey: ["generate-save-tags"],
queryFn: getTags,
staleTime: 30_000,
});
/* ── 素材数据 API ── */
const { data: libraries = [] } = useQuery({
@@ -316,6 +335,7 @@ const GeneratePage: React.FC = () => {
if (cancelled) return;
if (status.status === "completed") {
setCustomAudioUrl(status.output_audio_url);
setCompletedTtsJobId(ttsJobId);
setTtsJobId(null);
setTtsError(null);
message.success("语音合成完成!");
@@ -357,12 +377,98 @@ const GeneratePage: React.FC = () => {
});
}, [customVoiceText, selectedVoice, synthesizeMutation]);
/* ── 存为素材 mutation ── */
const saveToLibraryMutation = useMutation({
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
saveTtsToLibrary(completedTtsJobId!, params),
onSuccess: () => {
message.success({
content: (
<span>
{" "}
<a
onClick={handleGoToLibrary}
style={{
color: "var(--primary-500, #6366f1)",
cursor: "pointer",
}}
>
</a>
</span>
),
duration: 5,
});
setSaveModalOpen(false);
setSaveName("");
setSaveTagIds([]);
setSaveNewTag("");
setCompletedTtsJobId(null);
setCustomAudioUrl(null);
},
onError: (err: Error) => {
message.error(`保存失败:${err.message || "请重试"}`);
},
});
/** 打开存为素材弹窗 */
const handleOpenSaveModal = useCallback(() => {
setSaveName("");
setSaveTagIds([]);
setSaveNewTag("");
setSaveModalOpen(true);
}, []);
/** 确认保存 */
const handleConfirmSave = useCallback(() => {
if (!completedTtsJobId) return;
saveToLibraryMutation.mutate({
name: saveName.trim() || undefined,
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
});
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]);
/** 在弹窗中新增标签(先创建再选中) */
const handleAddTagInModal = useCallback(
async (tagName: string) => {
const trimmed = tagName.trim();
if (!trimmed) return;
/* 已在选中列表则跳过 */
const existing = allTags.find((t) => t.name === trimmed);
if (existing) {
if (!saveTagIds.includes(existing.id)) {
setSaveTagIds((prev) => [...prev, existing.id]);
}
return;
}
try {
const created = await createTag(trimmed);
setSaveTagIds((prev) => [...prev, created.id]);
setSaveNewTag("");
} catch {
message.error(`创建标签"${trimmed}"失败`);
}
},
[allTags, saveTagIds],
);
/** 保存成功后跳转到素材库 */
const handleGoToLibrary = useCallback(() => {
navigate("/app/voice-materials");
}, [navigate]);
const handleGenerate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
title,
selectedTemplate,
selectedMaterials,
voiceMode,
});
if (!title.trim()) {
message.warning("请先选择或输入标题");
return;
}
if (selectedMaterials.length === 0) {
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材");
return;
}
@@ -375,6 +481,7 @@ const GeneratePage: React.FC = () => {
setGenerating(true);
setProgress(0);
setGenerated(false);
setGenerateError(null);
try {
const voiceConfig: Record<string, unknown> = {};
@@ -400,6 +507,8 @@ const GeneratePage: React.FC = () => {
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
source_edit_plan_id: editPlanId || undefined,
@@ -433,6 +542,7 @@ const GeneratePage: React.FC = () => {
)?.error_message ||
"视频生成失败,请联系管理员或重试";
console.error("[生成失败] planId:", plan.id, "响应:", data);
setGenerateError(errorMsg);
message.error(errorMsg);
return;
}
@@ -462,20 +572,36 @@ const GeneratePage: React.FC = () => {
typeof setInterval
>;
} catch (err: unknown) {
console.error("生成失败:", err);
console.error("[handleGenerate] 生成失败:", err);
setGenerating(false);
// 提取 axios 响应中的后端错误信息
const axiosErr = err as {
response?: {
data?: { message?: string; error?: string; detail?: string };
data?: {
message?: string;
error?: string;
detail?: string;
msg?: string;
};
};
message?: string;
};
const backendMsg =
axiosErr.response?.data?.message ||
axiosErr.response?.data?.error ||
axiosErr.response?.data?.detail ||
axiosErr.response?.data?.msg ||
axiosErr.message ||
"";
message.error(backendMsg || "生成失败,请重试");
console.error(
"[handleGenerate] 错误信息:",
backendMsg,
"完整错误:",
axiosErr,
);
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
setGenerateError(errorMsg);
message.error(errorMsg);
}
}, [
title,
@@ -492,6 +618,8 @@ const GeneratePage: React.FC = () => {
bgm,
editPlanId,
selectedTemplate,
generateCount,
materialMode,
]);
/* ── 步骤导航 ── */
@@ -500,7 +628,11 @@ const GeneratePage: React.FC = () => {
message.warning("请先选择一个模板");
return;
}
if (currentStep === 2 && selectedMaterials.length === 0) {
if (
currentStep === 2 &&
materialMode === "manual" &&
selectedMaterials.length === 0
) {
message.warning("请至少选择一个素材");
return;
}
@@ -511,7 +643,13 @@ const GeneratePage: React.FC = () => {
if (currentStep < 5) {
setCurrentStep((s) => s + 1);
}
}, [currentStep, selectedTemplate, selectedMaterials.length, title]);
}, [
currentStep,
selectedTemplate,
selectedMaterials.length,
title,
materialMode,
]);
const goPrev = useCallback(() => {
if (currentStep > 1) {
@@ -571,11 +709,10 @@ const GeneratePage: React.FC = () => {
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
}}
>
{MODE_ABBRS[tpl.mode] || "TPL"}
🎬
</div>
<h4>{tpl.name}</h4>
<p>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "}
{tpl.estimated_duration}s · {tpl.segments.length}
</p>
{tpl.tags.length > 0 && (
@@ -610,11 +747,31 @@ const GeneratePage: React.FC = () => {
</div>
);
/** 步骤 2:选择素材 */
/** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
const renderStep2 = () => (
<div className="xx-form-section">
<h3>📦 </h3>
<div className="xx-form-field">
{/* ── 模式切换 Tab ── */}
<div className="xx-material-mode-tabs">
<button
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
onClick={() => setMaterialMode("manual")}
type="button"
>
</button>
<button
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
onClick={() => setMaterialMode("auto")}
type="button"
>
</button>
</div>
{/* ── 素材库选择(两种模式共用) ── */}
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
<select
value={selectedLibraryId}
@@ -627,90 +784,138 @@ const GeneratePage: React.FC = () => {
))}
</select>
</div>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
{selectedMaterials.length}
</span>
<Text style={{ color: "var(--text-tertiary, #94a3b8)", fontSize: 13 }}>
</Text>
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : materials.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.map((m) => {
const checked = selectedMaterials.includes(m.id);
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
);
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
);
})}
{/* ── 手动选择模式 ── */}
{materialMode === "manual" && (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
{selectedMaterials.length}
</span>
</div>
)}
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
>
</Text>
) : materials.length === 0 ? (
<Text
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.map((m) => {
const checked = selectedMaterials.includes(m.id);
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
);
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
);
})}
</div>
)}
</div>
</>
)}
{/* ── 自动匹配模式 ── */}
{materialMode === "auto" && (
<div className="xx-auto-match-card">
<div className="xx-auto-match-icon">🤖</div>
<div className="xx-auto-match-body">
<h4 className="xx-auto-match-title"></h4>
<p className="xx-auto-match-desc">
AI
</p>
<div className="xx-auto-match-features">
<span className="xx-auto-match-feature">📊 </span>
<span className="xx-auto-match-feature">🎯 </span>
<span className="xx-auto-match-feature"> </span>
</div>
</div>
{materialsLoading ? (
<Text
style={{
color: "var(--text-secondary)",
fontSize: 12,
marginTop: 8,
}}
>
</Text>
) : (
<Text
style={{
color: "var(--text-tertiary, #94a3b8)",
fontSize: 12,
marginTop: 8,
}}
>
{materials.length}
</Text>
)}
</div>
)}
</div>
);
@@ -720,14 +925,30 @@ const GeneratePage: React.FC = () => {
<h3>📝 </h3>
<div className="xx-form-field">
<label></label>
<select value={title} onChange={(e) => setTitle(e.target.value)}>
<option value=""></option>
{userTitles.map((t) => (
<option key={t.id} value={t.content}>
{t.content}
</option>
))}
</select>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={title || undefined}
onChange={(val) => setTitle(val || "")}
options={userTitles.map((t) => ({
label: t.content,
value: t.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "")
.toLowerCase()
.includes(input.toLowerCase())
}
notFoundContent={
userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</span>
) : null
}
/>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label></label>
@@ -753,35 +974,47 @@ const GeneratePage: React.FC = () => {
<div className="xx-form-section">
<h3>🎙 </h3>
{/* 配音方式选择卡片 */}
{/* 配音方式选择卡片 — 从 API 预设音色动态生成 */}
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
{VOICE_PRESET_CARDS.map((card) => (
{presetVoices.slice(0, 3).map((v) => (
<div
key={card.id}
className={`xx-voice-choice-item ${voiceMode === "preset" && selectedVoice === card.id ? "selected" : ""}`}
key={v.voice_id}
className={`xx-voice-choice-item ${voiceMode === "preset" && selectedVoice === v.voice_id ? "selected" : ""}`}
onClick={() => {
setVoiceMode("preset");
setSelectedVoice(card.id);
setSelectedVoice(v.voice_id);
}}
role="button"
tabIndex={0}
aria-pressed={voiceMode === "preset" && selectedVoice === card.id}
aria-pressed={
voiceMode === "preset" && selectedVoice === v.voice_id
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setVoiceMode("preset");
setSelectedVoice(card.id);
setSelectedVoice(v.voice_id);
}
}}
>
<span className="xx-voice-choice-check"></span>
<div className="xx-voice-choice-avatar">{card.icon}</div>
<div className="xx-voice-choice-avatar">
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
</div>
<div className="xx-voice-choice-info">
<h4>{card.label}</h4>
<p>{card.desc}</p>
<h4>{v.name}</h4>
<p>{v.description}</p>
</div>
</div>
))}
{presetVoicesLoading && (
<div className="xx-voice-choice-item" style={{ opacity: 0.5 }}>
<div className="xx-voice-choice-avatar"></div>
<div className="xx-voice-choice-info">
<h4></h4>
</div>
</div>
)}
{/* 克隆我的声音 */}
<div
className={`xx-voice-choice-item ${voiceMode === "clone" ? "selected" : ""}`}
@@ -904,16 +1137,136 @@ const GeneratePage: React.FC = () => {
{ttsError}
</Text>
)}
{customAudioUrl && (
<Text
{customAudioUrl && completedTtsJobId && (
<div
style={{
color: "var(--success, #10b981)",
marginTop: 8,
display: "block",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
</Text>
<Text style={{ color: "var(--success, #10b981)" }}>
</Text>
<button
className="xx-btn xx-btn-primary"
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
onClick={handleOpenSaveModal}
>
<SaveOutlined />
</button>
</div>
)}
{/* ── 存为素材弹窗 ── */}
{saveModalOpen && (
<div
className="xx-save-modal-overlay"
onClick={() => setSaveModalOpen(false)}
>
<div
className="xx-save-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="xx-save-modal-header">
<span></span>
<button
className="xx-save-modal-close"
onClick={() => setSaveModalOpen(false)}
>
<CloseOutlined />
</button>
</div>
<div className="xx-save-modal-body">
<label className="xx-save-modal-label"></label>
<input
className="xx-save-modal-input"
placeholder="留空则自动生成名称"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
maxLength={50}
/>
<label className="xx-save-modal-label">
<span
style={{
fontWeight: 400,
color: "var(--text-tertiary, #94a3b8)",
}}
>
</span>
</label>
<div className="xx-save-modal-tags">
{saveTagIds.map((id) => {
const tag = allTags.find((t) => t.id === id);
return tag ? (
<span key={id} className="xx-save-modal-tag active">
{tag.name}
<CloseOutlined
className="xx-save-modal-tag-remove"
onClick={() =>
setSaveTagIds((prev) =>
prev.filter((x) => x !== id),
)
}
/>
</span>
) : null;
})}
<input
className="xx-save-modal-tag-input"
placeholder="输入标签名回车添加"
value={saveNewTag}
onChange={(e) => setSaveNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTagInModal(saveNewTag);
}
}}
/>
</div>
{allTags.length > 0 && (
<div className="xx-save-modal-tag-presets">
{allTags
.filter((t) => !saveTagIds.includes(t.id))
.slice(0, 12)
.map((t) => (
<button
key={t.id}
className="xx-save-modal-tag-preset"
onClick={() =>
setSaveTagIds((prev) => [...prev, t.id])
}
>
{t.name}
<PlusOutlined
style={{ fontSize: 10, marginLeft: 4 }}
/>
</button>
))}
</div>
)}
</div>
<div className="xx-save-modal-footer">
<button
className="xx-btn xx-btn-ghost"
onClick={() => setSaveModalOpen(false)}
>
</button>
<button
className="xx-btn xx-btn-primary"
disabled={saveToLibraryMutation.isPending}
onClick={handleConfirmSave}
>
{saveToLibraryMutation.isPending ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
)}
</div>
)}
@@ -1040,7 +1393,9 @@ const GeneratePage: React.FC = () => {
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
{selectedMaterials.length}
{materialMode === "auto"
? "自动匹配"
: `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-summary-row">
@@ -1051,20 +1406,107 @@ const GeneratePage: React.FC = () => {
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{getVoiceName()}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={() => setGenerateCount((c) => Math.max(1, c - 1))}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={() => setGenerateCount((c) => Math.min(10, c + 1))}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
{/* 生成进度 */}
{generating && (
{/* 生成进度 / 结果反馈 */}
{(generating || generated || generateError) && (
<div style={{ marginTop: 16 }}>
<div className="xx-progress-bar">
{generating && (
<>
<div className="xx-progress-bar">
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
<LoadingOutlined style={{ marginRight: 6 }} />
{Math.round(progress)}%
</Text>
</>
)}
{generated && !generating && (
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
{Math.round(progress)}%
</Text>
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(82, 196, 26, 0.08)",
border: "1px solid rgba(82, 196, 26, 0.3)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
<div>
<Text
strong
style={{ color: "#52c41a", display: "block", fontSize: 14 }}
>
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
</Text>
</div>
</div>
)}
{generateError && !generating && (
<div
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(255, 77, 79, 0.08)",
border: "1px solid rgba(255, 77, 79, 0.3)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<CloseCircleOutlined
style={{
color: "#ff4d4f",
fontSize: 18,
marginTop: 2,
flexShrink: 0,
}}
/>
<div>
<Text
strong
style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}
>
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{generateError}
</Text>
</div>
</div>
)}
</div>
)}
</div>
@@ -1175,10 +1617,16 @@ const GeneratePage: React.FC = () => {
<button
className="xx-btn xx-btn-primary"
onClick={handleGenerate}
disabled={generating || generated}
disabled={generating || (generated && !generateError)}
>
<ThunderboltOutlined />
{generating ? "生成中…" : generated ? "已生成" : "✨ 确认生成"}
{generating
? "生成中…"
: generated && !generateError
? "已生成"
: generateError
? "🔄 重新生成"
: "✨ 确认生成"}
</button>
)}
</div>
+329
View File
@@ -883,3 +883,332 @@
flex-direction: column;
}
}
/* ── 存为素材弹窗 ── */
.xx-save-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: xxFadeIn 0.15s ease;
}
.xx-save-modal {
background: var(--bg-card, #fff);
border-radius: var(--radius-lg, 16px);
width: 420px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
animation: xxSlideUp 0.2s ease;
}
.xx-save-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light, #f1f5f9);
font-weight: 600;
font-size: 15px;
color: var(--text-primary, #0f172a);
}
.xx-save-modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary, #94a3b8);
font-size: 14px;
padding: 4px;
border-radius: var(--radius-sm, 6px);
transition: all 0.15s;
}
.xx-save-modal-close:hover {
background: var(--bg-hover, #f8fafc);
color: var(--text-primary, #0f172a);
}
.xx-save-modal-body {
padding: 20px;
}
.xx-save-modal-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary, #475569);
margin-bottom: 6px;
margin-top: 14px;
}
.xx-save-modal-label:first-child {
margin-top: 0;
}
.xx-save-modal-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: var(--radius-sm, 10px);
font-size: 14px;
outline: none;
background: var(--bg-surface, #fff);
color: var(--text-primary, #0f172a);
transition: border-color 0.15s;
}
.xx-save-modal-input:focus {
border-color: var(--primary-500, #6366f1);
box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff);
}
.xx-save-modal-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: var(--radius-sm, 10px);
min-height: 40px;
align-items: center;
cursor: text;
transition: border-color 0.15s;
}
.xx-save-modal-tags:focus-within {
border-color: var(--primary-500, #6366f1);
box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff);
}
.xx-save-modal-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 12px;
border-radius: 12px;
background: var(--primary-50, #eef2ff);
color: var(--primary-600, #4f46e5);
border: 1px solid var(--primary-200, #c7d2fe);
}
.xx-save-modal-tag-remove {
font-size: 10px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
}
.xx-save-modal-tag-remove:hover {
opacity: 1;
}
.xx-save-modal-tag-input {
border: none;
outline: none;
flex: 1;
min-width: 100px;
font-size: 13px;
background: transparent;
color: var(--text-primary, #0f172a);
}
.xx-save-modal-tag-input::placeholder {
color: var(--text-tertiary, #94a3b8);
}
.xx-save-modal-tag-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-light, #f1f5f9);
}
.xx-save-modal-tag-preset {
padding: 3px 10px;
font-size: 12px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 12px;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #475569);
cursor: pointer;
transition: all 0.15s;
}
.xx-save-modal-tag-preset:hover {
border-color: var(--primary-300, #a5b4fc);
color: var(--primary-600, #4f46e5);
background: var(--primary-50, #eef2ff);
}
.xx-save-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--border-light, #f1f5f9);
}
@keyframes xxFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes xxSlideUp {
from {
transform: translateY(12px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* ── 生成数量步进器 ── */
.xx-count-stepper {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xx-count-stepper-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 8px;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #64748b);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.xx-count-stepper-btn:hover:not(:disabled) {
border-color: var(--primary-400, #818cf8);
color: var(--primary-600, #4f46e5);
background: var(--primary-50, #eef2ff);
}
.xx-count-stepper-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.xx-count-stepper-value {
min-width: 24px;
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-count-stepper-hint {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
margin-left: 2px;
}
/* ── 素材选择模式切换 Tab ── */
.xx-material-mode-tabs {
display: flex;
gap: 0;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 10px;
overflow: hidden;
margin-bottom: 4px;
}
.xx-material-mode-tab {
flex: 1;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
border: none;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #64748b);
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.xx-material-mode-tab:first-child {
border-right: 1px solid var(--border-primary, #e2e8f0);
}
.xx-material-mode-tab:hover {
background: var(--primary-50, #eef2ff);
color: var(--primary-600, #4f46e5);
}
.xx-material-mode-tab.active {
background: var(--primary-500, #6366f1);
color: #fff;
font-weight: 600;
}
/* ── 自动匹配卡片 ── */
.xx-auto-match-card {
margin-top: 14px;
padding: 20px;
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 14px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.xx-auto-match-icon {
font-size: 36px;
margin-bottom: 10px;
}
.xx-auto-match-body {
width: 100%;
}
.xx-auto-match-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin: 0 0 8px;
}
.xx-auto-match-desc {
font-size: 13px;
color: var(--text-secondary, #64748b);
line-height: 1.6;
margin: 0 0 14px;
}
.xx-auto-match-features {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.xx-auto-match-feature {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
color: var(--primary-600, #4f46e5);
background: rgba(255, 255, 255, 0.8);
border: 1px solid var(--border-light, #f1f5f9);
border-radius: 20px;
}
+5 -3
View File
@@ -207,9 +207,11 @@ const MyVoices: React.FC = () => {
if (audioRef.current) {
audioRef.current.pause();
}
// Mock: 使用 sample_url 或占位 URL
const url = voice.sample_url || `/mock/audio/clone-${voice.id}.mp3`;
const audio = new Audio(url);
if (!voice.sample_url) {
showToast("暂无试听音频", "error");
return;
}
const audio = new Audio(voice.sample_url);
audioRef.current = audio;
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"));
audio.onended = () => setPlayingId(null);
+29 -1
View File
@@ -737,11 +737,39 @@ const ProductLibrary: React.FC = () => {
// ── Error 状态 ──
if (isError) {
console.error("[ProductLibrary] 加载失败:", error);
const errorMsg = error?.message || "加载失败";
// 404 视为空数据(API 尚未就绪或无数据)
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
if (is404) {
return (
<div className="xx-products-page">
<div className="xx-products-header">
<h2>
<VideoCameraOutlined />
</h2>
</div>
<div className="xx-products-empty">
<div className="xx-products-empty-icon">🎬</div>
<p></p>
<p
style={{
fontSize: 12,
color: "var(--text-tertiary)",
marginTop: 4,
}}
>
</p>
</div>
</div>
);
}
return (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>{error?.message || "加载失败"}</p>
<p>{errorMsg || "加载失败,请稍后重试"}</p>
<Button
buttonType="primary"
buttonSize="sm"
@@ -91,7 +91,7 @@ const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
id: item.id,
name: item.name,
type: inferTemplateType(item.category),
description: item.description,
description: item.description ?? "",
usageCount: 0,
isFavorite: item.is_favorite ?? false,
thumbnailGradient: gradientForCategory(item.category),
@@ -370,9 +370,10 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{template.description.slice(0, 80)}...
{(template.description ?? "").slice(0, 80)}...
</div>
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
@@ -386,7 +387,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{/* 信息区 */}
<div className="xx-template-info">
<div className="xx-template-info-top">
<h4 className="xx-template-name">{template.name}</h4>
<span
className="xx-template-category-pill"
style={{
@@ -397,7 +397,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{template.type}
</span>
</div>
<p className="xx-template-desc">{template.description}</p>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {template.usageCount}
@@ -483,7 +483,9 @@ const TemplateLibrary: React.FC = () => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description ?? "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
t.tags.some((tag) =>
tag.toLowerCase().includes(searchText.toLowerCase()),
);
@@ -233,6 +233,24 @@
pointer-events: none;
}
/* 缩略图底部名称 */
.xx-template-thumb-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 24px 14px 10px;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.55) 0%, transparent 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 1;
pointer-events: none;
}
/* 预览提示(hover 显示) */
.xx-template-preview-hint {
position: absolute;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7
View File
@@ -156,6 +156,13 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "voice-materials",
lazy: () =>
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
+2
View File
@@ -75,6 +75,8 @@ export const useAuthStore = create<AuthState>()(
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
}),
},
),
+6
View File
@@ -11,6 +11,12 @@ export default defineConfig({
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
exclude: [
"node_modules",
"e2e",
"dist",
"build",
],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
+62
View File
@@ -206,6 +206,68 @@ class VideoDeduplicator:
return None
def check_batch_duplicate(
self,
fingerprint: VideoFingerprint,
batch_id: str,
current_video_id: str,
session: Session,
) -> Optional[dict]:
"""检查视频是否与同批次内其他视频重复。
逻辑与 check_duplicate 一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
Args:
fingerprint: 待检测视频的指纹
batch_id: 批次 ID
current_video_id: 当前视频 ID(排除自身)
session: 数据库会话
Returns:
重复信息字典,或 None 表示未找到重复
"""
video_repo = SQLAlchemyGeneratedVideoRepository(session)
batch_videos = video_repo.list_by_batch(batch_id)
for existing in batch_videos:
if existing.id == current_video_id:
continue
if not existing.video_fingerprint:
continue
ef = existing.video_fingerprint
if fingerprint.md5 == ef.get("md5"):
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_exact_md5_match",
"similarity": 1.0,
}
existing_phashes = ef.get("keyframe_phashes", [])
if not existing_phashes:
continue
min_distances = []
for phash in fingerprint.keyframe_phashes:
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
min_distances.append(min(distances))
avg_distance = sum(min_distances) / len(min_distances) if min_distances else 100
if avg_distance >= self.PHASH_THRESHOLD:
continue
phash_similarity = 1.0 - (avg_distance / 64)
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_phash_similar",
"similarity": phash_similarity,
}
return None
@staticmethod
def _average_histogram_similarity(histograms_a: list[list[float]], histograms_b: list[list[float]]) -> float:
"""
+5
View File
@@ -41,6 +41,10 @@ def __getattr__(name: str):
from .tts_synthesis import process_tts_synthesis
return process_tts_synthesis
elif name == "process_tts_segment_synthesis":
from .tts_synthesis import process_tts_segment_synthesis
return process_tts_segment_synthesis
elif name == "run_ai_recommend":
from .ai_tasks import run_ai_recommend
@@ -62,6 +66,7 @@ __all__ = [
"extract_background_task",
"process_voice_clone",
"process_tts_synthesis",
"process_tts_segment_synthesis",
"run_ai_recommend",
"run_generate_cover",
]
+123 -12
View File
@@ -139,14 +139,16 @@ def _download_library_assets(
asset_library_id: str,
temp_path: Path,
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
asset_ids: list[str] | None = None,
) -> list[str]:
"""
从素材库下载所有视频素材
从素材库下载视频素材
Args:
asset_library_id: 素材库 ID
temp_path: 临时目录路径
video_extensions: 支持的视频扩展名
asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材
Returns:
下载成功的视频文件路径列表
@@ -161,16 +163,15 @@ def _download_library_assets(
try:
# 查询素材库中的视频素材
assets = (
session.query(AssetModel)
.filter(
AssetModel.asset_library_id == asset_library_id,
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
)
.order_by(AssetModel.created_at)
.all()
query = session.query(AssetModel).filter(
AssetModel.asset_library_id == asset_library_id,
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
)
# 如果指定了 asset_ids,则只下载这些素材
if asset_ids:
query = query.filter(AssetModel.id.in_(asset_ids))
assets = query.order_by(AssetModel.created_at).all()
if not assets:
logger.info(f"No video assets found in library {asset_library_id}")
@@ -259,6 +260,8 @@ def generate_video(self, task_id: str) -> dict:
asset_library_id = gen_task.asset_library_id
voice_library_id = gen_task.voice_library_id or ""
mode = gen_task.strategy_id or "one_take"
task_asset_ids = list(gen_task.asset_ids or [])
batch_id = getattr(gen_task, "batch_id", "") or ""
finally:
session.close()
@@ -275,8 +278,8 @@ def generate_video(self, task_id: str) -> dict:
temp_path = Path(temp_dir)
output_path = temp_path / output_name
# 从素材库下载视频素材
downloaded_videos = _download_library_assets(asset_library_id, temp_path)
# 从素材库下载视频素材(如果任务指定了 asset_ids 则只下载这些)
downloaded_videos = _download_library_assets(asset_library_id, temp_path, asset_ids=task_asset_ids or None)
audio_path = None
if voice_library_id:
@@ -297,6 +300,32 @@ def generate_video(self, task_id: str) -> dict:
file_size = output_path.stat().st_size
duration = _probe_duration(output_path)
# 上传到 OSS
bucket = _oss_bucket()
if bucket:
try:
bucket.put_object_from_file(storage_key, str(output_path))
except Exception as oss_err:
logger.warning(f"OSS upload failed: {oss_err}")
# 构建视频 URL
if bucket:
file_url = f"{PUBLIC_API_BASE_URL}/{storage_key}"
else:
file_url = f"{GENERATED_FILES_URL_PREFIX}/{task_id}/{output_name}"
# 创建 GeneratedVideo 记录 + 查重
_create_video_record_and_dedup(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=str(output_path),
mode=editing_mode.value,
)
return {
"status": "completed",
"task_id": task_id,
@@ -314,3 +343,85 @@ def generate_video(self, task_id: str) -> dict:
"task_id": task_id,
"error": str(error),
}
def _create_video_record_and_dedup(
*,
task_id: str,
project_id: str,
batch_id: str,
file_url: str,
file_size: int,
duration: float,
video_path: str,
mode: str,
) -> None:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。"""
from uuid import uuid4
from video_processing.dedup import VideoDeduplicator
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain import GeneratedVideo
session = SessionLocal()
try:
video_id = uuid4().hex
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
generation_task_id=task_id,
name=f"generated-{task_id[:8]}.mp4",
file_url=file_url,
file_size=file_size,
duration=duration,
width=OUTPUT_WIDTH,
height=OUTPUT_HEIGHT,
fps=OUTPUT_FPS,
status="completed",
generation_params={"mode": mode},
)
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
# 计算视频指纹
deduplicator = VideoDeduplicator()
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
except Exception as fp_err:
logger.warning(f"Fingerprint computation failed for {video_id}: {fp_err}")
session.commit()
return
generated_video.video_fingerprint = fingerprint.to_dict()
# (a) 历史成片查重
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
# (b) 批次内查重(仅当有 batch_id 时)
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
if duplicate_result:
generated_video.is_duplicate = True
generated_video.duplicate_of = duplicate_result["duplicate_of"]
logger.info(
f"Duplicate detected: {video_id} -> {duplicate_result['duplicate_of']} "
f"(reason={duplicate_result['reason']}, similarity={duplicate_result['similarity']:.3f})"
)
else:
generated_video.is_duplicate = False
generated_video.duplicate_of = None
video_repo.update(generated_video)
session.commit()
logger.info(f"GeneratedVideo record created: {video_id} (task={task_id}, dup={generated_video.is_duplicate})")
except Exception as e:
logger.error(f"Failed to create video record / dedup for task {task_id}: {e}")
session.rollback()
finally:
session.close()
+1
View File
@@ -179,6 +179,7 @@ def ingest_asset(job_id: str) -> dict:
width=int(metadata.get("width", 0)),
height=int(metadata.get("height", 0)),
status=AssetStatus.READY,
file_hash=job.file_hash,
)
asset_repo.create(asset)
@@ -104,3 +104,77 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
finally:
if session is not None:
session.close()
@celery_app.task(bind=True, max_retries=2, name="worker.process_tts_segment_synthesis")
def process_tts_segment_synthesis(self: Task, job_id: str) -> dict:
"""分段合成轮询任务 — 轮询多个 CosyVoice 子任务并合并音频。
与 process_tts_synthesis 类似,但超时更长(300s),
因为分段任务需要等待所有子任务完成。
"""
session = None
try:
session = SessionLocal()
repo = SQLAlchemyTTSJobRepository(session)
workflow = TTSWorkflowService(
repository=repo,
cosyvoice_service=CosyVoiceService(),
)
updated_job = workflow.poll_and_process_synthesis(job_id, timeout=300)
session.commit()
logger.info(f"TTS segment synthesis completed: job_id={job_id}, " f"audio_url={updated_job.output_audio_url}")
return {
"ok": True,
"job_id": job_id,
"audio_url": updated_job.output_audio_url,
}
except Retry:
raise
except CosyVoiceTimeoutError as e:
logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}")
if session is not None:
session.rollback()
raise self.retry(exc=e, countdown=60)
except CosyVoiceError as e:
logger.error(f"TTS segment synthesis failed for {job_id}: {e}")
if session is not None:
session.rollback()
try:
if session is not None:
job = repo.get(job_id)
if job is not None:
job.mark_failed(str(e))
repo.update(job)
session.commit()
except Exception as inner_e:
logger.error(f"Failed to mark job as failed: {inner_e}")
if session is not None:
session.rollback()
return {"ok": False, "job_id": job_id, "error": str(e)}
except Exception as e:
logger.error(f"TTS segment synthesis unexpected error for {job_id}: {e}")
if session is not None:
session.rollback()
try:
if session is not None:
job = repo.get(job_id)
if job is not None:
job.mark_failed(str(e))
repo.update(job)
session.commit()
except Exception as inner_e:
logger.error(f"Failed to mark job as failed: {inner_e}")
if session is not None:
session.rollback()
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session is not None:
session.close()
+140
View File
@@ -0,0 +1,140 @@
# 端口分配清单
> 本文档梳理 xiaoxia-saas 项目中所有服务、容器及 CI 环境使用的端口,
> 作为运维、排障和新功能开发时的统一参考。
>
> 最后更新:2026-07-24
---
## 一、应用服务端口
| 服务 | 容器内端口 | 环境变量名 | Staging 宿主机 | Production 宿主机 | 说明 |
| -------- | ---------- | ---------------- | -------------- | ----------------- | ----------------------------------- |
| API | 8000 | `API_PORT` | 8000 | 8001 | FastAPI 服务,Nginx 反代后端 |
| Web | 80 | `WEB_PORT` | 3001 | 3002 | Nginx + 前端静态文件 |
| Worker | — | — | — | — | Celery 任务队列,不暴露端口 |
### 补充说明
- API 容器内部固定监听 8000`API_HOST=0.0.0.0``API_PORT=8000`
- Web 容器内部 Nginx 固定监听 80
- 所有端口均绑定 `127.0.0.1`,不直接暴露公网,由前置 Nginx/CDN 转发
---
## 二、基础设施端口
### PostgreSQL
| 环境 | 容器内端口 | 宿主机映射 | 环境变量名 | 默认值 |
| ------------ | ---------- | ---------- | --------------------- | -------- |
| Production | 5432 | 5433 | `POSTGRES_PORT` | 5433 |
| Staging | 5432 | 5434 | `POSTGRES_PORT` | 5434 |
| 开发本地 | 5432 | 5432 | `DATABASE_URL` 中端口 | 5432 |
| CI 共享 PG | 5432 | 5433 | `CI_SHARED_PG_PORT` | 5433 |
| CI 本地 PG | 5432 | 5432 | `CI_LOCAL_PG_PORT` | 5432 |
### Redis
| 环境 | 容器内端口 | 宿主机映射 | 环境变量名 | 默认值 |
| ------------ | ---------- | ---------- | ------------------- | -------- |
| Production | 6379 | 6380 | `REDIS_URL` 中端口 | — |
| Staging | 6379 | 6381 | `REDIS_URL` 中端口 | — |
| 开发本地 | 6379 | 6379 | `REDIS_URL` | 6379 |
| CI 动态创建 | 6379 | 随机 | 运行时 `REDIS_PORT` | — |
> CI Integration Tests 中 Redis 容器使用 `-P` 随机映射端口,
> 通过 `docker port` 命令获取实际端口后写入 `REDIS_URL`。
### 容器镜像 Registry
| 服务 | 端口 | 地址 | 说明 |
| ----------------- | ----- | ---------------------- | ------------------------------ |
| Gitea Registry | 5000 | 172.30.18.198:5000 | CI 构建服务器内网 Registry |
| ACR(生产镜像源) | 443 | crpi-xxx.aliyuncs.com | 阿里云容器镜像服务(HTTPS) |
---
## 三、CI / DevOps 端口
| 服务/用途 | 端口 | 环境变量名 | 默认值 | 说明 |
| ------------------- | ----- | --------------------- | ------ | ------------------------------------- |
| CI ChatOps Webhook | 8090 | `CHATOPS_WEBHOOK_PORT`| 8090 | Gitea webhook 接收服务(`scripts/ci/chatops/` |
| Staging SSH 部署 | 22222 | `STAGING_SSH_PORT` | 22222 | Staging 服务器 SSH 端口(secrets 配置) |
| Preview SSH 部署 | 22222 | `PREVIEW_SSH_PORT` | 22222 | Preview 服务器 SSH 端口(secrets 配置) |
| Preview 前端访问 | 80 | — | 80 | Nginx 子域名路由,`*.preview.xiaoxiajianji.com` |
---
## 四、开发环境默认端口(.env.example
| 用途 | 端口 | 环境变量名 / 出处 |
| ------------ | ----- | ------------------------------------------ |
| API 服务 | 8000 | `API_PORT` |
| 数据库 | 5432 | `DATABASE_URL``postgresql+psycopg://...:5432/...` |
| Redis | 6379 | `REDIS_URL` / `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` |
| SMTP | 587 | `SMTP_PORT` |
| 前端开发服务 | 3000 | `APP_BASE_URL`(默认 localhost:3000 |
| Vite Dev | 5173 | `CORS_ORIGINS_RAW` 中包含 |
---
## 五、CI Workflow 中的端口变量
### ci-pipeline.yml 顶层 env
| 变量名 | 默认值 | 用途 |
| ------------------- | ------ | ------------------------ |
| `CI_PG_PORT` | 5432 | CI PG 容器端口(本地) |
| `CI_SHARED_PG_PORT` | 5433 | CI 共享常驻 PG 端口 |
### scripts/ci/ci_env.sh(统一常量)
| 变量名 | 默认值 | 说明 |
| ------------------- | ----------- | ----------------------------- |
| `CI_SHARED_PG_PORT` | 5433 | 共享常驻 PG 实例端口 |
| `CI_LOCAL_PG_PORT` | 5432 | 本地 PG 容器默认端口 |
| `CI_DEFAULT_DB` | xiaoxia_saas | 默认数据库名 |
---
## 六、命名规范
### 推荐命名格式
统一使用 `{服务/用途}_PORT` 格式:
```bash
API_PORT # 应用服务
WEB_PORT # 应用服务
POSTGRES_PORT # 基础设施
REDIS_PORT # 基础设施
SMTP_PORT # 外部服务
CI_SHARED_PG_PORT # CI 特定
CI_LOCAL_PG_PORT # CI 特定
CHATOPS_WEBHOOK_PORT # DevOps 服务
```
### 历史命名不一致(待统一)
- `WEBHOOK_PORT`chatops config.py 内部变量)→ 应与外部 env 名 `CHATOPS_WEBHOOK_PORT` 对齐
- `STAGING_SSH_PORT` / `PREVIEW_SSH_PORT` → 符合规范,保留
- `CI_PG_PORT`(workflow 中)→ 建议统一为 `CI_LOCAL_PG_PORT``ci_env.sh` 对齐
---
## 七、相关配置文件路径
| 文件路径 | 端口相关内容 |
| ------------------------------------- | -------------------------------- |
| `infra/docker/compose.yml` | API / Web / Worker 端口映射 |
| `infra/docker/infra.yml` | Staging PG / Redis 端口 |
| `infra/docker/infra-production.yml` | Production PG / Redis 端口 |
| `.env.example` | 开发环境全部端口变量 |
| `.gitea/workflows/ci-pipeline.yml` | CI PG 端口配置 |
| `scripts/ci/ci_env.sh` | CI 端口统一常量 |
| `scripts/ci/chatops/config.py` | ChatOps Webhook 端口 |
| `scripts/ci/run_integration_tests.sh` | Redis 动态端口 + PG 端口 |
| `scripts/ci/run_validate.sh` | PG 端口 |
| `scripts/ci/validate_migration.sh` | PG 端口 |
+134
View File
@@ -95,6 +95,39 @@
"id"
]
},
"asset_tags": {
"columns": [
{
"index": false,
"name": "asset_id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "tag_id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "created_at",
"nullable": false,
"primary_key": false,
"type": "DATETIME",
"unique": false
}
],
"indexes": [],
"primary_key": [
"asset_id",
"tag_id"
]
},
"assets": {
"columns": [
{
@@ -241,6 +274,14 @@
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "file_hash",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(64)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -288,6 +329,13 @@
"name": "ix_assets_created_at",
"unique": false
},
{
"columns": [
"file_hash"
],
"name": "ix_assets_file_hash",
"unique": false
},
{
"columns": [
"file_type"
@@ -1469,6 +1517,22 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "asset_select_mode",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(20)",
"unique": false
},
{
"index": true,
"name": "batch_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -1494,6 +1558,13 @@
"name": "ix_generation_tasks_asset_library_id",
"unique": false
},
{
"columns": [
"batch_id"
],
"name": "ix_generation_tasks_batch_id",
"unique": false
},
{
"columns": [
"created_by_user_id"
@@ -1599,6 +1670,14 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": true,
"name": "file_hash",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(64)",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -1617,6 +1696,13 @@
}
],
"indexes": [
{
"columns": [
"file_hash"
],
"name": "ix_ingest_jobs_file_hash",
"unique": false
},
{
"columns": [
"library_id"
@@ -2056,6 +2142,54 @@
"id"
]
},
"tags": {
"columns": [
{
"index": false,
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "name",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(100)",
"unique": false
},
{
"index": false,
"name": "created_at",
"nullable": false,
"primary_key": false,
"type": "DATETIME",
"unique": false
}
],
"indexes": [
{
"columns": [
"user_id"
],
"name": "ix_tags_user_id",
"unique": false
}
],
"primary_key": [
"id"
]
},
"template_categories": {
"columns": [
{
+1 -1
View File
@@ -4,7 +4,7 @@
# ============================================================
# 基础镜像:Python 3.12
FROM python:3.12-slim-bookworm
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/python:3.12-slim-bookworm
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/python:3.12-slim
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=0 \
+240
View File
@@ -0,0 +1,240 @@
#!/bin/sh
set -eu
# ============================================
# Production 部署脚本 - Registry 方式
# 用法:IMAGE_TAG=<version> REGISTRY_TOKEN=<token> sh deploy-production-registry.sh
# ============================================
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "Logging in to registry: $REGISTRY"
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ---- 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}"
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
# ---- Re-tag 成本地名 ----
docker tag "$REGISTRY_API" "$LOCAL_API"
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
echo "All images pulled and tagged."
# ---- 备份旧版 assets(部署期间缓存用户不 404 ----
echo "Backing up legacy assets from current web container..."
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
_tmpdir="/tmp/legacy-assets-$$"
rm -rf "$_tmpdir"
mkdir -p "$_tmpdir"
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
# 合并到 LEGACY_ASSETS_DIR(保留所有历史版本的 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"
fi
rm -rf "$_tmpdir"
else
echo "No existing web container, skipping legacy assets backup"
fi
# 清理超过 7 天的旧 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-production xiaoxia-redis-production; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo "ERROR: Required container not found: $c"
exit 1
fi
state=$(docker inspect -f '{{.State.Status}}' "$c")
if [ "$state" != "running" ]; then
echo "ERROR: Container not running: $c ($state)"
exit 1
fi
done
# ---- 确保生产网络存在 ----
docker network create xiaoxia-net-production 2>/dev/null || true
# ---- 执行数据库 Migration ----
echo "Running database migrations..."
docker run --rm \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
echo "Migrations completed."
# ---- 停止旧容器 ----
echo "Stopping old containers..."
docker rm -f xiaoxia-api-production 2>/dev/null || true
docker rm -f xiaoxia-worker-production 2>/dev/null || true
docker rm -f xiaoxia-web-production 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..."
docker run -d \
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$LOCAL_API"
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$LOCAL_WORKER"
# ---- 启动 Web ----
# Legacy assets 挂载到 /usr/share/nginx/html/assets-legacy/assets/
# nginx 配置中 assets location 有 fallback 逻辑
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-production \
--network xiaoxia-net-production \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$LOCAL_WEB"
# ---- 等待 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:8001/health >/dev/null 2>&1; then
echo "API is healthy!"
break
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"
docker logs --tail 50 xiaoxia-api-production
exit 1
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:3002/ >/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"
docker logs --tail 30 xiaoxia-web-production
exit 1
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 "=== Production deployment complete ==="
echo "API: http://127.0.0.1:8001"
echo "Web: http://127.0.0.1:3002"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
+35 -33
View File
@@ -6,25 +6,19 @@ ROOT_DIR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/repo"
COMPOSE_DIR="$ROOT_DIR/infra/docker"
ENV_FILE="$HOST_PREFIX/var/lib/xiaoxia-saas-production/.env"
RELEASE_VERSION="${RELEASE_VERSION:-}"
RUNTIME_IMAGE_TAR="${RUNTIME_IMAGE_TAR:-}"
WEB_IMAGE_TAR="${WEB_IMAGE_TAR:-}"
if [ -n "$RELEASE_VERSION" ] && [ -z "$RUNTIME_IMAGE_TAR" ]; then
RUNTIME_IMAGE_TAR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/runtime-images-$RELEASE_VERSION.tar"
fi
if [ -n "$RELEASE_VERSION" ] && [ -z "$WEB_IMAGE_TAR" ]; then
WEB_IMAGE_TAR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/web-$RELEASE_VERSION.tar"
fi
# Registry 配置
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ensure_container_running() {
name="$1"
if ! docker inspect "$name" >/dev/null 2>&1; then
echo "Required infrastructure container not found: $name"
echo "Run infra/docker/infra-production.yml first before deploying production applications."
exit 1
fi
state="$(docker inspect -f '{{.State.Status}}' "$name")"
state="$(docker inspect -f "{{.State.Status}}" "$name")"
if [ "$state" != "running" ]; then
echo "Required infrastructure container is not running: $name ($state)"
exit 1
@@ -39,10 +33,10 @@ fi
cd "$ROOT_DIR"
cp "$ENV_FILE" "$ROOT_DIR/.env"
mkdir -p "$ROOT_DIR/apps/web/public" "$HOST_PREFIX/var/lib/xiaoxia-saas-production/generated"
[ -f "$ROOT_DIR/apps/web/public/.keep" ] || printf 'placeholder' > "$ROOT_DIR/apps/web/public/.keep"
[ -f "$ROOT_DIR/apps/web/public/.keep" ] || printf "placeholder" > "$ROOT_DIR/apps/web/public/.keep"
if [ ! -f "$ROOT_DIR/apps/web/dist/index.html" ]; then
echo "Missing prebuilt web artifact: $ROOT_DIR/apps/web/dist/index.html"
echo "Production deploy must not build frontend assets on the server. Run npm run build before packaging the release artifact."
echo "Production deploy must not build frontend assets on the server."
exit 1
fi
@@ -50,23 +44,33 @@ ensure_container_running xiaoxia-postgres-production
ensure_container_running xiaoxia-redis-production
cd "$COMPOSE_DIR"
if [ -n "$RELEASE_VERSION" ]; then
if [ ! -f "$RUNTIME_IMAGE_TAR" ]; then
echo "Missing production runtime image artifact: $RUNTIME_IMAGE_TAR"
echo "Build it on a dedicated build host with scripts/build_release_images.sh $RELEASE_VERSION, then upload it before production deploy."
exit 1
fi
docker load -i "$RUNTIME_IMAGE_TAR"
if [ ! -f "$WEB_IMAGE_TAR" ]; then
echo "Missing production web image artifact: $WEB_IMAGE_TAR"
echo "Build it on a dedicated build host, then upload it before production deploy."
exit 1
fi
docker load -i "$WEB_IMAGE_TAR"
export API_IMAGE="xiaoxia-saas-api:$RELEASE_VERSION"
export WORKER_IMAGE="xiaoxia-saas-worker:$RELEASE_VERSION"
export WEB_IMAGE="${WEB_IMAGE:-xiaoxia-saas-web:$RELEASE_VERSION}"
export WEB_IMAGE="xiaoxia-saas-web:$RELEASE_VERSION"
export APP_VERSION="$RELEASE_VERSION"
# ---- 从 Registry Pull 镜像 ----
echo "Pulling images from registry: $REGISTRY"
if [ -n "$REGISTRY_TOKEN" ]; then
printf "%s" "$REGISTRY_TOKEN" | docker login git.xiaoxiajianji.com -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
fi
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:$RELEASE_VERSION"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:$RELEASE_VERSION"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$RELEASE_VERSION"
docker pull "$REGISTRY_API"
docker pull "$REGISTRY_WORKER"
docker pull "$REGISTRY_WEB"
# Re-tag 为本地镜像名,保持 compose 兼容
docker tag "$REGISTRY_API" "$API_IMAGE"
docker tag "$REGISTRY_WORKER" "$WORKER_IMAGE"
docker tag "$REGISTRY_WEB" "$WEB_IMAGE"
echo "All images pulled and tagged."
fi
export DOCKER_BUILDKIT=0
@@ -76,7 +80,6 @@ export ENV=production
export WEB_DOCKERFILE=infra/docker/web-artifact.Dockerfile
export WEB_NGINX_CONF=infra/docker/nginx-production.conf
# Ensure isolated production network exists
docker network create xiaoxia-net-production 2>/dev/null || true
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-1}"
export WORKER_MAX_TASKS_PER_CHILD="${WORKER_MAX_TASKS_PER_CHILD:-100}"
@@ -86,24 +89,23 @@ if [ "${ALLOW_PRODUCTION_BUILDS:-false}" = "true" ]; then
docker compose --env-file "$ENV_FILE" build --pull=false worker
docker compose --env-file "$ENV_FILE" build --pull=false web
else
echo "Skipping production API/worker/web image builds. Set ALLOW_PRODUCTION_BUILDS=true only on a dedicated build host."
echo "Skipping production image builds (using prebuilt images from registry)."
docker image inspect "${API_IMAGE:-xiaoxia-saas-api:dev}" >/dev/null
docker image inspect "${WORKER_IMAGE:-xiaoxia-saas-worker:dev}" >/dev/null
docker image inspect "${WEB_IMAGE:-xiaoxia-saas-web:dev}" >/dev/null
fi
docker compose --env-file "$ENV_FILE" run --rm --no-deps api sh -c '
docker compose --env-file "$ENV_FILE" run --rm --no-deps api sh -c "
cd /app &&
python /app/scripts/validate_release_env.py --from-environ --strict-external &&
alembic upgrade head
'
"
docker compose --env-file "$ENV_FILE" up -d api worker
# Recreate web after API so nginx resolves the current API container IP.
# Docker's embedded DNS is resolved by nginx at startup for this static upstream.
docker compose --env-file "$ENV_FILE" up -d --force-recreate web
docker compose --env-file "$ENV_FILE" ps
if [ -n "$RELEASE_VERSION" ] && [ -d "$HOST_PREFIX/etc/cron.d" ]; then
cat > "$HOST_PREFIX/etc/cron.d/xiaoxia-production-resource-check" <<EOF
cat > "$HOST_PREFIX/etc/cron.d/xiaoxia-production-resource-check" << EOF
*/5 * * * * root cd /var/lib/xiaoxia-saas-production/repo && EXPECTED_VERSION=$RELEASE_VERSION sh scripts/production_resource_check.sh >/var/log/xiaoxia-resource-check.log 2>&1
EOF
fi
+178
View File
@@ -0,0 +1,178 @@
#!/bin/sh
set -eu
# ============================================
# Staging 部署脚本 - Registry 方式
# 用法:IMAGE_TAG=<sha|version> REGISTRY_TOKEN=<token> sh deploy-staging.sh
# ============================================
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
COMPOSE_DIR="${COMPOSE_DIR:-/var/lib/xiaoxia-saas-staging/repo/infra/docker}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "Logging in to registry: $REGISTRY"
printf %s "$REGISTRY_TOKEN" | docker login "$(echo $REGISTRY | cut -d/ -f1)" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ---- 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}"
LOCAL_API="${REGISTRY}/xiaoxia-saas-api:staging"
LOCAL_WORKER="${REGISTRY}/xiaoxia-saas-worker:staging"
LOCAL_WEB="${REGISTRY}/xiaoxia-saas-web:staging"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
# ---- Re-tag 成本地名 ----
docker tag "$REGISTRY_API" "$LOCAL_API"
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
echo "All images pulled and tagged."
# ---- 确保基础设施容器在运行 ----
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo "ERROR: Required container not found: $c"
exit 1
fi
state=$(docker inspect -f {{.State.Status}} "$c")
if [ "$state" != "running" ]; then
echo "ERROR: Container not running: $c ($state)"
exit 1
fi
done
# ---- 确保 staging 网络存在 ----
docker network create xiaoxia-net-staging 2>/dev/null || true
# ---- 执行数据库 Migration ----
echo "Running database migrations..."
docker run --rm --env-file "$ENV_FILE" --network xiaoxia-net-staging "$LOCAL_API" sh -c "cd /app && alembic upgrade head"
echo "Migrations completed."
# ---- 停止旧容器 ----
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
docker rm -f xiaoxia-web-staging 2>/dev/null || true
# ---- 启动 API ----
echo "Starting API container..."
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-p 127.0.0.1:8000:8000 \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
"$LOCAL_API"
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
"$LOCAL_WORKER"
# ---- 启动 Web ----
# Web 镜像默认打包 production nginx.confstaging 需要挂载 staging 配置
NGINX_CONF="${NGINX_CONF:-${COMPOSE_DIR}/nginx-staging.conf}"
if [ ! -f "$NGINX_CONF" ]; then
echo "WARN: nginx config not found at $NGINX_CONF, using image default"
NGINX_VOLUME=""
else
NGINX_VOLUME="-v ${NGINX_CONF}:/etc/nginx/conf.d/default.conf:ro"
fi
echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
$NGINX_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
"$LOCAL_WEB"
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
i=0
while [ "$i" -lt 30 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/30)"
sleep 2
done
if [ "$i" -ge 30 ]; then
echo "ERROR: API did not become healthy within 60s"
docker logs --tail 30 xiaoxia-api-staging
exit 1
fi
# ---- 清理旧镜像 ----
docker image prune -af --filter "until=72h" 2>/dev/null || true
echo ""
echo "=== Staging deployment complete ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}" | grep staging
# Watchtower auto-update: 容器加com.centurylinklabs.watchtower.enable=true标签,用:staging tag启动
+9
View File
@@ -39,6 +39,15 @@ server {
alias /app/generated/;
}
# Assets with legacy fallback (higher priority than generic static regex)
# 部署期间,缓存了旧版 index.html 的用户会请求旧版带 hash 的 assets 文件
# 先在当前镜像中找,找不到去 legacy-assets 目录找(从旧版本容器中备份的)
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri /assets-legacy$uri =404;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
+1 -1
View File
@@ -1,4 +1,4 @@
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/nginx:alpine AS runner
ARG NGINX_CONF=infra/docker/nginx.conf
WORKDIR /usr/share/nginx/html
COPY apps/web/dist ./
+2 -2
View File
@@ -1,5 +1,5 @@
# Build stage
FROM docker.m.daocloud.io/library/node:20 AS builder
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/node:20 AS builder
WORKDIR /app
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
ENV VITE_API_URL=$VITE_API_URL
@@ -11,7 +11,7 @@ COPY apps/web/ ./
RUN npm run build
# Production stage with nginx
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/nginx:alpine AS runner
ARG NGINX_CONF=infra/docker/nginx.conf
WORKDIR /usr/share/nginx/html
COPY --from=builder /app/apps/web/dist ./
@@ -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/*
+1 -1
View File
@@ -4,7 +4,7 @@
# ============================================================
# 基础镜像:Python 3.12 + ffmpeg
FROM python:3.12-slim-bookworm
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/python:3.12-slim-bookworm
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
@@ -26,6 +26,13 @@ class InMemoryAssetRepository:
"""Alias for list_by_library to match the port interface."""
return self.list_by_library(library_id)
def find_by_library_and_file_type(self, library_id: str, file_type: str) -> list[Asset]:
return [
asset
for asset in self._assets.values()
if asset.library_id == library_id and asset.mime_type and asset.mime_type.startswith(file_type)
]
def update(self, asset: Asset) -> Asset:
self._assets[asset.id] = asset
return asset
@@ -35,3 +42,50 @@ class InMemoryAssetRepository:
del self._assets[asset_id]
return True
return False
def batch_delete(self, asset_ids: list[str]) -> int:
"""批量删除素材,返回实际删除数量。"""
count = 0
for aid in asset_ids:
if aid in self._assets:
del self._assets[aid]
count += 1
return count
def find_by_project(
self,
project_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
items = [a for a in self._assets.values() if a.project_id == project_id]
return items[skip : skip + limit]
def find_by_id(self, asset_id: str) -> Asset | None:
return self._assets.get(asset_id)
def find_by_tag_ids(
self,
tag_ids: list[str],
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
if not tag_ids:
return []
tag_set = set(tag_ids)
items = [a for a in self._assets.values() if tag_set.issubset(set(a.tag_ids))]
return items[skip : skip + limit]
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
if not file_hash:
return None
for asset in self._assets.values():
if asset.library_id == library_id and asset.file_hash == file_hash:
return asset
return None
@@ -0,0 +1,40 @@
"""标签 InMemory 仓储实现。"""
from packages.domain import Tag
class InMemoryTagRepository:
def __init__(self):
self._tags: dict[str, Tag] = {}
def create(self, tag: Tag) -> Tag:
self._tags[tag.id] = tag
return tag
def get(self, tag_id: str) -> Tag | None:
return self._tags.get(tag_id)
def find_by_name(self, user_id: str, name: str) -> Tag | None:
for tag in self._tags.values():
if tag.user_id == user_id and tag.name == name:
return tag
return None
def list_by_user(
self,
user_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Tag]:
tags = [tag for tag in self._tags.values() if tag.user_id == user_id]
tags.sort(key=lambda t: t.created_at, reverse=True)
return tags[skip : skip + limit]
def count_by_user(self, user_id: str) -> int:
return sum(1 for tag in self._tags.values() if tag.user_id == user_id)
def delete(self, tag_id: str) -> bool:
if tag_id in self._tags:
del self._tags[tag_id]
return True
return False
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.adapters.sqlalchemy_impl.models import AssetModel, AssetTagModel
from packages.domain import Asset, AssetStatus, ClassificationStatus
@@ -37,6 +37,22 @@ class SQLAlchemyAssetRepository:
)
return [self._to_domain(model) for model in models]
def find_by_library_and_file_type(
self,
library_id: str,
file_type: str,
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
models = (
self.session.query(AssetModel)
.filter(AssetModel.asset_library_id == library_id, AssetModel.file_type == file_type)
.offset(skip)
.limit(limit)
.all()
)
return [self._to_domain(model) for model in models]
def find_by_id(self, asset_id: str) -> Asset | None:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model is None:
@@ -67,10 +83,13 @@ class SQLAlchemyAssetRepository:
classification_result=(json.dumps(asset.metadata) if asset.metadata else None),
quality_score=asset.quality_score,
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
file_hash=asset.file_hash or None,
created_at=asset.created_at,
updated_at=now,
)
self.session.add(model)
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
self.session.commit()
return asset
@@ -92,7 +111,10 @@ class SQLAlchemyAssetRepository:
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
model.quality_score = asset.quality_score
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
model.file_hash = asset.file_hash or model.file_hash
model.updated_at = datetime.now(timezone.utc)
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
self.session.commit()
return asset
@@ -104,6 +126,14 @@ class SQLAlchemyAssetRepository:
return True
return False
def batch_delete(self, asset_ids: list[str]) -> int:
"""批量删除素材,返回实际删除数量。"""
if not asset_ids:
return 0
count = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).delete(synchronize_session=False)
self.session.commit()
return count
def count_by_project(self, project_id: str) -> int:
return self.session.query(AssetModel).filter(AssetModel.project_id == project_id).count()
@@ -179,6 +209,11 @@ class SQLAlchemyAssetRepository:
"audio": "audio/mpeg",
"image": "image/jpeg",
}.get(mime_type, mime_type)
# 查询关联的 tag_ids
tag_ids = [
row.tag_id
for row in self.session.query(AssetTagModel.tag_id).filter(AssetTagModel.asset_id == model.id).all()
]
return Asset(
id=model.id,
project_id=model.project_id,
@@ -197,7 +232,61 @@ class SQLAlchemyAssetRepository:
classification_status=ClassificationStatus(model.classification_status),
quality_score=model.quality_score,
uploaded_by_user_id=model.uploaded_by_user_id,
file_hash=model.file_hash or "",
metadata=metadata,
tag_ids=tag_ids,
created_at=model.created_at,
updated_at=model.updated_at,
)
def _sync_asset_tags(self, asset_id: str, tag_ids: list[str]) -> None:
"""同步素材-标签关联表(全量替换)。"""
self.session.query(AssetTagModel).filter(AssetTagModel.asset_id == asset_id).delete(synchronize_session=False)
for tag_id in tag_ids:
self.session.add(AssetTagModel(asset_id=asset_id, tag_id=tag_id))
def find_by_tag_ids(
self,
tag_ids: list[str],
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
if not tag_ids:
return []
from sqlalchemy import func
# 找出同时拥有所有指定 tag_id 的 asset_id
tag_set = set(tag_ids)
asset_ids = (
self.session.query(AssetTagModel.asset_id)
.filter(AssetTagModel.tag_id.in_(tag_set))
.group_by(AssetTagModel.asset_id)
.having(func.count(AssetTagModel.tag_id) == len(tag_set))
.all()
)
ids = [row[0] for row in asset_ids]
if not ids:
return []
models = self.session.query(AssetModel).filter(AssetModel.id.in_(ids)).offset(skip).limit(limit).all()
return [self._to_domain(m) for m in models]
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
if not file_hash:
return None
model = (
self.session.query(AssetModel)
.filter(
AssetModel.asset_library_id == library_id,
AssetModel.file_hash == file_hash,
)
.first()
)
if model is None:
return None
return self._to_domain(model)
@@ -78,7 +78,7 @@ class SQLAlchemyGeneratedVideoRepository:
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all()
return [self.get(model.id) for model in models if self.get(model.id) is not None]
return [self._to_domain(model) for model in models]
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
models = (
@@ -86,4 +86,40 @@ class SQLAlchemyGeneratedVideoRepository:
.filter(GeneratedVideoModel.generation_task_id == generation_task_id)
.all()
)
return [self.get(model.id) for model in models if self.get(model.id) is not None]
return [self._to_domain(model) for model in models]
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
"""通过 batch_id 查找同批次生成的所有视频(跨 generation_task 关联查询)。"""
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
task_ids = (
self.session.query(GenerationTaskModel.id).filter(GenerationTaskModel.batch_id == batch_id).subquery()
)
models = (
self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id.in_(task_ids)).all()
)
return [self._to_domain(model) for model in models]
@staticmethod
def _to_domain(model: GeneratedVideoModel) -> GeneratedVideo:
return GeneratedVideo(
id=model.id,
project_id=model.project_id,
generation_task_id=model.generation_task_id,
name=model.name,
file_url=model.file_url,
file_size=int(model.file_size or 0),
duration=model.duration,
thumbnail_url=model.thumbnail_url,
width=int(model.width or 0),
height=int(model.height or 0),
fps=model.fps,
status=getattr(model, "status", "completed"),
review_status=getattr(model, "review_status", "pending_review"),
generation_params=json.loads(getattr(model, "generation_params", "{}") or "{}"),
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
is_duplicate=getattr(model, "is_duplicate", False),
duplicate_of=getattr(model, "duplicate_of", None),
generated_at=model.generated_at,
created_at=model.created_at,
)
@@ -25,6 +25,8 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
completed_at=model.completed_at,
created_by_user_id=model.created_by_user_id,
source_edit_plan_id=model.source_edit_plan_id or "",
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
created_at=model.created_at,
)
@@ -52,6 +54,8 @@ class SQLAlchemyGenerationTaskRepository:
completed_at=task.completed_at,
created_by_user_id=task.created_by_user_id,
source_edit_plan_id=task.source_edit_plan_id or None,
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
created_at=task.created_at,
)
self.session.add(model)
@@ -123,5 +127,7 @@ class SQLAlchemyGenerationTaskRepository:
model.started_at = task.started_at
model.completed_at = task.completed_at
model.source_edit_plan_id = task.source_edit_plan_id or None
model.asset_select_mode = task.asset_select_mode or ""
model.batch_id = task.batch_id or ""
self.session.commit()
return task

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