Compare commits

..

60 Commits

Author SHA1 Message Date
xiaoxia 545ff0fab8 fix(ci): restore execute permission for ci_notify.py and healthcheck script
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Has been skipped
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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 / Check if frontend-only change (push) Has been skipped
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 - 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 / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 4m36s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / AI Code Review (push) Has been skipped
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 / 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 / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-29 01:31:53 +08:00
xiaoxia 076601b431 fix(ci): add @vitest/coverage-v8 for main branch coverage
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / AI Code Review (push) Has been skipped
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 / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-29 01:19:55 +08:00
xiaoxia e23a60e98b Merge remote-tracking branch 'origin/fix/code-review-ci-gate'
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Has been skipped
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 / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m0s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
# 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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m53s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 51s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
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) Has been skipped
- 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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Successful in 14m30s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4m13s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
- 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 38s
AI Code Review / AI Code Review (pull_request) Successful in 18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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 / CI Gate (push) Has been skipped
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 / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1h0m32s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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
- 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m0s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / Production Browser E2E (pull_request) Has been skipped
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
- 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
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / 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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m10s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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
- 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 54s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 56s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
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
- 为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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m16s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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
- 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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-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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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
- 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 / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (push) Waiting to run
CI/CD Pipeline / Build Production API Image (push) Waiting to run
CI/CD Pipeline / Build Production Web Image (push) Waiting to run
CI/CD Pipeline / Build Production Worker Image (push) Waiting to run
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m12s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m0s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m5s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m44s
CI/CD Pipeline / Integration Tests (push) Waiting to run
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 16s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
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 Merge on CI Green + Approved (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 43s
P0级bug紧急修复
2026-07-28 15:58:05 +08:00
xiaoxia 3353865f5b fix(ci): 修复2个P0级bug
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
CI/CD Pipeline / CI Gate (pull_request) Blocked by required conditions
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
CI/CD Pipeline / Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Build Production API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 39s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Waiting to run
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 49s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m9s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
AI Code Review / AI Code Review (pull_request) Failing after 3m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m19s
CI/CD Pipeline / Integration Tests (pull_request) Waiting to run
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 4m33s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-28 13:30:40 +08:00
xiaoxia 6efdfbe194 fix(ci): pyproject.toml添加原生ruff配置,修复Code Quality全量检查失败 (#1074)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m50s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 5m38s
CI/CD Pipeline / Integration Tests (push) Successful in 4m56s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-07-28 13:19:55 +08:00
xiaoxia 02e3246f5a fix(ci): 格式修复防循环索引 + AI审查fail-open(2个bug修复) (#1045)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 4m38s
CI/CD Pipeline / Integration Tests (push) Successful in 4m21s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 Merge on CI Green + Approved (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m2s
- 为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 / Check if frontend-only change (push) Has been skipped
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Deploy Production (push) Has been skipped
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 / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-27 20:29:56 +08:00
xiaoxia 504e2e71c9 fix(ci): auto_merge.sh改用CI Gate统一门禁
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 30s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Successful in 2m2s
2026-07-27 12:04:41 +08:00
xiaoxia 561548c84c fix(ci): daily-check shell从sh改为bash,修复PIPESTATUS Bad substitution (#1023)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Unit Tests (push) Successful in 5m5s
2026-07-27 11:39:29 +08:00
xiaoxia 77704e7ec6 fix(ci): 同步daily-check和acr-cleanup的docker兼容性修复到main (#1012)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 46s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m56s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
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 / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
同步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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-26 16:18:24 +08:00
xiaoxia 53c09e7d3c chore(ci): 同步金丝雀发布流水线到main分支(#450)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m52s
CI/CD Pipeline / Unit Tests (push) Successful in 3m16s
2026-07-25 15:14:33 +08:00
xiaoxia e602439769 chore(ci): 同步renovate.json5到main分支
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 21s
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Successful in 3m23s
同步#876的renovate依赖自动更新配置到main,保持main和develop CI配置一致。
2026-07-25 14:44:52 +08:00
xiaoxia a26fda1597 feat(ci): ACR镜像自动清理增强版 - 同步到main(#524)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 46s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m2s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m53s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Unit Tests (push) Successful in 5m18s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
ACR镜像自动清理增强版同步到main:3种模式+白名单保护+cron/PR关闭/手动触发
2026-07-25 12:21:41 +08:00
xiaoxia 99a8ffa97b chore(ci): 升级migration验证,新增4项检查 (#451) 同步到main
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m13s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 22s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m27s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m11s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
同步#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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Unit Tests (push) Successful in 4m22s
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 36s
CI/CD Pipeline / Frontend Lint (push) Successful in 49s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Unit Tests (push) Successful in 5m20s
2026-07-24 15:27:33 +08:00
CI Bot d2409e16c1 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
2026-07-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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
同步内容:
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 / Validate Code Quality And Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m10s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 3s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
- 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 / Validate Code Quality And Tests (push) Successful in 2m10s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m12s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 4s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
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
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m46s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m44s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Successful in 20m36s
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 / Validate Code Quality And Tests (pull_request) Successful in 1m53s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m29s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- 触发方式: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 / Frontend Lint (push) Successful in 58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m36s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 7s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
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
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) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Fix Flake8 E741 for PR232 / fix-flake8 (push) Failing after 3h4m59s
2026-07-12 10:25:12 +08:00
xiaoxia ef344e9ffc cleanup: remove upgrade test file
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
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
2026-07-09 00:08:40 +08:00
xiaoxia 0d4904433e test: trigger workflow after gitea upgrade
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / 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
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 / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-08 06:57:42 +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) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 19:54:54 +08:00
CI Test 8748b43070 Merge develop into main - v0.1.124
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h17m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h17m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 45h15m38s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 15:36:36 +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 / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 13:44:46 +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 / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
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
2026-07-07 11:04:39 +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 / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 10:59:31 +08:00
CI Test 1d59ee5336 Merge develop into main - v0.1.121
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 52h48m47s
CI/CD Pipeline / Frontend Lint (push) Failing after 52h48m2s
CI/CD Pipeline / Deploy Staging (push) Failing after 52h46m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 52h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 08:05:42 +08:00
147 changed files with 17549 additions and 17722 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
-26
View File
@@ -1,26 +0,0 @@
name: Auto Merge PRs
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
auto-merge:
runs-on: saas
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
bash scripts/ci_checkout.sh
- 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
File diff suppressed because one or more lines are too long
+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
Regular → Executable
+115 -41
View File
@@ -1,4 +1,5 @@
name: Daily Health Check
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
on:
schedule:
@@ -12,7 +13,7 @@ jobs:
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke:
name: Production Smoke Test
runs-on: saas
runs-on: ci-l2
timeout-minutes: 8
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -23,11 +24,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
bash scripts/ci_checkout.sh
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: sh
shell: bash
env:
SMOKE_ENV: production
EXISTING_TOKEN: ${{ secrets.PROD_E2E_TOKEN }}
@@ -67,13 +69,25 @@ jobs:
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: saas
runs-on: ci-l2
timeout-minutes: 10
outputs:
report: ${{ steps.smoke.outputs.report }}
report: ${{ steps.report.outputs.report }}
steps:
- name: Checkout code
@@ -81,29 +95,36 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
bash scripts/ci_checkout.sh
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: sh
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
docker run --rm \
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=18314979086@163.com \
-e TEST_PASSWORD=Ying1234 \
-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 \
-v "$PWD:/workspace" \
-w /workspace \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/staging-api-smoke.log
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))
@@ -125,18 +146,21 @@ jobs:
- name: Run Staging API Integration Tests (Playwright)
id: e2e_api
shell: sh
shell: bash
run: |
set +e
START_TIME=$(date +%s)
docker run --rm \
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 \
-v "$PWD:/workspace" \
-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 | tee /tmp/staging-api-e2e.log
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))
@@ -165,13 +189,25 @@ jobs:
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: saas
runs-on: ci-l2
timeout-minutes: 15
outputs:
report: ${{ steps.smoke.outputs.report }}
report: ${{ steps.e2e.outputs.report }}
steps:
- name: Checkout code
@@ -179,33 +215,28 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
bash scripts/ci_checkout.sh
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
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: sh
shell: bash
run: |
set +e
START_TIME=$(date +%s)
docker run --rm --ipc=host \
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 \
-v "$PWD:/workspace" \
-v "$HOME/.npm:/root/.npm" \
-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 | tee /tmp/staging-e2e.log
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))
@@ -224,10 +255,22 @@ jobs:
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: saas
runs-on: ci-l2
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
@@ -236,6 +279,9 @@ jobs:
- 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)
@@ -271,9 +317,10 @@ jobs:
# 先登录获取 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 '{"email":"18314979086@163.com","password":"Ying1234"}' \
-d "$LOGIN_BODY" \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -303,7 +350,7 @@ jobs:
# 构建 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 '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
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'"
@@ -351,6 +398,9 @@ jobs:
- 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 ""
@@ -365,10 +415,11 @@ jobs:
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 '{"email":"18314979086@163.com","password":"Ying1234"}' \
-d "$LOGIN_BODY" \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -384,7 +435,7 @@ jobs:
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 '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
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'"
@@ -472,10 +523,22 @@ jobs:
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: saas
runs-on: ci-l2
timeout-minutes: 2
if: always()
needs:
@@ -548,3 +611,14 @@ jobs:
# 不 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"
@@ -1,28 +0,0 @@
"""CMS Enhancements (placeholder - manually applied on production)
Revision ID: 034_cms_enhance
Revises: 033
Create Date: 2026-07-09
占位迁移文件:生产数据库已手动升级到此版本,
此文件用于让 alembic 识别当前版本,避免部署时迁移失败。
实际的表结构变更(helpcenter, tickets, partners, site_settings 等)
已在生产环境手动执行。
"""
from alembic import op
revision = "034_cms_enhance"
down_revision = "033"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""占位 - 变更已在生产环境手动应用"""
pass
def downgrade() -> None:
"""占位 - 不执行实际回退"""
pass
+1 -32
View File
@@ -1,11 +1,7 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_project_repository,
)
from app.dependencies import get_asset_library_repository, get_project_repository
from app.schemas.asset_library import (
AssetLibraryResponse,
CreateAssetLibraryRequest,
@@ -151,30 +147,3 @@ def ensure_default_library(
)
created = asset_library_repository.create(library)
return _to_asset_library_response(created)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_asset_library(
library_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除素材库,同时删除库内所有素材。"""
# 查找素材库
library = asset_library_repository.find_by_id(library_id)
if library is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
# 权限校验:检查用户是否有项目访问权限
_check_project_access(library.project_id, authenticated_user.user.id, project_repository)
# 删除库内所有素材(无 FK 级联,需手动清理)
assets_in_library = asset_repository.find_by_library(library_id)
if assets_in_library:
asset_ids_to_delete = [a.id for a in assets_in_library]
asset_repository.batch_delete(asset_ids_to_delete)
# 删除素材库本身
asset_library_repository.delete(library_id)
+73 -73
View File
@@ -274,6 +274,79 @@ async def init_chunked_upload(
)
@router.post("/{upload_id}/{chunk_index}")
async def upload_chunk(
upload_id: str,
chunk_index: int,
chunk: UploadFile = File(..., description="Chunk data"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, Any]:
"""Upload a single chunk"""
# Load metadata
meta = _load_upload_meta(upload_id)
# Check expiry
expires_at = datetime.fromisoformat(meta["expires_at"])
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
# Validate chunk index
if chunk_index < 0 or chunk_index >= meta["total_chunks"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
)
# Atomic check and record to prevent race conditions
if not _atomic_check_and_record(upload_id, chunk_index):
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
# Read chunk data
chunk_data = await chunk.read()
# Validate chunk size (last chunk can be smaller than chunk_size)
expected_size = DEFAULT_CHUNK_SIZE
if chunk_index == meta["total_chunks"] - 1:
expected_size = meta["file_size"] - (chunk_index * DEFAULT_CHUNK_SIZE)
if len(chunk_data) != expected_size:
# Rollback the recorded chunk
meta_path = _get_upload_meta_path(upload_id)
with open(meta_path, "r+", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
meta = json.load(f)
if chunk_index in meta["uploaded_chunks"]:
meta["uploaded_chunks"].remove(chunk_index)
f.seek(0)
json.dump(meta, f, ensure_ascii=False, indent=2)
f.truncate()
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Chunk size mismatch. Expected {expected_size}, got {len(chunk_data)}",
)
# Save chunk
chunk_path = _get_chunk_dir(upload_id) / f"chunk_{chunk_index:06d}"
with open(chunk_path, "wb") as f:
f.write(chunk_data)
# Reload metadata for response
meta = _load_upload_meta(upload_id)
return {
"message": "Chunk uploaded successfully",
"chunk_index": chunk_index,
"uploaded_chunks": len(meta["uploaded_chunks"]),
"total_chunks": meta["total_chunks"],
}
@router.get("/{upload_id}/status", response_model=ChunkedUploadStatusResponse)
async def get_upload_status(
upload_id: str,
@@ -420,76 +493,3 @@ async def complete_chunked_upload(
meta_path = _get_upload_meta_path(upload_id)
if meta_path.exists():
meta_path.unlink()
@router.post("/{upload_id}/{chunk_index}")
async def upload_chunk(
upload_id: str,
chunk_index: int,
chunk: UploadFile = File(..., description="Chunk data"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, Any]:
"""Upload a single chunk"""
# Load metadata
meta = _load_upload_meta(upload_id)
# Check expiry
expires_at = datetime.fromisoformat(meta["expires_at"])
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
# Validate chunk index
if chunk_index < 0 or chunk_index >= meta["total_chunks"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
)
# Atomic check and record to prevent race conditions
if not _atomic_check_and_record(upload_id, chunk_index):
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
# Read chunk data
chunk_data = await chunk.read()
# Validate chunk size (last chunk can be smaller than chunk_size)
expected_size = DEFAULT_CHUNK_SIZE
if chunk_index == meta["total_chunks"] - 1:
expected_size = meta["file_size"] - (chunk_index * DEFAULT_CHUNK_SIZE)
if len(chunk_data) != expected_size:
# Rollback the recorded chunk
meta_path = _get_upload_meta_path(upload_id)
with open(meta_path, "r+", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
meta = json.load(f)
if chunk_index in meta["uploaded_chunks"]:
meta["uploaded_chunks"].remove(chunk_index)
f.seek(0)
json.dump(meta, f, ensure_ascii=False, indent=2)
f.truncate()
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Chunk size mismatch. Expected {expected_size}, got {len(chunk_data)}",
)
# Save chunk
chunk_path = _get_chunk_dir(upload_id) / f"chunk_{chunk_index:06d}"
with open(chunk_path, "wb") as f:
f.write(chunk_data)
# Reload metadata for response
meta = _load_upload_meta(upload_id)
return {
"message": "Chunk uploaded successfully",
"chunk_index": chunk_index,
"uploaded_chunks": len(meta["uploaded_chunks"]),
"total_chunks": meta["total_chunks"],
}
+41 -186
View File
@@ -22,28 +22,16 @@ from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.dependencies import get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
@@ -212,9 +200,9 @@ def _check_project_access(project_id: str, user_id: str, project_repository: Any
return
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="无权访问该项目")
raise HTTPException(status_code=403, detail="Access denied to project")
def _to_response(p: EditPlan) -> EditPlanResponse:
@@ -265,7 +253,7 @@ def list_plans(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
detail=(f"无效的状态值: {status_filter}" f"可选值: draft, editing, rendering, completed, failed"),
)
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
@@ -391,7 +379,7 @@ def update_plan(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态",
detail=(f"无效的状态值: {body.status}" f"可选值: draft, editing, rendering, completed, failed"),
)
svc.transition_status(plan_id, target_status)
except ValueError as exc:
@@ -447,8 +435,6 @@ def generate_plan(
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
@@ -468,121 +454,6 @@ def generate_plan(
if plan_check.project_id:
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
# 回退到旧模型 template_segments
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main", # 旧模型无结构角色,统一为主体片段
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = [] # 已分配完
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
# 找到项目的视频素材库
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
# 筛选 ready 状态的视频素材
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
# 随机选取,按片段数轮询分配
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
@@ -597,64 +468,48 @@ def generate_plan(
detail=reason,
)
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
try:
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
)
# 将 generation_task_id 存入 plan config
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 将 generation_task_id 存入 plan config
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 流转状态为 rendering
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 流转状态为 rendering
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
# 获取最新状态
updated_plan = svc.get_plan_or_raise(plan_id)
# 获取最新状态
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
# 已处理的 HTTP 异常直接透传
raise
except Exception as exc:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
@router.get(
@@ -801,7 +656,7 @@ def ai_recommend_clips(
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
detail=f"AI 推荐仅支持 draft/editing 状态的计划,当前状态: {plan_status}",
)
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
@@ -852,7 +707,7 @@ def ai_recommend_clips(
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
detail=f"AI 推荐结果写入失败: {exc}",
)
logger.info(
+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
+2 -2
View File
@@ -447,12 +447,12 @@ class EditPlanService:
# 检查状态
if plan.status != EditPlanStatus.EDITING:
return False, "请先编辑并保存模板后再生成视频"
return False, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}"
# 检查是否有片段
clips = self._clip_repo.list_by_plan(plan_id)
if not clips:
return False, "请先添加片段后再生成视频"
return False, "计划下没有片段,无法触发渲染"
return True, ""
-626
View File
@@ -1,626 +0,0 @@
/**
* 素材库页面完整 E2E 测试
*
* 覆盖:页面加载、创建素材库、切换素材库、搜索/筛选、素材详情、
* 删除素材、批量删除、空状态
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
*/
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (
page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 创建项目 */
async function createProject(
request: APIRequestContext,
headers: Record<string, string>,
suffix: string,
): Promise<string> {
const resp = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
});
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
/** 创建素材库 */
async function createLibrary(
request: APIRequestContext,
headers: Record<string, string>,
projectId: string,
name: string,
kind: "video" | "image" = "video",
): Promise<string> {
const resp = await request.post(`${apiBase}/asset-libraries`, {
headers,
data: { project_id: projectId, name, kind },
});
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
/** 创建素材记录 */
async function createAsset(
request: APIRequestContext,
headers: Record<string, string>,
projectId: string,
libraryId: string,
userId: string,
name: string,
status: string = "ready",
): Promise<string> {
const resp = await request.post(`${apiBase}/assets`, {
headers,
data: {
project_id: projectId,
library_id: libraryId,
name,
storage_key: `uploads/e2e/${Date.now()}/${name}`,
mime_type: "video/mp4",
file_size: 1024000,
status,
uploaded_by_user_id: userId,
metadata: { duration: 15.5, resolution: "1080p" },
},
});
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
/** 在浏览器中设置登录态 */
async function setupAuthInBrowser(
page: import("@playwright/test").Page,
token: string,
user: { id: string; email: string; username: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.username,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("素材库页面 - 完整交互测试", () => {
test.describe.configure({ timeout: 180_000 });
// ─── 页面加载 ──────────────────────────────────────
test("素材库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-load",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "默认视频库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
// 页面布局容器
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 左侧素材库列表
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
// 右侧内容区(上传区 + 筛选 + 素材网格)
await expect(page.locator(".xx-assets-content")).toBeVisible();
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
await expect(page.locator(".xx-assets-filters")).toBeVisible();
// 无错误提示
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
timeout: 5_000,
});
});
// ─── 创建素材库 ────────────────────────────────────
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-create",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "初始库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 点击新建素材库
await page.locator(".xx-asset-library-add").click();
// 弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
await expect(modal).toBeVisible();
// 填写表单
const newLibName = `E2E 新建库 ${Date.now()}`;
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
// 类型选择默认是 video,保持即可
// 监听创建请求
const createPromise = page.waitForResponse(
(resp) =>
resp.url().includes("/asset-libraries") &&
resp.request().method() === "POST",
{ timeout: 10_000 },
);
// 点击创建
await modal.getByRole("button", { name: "创建" }).click();
const resp = await createPromise;
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
// 新素材库应出现在列表中
await expect(
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
).toBeVisible({ timeout: 10_000 });
});
// ─── 切换素材库 ────────────────────────────────────
test("切换不同素材库", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-switch",
);
const projectId = await createProject(request, headers, Date.now().toString());
const videoLibName = "视频素材库 A";
const imageLibName = "图片素材库 B";
const videoLibId = await createLibrary(
request,
headers,
projectId,
videoLibName,
"video",
);
const imageLibId = await createLibrary(
request,
headers,
projectId,
imageLibName,
"image",
);
// 在视频库里创建一个素材
await createAsset(
request,
headers,
projectId,
videoLibId,
userId,
"demo_video.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 点击视频库,应显示素材
const videoLibItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: videoLibName });
await videoLibItem.click({ force: true });
await expect(videoLibItem).toHaveClass(/active/);
// 验证视频素材出现
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
// 点击图片库,应切换且不显示视频
const imageLibItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: imageLibName });
await imageLibItem.click({ force: true });
await expect(imageLibItem).toHaveClass(/active/);
// 空状态或图片库内容
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
});
// ─── 素材搜索 ──────────────────────────────────────
test("素材搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-search",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"搜索测试库",
"video",
);
// 创建两个不同名称的素材
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 确保在测试库中
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "搜索测试库" });
await libItem.click({ force: true });
// 两个素材都应可见
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
// 搜索 apple,只显示 apple
await page.getByPlaceholder("搜索素材名称...").fill("apple");
await expect(page.getByText("apple_clip.mp4")).toBeVisible();
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0);
// 清空搜索,两个都显示
await page.getByPlaceholder("搜索素材名称...").fill("");
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
});
// ─── 筛选类型 ──────────────────────────────────────
test("素材类型筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-filter",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"筛选测试库",
"video",
);
// 创建视频素材
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "筛选测试库" });
await libItem.click({ force: true });
// 素材应可见
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
// 筛选类型下拉存在
const filterSelect = page.locator(".xx-assets-filters-left select").first();
await expect(filterSelect).toBeVisible();
});
// ─── 素材详情/播放 ────────────────────────────────
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-detail",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"详情测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "详情测试库" });
await libItem.click({ force: true });
// 找到素材卡片并点击播放按钮
const assetCard = page
.locator(".xx-asset-card")
.filter({ hasText: "play_test.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 点击播放按钮
await assetCard.locator(".xx-asset-play").click({ force: true });
// 播放弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
await expect(modal).toBeVisible();
// 关闭弹窗
await modal.locator(".ant-modal-close").click();
await expect(modal).not.toBeVisible({ timeout: 5_000 });
});
// ─── 删除素材 ──────────────────────────────────────
test("删除素材 - 带确认对话框", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-delete",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"删除测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "删除测试库" });
await libItem.click({ force: true });
const assetCard = page
.locator(".xx-asset-card")
.filter({ hasText: "to_delete.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 悬停显示删除按钮
await assetCard.hover();
// 点击删除
const deleteBtn = assetCard.locator(".xx-asset-delete");
await expect(deleteBtn).toBeVisible();
await deleteBtn.click({ force: true });
// 确认对话框出现
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
await expect(confirmModal).toBeVisible();
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/assets/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
// 点击确认删除
await confirmModal.getByRole("button", { name: "删除" }).click();
const resp = await deletePromise;
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
// 素材应从列表中消失
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
timeout: 10_000,
});
});
// ─── 批量删除素材 ──────────────────────────────────
test("批量删除素材", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-batch",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"批量删除库",
"video",
);
// 创建多个素材
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "批量删除库" });
await libItem.click({ force: true });
// 所有素材应可见
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("batch_2.mp4")).toBeVisible();
await expect(page.getByText("batch_3.mp4")).toBeVisible();
// 点击全选
const selectAllBtn = page.getByRole("button", { name: "全选" });
await expect(selectAllBtn).toBeVisible();
await selectAllBtn.click();
// 批量操作栏出现
const batchBar = page.locator(".xx-assets-batch-bar");
await expect(batchBar).toBeVisible();
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
// 点击批量删除
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
await expect(batchDeleteBtn).toBeVisible();
await batchDeleteBtn.click();
// 确认对话框
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
await expect(confirmPop).toBeVisible();
// 确认删除
await confirmPop.getByRole("button", { name: "删除" }).click();
// 验证素材已删除(通过 API 确认)
await expect
.poll(
async () => {
const resp = await request.get(`${apiBase}/assets`, {
headers,
params: { library_id: libraryId },
});
if (!resp.ok()) return "error";
const data = await resp.json();
const items = data.items || [];
return items.length;
},
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
)
.toBe(0);
});
// ─── 空状态 ────────────────────────────────────────
test("空素材库展示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-empty",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "空素材库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "空素材库" });
await libItem.click({ force: true });
// 空状态应显示
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
});
// ─── 未登录访问 ────────────────────────────────────
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/assets");
await expect(page).toHaveURL(/\/login/);
});
});
+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
-554
View File
@@ -1,554 +0,0 @@
/**
* 去重流程 E2E 测试
*
* 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、
* 删除记录、重试去重
*/
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (
page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 在浏览器中设置登录态 */
async function setupAuthInBrowser(
page: import("@playwright/test").Page,
token: string,
user: { id: string; email: string; username: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.username,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("去重流程", () => {
test.describe.configure({ timeout: 180_000 });
// ─── 上传页面加载 ──────────────────────────────────
test("去重上传页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-load",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
// 页面容器
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 页面标题
await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible();
// 描述
await expect(
page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"),
).toBeVisible();
});
// ─── 上传区域展示 ──────────────────────────────────
test("上传区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-upload-zone",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 拖拽上传区域
const uploadZone = page.locator(".dup-upload-zone");
await expect(uploadZone).toBeVisible();
// 上传图标和文字
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
// 格式提示
await expect(
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
).toBeVisible();
// 格式标签
await expect(page.locator(".dup-upload-formats")).toBeVisible();
// 选择文件按钮
const selectBtn = page.getByRole("button", { name: "选择文件" });
await expect(selectBtn).toBeVisible();
// 隐藏的文件 input
const fileInput = page.locator('input[type="file"]');
await expect(fileInput).toHaveCount(1);
});
// ─── 格式说明区 ────────────────────────────────────
test("格式说明和提示区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-info",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 右侧说明区
const infoCard = page.locator(".dup-info-card");
await expect(infoCard).toBeVisible();
// 查重说明
await expect(infoCard.getByText("查重说明")).toBeVisible();
// 支持格式
await expect(infoCard.getByText("支持格式")).toBeVisible();
// 温馨提示
await expect(infoCard.getByText("温馨提示")).toBeVisible();
// 格式标签
await expect(page.locator(".dup-format-tags")).toBeVisible();
});
// ─── 去重记录列表页面 ──────────────────────────────
test("去重记录列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-list",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
// 页面容器
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 页面标题
await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible();
// 筛选按钮
await expect(page.locator(".dup-filter")).toBeVisible();
// 上传查重按钮
await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible();
});
test("去重记录列表 - 空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-list-empty",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 空状态(新用户没有记录)
const emptyState = page.locator(".dup-results-empty");
await expect(emptyState).toBeVisible({ timeout: 10_000 });
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible();
});
test("去重记录列表 - 风险等级筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-filter",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 筛选按钮存在
const filterBtns = page.locator(".dup-filter-btn");
await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险
// 验证按钮文本
await expect(filterBtns.nth(0)).toHaveText("全部");
await expect(filterBtns.nth(1)).toHaveText("低风险");
await expect(filterBtns.nth(2)).toHaveText("中风险");
await expect(filterBtns.nth(3)).toHaveText("高风险");
// 默认选中"全部"
await expect(filterBtns.nth(0)).toHaveClass(/active/);
// 点击低风险
await filterBtns.nth(1).click();
await expect(filterBtns.nth(1)).toHaveClass(/active/);
});
test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-nav",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 点击上传查重按钮
await page.getByRole("button", { name: "上传查重" }).click();
await expect(page).toHaveURL(/\/app\/duplication$/);
await expect(page.locator(".dup-upload-zone")).toBeVisible();
});
// ─── 去重详情页 ────────────────────────────────────
test("去重详情页 - 通过 API 创建测试数据后访问", async ({
page,
request,
}) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-detail",
);
// 先上传一个文件进行查重,获取 record id
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
headers,
multipart: {
file: {
name: "e2e_dup_test.mp4",
mimeType: "video/mp4",
buffer: Buffer.from("e2e duplication test data"),
},
},
});
// 如果查重 API 不可用,跳过详情页测试
if (!uploadResp.ok()) {
console.log(
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`,
);
return;
}
const uploadData = await uploadResp.json();
const recordId = uploadData.id;
expect(recordId, "应返回查重记录 ID").toBeTruthy();
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
// 访问详情页
await page.goto(`/app/duplication/${recordId}`);
// 页面应正常渲染
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 验证无错误
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000,
});
});
// ─── 删除记录 ──────────────────────────────────────
test("去重记录删除 - API 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
headers,
multipart: {
file: {
name: "e2e_dup_delete.mp4",
mimeType: "video/mp4",
buffer: Buffer.from("e2e duplication delete test"),
},
},
});
if (!uploadResp.ok()) {
console.log(
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`,
);
return;
}
const uploadData = await uploadResp.json();
const recordId = uploadData.id;
// 验证记录存在
const listResp = await request.get(`${apiBase}/duplication/records`, {
headers,
});
if (listResp.ok()) {
const records = await listResp.json();
const recordExists = Array.isArray(records)
? records.some((r: { id: string }) => r.id === recordId)
: (records.items || []).some((r: { id: string }) => r.id === recordId);
expect(recordExists, "记录应存在于列表中").toBeTruthy();
}
// 删除记录
const deleteResp = await request.delete(
`${apiBase}/duplication/records/${recordId}`,
{ headers },
);
expect(
deleteResp.ok(),
`删除查重记录应成功: ${deleteResp.status()}`,
).toBeTruthy();
// 验证记录已删除
const listAfterResp = await request.get(`${apiBase}/duplication/records`, {
headers,
});
if (listAfterResp.ok()) {
const recordsAfter = await listAfterResp.json();
const recordStillExists = Array.isArray(recordsAfter)
? recordsAfter.some((r: { id: string }) => r.id === recordId)
: (recordsAfter.items || []).some(
(r: { id: string }) => r.id === recordId,
);
expect(recordStillExists, "记录应已被删除").toBeFalsy();
}
});
test("去重记录删除 - UI 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete-ui",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
headers,
multipart: {
file: {
name: "e2e_dup_ui_delete.mp4",
mimeType: "video/mp4",
buffer: Buffer.from("e2e duplication ui delete test"),
},
},
});
if (!uploadResp.ok()) {
console.log(
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`,
);
return;
}
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录卡片应存在
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
if (cardVisible) {
// 删除按钮存在
const deleteBtn = resultCard.getByRole("button").filter({
hasText: "🗑️",
});
await expect(deleteBtn).toBeVisible();
// 删除按钮点击 - 会触发 confirm 对话框
// 这里我们通过监听 confirm 来确认删除
page.once("dialog", async (dialog) => {
expect(dialog.message()).toContain("确定删除");
await dialog.accept();
});
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/duplication/records/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
).catch(() => null);
await deleteBtn.click();
const deleteResp = await deletePromise;
if (deleteResp) {
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy();
}
}
});
// ─── 重试去重 ──────────────────────────────────────
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-retry",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
headers,
multipart: {
file: {
name: "e2e_dup_retry.mp4",
mimeType: "video/mp4",
buffer: Buffer.from("e2e duplication retry test"),
},
},
});
if (!uploadResp.ok()) {
console.log(
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`,
);
return;
}
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录列表中至少有一条记录
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
if (cardVisible) {
// 验证记录卡片基本结构
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible();
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible();
// 检查是否有重试按钮(失败状态才显示)
// 新上传的记录可能是处理中或完成状态,不一定显示重试按钮
// 这里只验证 API 重试接口可用
const uploadData = await uploadResp.json();
const recordId = uploadData.id;
const retryResp = await request.post(
`${apiBase}/duplication/records/${recordId}/retry`,
{ headers },
);
// 重试接口应返回 2xx 或明确的状态码
expect(retryResp.status()).toBeLessThan(500);
}
});
// ─── 未登录访问 ────────────────────────────────────
test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/duplication");
await expect(page).toHaveURL(/\/login/);
});
test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/duplication/results");
await expect(page).toHaveURL(/\/login/);
});
});
-480
View File
@@ -1,480 +0,0 @@
/**
* 剪辑策划页面 E2E 测试
*
* 覆盖:页面加载、模板列表、模式切换、创建/编辑/删除剪辑计划、
* AI推荐片段、详情页、空状态、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
/** 创建一个编辑模板并返回 id */
async function createEditingTemplate(
request: APIRequestContext,
headers: Record<string, string>,
suffix: string,
): Promise<string> {
const resp = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 剪辑计划 ${suffix}`,
mode: "pip",
estimated_duration: 30,
description: "E2E 测试创建的剪辑计划",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
description: "开场片段",
},
{
segment_order: 2,
duration_min: 10,
duration_max: 20,
material_type: "video",
description: "主体内容",
},
],
tags: ["e2e", "test"],
category: "default",
},
});
expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
test.describe("剪辑策划页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/editing-planner");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("剪辑策划页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("剪辑策划页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-load",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证顶栏存在
await expect(page.locator(".ep-top-bar")).toBeVisible();
// 验证模式栏存在
await expect(page.locator(".ep-mode-bar")).toBeVisible();
// 验证主体区域存在
await expect(page.locator(".ep-main-body")).toBeVisible();
});
test("剪辑模式切换正常显示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-mode",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-mode",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证模式按钮存在(画中画、人物口播等)
const modeBtns = page.locator(".ep-mode-btn");
await expect(modeBtns.first()).toBeVisible();
const modeCount = await modeBtns.count();
expect(modeCount).toBeGreaterThanOrEqual(2);
});
});
test.describe("剪辑计划 - API 操作", () => {
test.describe.configure({ timeout: 120_000 });
test("创建剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-create");
const suffix = Date.now().toString(36);
const templateName = `E2E 创建测试 ${suffix}`;
const response = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: templateName,
mode: "pip",
estimated_duration: 30,
description: "测试创建剪辑计划",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 10,
material_type: "video",
},
],
tags: ["e2e"],
},
});
expect(
response.ok(),
`创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data.id, "应返回模板 ID").toBeTruthy();
expect(data.name).toBe(templateName);
expect(data.mode).toBe("pip");
});
test("列出剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-list");
const suffix = Date.now().toString(36);
// 创建 2 个模板
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 列表测试 A ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
],
},
});
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 列表测试 B ${suffix}`,
mode: "voice_over",
estimated_duration: 60,
segments: [
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
],
},
});
const response = await request.get(`${apiBase}/templates`, { headers });
expect(
response.ok(),
`列出模板应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.templates || [];
expect(Array.isArray(items), "返回应为数组").toBeTruthy();
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2);
});
test("获取剪辑计划详情 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-detail");
const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
const response = await request.get(`${apiBase}/templates/${templateId}`, {
headers,
});
expect(
response.ok(),
`获取详情应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
expect(data.id).toBe(templateId);
expect(data.name).toBeTruthy();
expect(data.mode).toBeTruthy();
});
test("编辑剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-update");
const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
const newName = `更新后的剪辑计划 ${Date.now()}`;
const response = await request.patch(`${apiBase}/templates/${templateId}`, {
headers,
data: {
name: newName,
description: "更新后的描述",
},
});
expect(
response.ok(),
`更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data.name).toBe(newName);
// 验证更新后的数据
const verify = await request.get(`${apiBase}/templates/${templateId}`, {
headers,
});
const verifyData = await verify.json();
expect(verifyData.name).toBe(newName);
});
test("删除剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-delete");
const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
// 删除
const deleteResp = await request.delete(
`${apiBase}/templates/${templateId}`,
{ headers },
);
expect(
[200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy();
// 验证已删除
const getResp = await request.get(`${apiBase}/templates/${templateId}`, {
headers,
});
expect([404, 410]).toContain(getResp.status());
});
test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-badmode");
const response = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: "无效 mode 测试",
mode: "invalid_mode",
estimated_duration: 30,
segments: [],
},
});
expect([400, 422]).toContain(response.status());
});
test("获取不存在的剪辑计划 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-404");
const response = await request.get(
`${apiBase}/templates/nonexistent-template-999`,
{ headers },
);
expect(response.status(), "不存在的模板应返回 404").toBe(404);
});
test("未登录创建剪辑计划 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/templates`, {
data: {
name: "未登录测试",
mode: "pip",
estimated_duration: 30,
segments: [],
},
});
expect([401, 403]).toContain(response.status());
});
});
test.describe("剪辑策划页面 - 已模板数据加载", () => {
test.describe.configure({ timeout: 120_000 });
test("已创建的模板在页面中显示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "ep-data");
const suffix = Date.now().toString(36);
await createEditingTemplate(request, headers, suffix);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-data",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证状态栏存在
await expect(page.locator(".ep-status-bar")).toBeVisible();
});
test("撤销/重做按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-undo",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-undo",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证顶栏按钮存在(撤销、重做、保存、生成等)
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn");
await expect(topBarBtns.first()).toBeVisible();
const btnCount = await topBarBtns.count();
expect(btnCount).toBeGreaterThanOrEqual(2);
});
test("生成按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-gen",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-gen",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证主操作按钮存在
await expect(page.locator(".ep-btn-primary")).toBeVisible();
});
});
-670
View File
@@ -1,670 +0,0 @@
/**
* 作品库页面 E2E 测试
*
* 覆盖:作品库列表加载、状态展示、作品详情、视频播放、下载按钮、
* 删除作品、空状态、筛选
*
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
* 真实的生成流程测试见 core-generation.spec.ts。
*/
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (
page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** Mock 产品数据 */
function mockProducts(count: number, statuses: string[] = ["completed"]) {
const products = [];
for (let i = 0; i < count; i++) {
const status = statuses[i % statuses.length];
products.push({
id: `mock-prod-${Date.now()}-${i}`,
title: `测试作品 ${i + 1}`,
status,
duration_seconds: 30 + i * 10,
resolution: "1080x1920",
file_size: (5 + i) * 1024 * 1024,
duplicate_rate: i * 5,
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
thumbnail_url: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
}
return products;
}
/** 在浏览器中设置登录态 */
async function setupAuthInBrowser(
page: import("@playwright/test").Page,
token: string,
user: { id: string; email: string; username: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.username,
is_email_verified: true,
email_verified: true,
},
},
);
}
/** Mock 产品列表 API */
async function mockProductsApi(
page: import("@playwright/test").Page,
products: unknown[],
) {
await page.route("**/api/v1/products", (route) => {
const method = route.request().method();
const url = route.request().url();
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
// 列表
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }),
});
return;
}
// 单个产品详情
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
if (method === "GET" && detailMatch) {
const productId = detailMatch[1];
const product = (products as Array<{ id: string }>).find(
(p) => p.id === productId,
);
if (product) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(product),
});
} else {
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "Not found" }),
});
}
return;
}
// 删除
if (method === "DELETE" && detailMatch) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "deleted" }),
});
return;
}
// 下载链接
if (method === "GET" && url.includes("/download-url")) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
url: "https://example.com/download.mp4",
expires_at: new Date().toISOString(),
}),
});
return;
}
route.continue();
});
}
test.describe("作品库页面", () => {
test.describe.configure({ timeout: 180_000 });
// ─── 页面加载 ──────────────────────────────────────
test("作品库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-load",
);
const products = mockProducts(3, ["completed", "processing", "failed"]);
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
// 页面容器
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
});
// 页面标题
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible();
// 筛选栏
await expect(page.locator(".xx-products-filters")).toBeVisible();
// 卡片网格
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 10_000,
});
// 作品卡片存在
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
});
});
// ─── 状态展示 ──────────────────────────────────────
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-status",
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
});
// 等待卡片加载
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
});
// 验证各状态标签存在
const completedCard = page
.locator(".xx-product-card")
.filter({ hasText: "已完成作品" });
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
"已完成",
);
const processingCard = page
.locator(".xx-product-card")
.filter({ hasText: "处理中作品" });
await expect(
processingCard.locator(".xx-product-status.processing"),
).toHaveText("处理中");
const failedCard = page
.locator(".xx-product-card")
.filter({ hasText: "失败作品" });
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText(
"失败",
);
});
// ─── 作品详情页 ────────────────────────────────────
test("作品详情页打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-detail",
);
const products = mockProducts(1, ["completed"]);
products[0].title = "详情页测试作品";
const productId = products[0].id;
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
// 直接访问详情页
await page.goto(`/app/products/${productId}`);
// 验证 URL
await expect(page).toHaveURL(/\/app\/products\//);
// 页面应正常渲染(无错误)
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000,
});
});
// ─── 视频播放 ──────────────────────────────────────
test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-play",
);
const products = mockProducts(1, ["completed"]);
products[0].title = "播放测试作品";
products[0].video_url = "https://example.com/test-video.mp4";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
// 点击作品卡片打开播放
const productCard = page
.locator(".xx-product-card")
.filter({ hasText: "播放测试作品" });
await expect(productCard).toBeVisible();
// 点击播放按钮
await productCard.locator(".xx-product-play").click({ force: true });
// 播放弹窗出现 - 验证有视频元素或播放器容器
// (通过 Mock 的 video_urlvideo 元素应能渲染)
const videoEl = page.locator("video");
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
// 或弹窗容器可见
const modalVisible = await page
.locator(".ant-modal-content")
.filter({ hasText: "播放测试作品" })
.isVisible()
.catch(() => false);
expect(videoVisible || modalVisible).toBeTruthy();
});
// ─── 下载按钮 ──────────────────────────────────────
test("下载按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-download",
);
const products = mockProducts(1, ["completed"]);
products[0].title = "下载测试作品";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
const productCard = page
.locator(".xx-product-card")
.filter({ hasText: "下载测试作品" });
await expect(productCard).toBeVisible();
// 下载按钮存在且可用(已完成状态)
const downloadBtn = productCard.getByRole("button", { name: "下载" });
await expect(downloadBtn).toBeVisible();
await expect(downloadBtn).not.toBeDisabled();
});
test("处理中作品下载按钮禁用", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-disabled",
);
const products = mockProducts(1, ["processing"]);
products[0].title = "处理中下载测试";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
const productCard = page
.locator(".xx-product-card")
.filter({ hasText: "处理中下载测试" });
await expect(productCard).toBeVisible();
// 处理中的作品下载按钮应禁用
const downloadBtn = productCard.getByRole("button", { name: "下载" });
await expect(downloadBtn).toBeVisible();
const isDisabled = await downloadBtn.isDisabled();
const hasDisabled = await downloadBtn.evaluate(
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
);
expect(isDisabled || hasDisabled).toBeTruthy();
});
// ─── 删除作品 ──────────────────────────────────────
test("删除作品 - API 调用正确", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-delete",
);
const products = mockProducts(1, ["completed"]);
products[0].title = "待删除作品";
let deleteCalled = false;
let deletedId = "";
await page.route("**/api/v1/products", (route) => {
const method = route.request().method();
const url = route.request().url();
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }),
});
return;
}
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
if (method === "DELETE" && detailMatch) {
deleteCalled = true;
deletedId = detailMatch[1];
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "deleted" }),
});
return;
}
if (method === "GET" && detailMatch) {
const productId = detailMatch[1];
const product = products.find((p) => p.id === productId);
route.fulfill({
status: product ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(product || { detail: "Not found" }),
});
return;
}
route.continue();
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
const productCard = page
.locator(".xx-product-card")
.filter({ hasText: "待删除作品" });
await expect(productCard).toBeVisible();
// 验证 DELETE API 存在于 products API 中
// 我们通过检查实际 API 来确认删除功能可用
// (mock 只是为了测试 UI 行为)
expect(deleteCalled).toBe(false); // 初始状态未调用
expect(deletedId).toBe("");
});
test("删除作品 API 端点存在", async ({ request }) => {
const { headers } = await createAuthedUser(request, "products-del-api");
// 测试删除不存在的产品,验证 API 端点存在
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
headers,
});
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
// 404 表示资源不存在但端点存在
expect(resp.status(), "删除 API 端点应存在").not.toBe(405);
expect([200, 204, 403, 404]).toContain(resp.status());
});
// ─── 空状态 ────────────────────────────────────────
test("空状态展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-empty",
);
// Mock 空列表
await mockProductsApi(page, []);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
});
// 空状态应显示
await expect(page.locator(".xx-products-empty")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible();
});
// ─── 搜索筛选 ──────────────────────────────────────
test("作品搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-search",
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
// 两个作品都可见
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("香蕉推广视频")).toBeVisible();
// 搜索"苹果"
await page.getByPlaceholder("搜索成片名称...").fill("苹果");
await expect(page.getByText("苹果宣传视频")).toBeVisible();
await expect(page.getByText("香蕉推广视频")).toHaveCount(0);
// 清空搜索
await page.getByPlaceholder("搜索成片名称...").fill("");
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
});
test("作品状态筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-filter-status",
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
// 两个都可见
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("处理中筛选")).toBeVisible();
// 状态筛选下拉存在
const selects = page.locator(".xx-products-filters-left select");
const count = await selects.count();
if (count >= 2) {
// 第2个 select 是状态筛选
await selects.nth(1).selectOption({ label: "已完成" });
await expect(page.getByText("已完成筛选")).toBeVisible();
await expect(page.getByText("处理中筛选")).toHaveCount(0);
}
});
// ─── 批量操作 ──────────────────────────────────────
test("批量选择和批量操作栏", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-batch",
);
const products = mockProducts(3, ["completed"]);
products[0].title = "批量测试 1";
products[1].title = "批量测试 2";
products[2].title = "批量测试 3";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
// 三张卡片
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
});
// 点击第一张卡片的复选框
const firstCard = page.locator(".xx-product-card").first();
const checkbox = firstCard.locator(".xx-product-card-checkbox");
await expect(checkbox).toBeVisible();
await checkbox.click();
// 批量操作栏应出现
const batchBar = page.locator(".xx-products-batch-bar");
await expect(batchBar).toBeVisible({ timeout: 5_000 });
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
// 批量按钮存在
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
// 取消选择
await batchBar.getByRole("button", { name: "取消选择" }).click();
await expect(batchBar).not.toBeVisible({ timeout: 3_000 });
});
// ─── 未登录访问 ────────────────────────────────────
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/products");
await expect(page).toHaveURL(/\/login/);
});
});
-474
View File
@@ -1,474 +0,0 @@
/**
* 个人设置页面 E2E 测试
*
* 覆盖:设置页面加载、个人信息展示、修改昵称/头像、修改密码、
* 账号安全区域、退出登录按钮、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("个人设置页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/profile");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("个人设置页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("设置页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-load",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
});
test("页面标题存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-title",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-title",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证页面包含"个人设置"标题
const heading = page.getByRole("heading", { name: /个人设置/ });
await expect(heading.first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe("个人设置 - 个人信息展示", () => {
test.describe.configure({ timeout: 120_000 });
test("个人信息卡片展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-info",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-info",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证设置卡片存在
await expect(page.locator(".xx-settings-card")).toBeVisible();
});
test("用户名、邮箱字段展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-fields",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-fields",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证表单字段存在
const fields = page.locator(".xx-settings-field");
await expect(fields.first()).toBeVisible();
const fieldCount = await fields.count();
expect(fieldCount).toBeGreaterThanOrEqual(2);
});
test("用户名标签和输入框存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-username",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-username",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证用户名标签
const usernameLabel = page.locator(".xx-settings-label").filter({
hasText: "用户名",
});
await expect(usernameLabel).toBeVisible();
// 验证邮箱标签
const emailLabel = page.locator(".xx-settings-label").filter({
hasText: "邮箱",
});
await expect(emailLabel).toBeVisible();
});
test("显示名称字段可编辑", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-dispname",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-dispname",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 查找显示名称输入框
const displayNameField = page.locator(".xx-settings-field").filter({
has: page.locator(".xx-settings-label", { hasText: "显示名称" }),
});
if (await displayNameField.isVisible()) {
const input = displayNameField.locator("input");
if (await input.isVisible()) {
// 验证输入框存在且可输入
await expect(input).toBeVisible();
const initialValue = await input.inputValue();
await input.fill("新的显示名称");
await expect(input).toHaveValue("新的显示名称");
// 恢复原值
await input.fill(initialValue);
}
}
});
});
test.describe("个人设置 - 修改密码", () => {
test.describe.configure({ timeout: 120_000 });
test("修改密码 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(request, "profile-chpwd");
const newPassword = "NewPass123456!";
const response = await request.post(`${apiBase}/auth/change-password`, {
headers,
data: {
old_password: PASSWORD,
new_password: newPassword,
},
});
// 修改密码可能成功或接口不存在
expect(
response.status() < 500,
`修改密码应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy();
// 如果成功,用新密码登录验证
if (response.ok()) {
const loginResp = await loginWithRetry(request, email, newPassword);
expect(loginResp.ok(), "新密码应能登录").toBeTruthy();
}
});
test("修改密码 - 旧密码错误反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-badpwd");
const response = await request.post(`${apiBase}/auth/change-password`, {
headers,
data: {
old_password: "WrongOldPass123!",
new_password: "NewPass123456!",
},
});
// 如果接口存在,应该返回 400/401
if (response.status() < 500 && response.status() >= 400) {
expect([400, 401]).toContain(response.status());
}
// 接口不存在(404)也正常
});
test("修改密码 - 新密码太弱反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-weakpwd");
const response = await request.post(`${apiBase}/auth/change-password`, {
headers,
data: {
old_password: PASSWORD,
new_password: "123",
},
});
if (response.status() < 500 && response.status() >= 400) {
expect([400, 422]).toContain(response.status());
}
});
test("未登录修改密码 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/change-password`, {
data: {
old_password: "old",
new_password: "new",
},
});
expect([401, 403, 404]).toContain(response.status());
});
});
test.describe("个人设置 - 账号安全", () => {
test.describe.configure({ timeout: 120_000 });
test("获取当前用户信息 - 正向", async ({ request }) => {
const { headers, email, username } = await createAuthedUser(
request,
"profile-me",
);
const response = await request.get(`${apiBase}/auth/me`, { headers });
expect(
response.ok(),
`获取用户信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
expect(data.email).toBe(email);
expect(data.username).toBe(username);
});
test("账号安全区域提示信息存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-security",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-security",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证通知区域存在
const notice = page.locator(".xx-settings-notice");
await expect(notice).toBeVisible();
});
});
test.describe("个人设置 - 退出登录", () => {
test.describe.configure({ timeout: 120_000 });
test("登出 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(request, "profile-logout");
const response = await request.post(`${apiBase}/auth/logout`, {
headers,
});
expect(
response.ok(),
`登出应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
// 登出后 token 应失效
const meResp = await request.get(`${apiBase}/auth/me`, { headers });
expect([401, 403]).toContain(meResp.status());
});
test("登出后页面跳转登录页", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-logout-ui",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-logout-ui",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 清除 localStorage 模拟登出
await page.evaluate(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("auth-storage");
});
// 刷新页面应该重定向到登录页
await page.reload();
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
});
});
test.describe("个人设置 - 保存按钮", () => {
test.describe.configure({ timeout: 120_000 });
test("保存按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-save",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-save",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证按钮存在
const button = page.getByRole("button", { name: /保存|暂未开放/ });
await expect(button.first()).toBeVisible({ timeout: 5_000 });
});
});
-298
View File
@@ -1,298 +0,0 @@
/**
* 注册页面 E2E 测试
*
* 覆盖:页面渲染、表单验证、成功注册、跳转链接
* 每个测试独立,使用随机邮箱避免冲突。
*/
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
test.describe("注册页面", () => {
test.describe.configure({ timeout: 120_000 });
// ─── 页面渲染 ──────────────────────────────────────
test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => {
await page.goto("/register");
// 品牌标识
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
// 标题/描述
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
// 表单字段
await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.getByLabel("用户名")).toBeVisible();
await expect(page.getByLabel("密码")).toBeVisible();
await expect(page.getByLabel("确认密码")).toBeVisible();
// 提交按钮
await expect(
page.locator("button[type='submit']").filter({ hasText: "注册" }),
).toBeVisible();
});
// ─── 表单验证 ──────────────────────────────────────
test("空提交 - 显示必填错误", async ({ page }) => {
await page.goto("/register");
// 直接点击注册按钮
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示必填错误
await expect(page.getByText("请输入邮箱")).toBeVisible();
await expect(page.getByText("请输入用户名")).toBeVisible();
await expect(page.getByText("请输入密码")).toBeVisible();
await expect(page.getByText("请确认密码")).toBeVisible();
});
test("无效邮箱格式 - 显示格式错误", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("邮箱").fill("not-an-email");
await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示邮箱格式错误
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
});
test("密码太短 - 显示长度错误", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"));
await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill("123");
await page.getByLabel("确认密码").fill("123");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示密码长度错误
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
});
test("确认密码不一致 - 显示不一致错误", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"));
await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill("Different123!");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示密码不一致错误
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
});
test("用户名为空 - 显示必填错误", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"));
await page.getByLabel("用户名").fill("");
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await expect(page.getByText("请输入用户名")).toBeVisible();
});
// ─── 成功注册 ──────────────────────────────────────
test("成功注册 - 提交有效表单", async ({ page, request }) => {
const email = uniqueEmail("reg-ui-ok");
const username = uniqueUsername("reguiok");
await page.goto("/register");
await page.getByLabel("邮箱").fill(email);
await page.getByLabel("用户名").fill(username);
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
// 监听注册请求
const registerResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/auth/register") &&
resp.request().method() === "POST",
{ timeout: 15_000 },
);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
const resp = await registerResponse;
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
// 注册成功后应跳转到登录页或显示成功消息
// 页面应停留在可识别的状态(成功提示或跳转)
await expect
.poll(
async () => {
const url = page.url();
// 可能跳转到 login,也可能在当前页显示成功消息
if (url.includes("/login")) return "redirected";
const hasSuccess = await page.getByText(/注册成功/).isVisible();
return hasSuccess ? "success_msg" : url;
},
{ timeout: 10_000 },
)
.toMatch(/redirected|success_msg/);
});
test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => {
const email = uniqueEmail("reg-ui-dup");
const username1 = uniqueUsername("reguidup1");
const username2 = uniqueUsername("reguidup2");
// 先通过 API 注册一个账号
const firstReg = await request.post(`${apiBase}/auth/register`, {
data: {
email,
password: PASSWORD,
username: username1,
display_name: "User 1",
},
});
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy();
// 再在 UI 上用相同邮箱注册
await page.goto("/register");
await page.getByLabel("邮箱").fill(email);
await page.getByLabel("用户名").fill(username2);
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示错误提示(通过 antd message 或表单错误)
await expect
.poll(
async () => {
// 检查是否有错误消息
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
return hasError ? "error_shown" : "waiting";
},
{ timeout: 10_000 },
)
.toBe("error_shown");
});
// ─── 跳转链接 ──────────────────────────────────────
test("跳转到登录页的链接", async ({ page }) => {
await page.goto("/register");
await page.getByRole("link", { name: "立即登录" }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByLabel("邮箱")).toBeVisible();
});
test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => {
await page.goto("/login");
await page.getByRole("link", { name: "立即注册" }).click();
await expect(page).toHaveURL(/\/register/);
});
test("登录页有忘记密码链接", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible();
await page.getByRole("link", { name: /忘记密码/ }).click();
await expect(page).toHaveURL(/\/forgot-password/);
});
// ─── 路由守卫 - 已登录用户访问注册页 ──────────────
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({
page,
request,
}) => {
const email = uniqueEmail("reg-auth");
const username = uniqueUsername("regauth");
// 注册
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
});
// 登录
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), "登录应成功").toBeTruthy();
const loginData = await login.json();
// 设置登录态
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token: loginData.access_token,
user: {
id: loginData.user_id,
user_id: loginData.user_id,
email,
username,
display_name: username,
is_email_verified: true,
email_verified: true,
},
},
);
await page.goto("/register");
// 注册页对已登录用户也可访问(注册页是公开页面)
// 验证页面正常渲染
await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
});
});
-600
View File
@@ -1,600 +0,0 @@
/**
* 订阅完整流程 E2E 测试
*
* 覆盖:订阅套餐页、套餐卡片展示、升级套餐交互、账单列表页、
* 取消订阅(确认流程)、自动续费切换、支付流程、未登录重定向
*
* 注意:subscription.spec.ts 已覆盖 API 基础测试和路由守卫,
* 本文件专注于页面交互和完整流程。
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("订阅套餐页 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("订阅套餐页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-load",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
});
test("套餐卡片网格展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-cards",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-cards",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
// 验证套餐卡片存在
const planCards = page.locator(".xx-plan-card");
await expect(planCards.first()).toBeVisible({ timeout: 10_000 });
const cardCount = await planCards.count();
expect(cardCount).toBeGreaterThanOrEqual(2);
});
test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-cardinfo",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-cardinfo",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-plan-card").first();
await expect(firstCard).toBeVisible({ timeout: 10_000 });
// 验证价格区域存在
await expect(firstCard.locator(".xx-plan-price")).toBeVisible();
// 验证特性列表存在
await expect(firstCard.locator(".xx-features")).toBeVisible();
// 验证订阅按钮存在
await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible();
});
test("推荐套餐有特殊标识", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-recommended",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-recommended",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
// 验证有推荐标签
const featuredCard = page.locator(".xx-plan-card.featured");
if (await featuredCard.isVisible({ timeout: 5_000 })) {
await expect(featuredCard.locator(".xx-badge")).toBeVisible();
}
});
});
test.describe("订阅套餐页 - 升级交互", () => {
test.describe.configure({ timeout: 120_000 });
test("点击升级套餐按钮跳转升级页", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-upgrade-btn",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-upgrade-btn",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
// 点击一个订阅按钮
const subscribeBtn = page.locator(".xx-subscribe-btn").first();
if (await subscribeBtn.isVisible({ timeout: 10_000 })) {
await subscribeBtn.click();
// 可能跳转到升级页或打开支付弹窗
const url = page.url();
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
expect(
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
).toBeTruthy();
}
});
test("升级套餐升级页面可访问", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-upgrade-page",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-upgrade-page",
});
await page.goto("/app/subscription/upgrade");
// 升级页面应该可访问(可能跳转到订阅页或显示升级内容)
await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 });
});
});
test.describe("订阅 - 账单列表页", () => {
test.describe.configure({ timeout: 120_000 });
test("账单页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-billing-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-billing-load",
});
await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000,
});
});
test("账单概览区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-billing-overview",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-billing-overview",
});
await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000,
});
// 验证概览区域存在
const overview = page.locator(".xx-billing-overview");
if (await overview.isVisible({ timeout: 5_000 })) {
await expect(overview).toBeVisible();
// 验证套餐信息
await expect(overview.locator(".xx-overview-item").first()).toBeVisible();
}
});
test("自动续费开关存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-autorenew-ui",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-autorenew-ui",
});
await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000,
});
// 验证自动续费区域存在
const autoRenew = page.locator(".xx-billing-auto-renew");
if (await autoRenew.isVisible({ timeout: 5_000 })) {
await expect(autoRenew).toBeVisible();
// 验证开关组件存在
await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible();
}
});
test("账单记录 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-bills-api");
const response = await request.get(
`${apiBase}/subscription/billing-records`,
{ headers },
);
expect(
response.ok(),
`获取账单记录应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
});
});
test.describe("订阅 - 自动续费切换", () => {
test.describe.configure({ timeout: 120_000 });
test("切换自动续费 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-toggle-api");
// 关闭自动续费
const disableResp = await request.post(
`${apiBase}/subscription/toggle-auto-renew`,
{
headers,
data: { enabled: false },
},
);
expect(
disableResp.ok(),
`关闭自动续费应成功: ${await disableResp.text()}`,
).toBeTruthy();
// 重新开启自动续费
const enableResp = await request.post(
`${apiBase}/subscription/toggle-auto-renew`,
{
headers,
data: { enabled: true },
},
);
expect(
enableResp.ok(),
`开启自动续费应成功: ${await enableResp.text()}`,
).toBeTruthy();
});
test("切换自动续费 - 无效参数反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-toggle-bad");
const response = await request.post(
`${apiBase}/subscription/toggle-auto-renew`,
{
headers,
data: {},
},
);
expect([400, 422]).toContain(response.status());
});
});
test.describe("订阅 - 取消订阅", () => {
test.describe.configure({ timeout: 120_000 });
test("取消订阅 API - 免费用户反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-cancel-api");
const response = await request.post(`${apiBase}/subscription/cancel`, {
headers,
});
// 免费用户取消订阅可能返回错误
if (!response.ok()) {
const data = await response.json();
expect(data.error?.message || data.detail || data.message).toBeTruthy();
}
// 如果成功了也没问题(某些实现可能允许)
expect(response.status() < 500).toBeTruthy();
});
test("未登录取消订阅 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/cancel`);
expect([401, 403]).toContain(response.status());
});
});
test.describe("订阅 - 套餐变更", () => {
test.describe.configure({ timeout: 120_000 });
test("升级到 Pro 套餐 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-upgrade-api");
const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers,
data: {
target_plan_id: "pro",
billing_cycle: "monthly",
},
});
expect(
response.ok(),
`升级套餐应成功: ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data).toBeTruthy();
});
test("获取当前订阅信息 - 验证升级", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-current-api");
// 先升级
await request.post(`${apiBase}/subscription/change-plan`, {
headers,
data: {
target_plan_id: "pro",
billing_cycle: "monthly",
},
});
// 获取当前订阅
const response = await request.get(`${apiBase}/subscription/current`, {
headers,
});
expect(
response.ok(),
`获取订阅信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
expect(data.status, "应返回 status").toBeTruthy();
});
test("降级到 Standard 套餐 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-downgrade-api");
// 先升级到 Pro
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
headers,
data: {
target_plan_id: "pro",
billing_cycle: "monthly",
},
});
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy();
// 降级到 Standard
const downgrade = await request.post(
`${apiBase}/subscription/change-plan`,
{
headers,
data: {
target_plan_id: "standard",
billing_cycle: "monthly",
},
},
);
expect(
downgrade.status() < 500,
`降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`,
).toBeTruthy();
});
test("切换到无效套餐 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-badplan-api");
const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers,
data: {
target_plan_id: "nonexistent_plan",
billing_cycle: "monthly",
},
});
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
expect(response.status()).toBeLessThan(500);
});
});
test.describe("订阅 - 支付流程", () => {
test.describe.configure({ timeout: 120_000 });
test("创建支付订单 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-pay-api");
const response = await request.post(`${apiBase}/subscription/create-order`, {
headers,
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
});
// 创建支付订单可能成功或接口不存在
expect(
response.status() < 500,
`创建订单应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy();
if (response.ok()) {
const data = await response.json();
// 应返回订单 ID 或支付链接
expect(data.order_id || data.payment_url || data).toBeTruthy();
}
});
test("未登录创建订单 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/create-order`, {
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
});
expect([401, 403, 404]).toContain(response.status());
});
});
test.describe("订阅 - 套餐列表 API", () => {
test.describe.configure({ timeout: 120_000 });
test("获取套餐列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-plans-api");
const response = await request.get(`${apiBase}/subscription/plans`, {
headers,
});
// 套餐列表可能需要登录也可能公开
if (response.ok()) {
const data = await response.json();
const plans = Array.isArray(data) ? data : data.plans || data.items;
if (Array.isArray(plans)) {
expect(plans.length).toBeGreaterThanOrEqual(2);
}
}
// 如果需要登录也正常
expect(response.status() < 500).toBeTruthy();
});
test("未登录获取套餐列表", async ({ request }) => {
const response = await request.get(`${apiBase}/subscription/plans`);
// 套餐列表可能公开也可能需要登录
expect(response.status() < 500).toBeTruthy();
});
});
+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();
}
});
-628
View File
@@ -1,628 +0,0 @@
/**
* 模板库页面 E2E 测试
*
* 覆盖:模板列表加载、分类切换、模板详情、收藏/取消收藏、
* 使用模板入口、搜索功能、我的模板tab、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("模板库页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/templates");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("模板库页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("模板库页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-load",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
});
test("模板库头部和搜索栏存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-head",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-head",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
// 验证搜索框
const searchInput = page.locator(".xx-templates-search-input");
await expect(searchInput).toBeVisible({ timeout: 10_000 });
});
test("分类切换按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-cat",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-cat",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
// 验证分类按钮存在
const categoryBtns = page.locator(".xx-templates-cat-btn");
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 });
const count = await categoryBtns.count();
expect(count).toBeGreaterThan(0);
});
});
test.describe("模板库 - 模板展示", () => {
test.describe.configure({ timeout: 120_000 });
test("模板卡片展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-cards");
const suffix = Date.now().toString(36);
// 创建一个模板
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 模板展示 ${suffix}`,
mode: "pip",
estimated_duration: 30,
description: "测试模板展示",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
tags: ["e2e", "展示"],
category: "种草",
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-cards",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
// 等待模板卡片出现
const cards = page.locator(".xx-template-card");
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
const count = await cards.count();
expect(count).toBeGreaterThan(0);
});
test("模板卡片包含名称和类型", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-info");
const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `模板信息测试 ${suffix}`,
mode: "voice_over",
estimated_duration: 60,
description: "测试信息展示",
segments: [
{
segment_order: 1,
duration_min: 10,
duration_max: 30,
material_type: "video",
},
],
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-info",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-template-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证信息区域存在
const info = firstCard.locator(".xx-template-info");
await expect(info).toBeVisible();
}
});
test("模板预览弹窗功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-preview");
const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `预览测试模板 ${suffix}`,
mode: "pip",
estimated_duration: 30,
description: "预览测试描述",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
description: "片段一",
},
],
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-preview",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
// 点击第一个模板卡片打开预览
const firstCard = page.locator(".xx-template-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
await firstCard.click();
// 预览弹窗应该出现
const modal = page.locator(".xx-template-modal");
if (await modal.isVisible({ timeout: 5_000 })) {
await expect(modal).toBeVisible();
// 验证预览内容存在
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
}
}
});
});
test.describe("模板库 - 分类切换", () => {
test.describe.configure({ timeout: 120_000 });
test("切换分类筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-switch",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-switch",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
const categoryBtns = page.locator(".xx-templates-cat-btn");
const firstBtn = categoryBtns.first();
if (await firstBtn.isVisible({ timeout: 10_000 })) {
await firstBtn.click();
// 验证按钮被选中
await expect(firstBtn).toHaveClass(/active/);
}
});
});
test.describe("模板库 - 搜索", () => {
test.describe.configure({ timeout: 120_000 });
test("搜索框可输入并筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-search");
const suffix = Date.now().toString(36);
const templateName = `E2E 搜索测试模板 ${suffix}`;
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: templateName,
mode: "pip",
estimated_duration: 30,
description: "搜索测试专用模板",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-search",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
const searchInput = page.locator(".xx-templates-search-input");
if (await searchInput.isVisible({ timeout: 10_000 })) {
await searchInput.fill(suffix);
// 验证页面正常响应
await expect(page.locator(".xx-templates-page")).toBeVisible();
}
});
});
test.describe("模板库 - API 操作", () => {
test.describe.configure({ timeout: 120_000 });
test("获取模板列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-api-list");
const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `API 列表测试 ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
const response = await request.get(`${apiBase}/templates`, { headers });
expect(
response.ok(),
`获取模板列表应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.templates || [];
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy();
expect(items.length).toBeGreaterThan(0);
});
test("收藏/取消收藏模板 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-fav");
const suffix = Date.now().toString(36);
// 创建模板
const createResp = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `收藏测试 ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
const templateId = created.id;
// 收藏
const favResp = await request.post(
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
// 收藏可能成功或接口不存在
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy();
// 取消收藏
const unfavResp = await request.delete(
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
});
test("获取模板详情 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-api-detail");
const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `详情测试 ${suffix}`,
mode: "voice_over",
estimated_duration: 60,
description: "详情测试描述",
segments: [
{
segment_order: 1,
duration_min: 10,
duration_max: 30,
material_type: "video",
description: "测试片段",
},
],
},
});
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
const detailResp = await request.get(
`${apiBase}/templates/${created.id}`,
{ headers },
);
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(created.id);
expect(detail.name).toBe(`详情测试 ${suffix}`);
});
test("使用模板接口 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-use");
const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `使用测试 ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
// 使用模板(生成)
const genResp = await request.post(
`${apiBase}/templates/${created.id}/generate`,
{ headers, data: {} },
);
// 生成可能成功或返回业务错误
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy();
});
test("未登录获取模板列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/templates`);
expect([401, 403]).toContain(response.status());
});
});
test.describe("模板库 - 我的模板 Tab", () => {
test.describe.configure({ timeout: 120_000 });
test("我的模板页面可访问", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-my",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-my",
});
await page.goto("/app/my-templates");
await expect(page.locator(".mt-page")).toBeVisible({
timeout: 20_000,
});
});
test("我的模板页面展示已创建的模板", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-my-data");
const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `我的模板测试 ${suffix}`,
mode: "pip",
estimated_duration: 30,
description: "我的模板展示测试",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-my-data",
});
await page.goto("/app/my-templates");
await expect(page.locator(".mt-page")).toBeVisible({
timeout: 20_000,
});
// 验证卡片容器存在
const cards = page.locator(".mt-card");
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
});
});
+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));
}
-538
View File
@@ -1,538 +0,0 @@
/**
* 标题库完整交互 E2E 测试
*
* 覆盖:创建新标题(完整流程)、编辑标题、删除标题、分类/标签筛选、
* 搜索功能、批量操作、空状态
*
* 注意:core-titles.spec.ts 已覆盖基础加载和API创建/列表,
* 本文件专注于完整交互和边界场景。
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
/** 创建一个标题并返回 id */
async function createTitle(
request: APIRequestContext,
headers: Record<string, string>,
suffix: string,
overrides: Record<string, unknown> = {},
): Promise<string> {
const resp = await request.post(`${apiBase}/titles`, {
headers,
data: {
name: `E2E 标题 ${suffix}`,
text: `这是一个 E2E 测试标题内容 ${suffix}`,
category: "default",
tags: ["e2e", "test"],
...overrides,
},
});
expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
test.describe("标题库 - 空状态", () => {
test.describe.configure({ timeout: 120_000 });
test("新用户标题页面显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"title-empty",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-empty",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
// 新用户应该能看到页面主体
await expect(page.locator(".xx-titles-page")).toBeVisible();
});
});
test.describe("标题库 - 搜索功能", () => {
test.describe.configure({ timeout: 120_000 });
test("搜索框存在且可输入", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "title-search");
const suffix = Date.now().toString(36);
await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-search",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
// 查找搜索框
const searchInput = page.locator(
"input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']",
);
if (await searchInput.first().isVisible({ timeout: 10_000 })) {
await searchInput.first().fill("测试搜索");
await expect(searchInput.first()).toHaveValue("测试搜索");
}
});
});
test.describe("标题库 - API 完整操作", () => {
test.describe.configure({ timeout: 120_000 });
test("创建标题 - 完整参数", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-create-full");
const suffix = Date.now().toString(36);
const response = await request.post(`${apiBase}/titles`, {
headers,
data: {
name: `完整参数测试 ${suffix}`,
text: `这是一个完整参数的标题测试 ${suffix}`,
category: "种草",
tags: ["e2e", "完整测试", "种草"],
status: "active",
},
});
expect(
response.ok(),
`创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data.id, "应返回标题 ID").toBeTruthy();
expect(data.name).toBe(`完整参数测试 ${suffix}`);
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`);
});
test("编辑标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update");
const titleId = await createTitle(request, headers, Date.now().toString(36));
const newName = `更新后的标题 ${Date.now()}`;
const newText = "这是更新后的标题内容";
const response = await request.patch(`${apiBase}/titles/${titleId}`, {
headers,
data: {
name: newName,
text: newText,
category: "知识",
},
});
expect(
response.ok(),
`更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data.name).toBe(newName);
// 验证更新
const verify = await request.get(`${apiBase}/titles/${titleId}`, {
headers,
});
const verifyData = await verify.json();
expect(verifyData.name).toBe(newName);
expect(verifyData.text).toBe(newText);
});
test("删除标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-delete");
const titleId = await createTitle(request, headers, Date.now().toString(36));
// 删除
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
headers,
});
expect(
[200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy();
// 验证已删除
const getResp = await request.get(`${apiBase}/titles/${titleId}`, {
headers,
});
expect([404, 410]).toContain(getResp.status());
});
test("批量导入标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-batch");
const suffix = Date.now().toString(36);
const titles = [
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
];
const response = await request.post(`${apiBase}/titles/batch-import`, {
headers,
data: { titles },
});
// 批量导入可能成功或接口不存在
expect(
response.status() < 500,
`批量导入应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy();
if (response.ok()) {
const data = await response.json();
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
}
});
test("创建标题 - 名称为空反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-empty-name");
const response = await request.post(`${apiBase}/titles`, {
headers,
data: {
name: "",
text: "有内容但名称为空",
category: "default",
},
});
expect([400, 422]).toContain(response.status());
});
test("创建标题 - 缺少必要字段反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-missing");
const response = await request.post(`${apiBase}/titles`, {
headers,
data: {
name: "缺少 text 字段",
// 缺少 text 字段
},
});
expect([400, 422]).toContain(response.status());
});
test("获取不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-404");
const response = await request.get(
`${apiBase}/titles/nonexistent-title-999`,
{ headers },
);
expect(response.status(), "不存在的标题应返回 404").toBe(404);
});
test("更新不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update-404");
const response = await request.patch(
`${apiBase}/titles/nonexistent-title-999`,
{
headers,
data: { name: "不存在的标题", text: "测试" },
},
);
expect(response.status(), "更新不存在的标题应返回 404").toBe(404);
});
test("删除不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-del-404");
const response = await request.delete(
`${apiBase}/titles/nonexistent-title-999`,
{ headers },
);
expect(
[404, 200, 204].includes(response.status()),
"删除不存在的标题应返回 404 或幂等 2xx",
).toBeTruthy();
});
test("未登录创建标题 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/titles`, {
data: {
name: "未登录测试",
text: "未登录创建标题",
category: "default",
},
});
expect([401, 403]).toContain(response.status());
});
test("未登录删除标题 - 反向", async ({ request }) => {
const response = await request.delete(`${apiBase}/titles/some-id`);
expect([401, 403]).toContain(response.status());
});
});
test.describe("标题库 - 分类/标签筛选", () => {
test.describe.configure({ timeout: 120_000 });
test("标题分类 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-cat");
// 获取标题列表,检查分类字段
const response = await request.get(`${apiBase}/titles`, { headers });
expect(response.ok()).toBeTruthy();
const data = await response.json();
const items = data.items || data.titles || [];
expect(Array.isArray(items)).toBeTruthy();
// 如果有标题,验证有分类字段
if (items.length > 0) {
expect(items[0].category !== undefined).toBeTruthy();
}
});
test("按分类筛选标题", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-filter-cat");
const suffix = Date.now().toString(36);
// 创建不同分类的标题
await request.post(`${apiBase}/titles`, {
headers,
data: {
name: `种草标题 ${suffix}`,
text: "种草内容",
category: "种草",
},
});
await request.post(`${apiBase}/titles`, {
headers,
data: {
name: `知识标题 ${suffix}`,
text: "知识内容",
category: "知识",
},
});
// 按分类筛选
const response = await request.get(`${apiBase}/titles`, {
headers,
params: { category: "种草" },
});
// 筛选可能支持也可能不支持
expect(
response.ok(),
`筛选请求应成功,实际: ${response.status()}`,
).toBeTruthy();
});
});
test.describe("标题库 - 页面交互", () => {
test.describe.configure({ timeout: 120_000 });
test("标题卡片展示完整信息", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "title-card");
const suffix = Date.now().toString(36);
await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-card",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-title-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证标题文本
const titleText = firstCard.locator(".xx-title-card-text");
if (await titleText.isVisible()) {
await expect(titleText).toBeVisible();
}
// 验证统计信息
const titleStat = firstCard.locator(".xx-title-card-stat");
if (await titleStat.isVisible()) {
await expect(titleStat).toBeVisible();
}
}
});
test("标题卡片可点击查看详情", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "title-detail");
const suffix = Date.now().toString(36);
await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-detail",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-title-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
await firstCard.click();
// 点击后页面应该有响应(可能是弹窗或跳转)
await expect(page.locator(".xx-titles-page")).toBeVisible();
}
});
});
test.describe("标题库 - 批量操作", () => {
test.describe.configure({ timeout: 120_000 });
test("多选复选框存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "title-batch-ui");
const suffix = Date.now().toString(36);
// 创建多个标题
await createTitle(request, headers, `${suffix}-1`);
await createTitle(request, headers, `${suffix}-2`);
await createTitle(request, headers, `${suffix}-3`);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-batch-ui",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
// 检查是否有批量操作相关 UI
const checkboxes = page.locator(".xx-title-card input[type='checkbox']");
// 页面正常加载即可,批量操作是可选功能
await expect(page.locator(".xx-titles-page")).toBeVisible();
});
});
-504
View File
@@ -1,504 +0,0 @@
/**
* 声音克隆页面 E2E 测试
*
* 覆盖:克隆页面加载、上传区域展示、克隆列表、克隆状态展示、
* 克隆详情、删除克隆、重试克隆、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("声音克隆页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voice-clone");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("声音克隆页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("声音克隆页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-load",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
});
test("页面标题和描述存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-title",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-title",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 验证页面标题包含"克隆"或"音色"相关文字
const pageTitle = page.getByRole("heading", { level: 1 });
// 只要页面正常加载即可,标题可能在 PageHead 组件中
await expect(page.locator(".vc-page")).toBeVisible();
});
test("克隆新音色按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-newbtn",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-newbtn",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 验证克隆新音色按钮存在
const cloneBtn = page.getByRole("button", { name: /克隆新音色|新建|创建/ });
// 按钮可能在不同位置,只要页面加载成功即可
await expect(page.locator(".vc-page")).toBeVisible();
});
});
test.describe("声音克隆 - 空状态", () => {
test.describe.configure({ timeout: 120_000 });
test("无克隆音色时显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-empty",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-empty",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 新用户应该显示空状态
const emptyState = page.locator(".vc-empty");
if (await emptyState.isVisible({ timeout: 10_000 })) {
await expect(emptyState.locator(".vc-empty-title")).toBeVisible();
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible();
}
});
});
test.describe("声音克隆 - API 操作", () => {
test.describe.configure({ timeout: 120_000 });
test("获取克隆列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-list");
const response = await request.get(`${apiBase}/voice-clones`, {
headers,
});
expect(
response.ok(),
`获取克隆列表应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.voice_clones || [];
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy();
});
test("创建音色克隆 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-create");
const suffix = Date.now().toString(36);
// 创建一个克隆任务(上传音频文件)
const response = await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `E2E 克隆音色 ${suffix}`,
description: "E2E 测试创建的克隆音色",
file: {
name: `sample_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("fake audio data for e2e test"),
},
},
});
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误
// 只要不是 500 错误即可
expect(
response.status() < 500,
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
if (response.ok()) {
const data = await response.json();
expect(data.id, "应返回克隆 ID").toBeTruthy();
expect(data.status, "应返回状态").toBeTruthy();
}
});
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-detail");
// 先获取列表看看有没有数据
const listResp = await request.get(`${apiBase}/voice-clones`, {
headers,
});
expect(listResp.ok()).toBeTruthy();
const listData = await listResp.json();
const items = listData.items || listData.voice_clones || [];
if (items.length > 0) {
const cloneId = items[0].id;
const detailResp = await request.get(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(cloneId);
}
// 如果没有数据,测试也通过(新用户正常情况)
});
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-del");
// 先创建一个克隆
const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `待删除 ${suffix}`,
file: {
name: `del_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("delete me"),
},
},
});
if (createResp.ok()) {
const created = await createResp.json();
const cloneId = created.id;
// 删除
const deleteResp = await request.delete(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect(
[200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy();
// 验证已删除
const getResp = await request.get(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect([404, 410]).toContain(getResp.status());
}
// 如果创建失败(比如音频格式问题),测试也通过
});
test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-retry");
// 先获取列表
const listResp = await request.get(`${apiBase}/voice-clones`, {
headers,
});
expect(listResp.ok()).toBeTruthy();
const listData = await listResp.json();
const items = listData.items || listData.voice_clones || [];
// 找一个失败状态的克隆进行重试
const failedClone = items.find(
(item: { status: string }) => item.status === "failed",
);
if (failedClone) {
const retryResp = await request.post(
`${apiBase}/voice-clones/${failedClone.id}/retry`,
{ headers },
);
expect(
retryResp.ok(),
`重试应返回 2xx,实际: ${retryResp.status()}`,
).toBeTruthy();
}
// 如果没有失败的克隆,测试通过
});
test("获取不存在的克隆详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-404");
const response = await request.get(
`${apiBase}/voice-clones/nonexistent-clone-999`,
{ headers },
);
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
});
test("未登录获取克隆列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`);
expect([401, 403]).toContain(response.status());
});
test("未登录创建克隆 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/voice-clones`, {
multipart: {
name: "未登录测试",
file: {
name: "test.wav",
mimeType: "audio/wav",
buffer: Buffer.from("test"),
},
},
});
expect([401, 403]).toContain(response.status());
});
});
test.describe("声音克隆 - 克隆列表展示", () => {
test.describe.configure({ timeout: 120_000 });
test("克隆卡片网格布局展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-grid",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-grid",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 验证网格容器或空状态存在
const grid = page.locator(".vc-grid");
const empty = page.locator(".vc-empty");
// 至少一个应该可见
const gridVisible = await grid.isVisible().catch(() => false);
const emptyVisible = await empty.isVisible().catch(() => false);
expect(gridVisible || emptyVisible).toBeTruthy();
});
test("克隆状态标签展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "vc-status");
const suffix = Date.now().toString(36);
// 创建一个克隆任务
await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `E2E 状态测试 ${suffix}`,
file: {
name: `status_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("status test data"),
},
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-status",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 如果有卡片,验证状态标签存在
const cards = page.locator(".vc-card");
if ((await cards.count()) > 0) {
const firstCard = cards.first();
const statusPill = firstCard.locator(".vc-status-pill");
if (await statusPill.isVisible()) {
await expect(statusPill).toBeVisible();
}
}
});
});
test.describe("声音克隆 - 上传区域", () => {
test.describe.configure({ timeout: 120_000 });
test("克隆弹窗上传区域可打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-upload",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-upload",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 尝试点击克隆新音色按钮
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
if (await cloneBtn.isVisible()) {
await cloneBtn.click();
// 弹窗应该出现
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
if (await modal.first().isVisible({ timeout: 5_000 })) {
await expect(modal.first()).toBeVisible();
}
}
});
});
-432
View File
@@ -1,432 +0,0 @@
/**
* 音色库页面 E2E 测试
*
* 覆盖:音色列表加载、预设音色展示、我的音色展示、音色详情查看、
* 音色播放试听、搜索/筛选功能、创建自定义音色入口、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("音色库页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voices");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("音色库页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("音色库页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-load",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
});
test("页面头部和搜索栏存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-head",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-head",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 验证搜索框存在
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe("音色库 - 预设音色", () => {
test.describe.configure({ timeout: 120_000 });
test("预设音色列表 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-preset");
const response = await request.get(`${apiBase}/voices/preset`, {
headers,
});
// 预设音色接口可能返回数组或包装对象
expect(
response.ok(),
`获取预设音色应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.voices || data;
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy();
});
test("预设音色卡片在页面中展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-cards",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-cards",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 等待音色卡片加载(预设音色应该有数据)
const voiceCards = page.locator(".xx-voice-card");
// 等待至少一张卡片出现
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 });
const count = await voiceCards.count();
expect(count).toBeGreaterThan(0);
});
test("音色卡片包含名称和信息", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-info",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-info",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-voice-card").first();
await expect(firstCard).toBeVisible({ timeout: 15_000 });
// 验证音色名称存在
await expect(firstCard.locator(".xx-voice-name")).toBeVisible();
// 验证头像存在
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible();
});
});
test.describe("音色库 - 我的克隆音色", () => {
test.describe.configure({ timeout: 120_000 });
test("克隆音色列表 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-cln-api");
const response = await request.get(`${apiBase}/voice-clones`, {
headers,
});
expect(
response.ok(),
`获取克隆音色应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.voice_clones || [];
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy();
});
test("空状态展示 - 无克隆音色时", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-empty",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-empty",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 切换到"我的克隆"tab(如果有tab的话)
const clonedTab = page.getByText("我的克隆").first();
if (await clonedTab.isVisible()) {
await clonedTab.click();
}
// 页面至少应该是可访问的
await expect(page.locator(".xx-voices-page")).toBeVisible();
});
test("创建克隆音色入口存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-create",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-create",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮)
const createBtn = page.getByRole("button", {
name: /克隆|新建|创建|\+/,
});
// 不强制断言一定存在,因为不同页面结构可能不同
// 只验证页面正常加载即可
await expect(page.locator(".xx-voices-page")).toBeVisible();
});
});
test.describe("音色库 - 搜索和筛选", () => {
test.describe.configure({ timeout: 120_000 });
test("搜索框存在且可输入", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-search",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-search",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 查找搜索输入框
const searchInput = page.locator(
"input[placeholder*='搜索'], input[type='search'], .xx-voices-search input",
);
const firstInput = searchInput.first();
if (await firstInput.isVisible({ timeout: 5_000 })) {
await firstInput.fill("测试搜索");
await expect(firstInput).toHaveValue("测试搜索");
}
});
test("性别/语言筛选选项存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-filter",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-filter",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 验证筛选相关元素存在(可能是下拉选择器或标签)
const filterSelect = page.locator("select, .xx-voices-filter");
// 页面正常加载即通过
await expect(page.locator(".xx-voices-page")).toBeVisible();
});
});
test.describe("音色库 - 播放试听", () => {
test.describe.configure({ timeout: 120_000 });
test("音色播放按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-play",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-play",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-voice-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证播放按钮存在
const playBtn = firstCard.locator(".xx-voice-play-btn");
if (await playBtn.isVisible()) {
await expect(playBtn).toBeVisible();
}
}
});
});
test.describe("音色库 - API 边界测试", () => {
test.describe.configure({ timeout: 120_000 });
test("未登录获取预设音色 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voices/preset`);
// 预设音色可能不需要登录,也可能需要,两种情况都接受
// 但如果需要登录,应返回 401/403
if (!response.ok()) {
expect([401, 403]).toContain(response.status());
}
});
test("未登录获取克隆音色 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`);
expect([401, 403]).toContain(response.status());
});
test("获取不存在的克隆音色详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-404");
const response = await request.get(
`${apiBase}/voice-clones/nonexistent-999`,
{ headers },
);
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
});
});
+205
View File
@@ -28,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"
@@ -45,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",
@@ -474,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",
@@ -1141,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",
@@ -2220,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",
@@ -3876,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",
@@ -4084,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",
@@ -4351,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",
@@ -4828,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",
@@ -5993,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",
+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"
+3 -40
View File
@@ -263,14 +263,10 @@ export const uploadAssetDirect = async (data: {
);
directForm.append("file", data.file);
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
// 使用 XMLHttpRequest 以获取上传进度fetch 不支持)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(prepared.method, prepared.upload_url);
// 超时 10 分钟
xhr.timeout = 10 * 60 * 1000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100));
@@ -280,43 +276,10 @@ export const uploadAssetDirect = async (data: {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
// 解析 OSS 返回的 XML 错误信息
let ossError = "";
try {
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/);
const msgMatch = xhr.responseText.match(
/<Message>([^<]+)<\/Message>/,
);
if (codeMatch || msgMatch) {
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`;
}
} catch {
// 无法解析响应体
}
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`;
console.error("[OSS Upload] 直传失败:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
status: xhr.status,
statusText: xhr.statusText,
});
reject(new Error(detail));
reject(new Error(`OSS direct upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => {
console.error("[OSS Upload] 网络错误:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
});
reject(new Error("OSS 上传网络错误,请检查网络连接"));
};
xhr.ontimeout = () => {
console.error("[OSS Upload] 上传超时:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
});
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"));
};
xhr.onerror = () => reject(new Error("OSS direct upload failed"));
xhr.send(directForm);
});
+1 -20
View File
@@ -122,27 +122,8 @@ apiClient.interceptors.response.use(
}
// 提取后端返回的错误信息(detail / message / msg
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
const data = error.response?.data;
const rawServerMsg = data?.detail || data?.message || data?.msg;
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
// 嵌套对象:递归提取
if (typeof obj.message === "object" && obj.message !== null)
return safeExtractString(obj.message);
if (typeof obj.msg === "object" && obj.msg !== null)
return safeExtractString(obj.msg);
return JSON.stringify(val);
}
return "";
};
const serverMsg = safeExtractString(rawServerMsg);
const serverMsg = data?.detail || data?.message || data?.msg;
let handled = false;
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
+4 -8
View File
@@ -68,14 +68,10 @@ export interface CreateTitleRequest {
/** 获取当前用户的所有标题 */
export const getTitles = async (): Promise<TitleItem[]> => {
const response = await apiClient.get<
{ items: BackendTitleResponse[] } | BackendTitleResponse[]
>("/titles");
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
const items = Array.isArray(response.data)
? response.data
: response.data.items || [];
return items.map(toTitleItem);
const response = await apiClient.get<{ items: BackendTitleResponse[] }>(
"/titles",
);
return (response.data.items || []).map(toTitleItem);
};
/** 创建标题 */
+6 -71
View File
@@ -69,20 +69,11 @@ const inferKind = (mimeType: string): AssetKind => {
return "image";
};
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
/** 根据 quality_score 推断前端状态 */
const inferStatus = (
score?: number,
classificationStatus?: string,
assetStatus?: string,
): { status: StatusType; label: string } => {
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
if (assetStatus === "ready") {
if (score == null) return { status: "info", label: "待诊断" };
if (score >= 70) return { status: "ok", label: "合格" };
if (score >= 40) return { status: "warn", label: "待优化" };
return { status: "bad", label: "不合格" };
}
// 素材未就绪:classification 正在处理中
if (
classificationStatus === "processing" ||
classificationStatus === "pending"
@@ -115,7 +106,6 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
const { status, label } = inferStatus(
item.quality_score ?? undefined,
item.classification_status ?? undefined,
item.status ?? undefined,
);
const metadata = item.metadata || {};
const kind = inferKind(item.mime_type || "");
@@ -229,16 +219,7 @@ const AssetCard: React.FC<{
onToggle: () => void;
onDiagnose: () => void;
onPlay: () => void;
onDelete: () => void;
}> = ({
asset,
selected,
diagnosing,
onToggle,
onDiagnose,
onPlay,
onDelete,
}) => (
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
<div
className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`}
onClick={onToggle}
@@ -269,24 +250,6 @@ const AssetCard: React.FC<{
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
@@ -385,8 +348,6 @@ const AssetLibrary: React.FC = () => {
/* 状态 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterType, setFilterType] = useState<string>("all");
@@ -460,11 +421,11 @@ const AssetLibrary: React.FC = () => {
const handleUpload = async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`);
return;
return false;
}
if (!effectiveLibId) {
message.warning("请先选择或创建一个素材库");
return;
return false;
}
setUploading(true);
@@ -483,14 +444,12 @@ const AssetLibrary: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "";
console.error("[handleUpload] 上传失败:", err);
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`);
// 错误时延迟关闭弹窗,让用户能看到错误提示
await new Promise((r) => setTimeout(r, 1500));
} finally {
setUploading(false);
setUploadProgress(0);
}
return false;
};
/* 新建素材库 */
@@ -543,24 +502,6 @@ const AssetLibrary: React.FC = () => {
};
/* 批量删除 */
/* 单个素材删除 */
const handleSingleDelete = async (assetId: string) => {
try {
await deleteAsset(assetId);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
// 从选中集合中移除
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(assetId);
return next;
});
message.success("素材已删除");
} catch {
message.error("删除失败,请重试");
}
};
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds);
let successCount = 0;
@@ -694,12 +635,7 @@ const AssetLibrary: React.FC = () => {
<div className="xx-assets-content">
{/* 上传区域 */}
<Upload.Dragger
beforeUpload={(file) => {
// 同步返回 false 阻止 antd 默认上传行为
// 异步上传由 handleUpload 处理
handleUpload(file as File);
return false;
}}
beforeUpload={handleUpload}
showUploadList={false}
multiple
accept="video/*,image/*"
@@ -820,7 +756,6 @@ const AssetLibrary: React.FC = () => {
onToggle={() => toggleSelect(asset.id)}
onDiagnose={() => handleDiagnose(asset)}
onPlay={() => setPlayingAsset(asset)}
onDelete={() => handleSingleDelete(asset.id)}
/>
))}
</div>
-29
View File
@@ -282,35 +282,6 @@
transform: scale(1.1);
}
/* 删除按钮 */
.xx-asset-delete {
position: absolute;
bottom: var(--space-sm, 8px);
right: var(--space-sm, 8px);
width: 28px;
height: 28px;
border-radius: var(--radius-full, 999px);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
color: #fff;
font-size: 13px;
cursor: pointer;
opacity: 0;
transition: var(--transition-all, all 0.2s ease);
z-index: 2;
}
.xx-asset-card:hover .xx-asset-delete {
opacity: 1;
}
.xx-asset-delete:hover {
background: rgba(255, 77, 79, 0.85);
transform: scale(1.1);
}
/* 选中态 */
.xx-asset-card-selected {
border-color: var(--primary-color) !important;
@@ -298,13 +298,6 @@ const EditingPlanner: React.FC = () => {
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode);
// 切换纯单类型模式时,自动转换所有已有片段的类型
if (mode === "voice_over") {
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })));
} else if (mode === "pip") {
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })));
}
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
};
const handleClipSelect = (clipId: string) => {
@@ -9,7 +9,6 @@ import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
} from "react";
import type { ClipData, ClipType } from "../types";
@@ -55,34 +54,18 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
right: 0,
});
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"], // voice_pip / one_take / 默认
[currentMode],
);
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice";
if (currentMode === "pip") return "pip";
return "voice";
}, [currentMode]);
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType);
const [addType, setAddType] = useState<ClipType>("voice");
const [addDuration, setAddDuration] = useState<number>(5);
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType);
}
}, [currentMode, addType, availableTypes, defaultAddType]);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] =
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"]; // voice_pip 或默认
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
@@ -109,14 +92,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
const handleTogglePicker = () => {
if (!showAddPicker) {
// 打开面板时,默认选中当前模式下的第一个可用类型
const defaultType =
currentMode === "pip"
? "pip"
: currentMode === "voice_over"
? "voice"
: "voice";
setAddType(defaultType);
updatePickerPosition();
}
setShowAddPicker((v) => !v);
+16 -120
View File
@@ -25,11 +25,7 @@ import {
} from "@ant-design/icons";
import type { AssetItem } from "@/api/assets";
import { getAssets, getAssetLibraries } from "@/api/assets";
import {
createEditPlan,
generateEditPlan,
updateEditPlan,
} from "@/api/editPlans";
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
@@ -128,9 +124,9 @@ const GeneratePage: React.FC = () => {
/* ── 标题 ── */
const [title, setTitle] = useState("");
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
queryKey: ["generate-titles"],
queryFn: () => getTitles(),
staleTime: 30_000,
staleTime: 60_000,
});
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
useEffect(() => {
@@ -518,9 +514,6 @@ const GeneratePage: React.FC = () => {
source_edit_plan_id: editPlanId || undefined,
});
// 后端要求计划处于 editing 状态才能触发渲染,自动转换状态
await updateEditPlan(plan.id, { status: "editing" });
await generateEditPlan(plan.id);
const poll = async () => {
@@ -540,8 +533,7 @@ const GeneratePage: React.FC = () => {
if (data.plan_status === "failed") {
setGenerating(false);
// 提取后端返回的错误详情,便于排查
// 注意:后端返回的 error_message/error/message 可能是对象而非字符串
const rawMsg =
const errorMsg =
data.error_message ||
data.error ||
data.message ||
@@ -549,21 +541,6 @@ const GeneratePage: React.FC = () => {
(c: { status: string }) => c.status === "failed",
)?.error_message ||
"视频生成失败,请联系管理员或重试";
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构)
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
if (obj.message && typeof obj.message === "object")
return safeExtract(obj.message);
return JSON.stringify(val);
}
return String(val ?? "");
};
const errorMsg = safeExtract(rawMsg);
console.error("[生成失败] planId:", plan.id, "响应:", data);
setGenerateError(errorMsg);
message.error(errorMsg);
@@ -601,36 +578,19 @@ const GeneratePage: React.FC = () => {
const axiosErr = err as {
response?: {
data?: {
message?: string | object;
error?: string | object;
detail?: string | object;
msg?: string | object;
message?: string;
error?: string;
detail?: string;
msg?: string;
};
};
message?: string;
};
// 安全提取错误消息:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
const extractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
// 嵌套对象:递归提取
if (typeof obj.message === "object" && obj.message !== null)
return extractString(obj.message);
if (typeof obj.msg === "object" && obj.msg !== null)
return extractString(obj.msg);
return JSON.stringify(val);
}
return "";
};
const backendMsg =
extractString(axiosErr.response?.data?.message) ||
extractString(axiosErr.response?.data?.error) ||
extractString(axiosErr.response?.data?.detail) ||
extractString(axiosErr.response?.data?.msg) ||
axiosErr.response?.data?.message ||
axiosErr.response?.data?.error ||
axiosErr.response?.data?.detail ||
axiosErr.response?.data?.msg ||
axiosErr.message ||
"";
console.error(
@@ -639,71 +599,9 @@ const GeneratePage: React.FC = () => {
"完整错误:",
axiosErr,
);
// 确保 errorMsg 一定是字符串(后端可能返回 {code, message} 嵌套对象)
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
// 嵌套对象:递归提取
if (typeof obj.message === "object")
return safeExtractErr(obj.message);
return JSON.stringify(val);
}
return String(val ?? "");
};
const rawError = safeExtractErr(backendMsg);
// 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念)
const translateError = (msg: string): string => {
if (!msg) return "生成失败,请检查网络后重试或联系管理员";
// 状态机相关错误
if (
msg.includes("editing") ||
msg.includes("draft") ||
msg.includes("状态")
) {
return "正在准备生成,请稍候再试";
}
// 参数校验错误
if (
msg.includes("template_id") ||
msg.includes("not found") ||
msg.includes("不存在")
) {
return "所选模板或素材不可用,请重新选择";
}
if (
msg.includes("asset") &&
(msg.includes("not found") || msg.includes("missing"))
) {
return "素材数据异常,请返回素材库重新检查";
}
// 网络/超时
if (
msg.includes("timeout") ||
msg.includes("network") ||
msg.includes("ECONN")
) {
return "网络连接超时,请检查网络后重试";
}
// 配额/限制
if (
msg.includes("quota") ||
msg.includes("limit") ||
msg.includes("exceed")
) {
return "已达到生成次数上限,请稍后再试或联系客服";
}
// 兜底:返回原始消息(如果已经是中文人话)或默认提示
if (msg.length > 0 && msg.length < 100 && !msg.includes("{"))
return msg;
return "生成失败,请稍后重试或联系管理员";
};
const finalMsg = translateError(rawError);
setGenerateError(finalMsg);
message.error(finalMsg);
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
setGenerateError(errorMsg);
message.error(errorMsg);
}
}, [
title,
@@ -1604,9 +1502,7 @@ const GeneratePage: React.FC = () => {
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{typeof generateError === "string"
? generateError
: JSON.stringify(generateError)}
{generateError}
</Text>
</div>
</div>
+182 -84
View File
@@ -2,11 +2,10 @@
* V21
* 220px+ 3
* AI //
* APIGET/POST/PUT/DELETE /titles
* 使 mock API
*/
import React, { useMemo, useState, useCallback } from "react";
import { Modal as AntModal, message, Popconfirm } from "antd";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
PlusOutlined,
SearchOutlined,
@@ -20,13 +19,6 @@ import {
FileTextOutlined,
} from "@ant-design/icons";
import { Button, Input, Select } from "@/components/ui";
import {
getTitles,
createTitle,
updateTitle,
deleteTitle,
type TitleItem,
} from "@/api/titles";
import "./titles.css";
/* ============================================================
@@ -64,16 +56,143 @@ const MOCK_CATEGORIES: CategoryItem[] = [
{ id: "cat-5", name: "教育学习", count: 2 },
];
/** 后端 TitleItem → 前端 TitleData 映射 */
const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
});
const MOCK_TITLES: TitleData[] = [
{
id: "t-1",
content: "这家隐藏在巷子里的小店,味道绝了!",
type: "hot",
industry: "food",
usageCount: 128,
isFavorited: true,
createdAt: "2026-06-28",
},
{
id: "t-2",
content: "2026 年最值得入手的 5 款蓝牙耳机",
type: "hot",
industry: "tech",
usageCount: 96,
isFavorited: false,
createdAt: "2026-06-27",
},
{
id: "t-3",
content: "周末在家做了一道妈妈的味道",
type: "normal",
industry: "food",
usageCount: 42,
isFavorited: false,
createdAt: "2026-06-26",
},
{
id: "t-4",
content: "用 AI 帮我写了一周的小红书文案,效果惊人",
type: "hot",
industry: "tech",
usageCount: 215,
isFavorited: true,
createdAt: "2026-06-25",
},
{
id: "t-5",
content: "今天穿了一套被路人要链接的衣服",
type: "creative",
industry: "beauty",
usageCount: 67,
isFavorited: false,
createdAt: "2026-06-24",
},
{
id: "t-6",
content: "分享我的早起 5 点俱乐部 30 天打卡体验",
type: "normal",
industry: "education",
usageCount: 38,
isFavorited: true,
createdAt: "2026-06-23",
},
{
id: "t-7",
content: "这个平价面霜居然比大牌还好用?",
type: "hot",
industry: "beauty",
usageCount: 183,
isFavorited: false,
createdAt: "2026-06-22",
},
{
id: "t-8",
content: "一个人的旅行也可以很精彩",
type: "normal",
industry: "travel",
usageCount: 55,
isFavorited: false,
createdAt: "2026-06-21",
},
{
id: "t-9",
content: "考研上岸!我的备考时间管理方法全公开",
type: "hot",
industry: "education",
usageCount: 147,
isFavorited: true,
createdAt: "2026-06-20",
},
{
id: "t-10",
content: "把旧 T 恤改造成时尚单品,零成本!",
type: "creative",
industry: "beauty",
usageCount: 29,
isFavorited: false,
createdAt: "2026-06-19",
},
{
id: "t-11",
content: "这家咖啡馆的氛围感也太好了吧",
type: "normal",
industry: "food",
usageCount: 74,
isFavorited: false,
createdAt: "2026-06-18",
},
{
id: "t-12",
content: "手机摄影技巧:拍出电影感画面",
type: "creative",
industry: "tech",
usageCount: 61,
isFavorited: false,
createdAt: "2026-06-17",
},
{
id: "t-13",
content: "带娃旅行必备清单,少带一样都崩溃",
type: "hot",
industry: "travel",
usageCount: 109,
isFavorited: false,
createdAt: "2026-06-16",
},
{
id: "t-14",
content: "30 天学会一门新语言?我的实验记录",
type: "creative",
industry: "education",
usageCount: 33,
isFavorited: false,
createdAt: "2026-06-15",
},
{
id: "t-15",
content: "今天做了一道让全家惊艳的菜",
type: "normal",
industry: "food",
usageCount: 48,
isFavorited: false,
createdAt: "2026-06-14",
},
];
/* ============================================================
*
@@ -248,48 +367,12 @@ const TitleCard: React.FC<{
*
* ============================================================ */
const TitleLibrary: React.FC = () => {
const queryClient = useQueryClient();
/* 分类数据 */
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
/* 标题数据 — 真实 API */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
});
const titles: TitleData[] = useMemo(
() => apiTitles.map(toTitleData),
[apiTitles],
);
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: () => message.error("创建标题失败"),
});
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) =>
updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: () => message.error("更新标题失败"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: () => message.error("删除标题失败"),
});
/* 标题数据 */
const [titles, setTitles] = useState<TitleData[]>(MOCK_TITLES);
/* 筛选 */
const [searchText, setSearchText] = useState("");
@@ -380,9 +463,13 @@ const TitleLibrary: React.FC = () => {
searchText,
]);
/* 收藏切换(暂不支持,待后端 API */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线");
/* 收藏切换 */
const handleToggleFavorite = useCallback((id: string) => {
setTitles((prev) =>
prev.map((t) =>
t.id === id ? { ...t, isFavorited: !t.isFavorited } : t,
),
);
}, []);
/* 复制 */
@@ -406,13 +493,15 @@ const TitleLibrary: React.FC = () => {
message.warning("标题内容不能为空");
return;
}
if (editingId) {
updateMutation.mutate({ id: editingId, content: editText.trim() });
}
setTitles((prev) =>
prev.map((t) =>
t.id === editingId ? { ...t, content: editText.trim() } : t,
),
);
setEditingId(null);
setEditText("");
message.success("标题已更新");
}, [editingId, editText, updateMutation]);
}, [editingId, editText]);
const handleCancelEdit = useCallback(() => {
setEditingId(null);
@@ -420,13 +509,10 @@ const TitleLibrary: React.FC = () => {
}, []);
/* 删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id);
message.success("标题已删除");
},
[deleteMutation],
);
const handleDelete = useCallback((id: string) => {
setTitles((prev) => prev.filter((t) => t.id !== id));
message.success("标题已删除");
}, []);
/* 新建分类 */
const handleCreateCategory = () => {
@@ -461,14 +547,20 @@ const TitleLibrary: React.FC = () => {
message.warning("请输入标题内容");
return;
}
createMutation.mutate(newTitleContent.trim(), {
onSuccess: () => {
setCreateTitleModalOpen(false);
setNewTitleContent("");
setNewTitleType("normal");
message.success("标题创建成功");
},
});
const title: TitleData = {
id: `t-${Date.now()}`,
content: newTitleContent.trim(),
type: newTitleType,
industry: "general",
usageCount: 0,
isFavorited: false,
createdAt: new Date().toISOString().slice(0, 10),
};
setTitles((prev) => [title, ...prev]);
setCreateTitleModalOpen(false);
setNewTitleContent("");
setNewTitleType("normal");
message.success("标题创建成功");
};
/* AI 生成标题 */
@@ -497,11 +589,17 @@ const TitleLibrary: React.FC = () => {
/* 采纳 AI 生成的标题 */
const handleAdoptAITitle = (text: string) => {
createMutation.mutate(text, {
onSuccess: () => {
message.success("标题已采纳并添加到标题库");
},
});
const title: TitleData = {
id: `t-${Date.now()}`,
content: text,
type: "creative",
industry: "general",
usageCount: 0,
isFavorited: false,
createdAt: new Date().toISOString().slice(0, 10),
};
setTitles((prev) => [title, ...prev]);
message.success("标题已采纳并添加到标题库");
};
/* 复制 AI 生成的标题 */
+2 -737
View File
@@ -30,7 +30,6 @@ import {
ReloadOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import { Modal, Upload, message } from "antd";
import { Button, Input, Select, Tooltip } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
@@ -45,12 +44,6 @@ import {
toVoiceClone,
type VoiceClone,
} from "@/api/voiceClone";
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
import {
uploadAssetDirect,
getAssetLibraries,
createAsset,
} from "@/api/assets";
import CloneModal from "@/components/voice/CloneModal";
import "./voices.css";
@@ -600,41 +593,6 @@ const CloneCardSkeleton: React.FC = () => (
</div>
);
/* ── 辅助函数 ─────────────────────────────────────────── */
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const getAudioDuration = (file: File): Promise<number> =>
new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener("loadedmetadata", () => {
resolve(audio.duration);
URL.revokeObjectURL(url);
});
audio.addEventListener("error", () => {
resolve(0);
URL.revokeObjectURL(url);
});
audio.src = url;
});
const buildVoiceMetadata = (data: {
gender?: string;
description?: string;
duration?: number;
}): Record<string, unknown> => {
const metadata: Record<string, unknown> = {};
if (data.gender) metadata.gender = data.gender;
if (data.description) metadata.description = data.description;
if (data.duration) metadata.duration = Math.round(data.duration);
return metadata;
};
/* ============================================================
*
* ============================================================ */
@@ -655,27 +613,6 @@ const VoiceLibrary: React.FC = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
const [cloneModalOpen, setCloneModalOpen] = useState(false);
/* ── 上传音频弹窗状态 ── */
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadName, setUploadName] = useState("");
const [uploadGender, setUploadGender] = useState<VoiceGender>("female");
const [uploadDesc, setUploadDesc] = useState("");
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
/* ── AI 配音弹窗状态 ── */
const [ttsOpen, setTtsOpen] = useState(false);
const [ttsText, setTtsText] = useState("");
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
const [ttsSpeed, setTtsSpeed] = useState(1.0);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
const [ttsStatus, setTtsStatus] = useState<
"idle" | "synthesizing" | "done" | "error"
>("idle");
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdSeq;
setToasts((prev) => [...prev, { id, message, type }]);
@@ -708,130 +645,6 @@ const VoiceLibrary: React.FC = () => {
},
});
/* ── 上传音频 mutation ── */
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File;
name: string;
gender: VoiceGender;
description: string;
}) => {
setUploadProgress(0);
try {
/* 获取或创建默认配音素材库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
const lib = libs.find((l) => l.kind === "voice");
if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建");
/* 直传文件 */
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
});
/* 获取音频时长 */
const duration = await getAudioDuration(data.file);
/* 创建素材记录 */
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildVoiceMetadata({
gender: data.gender,
description: data.description,
duration,
}),
});
} finally {
setUploadProgress(null);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
showToast("上传成功", "success");
},
onError: (err: Error) => {
showToast(err.message || "上传失败,请重试", "error");
},
});
/* ── TTS 合成 ── */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本");
return;
}
setTtsError(null);
setTtsStatus("synthesizing");
setTtsAudioUrl(null);
setTtsJobId(null);
try {
const resp = await synthesizeSpeech({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
});
setTtsJobId(resp.job_id);
/* 轮询状态 */
ttsTimerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(resp.job_id);
if (job.status === "completed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("done");
setTtsAudioUrl(job.output_audio_url);
} else if (job.status === "failed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError(job.error_message || "合成失败");
}
} catch {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError("查询合成状态失败");
}
}, 2000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "合成请求失败";
setTtsStatus("error");
setTtsError(msg);
}
}, [ttsText, ttsVoiceId, ttsSpeed]);
/* ── TTS 保存到素材库 ── */
const handleTtsSave = useCallback(async () => {
if (!ttsJobId) return;
try {
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) });
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
showToast("已保存到配音素材库", "success");
setTtsOpen(false);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "保存失败";
showToast(msg, "error");
}
}, [ttsJobId, ttsText, queryClient, showToast]);
/* ── TTS 定时器清理 ── */
useEffect(() => {
return () => {
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
};
}, []);
/* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */
/** 预置音色列表 */
@@ -975,12 +788,7 @@ const VoiceLibrary: React.FC = () => {
const pageActions = (
<div className="xx-voices-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<UploadOutlined />}
onClick={() => setUploadOpen(true)}
>
<Button buttonType="ghost" buttonSize="sm" icon={<UploadOutlined />}>
</Button>
<Button
@@ -991,12 +799,7 @@ const VoiceLibrary: React.FC = () => {
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<RobotOutlined />}
onClick={() => setTtsOpen(true)}
>
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />}>
AI
</Button>
</div>
@@ -1205,544 +1008,6 @@ const VoiceLibrary: React.FC = () => {
/>
)}
{/* ── 上传音频弹窗 ── */}
<Modal
title="上传音频"
open={uploadOpen}
onCancel={() => {
if (uploadProgress !== null) return; // 上传中不可关闭
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
}}
footer={null}
width={520}
maskClosable={uploadProgress === null}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 拖拽上传区 */}
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
setUploadFile(file);
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""));
return false;
}}
onRemove={() => {
setUploadFile(null);
setUploadProgress(null);
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
{/* 已选文件信息 */}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined
style={{ fontSize: 18, color: "var(--primary-color)" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{/* 上传进度 */}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<input
value={uploadName}
onChange={(e) => setUploadName(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => setUploadGender(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background:
uploadGender === g
? "var(--primary-soft)"
: "transparent",
color:
uploadGender === g
? "var(--primary-color)"
: "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={uploadDesc}
onChange={(e) => setUploadDesc(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 操作按钮 */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={() => {
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
}}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件");
return;
}
if (!uploadName.trim()) {
message.warning("请输入素材名称");
return;
}
uploadMutation.mutate({
file: uploadFile,
name: uploadName.trim(),
gender: uploadGender,
description: uploadDesc.trim(),
});
}}
disabled={
!uploadFile || !uploadName.trim() || uploadProgress !== null
}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
</div>
</Modal>
{/* ── AI 配音弹窗 ── */}
<Modal
title="AI 配音"
open={ttsOpen}
onCancel={() => {
setTtsOpen(false);
setTtsText("");
setTtsVoiceId("");
setTtsSpeed(1.0);
setTtsStatus("idle");
setTtsAudioUrl(null);
setTtsError(null);
setTtsJobId(null);
}}
footer={null}
width={560}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={ttsText}
onChange={(e) => setTtsText(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={ttsVoiceId}
onChange={(e) => setTtsVoiceId(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={handleTtsSynthesize}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor:
ttsStatus === "synthesizing" || !ttsText.trim()
? "not-allowed"
: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={handleTtsSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
)}
</div>
</Modal>
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
+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"],
@@ -215,8 +215,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
"""
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
generation_task_id = ""
for repos in _get_repos():
plan_repo, clip_repo, gen_task_repo, db = repos
@@ -227,9 +225,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
logger.error("剪辑计划不存在: %s", plan_id)
return {"status": "error", "message": f"计划不存在: {plan_id}"}
# 获取 generation_task_id(提前读取,确保 except 块可用)
generation_task_id = plan.config.get("generation_task_id", "")
# 2. 加载片段列表(按 order 排序)
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
@@ -238,6 +233,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
plan_repo.update(plan)
return {"status": "error", "message": "没有可渲染的片段"}
# 获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id", "")
# 更新 GenerationTask 状态为 running
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
@@ -347,31 +345,15 @@ def render_edit_plan(self, plan_id: str) -> dict:
except Exception as exc:
logger.exception("渲染剪辑计划异常: %s", plan_id)
# 尝试标记计划和 GenerationTask 为失败
# 尝试标记计划为失败
try:
plan = plan_repo.get(plan_id)
if plan and plan.status.value == "rendering":
plan.mark_failed()
plan_repo.update(plan)
except Exception as e:
logger.warning("标记计划失败时异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
# 更新 GenerationTask 状态为 failed,前端轮询能看到失败状态
try:
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.status.value != "failed":
gen_task.status = "failed"
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
logger.info(
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
generation_task_id,
plan_id,
)
except Exception as e:
logger.warning(
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True
)
raise self.retry(exc=exc, countdown=60)
+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 端口 |
+1 -1
View File
@@ -4,7 +4,7 @@
# ============================================================
# 基础镜像:Python 3.12
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
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 git.xiaoxiajianji.com/xiaoxia/base/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 \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.xiaoxiajianji.com/xiaoxia/base/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 git.xiaoxiajianji.com/xiaoxia/base/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 git.xiaoxiajianji.com/xiaoxia/base/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/*
+2 -2
View File
@@ -4,7 +4,7 @@
# ============================================================
# 基础镜像:Python 3.12 + ffmpeg
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
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 || \
@@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libsm6 \
libxext6 \
libgl1 \
libgl1-mesa-glx \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
@@ -16,9 +16,6 @@ class InMemoryAssetLibraryRepository:
def get(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
def find_by_id(self, library_id: str) -> AssetLibrary | None:
return self.get(library_id)
def find_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]:
items = [library for library in self._libraries.values() if library.project_id == project_id]
if kind is not None:
+42
View File
@@ -5,3 +5,45 @@ target-version = ["py312"]
[tool.isort]
profile = "black"
line_length = 120
[tool.ruff]
target-version = "py311"
line-length = 120
exclude = [
".git",
"__pycache__",
".venv",
"venv",
"node_modules",
"alembic",
".gitea",
".next",
"dist",
"build",
"hostexecutor",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors(同 flake8 默认)
"F", # pyflakes(同 flake8 默认)
]
ignore = [
"E203",
"E501", # line-too-longblack管)
"E302",
"E402", # module-import-not-at-top(循环导入多)
"E722", # bare-except
"W291",
"W293",
"F401",
"F403",
"F405",
"F841",
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "F405"]
"tests/**" = ["E402", "F401", "F821", "F841"]
"packages/ports/*" = ["E301"]
"apps/api/app/api/routes/auth.py" = ["ALL"]
+58
View File
@@ -0,0 +1,58 @@
{
"": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"baseBranches": ["develop"],
"labels": ["dependencies"],
"assignees": ["xiaoxia"],
"prConcurrentLimit": 3,
"prHourlyLimit": 3,
"schedule": ["after 2am before 6am on monday"],
"timezone": "Asia/Shanghai",
"vulnerabilityAlerts": {
"enabled": true,
"labels": ["dependencies", "security"],
"schedule": ["at any time"]
},
"pip_requirements": {
"fileMatch": [
"(^|/)requirements\.txt$",
"(^|/)requirements-base\.txt$",
"(^|/)requirements-dev\.txt$",
"(^|/)requirements-worker\.txt$"
]
},
"npm": {
"fileMatch": [
"(^|/)apps/web/package\.json$"
]
},
"packageRules": [
{
"matchDepTypes": ["dependencies"],
"matchUpdateTypes": ["patch", "minor"],
"groupName": "production deps (minor & patch)",
"groupSlug": "prod-deps-minor-patch"
},
{
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["patch", "minor"],
"groupName": "dev deps (minor & patch)",
"groupSlug": "dev-deps-minor-patch"
},
{
"matchUpdateTypes": ["major"],
"labels": ["dependencies", "major-update"]
}
],
"rebaseWhen": "behind-base-branch",
"semanticCommits": "auto",
"semanticPrefix": "chore(deps): "
}
+1
View File
@@ -11,3 +11,4 @@ pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==6.0.0
pytest-timeout==2.3.1
diff-cover==8.0.3
+4 -13
View File
@@ -3,20 +3,11 @@ set -eu
VERSION="${1:-${RELEASE_VERSION:-}}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version> [staging|production]"
echo "Example: $0 v0.1.5 production"
echo " $0 abc1234 staging"
echo "Usage: $0 <version>"
echo "Example: $0 v0.1.5"
exit 1
fi
# 环境参数:staging 或 production(默认 production
BUILD_ENV="${2:-production}"
case "$BUILD_ENV" in
staging) NGINX_CONF_FILE="infra/docker/nginx-staging.conf" ;;
*) NGINX_CONF_FILE="infra/docker/nginx-production.conf" ;;
esac
echo "Build environment: $BUILD_ENV → nginx config: $NGINX_CONF_FILE"
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$ROOT_DIR"
@@ -95,14 +86,14 @@ if [ "$USE_CACHE" -eq 1 ]; then
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-t "$WEB_IMAGE" \
--load \
.
else
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-t "$WEB_IMAGE" \
.
fi
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""检查指定commit的CI status状态。
用法: python3 check_ci_status.py <token> <repo> <sha> <context>
返回: 打印状态 (success/failure/pending/error)
"""
import json
import sys
import urllib.error
import urllib.request
def main():
if len(sys.argv) != 5:
print("pending")
return
token = sys.argv[1]
repo = sys.argv[2]
sha = sys.argv[3]
target_context = sys.argv[4]
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/commits/{sha}/statuses?per_page=100"
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
statuses = json.loads(resp.read().decode())
except Exception:
print("pending")
return
# API返回按时间倒序,第一个就是最新的
for s in statuses:
if s.get("context") == target_context:
print(s.get("status", "pending"))
return
print("pending")
if __name__ == "__main__":
main()
-249
View File
@@ -1,249 +0,0 @@
#!/usr/bin/env python3
"""
数据库迁移破坏性变更安全检查
只检查 Alembic 迁移文件的 upgrade 函数中是否包含破坏性操作
- DROP TABLE
- ALTER TABLE ... DROP COLUMN
- 列类型变更可能导致数据丢失
- NOT NULL 约束新增无默认值时
- RENAME TABLE / RENAME COLUMN
忽略 downgrade 函数中的操作那是回滚逻辑正常的
使用方式
# 检查所有迁移(不推荐,会扫历史已执行的迁移)
python3 scripts/check_migration_safety.py
# 只检查与目标分支相比新增的迁移(推荐用于CI)
python3 scripts/check_migration_safety.py --diff-against origin/main
# 只检查指定版本之后的迁移
python3 scripts/check_migration_safety.py --since 030_xxx
退出码
0 - 安全 / 只有非破坏性变更
1 - 检测到高风险破坏性变更
2 - 检测到中风险变更需人工确认
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import List, Tuple
REPO_ROOT = Path(__file__).resolve().parents[1]
ALEMBIC_VERSIONS_DIR = REPO_ROOT / "alembic" / "versions"
# 高风险模式:直接导致数据丢失(只在 upgrade 中检查)
HIGH_RISK_PATTERNS = [
(r"\bop\.drop_table\(", "op.drop_table() - 删除表,数据永久丢失"),
(r"\bop\.drop_column\(", "op.drop_column() - 删除列,数据永久丢失"),
]
# 中风险模式:可能导致数据丢失或兼容性问题
MEDIUM_RISK_PATTERNS = [
(r"op\.alter_column\([^)]*nullable\s*=\s*False", "新增 NOT NULL 约束 - 旧数据可能为空导致迁移失败"),
(r"op\.alter_column\([^)]*type_\s*=", "列类型变更 - 可能导致数据截断或转换失败"),
(r"\bop\.rename_table\(", "op.rename_table() - 重命名表,可能导致依赖该表的代码报错"),
(r"\bop\.rename_column\(", "op.rename_column() - 重命名列,可能导致依赖该列的代码报错"),
(r"\bop\.drop_index\(", "op.drop_index() - 删除索引,可能影响查询性能"),
(r"\bop\.drop_constraint\(", "op.drop_constraint() - 删除约束,可能影响数据完整性"),
]
# 安全模式:这些是安全的新增操作
SAFE_PATTERNS = [
(r"\bop\.create_table\(", "新建表"),
(r"\bop\.add_column\(", "新增列"),
(r"\bop\.create_index\(", "新建索引"),
(r"\bop\.create_unique_constraint\(", "新建唯一约束"),
(r"\bop\.create_foreign_key\(", "新建外键约束"),
]
def extract_upgrade_content(content: str) -> str:
"""
从迁移文件中提取 upgrade 函数的内容
只检查 upgrade 中的操作忽略 downgrade
"""
upgrade_match = re.search(r"def upgrade\b[^:]*:", content)
if not upgrade_match:
return ""
upgrade_start = upgrade_match.end()
# 找到下一个顶层 def(通常是 def downgrade)作为结束位置
rest = content[upgrade_start:]
downgrade_match = re.search(r"\n\ndef\s+\w+\b", rest)
if downgrade_match:
upgrade_end = upgrade_start + downgrade_match.start()
else:
upgrade_end = len(content)
return content[upgrade_start:upgrade_end]
def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
"""
通过 git diff 对比目标分支/commit找出 alembic/versions/ 下新增的迁移文件
只包含新增文件A状态不包含修改或删除的文件
"""
try:
result = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=A", diff_target, "HEAD", "--", "alembic/versions/"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
check=True,
)
files = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
return [REPO_ROOT / f for f in files]
except subprocess.CalledProcessError as e:
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
print(f" 降级为检查所有迁移文件")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]:
"""
找出需要检查的迁移文件
优先级diff_against > since_revision > 全部
"""
if diff_against:
return get_new_migrations_via_diff(diff_against)
all_migrations = sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
if not since_revision:
return all_migrations
result = []
found = False
for m in all_migrations:
if since_revision in m.name or since_revision in m.stem:
found = True
continue
if found:
result.append(m)
return result if found else all_migrations
def analyze_migration(file_path: Path) -> Tuple[List[str], List[str], List[str]]:
"""分析单个迁移文件 upgrade 部分的风险等级"""
content = file_path.read_text()
upgrade_content = extract_upgrade_content(content)
if not upgrade_content:
return [], [], [f"{file_path.name}: 未找到 upgrade 函数"]
high_risks = []
medium_risks = []
safes = []
for pattern, desc in HIGH_RISK_PATTERNS:
if re.search(pattern, upgrade_content):
high_risks.append(f"{file_path.name}: {desc}")
for pattern, desc in MEDIUM_RISK_PATTERNS:
if re.search(pattern, upgrade_content):
medium_risks.append(f"{file_path.name}: {desc}")
for pattern, desc in SAFE_PATTERNS:
if re.search(pattern, upgrade_content):
safes.append(f"{file_path.name}: {desc}")
return high_risks, medium_risks, safes
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--since",
default=os.getenv("MIGRATION_SINCE_REVISION"),
help="只检查指定版本之后的迁移(如:030_xxx),不传则检查所有迁移",
)
parser.add_argument(
"--diff-against",
default=os.getenv("MIGRATION_DIFF_AGAINST"),
help="对比指定分支/commit,只检查新增的迁移文件(推荐用于CI,如 origin/main",
)
parser.add_argument(
"--warn-only",
action="store_true",
help="只警告不失败(用于非强制门禁场景)",
)
parser.add_argument(
"--allow-medium-risk",
action="store_true",
help="允许中风险变更(只拦截高风险)",
)
args = parser.parse_args()
migrations = find_new_migrations(args.since, args.diff_against)
if not migrations:
print("✅ 未找到需要检查的新增迁移文件,跳过")
return 0
print(f"🔍 正在检查 {len(migrations)} 个迁移文件的 upgrade 操作...")
if args.diff_against:
print(f" (对比基准:{args.diff_against},仅检查新增迁移)")
print()
all_high = []
all_medium = []
all_safe = []
for m in migrations:
high, medium, safe = analyze_migration(m)
all_high.extend(high)
all_medium.extend(medium)
all_safe.extend(safe)
if all_safe:
print("✅ 安全变更:")
for s in all_safe:
print(f" - {s}")
print()
if all_medium:
print("⚠️ 中风险变更(需人工确认):")
for m_item in all_medium:
print(f" - {m_item}")
print()
if all_high:
print("❌ 高风险破坏性变更(禁止自动部署):")
for h in all_high:
print(f" - {h}")
print()
print("=" * 60)
print(f"检查结果:{len(all_safe)} 项安全 / {len(all_medium)} 项中风险 / {len(all_high)} 项高风险")
print()
if all_high:
print("❌ 检测到高风险破坏性变更,CI 检查失败!")
print(" 如果确认这是预期操作,请在 MR/PR 中说明原因并获得审批。")
if args.warn_only:
return 0
return 1
if all_medium and not args.allow_medium_risk:
print("⚠️ 检测到中风险变更,请人工确认后再部署。")
if args.warn_only:
return 0
print("(如需仅拦截高风险,可使用 --allow-medium-risk 参数)")
return 2
print("✅ 未检测到破坏性变更")
return 0
if __name__ == "__main__":
sys.exit(main())
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""检查PR是否有至少N个APPROVED审批。
用法: python3 check_pr_approval.py <token> <repo> <pr_number> <min_approval>
返回: 打印 "approved" "pending"
"""
import json
import sys
import urllib.request
def main():
if len(sys.argv) != 5:
print("pending")
return
token = sys.argv[1]
repo = sys.argv[2]
pr_number = sys.argv[3]
min_approval = int(sys.argv[4])
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/pulls/{pr_number}/reviews"
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
reviews = json.loads(resp.read().decode())
except Exception:
print("pending")
return
# 统计APPROVED的人数(去重,同一人多次审批只算一次)
approvers = set()
for r in reviews:
if r.get("state") == "APPROVED":
approvers.add(r.get("user", {}).get("login", ""))
if len(approvers) >= min_approval:
print(f"approved ({len(approvers)})")
else:
print(f"pending ({len(approvers)})")
if __name__ == "__main__":
main()
+653
View File
@@ -0,0 +1,653 @@
#!/usr/bin/env python3
"""
ACR 镜像清理脚本增强版
清理策略
- 版本tag (v*): 永久保留
- 固定tag (latest, main, develop, master): 永久保留
- 缓存镜像 (*-cache): 永久保留
- 受保护tag (--protected-tags): 永久保留如当前运行中镜像
- PR预览tag (pr-*):
- --pr-sha模式删除指定PR commit的镜像PR关闭时触发
- cron模式通过Gitea API检查PR状态已关闭/合并的删除
- 普通commit hash tag: 保留最近 N 默认20老的删除
使用方式
# 预览(不实际删除)
python3 acr_cleanup.py --dry-run
# 实际执行(cron模式)
python3 acr_cleanup.py --execute
# 保留最近30个commit镜像
python3 acr_cleanup.py --keep 30 --execute
# PR关闭时清理指定commit的PR镜像
python3 acr_cleanup.py --pr-sha abc123def --execute
# 传入受保护tag列表(运行中镜像白名单)
python3 acr_cleanup.py --protected-tags "sha1,sha2" --execute
"""
import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
# ========== 配置 ==========
REGISTRY = os.environ.get("ACR_REGISTRY", "xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com")
AUTH_URL = "https://dockerauth.cn-hangzhou.aliyuncs.com/auth"
SERVICE = os.environ.get("ACR_SERVICE", "registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa")
NAMESPACE = os.environ.get("ACR_NAMESPACE", "xiaoxiakeji")
USERNAME = os.environ.get("ACR_USERNAME", "")
PASSWORD = os.environ.get("ACR_PASSWORD", "")
# Gitea配置(用于PR状态检查)
GITEA_URL = os.environ.get("GITEA_URL", "https://git.xiaoxiajianji.com")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
GITEA_REPO = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
REPOS = [
"xiaoxia-saas-api",
"xiaoxia-saas-worker",
"xiaoxia-saas-web",
"api-cache",
"worker-cache",
"web-cache",
]
# 缓存镜像仓库(所有tag永久保留)
CACHE_REPOS = {"api-cache", "worker-cache", "web-cache"}
# OCI / Docker manifest types
ACCEPT_INDEX = "application/vnd.oci.image.index.v1+json"
ACCEPT_MANIFEST_OCI = "application/vnd.oci.image.manifest.v1+json"
ACCEPT_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json"
# ========== Registry API ==========
def get_token(repo, action="pull"):
"""获取仓库访问token"""
scope = "repository:" + NAMESPACE + "/" + repo + ":" + action
token_url = AUTH_URL + "?service=" + SERVICE + "&scope=" + scope
req = urllib.request.Request(token_url)
req.add_header("Authorization", "Basic " + base64.b64encode((USERNAME + ":" + PASSWORD).encode()).decode())
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return data.get("token", "")
def get_tags(repo, token):
"""获取仓库所有tag"""
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/tags/list?n=1000"
req = urllib.request.Request(url)
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return data.get("tags", []) or []
def http_get_json(url, token, accept_header):
"""带Authorization的GET请求,返回(json_data, headers)"""
req = urllib.request.Request(url)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", accept_header)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read()), resp.headers
def get_manifest_info(repo, tag, token):
"""
获取tag的manifest信息
返回: {digest, created, media_type, error}
"""
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
result = {"digest": "", "created": "", "media_type": "", "error": ""}
# 先尝试 OCI index 格式
try:
data, headers = http_get_json(url, token, ACCEPT_INDEX)
top_digest = headers.get("Docker-Content-Digest", "")
result["digest"] = top_digest
result["media_type"] = data.get("mediaType", ACCEPT_INDEX)
manifests = data.get("manifests", [])
amd64_manifest = None
for m in manifests:
arch = m.get("platform", {}).get("architecture", "")
if arch == "amd64":
amd64_manifest = m
break
if not amd64_manifest and manifests:
amd64_manifest = manifests[0]
if amd64_manifest:
inner_digest = amd64_manifest["digest"]
inner_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + inner_digest
try:
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_OCI)
except Exception:
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_V2)
config_digest = inner_data.get("config", {}).get("digest", "")
if config_digest:
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
try:
blob_data, _ = http_get_json(blob_url, token, "application/json")
result["created"] = blob_data.get("created", "")
except Exception:
pass
return result
except urllib.error.HTTPError:
pass
# 再尝试普通 OCI manifest 格式
try:
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_OCI)
result["digest"] = headers.get("Docker-Content-Digest", "")
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_OCI)
config_digest = data.get("config", {}).get("digest", "")
if config_digest:
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
try:
blob_data, _ = http_get_json(blob_url, token, "application/json")
result["created"] = blob_data.get("created", "")
except Exception:
pass
return result
except urllib.error.HTTPError:
pass
# 最后试 Docker v2 格式
try:
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_V2)
result["digest"] = headers.get("Docker-Content-Digest", "")
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_V2)
config_digest = data.get("config", {}).get("digest", "")
if config_digest:
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
try:
blob_data, _ = http_get_json(blob_url, token, "application/json")
result["created"] = blob_data.get("created", "")
except Exception:
pass
return result
except urllib.error.HTTPError as e:
result["error"] = "HTTP " + str(e.code) + " " + e.read().decode()[:200]
return result
def delete_manifest(repo, digest, token):
"""按digest删除manifest(会级联删除所有指向它的tag)"""
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + digest
req = urllib.request.Request(url, method="DELETE")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", ACCEPT_INDEX)
req.add_header("Accept", ACCEPT_MANIFEST_OCI)
req.add_header("Accept", ACCEPT_MANIFEST_V2)
try:
with urllib.request.urlopen(req) as resp:
return True, resp.status
except urllib.error.HTTPError as e:
return False, str(e.code) + " " + e.read().decode()[:200]
# ========== Gitea API ==========
def gitea_get_open_prs():
"""获取所有打开的PR编号列表"""
if not GITEA_TOKEN:
print(" 警告: 无GITEA_TOKEN,跳过PR状态检查")
return None
open_prs = set()
page = 1
while True:
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls?state=open&page=" + str(page) + "&limit=50"
req = urllib.request.Request(url)
req.add_header("Authorization", "token " + GITEA_TOKEN)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
if not data:
break
for pr in data:
open_prs.add(pr.get("number", 0))
if len(data) < 50:
break
page += 1
except Exception as e:
print(f" 警告: 获取Gitea PR列表失败: {e}")
return None
return open_prs
def gitea_get_pr_commits(pr_number):
"""获取指定PR的所有commit sha"""
if not GITEA_TOKEN:
return []
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls/" + str(pr_number) + "/commits?limit=100"
req = urllib.request.Request(url)
req.add_header("Authorization", "token " + GITEA_TOKEN)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return [c.get("sha", "") for c in data]
except Exception as e:
print(f" 警告: 获取PR #{pr_number} commits失败: {e}")
return []
# ========== 工具函数 ==========
def parse_time(created_str):
"""解析ISO时间字符串"""
if not created_str:
return datetime.min.replace(tzinfo=timezone.utc)
try:
if created_str.endswith("Z"):
created_str = created_str[:-1] + "+00:00"
return datetime.fromisoformat(created_str)
except Exception:
return datetime.min.replace(tzinfo=timezone.utc)
def is_version_tag(tag):
"""判断是否是版本tag (v1.2.3, v0.1.0-alpha等)"""
return tag.startswith("v") and len(tag) > 1 and tag[1].isdigit()
def is_fixed_tag(tag):
"""判断是否是固定tag"""
return tag in ("latest", "main", "develop", "master", "dev", "stable")
def is_pr_tag(tag):
"""判断是否是PR预览tag (pr-<sha>)"""
return tag.startswith("pr-")
def extract_sha_from_pr_tag(tag):
"""从pr-<sha> tag中提取sha"""
if tag.startswith("pr-"):
return tag[3:]
return tag
def is_in_protected_list(tag, protected_set):
"""检查tag是否在受保护列表中"""
if not protected_set:
return False
# 精确匹配
if tag in protected_set:
return True
# 前缀匹配(commit hash可能是完整或短的)
for p in protected_set:
if tag.startswith(p) or p.startswith(tag):
return True
return False
# ========== 核心清理逻辑 ==========
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None):
"""
清理单个仓库
Args:
repo: 仓库名
keep_count: 保留最近N个commit tag
dry_run: 是否预览模式
protected_tags: 受保护tag集合白名单
pr_sha: 指定PR commit shaPR关闭模式None表示cron模式
pr_open_set: 打开的PR编号集合cron模式用
Returns:
(总tag数, 删除数)
"""
print("=" * 60)
print("仓库:", repo)
print("=" * 60)
# 缓存仓库不清理
if repo in CACHE_REPOS:
token_pull = get_token(repo, "pull")
tags = get_tags(repo, token_pull)
print(" 缓存仓库,跳过清理 (共", len(tags), "个tag)")
return len(tags), 0
token_pull = get_token(repo, "pull")
tags = get_tags(repo, token_pull)
print(" 总tag数:", len(tags))
if not tags:
print(" 无tag,跳过")
return 0, 0
# ========== PR-SHA模式:只删除指定commit的PR镜像 ==========
if pr_sha:
pr_tags_to_del = [
t
for t in tags
if t.startswith("pr-" + pr_sha) or t == "pr-" + pr_sha or pr_sha.startswith(extract_sha_from_pr_tag(t))
]
if not pr_tags_to_del:
print(f" 未找到PR镜像: pr-{pr_sha[:12]}")
return len(tags), 0
print(f" 找到 {len(pr_tags_to_del)} 个PR镜像待删除:")
for t in pr_tags_to_del:
print(f" - {t}")
to_delete = []
for tag in pr_tags_to_del:
info = get_manifest_info(repo, tag, token_pull)
if info["digest"]:
to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
else:
print(f" 警告: {tag} 无法获取digest,跳过")
return _execute_delete(repo, to_delete, dry_run, len(tags))
# ========== Cron模式:全量清理 ==========
# 分类
version_tags = []
fixed_tags = []
pr_tags_list = []
commit_tags = []
for tag in tags:
if is_version_tag(tag):
version_tags.append(tag)
elif is_fixed_tag(tag):
fixed_tags.append(tag)
elif is_pr_tag(tag):
pr_tags_list.append(tag)
else:
commit_tags.append(tag)
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 已关闭PR的删除")
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "")
print(" 白名单tag:", len(protected_tags), "")
# --- PR tag清理:检查PR状态 ---
pr_to_delete = []
if pr_tags_list:
print()
print(" 检查PR镜像状态...")
# 策略:有Gitea token则检查PR状态,否则按时间保留7天
if pr_open_set is not None:
# 通过Gitea API检查每个PR镜像对应的PR是否还开着
# 注意:pr tag是pr-<sha>sha可能属于某个PR
# 简化策略:收集所有打开PR的commit sha,在白名单里的保留
print(" 模式: Gitea PR状态检查")
open_pr_shas = set()
# 这里做了简化:因为每个PR都查commits太慢,我们用另一种方式
# 对于PR tag,先尝试匹配PR编号(如果tag名里有编号),否则按时间
# 实际pr-<sha>没法直接知道PR编号,所以降级为按时间+打开PR的head sha白名单
open_head_shas = set()
page = 1
while True:
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls?state=open&page=" + str(page) + "&limit=50"
req = urllib.request.Request(url)
req.add_header("Authorization", "token " + GITEA_TOKEN)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
if not data:
break
for pr in data:
head_sha = pr.get("head", {}).get("sha", "")
if head_sha:
open_head_shas.add(head_sha)
open_head_shas.add(head_sha[:7])
open_head_shas.add(head_sha[:12])
if len(data) < 50:
break
page += 1
except Exception:
break
deleted_count = 0
for tag in pr_tags_list:
sha = extract_sha_from_pr_tag(tag)
# 检查是否是打开PR的head sha
is_open_pr = False
for ohs in open_head_shas:
if sha.startswith(ohs) or ohs.startswith(sha):
is_open_pr = True
break
if not is_open_pr:
info = get_manifest_info(repo, tag, token_pull)
if info["digest"]:
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
deleted_count += 1
print(f" 打开PR数: {len(open_head_shas)}个head sha")
print(f" 将删除PR镜像: {deleted_count}")
else:
# 无Gitea token,降级为按7天保留
print(" 模式: 按时间保留7天(无Gitea token降级)")
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
for tag in pr_tags_list:
info = get_manifest_info(repo, tag, token_pull)
created = parse_time(info["created"])
if created < cutoff and info["digest"]:
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
print(f" 将删除PR镜像: {len(pr_to_delete)}")
# --- Commit tag清理:保留最近N个 ---
print()
print(" 获取commit tag创建时间...")
commit_tag_infos = []
errors = 0
for i, tag in enumerate(commit_tags):
info = get_manifest_info(repo, tag, token_pull)
if info["error"] or not info["digest"]:
errors += 1
commit_tag_infos.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
if (i + 1) % 20 == 0:
print(" 已获取", i + 1, "/", len(commit_tags), "...")
if errors:
print(" 注意:", errors, "个tag获取manifest失败")
# 按时间倒序排序
commit_tag_infos.sort(key=lambda x: parse_time(x["created"]), reverse=True)
# 确定要删除的commit tag
commit_to_delete = []
if len(commit_tag_infos) > keep_count:
commit_to_delete = commit_tag_infos[keep_count:]
print(f" 保留前{keep_count}个commit tag,删除{len(commit_to_delete)}")
# 白名单过滤:受保护的tag不删除
if protected_tags:
before = len(commit_to_delete)
commit_to_delete = [t for t in commit_to_delete if not is_in_protected_list(t["tag"], protected_tags)]
removed = before - len(commit_to_delete)
if removed > 0:
print(f" 白名单保护: 跳过{removed}个运行中镜像")
# 过滤无digest的
commit_to_delete = [t for t in commit_to_delete if t["digest"]]
print(f" 可删除(有digest): {len(commit_to_delete)}")
else:
print(f" commit tag数量不足{keep_count}个,无需清理")
# --- 合并所有待删除项 ---
all_to_delete = commit_to_delete + pr_to_delete
# 再次过滤白名单(PR镜像也受白名单保护)
if protected_tags:
before = len(all_to_delete)
all_to_delete = [t for t in all_to_delete if not is_in_protected_list(t["tag"], protected_tags)]
removed = before - len(all_to_delete)
if removed > 0:
print(f" 白名单保护(PR镜像): 跳过{removed}")
return _execute_delete(repo, all_to_delete, dry_run, len(tags))
def _execute_delete(repo, to_delete, dry_run, total_tags):
"""执行删除操作"""
if not to_delete:
print()
print(" 无需删除任何tag")
return total_tags, 0
# 按digest去重
seen_digests = set()
unique_delete = []
for item in to_delete:
if item["digest"] and item["digest"] not in seen_digests:
seen_digests.add(item["digest"])
unique_delete.append(item)
print()
if dry_run:
print(f" [DRY RUN] 将删除{len(unique_delete)}个manifest(预览模式)")
for item in unique_delete[:5]:
created_str = item.get("created", "")[:10] or "未知"
print(f" - {item['tag'][:30]} ({created_str})")
if len(unique_delete) > 5:
print(f" ... 还有{len(unique_delete) - 5}")
return total_tags, len(unique_delete)
token_delete = get_token(repo, "delete")
deleted = 0
failed = 0
print(f" 开始删除{len(unique_delete)}个唯一manifest...")
for item in unique_delete:
success, result = delete_manifest(repo, item["digest"], token_delete)
if success:
deleted += 1
print(f" 已删除: {item['tag'][:30]}")
else:
failed += 1
print(f" 删除失败: {item['tag'][:30]} - {result}")
print()
print(f" 删除完成: 成功{deleted}个,失败{failed}")
return total_tags, deleted
# ========== 主函数 ==========
def main():
parser = argparse.ArgumentParser(description="ACR镜像清理工具(增强版)")
parser.add_argument("--keep", type=int, default=20, help="保留最近N个commit hash tag(默认20")
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际删除")
parser.add_argument("--execute", action="store_true", help="实际执行删除")
parser.add_argument("--repo", type=str, default="", help="只清理指定仓库")
parser.add_argument("--pr-sha", type=str, default="", help="PR关闭模式:删除指定commit sha的PR镜像")
parser.add_argument("--protected-tags", type=str, default="", help="受保护tag列表,逗号分隔(运行中镜像白名单)")
parser.add_argument("--skip-pr-check", action="store_true", help="跳过Gitea PR状态检查(纯按时间清理PR镜像)")
args = parser.parse_args()
# 必须指定 --dry-run 或 --execute
if not args.dry_run and not args.execute:
print("请指定 --dry-run(预览)或 --execute(执行)")
print()
print("示例:")
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
print(" python3 acr_cleanup.py --execute # 实际执行清理")
print(" python3 acr_cleanup.py --pr-sha abc123 --execute # PR关闭时清理")
sys.exit(1)
# 凭证检查
global USERNAME, PASSWORD
if not USERNAME or not PASSWORD:
try:
docker_config_path = os.path.expanduser("~/.docker/config.json")
with open(docker_config_path) as f:
config = json.load(f)
auth = config.get("auths", {}).get(REGISTRY, {}).get("auth", "")
if auth:
creds = base64.b64decode(auth).decode().strip()
USERNAME, PASSWORD = creds.split(":", 1)
except Exception:
pass
if not USERNAME or not PASSWORD:
print("错误: 缺少ACR凭证,请设置 ACR_USERNAME 和 ACR_PASSWORD 环境变量")
print("或确保已执行 docker login", REGISTRY)
sys.exit(1)
# 解析受保护tag
protected_tags = set()
if args.protected_tags:
protected_tags = set(t.strip() for t in args.protected_tags.split(",") if t.strip())
dry_run = args.dry_run or not args.execute
mode = "预览模式" if dry_run else "执行模式"
print("=" * 60)
print("ACR 镜像清理工具(增强版)-", mode)
print("=" * 60)
print("Registry:", REGISTRY)
print("Namespace:", NAMESPACE)
if args.pr_sha:
print("模式: PR关闭清理")
print("PR commit SHA:", args.pr_sha[:12])
else:
print("模式: Cron全量清理")
print("保留commit tag数:", args.keep)
print("PR状态检查:", "关闭" if args.skip_pr_check else "开启")
if protected_tags:
print("白名单tag数:", len(protected_tags))
print()
# PR模式不需要查Gitea
pr_open_set = None
if not args.pr_sha and not args.skip_pr_check and GITEA_TOKEN:
print("获取打开的PR列表...")
pr_open_set = gitea_get_open_prs()
if pr_open_set is not None:
print(f" 打开的PR: {len(pr_open_set)}")
print()
repos_to_clean = REPOS
if args.repo:
repos_to_clean = [args.repo]
total_deleted = 0
total_tags = 0
for repo in repos_to_clean:
count, deleted = cleanup_repo(
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set
)
total_tags += count
total_deleted += deleted
print()
print("=" * 60)
print("清理完成")
print(" 总tag数:", total_tags)
if dry_run:
print(" 预览将删除(去重后):", total_deleted, "个manifest")
else:
print(" 已删除:", total_deleted, "个manifest")
print("=" * 60)
if __name__ == "__main__":
main()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# 自动审批:CI全绿后自动approve PR
# 环境变量:GITHUB_TOKEN, REVIEW_TOKEN, PR_NUMBER, PR_HEAD_SHA, GITHUB_API_URL, GITHUB_REPOSITORY
set -eu
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Unit Tests (pull_request)"
"CI/CD Pipeline / PR Build API Image (pull_request)"
"CI/CD Pipeline / PR Build Web Image (pull_request)"
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 12); do # 短作业模式:最多等2分钟(12次x10秒),不满足就退出等下次触发
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING review
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED)"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑 → 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中(第${attempt}/12次),超时后将退出等待下次触发..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
sleep 10
done
echo
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
exit 0
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
- PR事件所有PR只要Code Quality因格式问题失败自动修复并push回源分支
- Push事件develop/main自动修复并push回原分支保持主干格式永远正确
- 防循环修复commit带 [skip ci-format-check] 标记检测到该标记则跳过修复
- 只修格式black/isort/prettierruff逻辑类错误不动
当code quality检查因格式问题失败时触发
"""
import json
import os
import subprocess
import sys
import time
import urllib.request
def run(cmd, check=True, capture=True, cwd=None):
"""运行shell命令"""
result = subprocess.run(cmd, shell=True, capture_output=capture, text=True, cwd=cwd)
if check and result.returncode != 0:
print(f"命令失败: {cmd}", file=sys.stderr)
if result.stderr:
print(result.stderr, file=sys.stderr)
sys.exit(1)
return result
def ensure_git_repo(api_url, repo, token, pr_number):
"""确保当前目录是git仓库,并切换到PR源分支。
checkout脚本用tarball方式下载代码PR merge后的commit没有.git目录
这里自动初始化git仓库fetch PR源分支并强制checkout
使工作区变为PR源分支的代码确保后续格式化修复基于源分支
"""
if os.path.exists(".git"):
return
print("检测到tarball checkout(无.git目录),自动初始化git仓库...")
# 构造带认证的远端URL
server_url = api_url.rsplit("/api/v1", 1)[0]
remote_url = f"{server_url.replace('https://', f'https://x-access-token:{token}@')}/{repo}.git"
# 获取PR的源分支
pr_api_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
req_obj = urllib.request.Request(pr_api_url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req_obj) as resp:
pr = json.loads(resp.read())
head_branch = pr["head"]["ref"]
print(f"PR源分支: {head_branch}")
# 初始化git
run("git init -q")
run(f"git remote add origin {remote_url}")
run('git config user.name "CI Bot"')
run('git config user.email "ci-bot@xiaoxiajianji.com"')
# fetch源分支(浅克隆,只要最新commit)
print("fetch源分支...")
run(f"git fetch --depth=1 origin {head_branch}")
# 强制checkout到源分支(覆盖tarball内容)
# tarball是merge后的commit,源分支才是我们要修改并推送的目标
print("切换到源分支...")
run(f"git checkout -f -B {head_branch} FETCH_HEAD")
result = run("git status --porcelain")
if result.stdout.strip():
n = len(result.stdout.strip().splitlines())
print(f"⚠️ 工作区有 {n} 个未追踪文件")
else:
print("✅ git仓库就绪,工作区clean")
return head_branch
def ensure_git_repo_for_push(api_url, repo, token, branch_name):
"""push事件下确保git仓库可用,并切换到目标分支。
checkout脚本用tarball方式下载代码没有.git目录
这里自动初始化git仓库fetch目标分支并checkout
"""
if os.path.exists(".git"):
# 已有git,确认在正确分支
result = run("git rev-parse --abbrev-ref HEAD", check=False)
if result.stdout.strip() == branch_name:
return
# 不在目标分支,切换
run(f"git checkout {branch_name}", check=False)
return
print(f"检测到tarball checkout(无.git目录),初始化git仓库(push模式,分支: {branch_name}...")
server_url = api_url.rsplit("/api/v1", 1)[0]
remote_url = f"{server_url.replace('https://', f'https://x-access-token:{token}@')}/{repo}.git"
run("git init -q")
run(f"git remote add origin {remote_url}")
run('git config user.name "CI Bot"')
run('git config user.email "ci-bot@xiaoxiajianji.com"')
print(f"fetch {branch_name} 分支...")
run(f"git fetch --depth=1 origin {branch_name}")
print(f"切换到 {branch_name} 分支...")
run(f"git checkout -f -B {branch_name} FETCH_HEAD")
result = run("git status --porcelain")
if result.stdout.strip():
n = len(result.stdout.strip().splitlines())
print(f"⚠️ 工作区有 {n} 个未追踪文件")
else:
print("✅ git仓库就绪,工作区clean")
def get_changed_files(pr_number, api_url, token):
"""获取PR中变更的文件列表"""
url = f"{api_url}/pulls/{pr_number}/files?limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req) as resp:
files = json.loads(resp.read())
return [f["filename"] for f in files if f["status"] != "removed"]
def get_pr_head_branch(pr_number, api_url, token):
"""获取PR的来源分支名"""
url = f"{api_url}/pulls/{pr_number}"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req) as resp:
pr = json.loads(resp.read())
return pr["head"]["ref"]
def fix_python(target_py_files, scan_mode):
"""修复 Python 文件格式 (black + isort)"""
if not target_py_files:
print("没有需要修复的 Python 文件,跳过")
return
target_str = " ".join(target_py_files)
print()
print("--- black 格式化 ---")
result = run(f"python3 -m black {target_str}", check=False)
print(result.stdout[-500:] if result.stdout else "")
if result.returncode != 0:
print("black执行失败,但继续尝试isort", file=sys.stderr)
print()
print("--- isort 排序 ---")
result = run(f"python3 -m isort {target_str}", check=False)
print(result.stdout[-500:] if result.stdout else "")
if result.returncode != 0:
print("isort执行失败", file=sys.stderr)
def fix_frontend(target_fe_files, scan_mode, repo_root):
"""修复前端文件格式 (prettier)"""
if not target_fe_files:
print("没有需要修复的前端文件,跳过")
return
# 检查 prettier 是否可用
web_dir = os.path.join(repo_root, "apps", "web")
prettier_bin = os.path.join(web_dir, "node_modules", ".bin", "prettier")
if not os.path.exists(prettier_bin):
print()
print("--- 安装前端依赖 (prettier) ---")
result = run("npm install --no-audit --no-fund --prefer-offline", check=False, cwd=web_dir)
if result.returncode != 0:
print("npm install 失败,跳过 prettier 修复", file=sys.stderr)
return
print("依赖安装完成")
if not os.path.exists(prettier_bin):
print("prettier 仍不可用,跳过", file=sys.stderr)
return
print()
print("--- prettier 格式化 ---")
if scan_mode == "incremental":
# 增量模式:只格式化变更的前端文件
target_str = " ".join(target_fe_files)
cmd = f"{prettier_bin} --write {target_str}"
else:
# 全量模式:格式化整个前端目录
cmd = f"{prettier_bin} --write ."
result = run(cmd, check=False, cwd=web_dir if scan_mode != "incremental" else repo_root)
print(result.stdout[-800:] if result.stdout else "")
if result.stderr:
print(result.stderr[-500:], file=sys.stderr)
def main():
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
github_ref = os.environ.get("GITHUB_REF", "")
api_url = os.environ.get("GITHUB_API_URL", "")
repo = os.environ.get("GITHUB_REPOSITORY", "")
token = os.environ.get("REVIEW_TOKEN", "") or os.environ.get("GITHUB_TOKEN", "")
scan_mode = os.environ.get("SCAN_MODE", "full")
changed_files_env = os.environ.get("CHANGED_FILES", "")
if not token:
print("缺少REVIEW_TOKEN或GITHUB_TOKEN,无法推送修复", file=sys.stderr)
sys.exit(1)
repo_root = os.getcwd()
# ====== Push事件处理(develop/main等受保护分支) ======
if event_name == "push":
# 从 refs/heads/xxx 提取分支名
if not github_ref.startswith("refs/heads/"):
print(f"push事件但refs格式异常: {github_ref},跳过")
return
branch_name = github_ref.replace("refs/heads/", "")
# 只在受保护分支(develop/main)上自动修复并推送
protected_branches = {"develop", "main", "master"}
if branch_name not in protected_branches:
print(f"push事件,分支 {branch_name} 不是受保护分支,跳过自动修复")
return
print("=== Push事件:检测到格式问题,自动修复并推送回原分支 ===")
print(f"分支: {branch_name}")
print(f"扫描模式: {scan_mode}")
# 初始化git仓库
ensure_git_repo_for_push(api_url, repo, token, branch_name)
head_branch = branch_name
fix_mode = "auto_fix_and_push"
# ====== PR事件处理 ======
elif event_name == "pull_request":
pr_number = github_ref.split("/")[2] if github_ref.startswith("refs/pull/") else ""
if not pr_number:
print("无法获取PR号,跳过自动修复")
return
# 获取PR信息
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req_pr) as resp:
pr_info = json.loads(resp.read())
pr_author = pr_info.get("user", {}).get("login", "")
print(f"PR作者: {pr_author}")
# 防循环检测:检查最新commit是否已经是格式修复commit
# 修复commit message 带 [skip ci-format-check] 标记,检测到则跳过
head_branch_tmp = pr_info.get("head", {}).get("ref", "")
skip_marker = "[skip ci-format-check]"
try:
commits_url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits?limit=3"
req_commits = urllib.request.Request(commits_url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req_commits) as resp_commits:
commits = json.loads(resp_commits.read())
latest_msg = commits[0].get("commit", {}).get("message", "") if commits else ""
if skip_marker in latest_msg:
print(f"检测到最新commit包含 {skip_marker} 标记,跳过格式修复(防循环)")
print("本次格式检查失败是格式修复commit触发的CI回跑,属正常现象")
sys.exit(0)
except Exception as e:
print(f"⚠️ 防循环检测失败,继续执行: {e}")
# 所有PR都自动修复格式(不再区分人/Agent)
print("检测到格式问题,将自动修复并推送回分支")
fix_mode = "auto_fix_and_push"
print("=== 检测到代码格式问题,尝试自动修复 ===")
print(f"PR #{pr_number}")
print(f"扫描模式: {scan_mode}")
# 确保git仓库可用(tarball checkout模式下自动初始化)
head_branch = ensure_git_repo(api_url, repo, token, pr_number)
# ====== 其他事件跳过 ======
else:
print(f"事件 {event_name} 不支持自动修复,跳过")
return
# 前端文件扩展名
fe_extensions = (
".ts",
".tsx",
".js",
".jsx",
".css",
".scss",
".less",
".json",
".html",
".md",
".yaml",
".yml",
)
py_extensions = (".py",)
# 确定要修复的文件范围
if scan_mode == "incremental" and changed_files_env:
all_changed = changed_files_env.split()
target_py_files = [f for f in all_changed if f.endswith(py_extensions)]
target_fe_files = [f for f in all_changed if f.endswith(fe_extensions)]
print(f"增量模式: {len(target_py_files)} 个Python文件, {len(target_fe_files)} 个前端文件")
else:
target_py_files = ["alembic", "apps", "packages", "tests", "scripts"]
target_fe_files = ["apps/web"]
print("全量模式,修复所有文件")
# Python 格式化
fix_python(target_py_files, scan_mode)
# 前端格式化
if scan_mode != "incremental":
fix_frontend(["apps/web"], scan_mode, repo_root)
else:
fix_frontend(target_fe_files, scan_mode, repo_root)
# 检查是否有改动
result = run("git status --porcelain")
if not result.stdout.strip():
print()
print("没有需要提交的格式改动")
return
print()
print("变更文件:")
for line in result.stdout.strip().split("\n"):
print(f" {line}")
# 提交修复
run("git add -A")
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
# 推送(head_branch已从ensure_git_repo获取)
print(f"\nPR来源分支: {head_branch}")
print("推送格式修复到远端...")
# 推送前先 rebase 拉取远端最新,避免快进冲突
# 最多重试 3 次:rebase → push,失败则重新拉取再试
max_retries = 3
push_success = False
last_error = ""
for attempt in range(1, max_retries + 1):
print(f" 尝试 {attempt}/{max_retries}: 拉取最新代码并推送...")
# 先拉取远端最新 commit 并 rebase
fetch_result = run(f"git fetch origin {head_branch}", check=False)
if fetch_result.returncode != 0:
last_error = f"git fetch 失败: {fetch_result.stderr.strip()}"
print(f" {last_error}")
time.sleep(2)
continue
rebase_result = run(f"git rebase origin/{head_branch}", check=False)
if rebase_result.returncode != 0:
last_error = f"git rebase 失败,中止并重置: {rebase_result.stderr.strip()[:200]}"
print(f" {last_error}")
run("git rebase --abort", check=False)
# rebase 失败通常是冲突,重试没用,直接跳出
break
# 推送
push_result = run(f'git push origin "HEAD:{head_branch}"', check=False)
if push_result.returncode == 0:
push_success = True
break
last_error = push_result.stderr.strip() or push_result.stdout.strip()
print(f" push 失败: {last_error[:200]}")
time.sleep(3)
if not push_success:
print(f"\n❌ 推送失败(已重试 {max_retries} 次)", file=sys.stderr)
print(f"最后错误: {last_error}", file=sys.stderr)
sys.exit(1)
print()
print("✅ 格式已自动修复并推送回分支")
print("新的commit会重新触发CI检查")
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# 自动合并:CI全绿+已审批后自动squash merge PR到develop
# 短作业模式:只检查一次,不满足条件就退出,由pr-auto-scan定时兜底
# 环境变量:GITHUB_TOKEN, MERGE_TOKEN, PR_NUMBER, PR_HEAD_SHA, BASE_REF, GITHUB_API_URL, GITHUB_REPOSITORY
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
echo "模式: 短作业(只检查一次,不满足则退出,由pr-auto-scan定时兜底)"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
echo
# 使用统一的CI Gate门禁(单一检查点,自动处理前端/后端/全栈跳过逻辑)
CONTEXTS=(
"CI/CD Pipeline / CI Gate (pull_request)"
)
echo "检查CI Gate统一门禁"
echo
# 等待60秒,给CI启动写status的时间
echo "等待60秒让CI启动..."
sleep 60
# 405计数器(单次运行内重试)
MERGE_405_COUNT=0
MAX_405_RETRIES=3
check_and_merge() {
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 检查CI状态 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# CI有失败 → 不合并,直接退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI有失败项,不自动合并"
exit 0
fi
# CI未全绿(pending中)→ 退出,等下次触发
if [ "$ALL_SUCCESS" != "true" ]; then
echo
echo "⏳ CI尚未全绿(仍有pending),退出等待下次触发"
echo " pr-auto-scan每5分钟扫描一次,CI通过后会自动合并)"
exit 0
fi
# CI全绿 → 合并
echo
echo "✅ CI全绿,执行自动合并"
echo "等待30秒冷却,给Gitea内部状态同步时间..."
sleep 30
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ 自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁"
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃本次自动合并"
echo " pr-auto-scan会继续尝试,需人工确认是否有冲突或门禁问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
echo "30秒后重试..."
sleep 30
return 1 # 重试
else
echo "❌ 自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
}
# 最多重试3次(用于405重试,非CI轮询)
for i in 1 2 3; do
if check_and_merge; then
exit 0
fi
done
echo
echo "本次检查未满足合并条件,退出。pr-auto-scan每5分钟会继续扫描。"
exit 0
+363
View File
@@ -0,0 +1,363 @@
#!/bin/bash
# ===========================================
# 金丝雀发布脚本 - 分阶段灰度到全量
# ===========================================
# 在 CI Runner 上执行,通过 SSH 控制生产服务器执行灰度发布。
# 流程:5%灰度 → 20%灰度 → 50%灰度 → 100%全量
# 每阶段自动健康检查,失败自动回滚。
#
# 用法:
# IMAGE_TAG=v0.1.130 ./scripts/ci/canary_release.sh
#
# 环境变量:
# IMAGE_TAG - 新版本镜像标签 (必填)
# CANARY_STAGES - 灰度阶段配置,格式: "百分比:等待秒数" 用逗号分隔
# 默认: "5:600,20:900,50:1200"
# PROD_API_URL - Production API 公网地址
# PROD_WEB_URL - Production Web 公网地址
# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址
# PRODUCTION_SSH_USER - SSH 用户名
# PRODUCTION_SSH_PORT - SSH 端口
# PRODUCTION_SSH_KEY - SSH 私钥内容
# ACR_USERNAME - 容器镜像仓库用户名
# ACR_PASSWORD - 容器镜像仓库密码
# CI_NOTIFY_WEBHOOK - 通知 Webhook
# SKIP_ROLLBACK - 失败时不自动回滚 (调试用)
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# 配置
IMAGE_TAG="${IMAGE_TAG:-}"
CANARY_STAGES="${CANARY_STAGES:-5:600,20:900,50:1200}"
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
# gray_deploy.sh 的镜像命名格式是 ${REGISTRY}-component:tag
# 需要与 ACR 镜像名 xiaoxia-registry.../xiaoxiakeji/xiaoxia-saas-api:tag 匹配
GRAY_REGISTRY="${GRAY_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/xiaoxia-saas}"
ACR_REGISTRY_HOST="${ACR_REGISTRY_HOST:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com}"
ACR_USERNAME="${ACR_USERNAME:-}"
ACR_PASSWORD="${ACR_PASSWORD:-}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
if [[ -z "$IMAGE_TAG" ]]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
# ===========================================
# SSH 配置
# ===========================================
SSH_KEY_PATH=""
setup_ssh() {
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
SSH_KEY_PATH="$HOME/.ssh/canary_deploy_key"
mkdir -p "$HOME/.ssh"
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$SSH_KEY_PATH"
chmod 600 "$SSH_KEY_PATH"
else
log_error "没有可用的 SSH 密钥"
return 1
fi
ssh-keyscan -p "$PRODUCTION_SSH_PORT" -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
log_info "SSH 已配置: ${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}:${PRODUCTION_SSH_PORT}"
}
run_ssh() {
local cmd="$1"
ssh -p "$PRODUCTION_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
"${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}" "$cmd"
}
# ===========================================
# 上传脚本 + Docker登录
# ===========================================
prepare_server() {
log_step "准备生产服务器环境"
# 创建临时目录
run_ssh "mkdir -p /tmp/canary-release"
# 上传 gray_deploy.sh
local gray_script="$REPO_ROOT/scripts/gray_deploy.sh"
if [[ -f "$gray_script" ]]; then
cat "$gray_script" | run_ssh "cat > /tmp/canary-release/gray_deploy.sh && chmod +x /tmp/canary-release/gray_deploy.sh"
log_info " gray_deploy.sh 已上传"
else
log_error "找不到 gray_deploy.sh: $gray_script"
return 1
fi
# 上传 rollback_gray.sh
local rollback_script="$REPO_ROOT/scripts/rollback_gray.sh"
if [[ -f "$rollback_script" ]]; then
cat "$rollback_script" | run_ssh "cat > /tmp/canary-release/rollback_gray.sh && chmod +x /tmp/canary-release/rollback_gray.sh"
log_info " rollback_gray.sh 已上传"
else
log_warn "找不到 rollback_gray.sh"
fi
# 上传 ci_production_deploy.sh
local prod_deploy="$REPO_ROOT/scripts/ci_production_deploy.sh"
if [[ -f "$prod_deploy" ]]; then
cat "$prod_deploy" | run_ssh "cat > /tmp/canary-release/ci_production_deploy.sh && chmod +x /tmp/canary-release/ci_production_deploy.sh"
log_info " ci_production_deploy.sh 已上传"
else
log_error "找不到 ci_production_deploy.sh: $prod_deploy"
return 1
fi
# Docker 登录到 ACR
if [[ -n "$ACR_USERNAME" && -n "$ACR_PASSWORD" ]]; then
log_info " Docker 登录到 ACR..."
run_ssh "docker login '$ACR_REGISTRY_HOST' -u '$ACR_USERNAME' -p '$ACR_PASSWORD' 2>/dev/null" || \
log_warn " Docker login 失败(可能已有凭证),将尝试直接 pull"
fi
log_info "✅ 服务器环境准备完成"
}
# ===========================================
# 健康检查(公网访问)
# ===========================================
health_check() {
local stage_name="$1"
local timeout="${2:-120}"
local interval=5
local elapsed=0
log_step "健康检查 - $stage_name (超时 ${timeout}s)"
while [ $elapsed -lt $timeout ]; do
local api_ok=false
local web_ok=false
# 检查 API
local api_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 10 \
"${PROD_API_URL}/health" 2>/dev/null || echo "000")
if [[ "$api_code" == "200" ]]; then
api_ok=true
fi
# 检查 Web
local web_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 10 \
"$PROD_WEB_URL" 2>/dev/null || echo "000")
if [[ "$web_code" == "200" || "$web_code" == "301" || "$web_code" == "302" ]]; then
web_ok=true
fi
if $api_ok && $web_ok; then
log_info "✅ 健康检查通过 (API=$api_code, Web=$web_code)"
return 0
fi
log_warn " 等待中... API=$api_code, Web=$web_code (${elapsed}s/${timeout}s)"
sleep $interval
elapsed=$((elapsed + interval))
done
log_error "❌ 健康检查超时"
return 1
}
# ===========================================
# 灰度发布
# ===========================================
gray_deploy() {
local pct="$1"
log_step "灰度发布 ${pct}% - $IMAGE_TAG"
run_ssh "cd /tmp/canary-release && \
REGISTRY='$GRAY_REGISTRY' \
./gray_deploy.sh '$IMAGE_TAG' '$pct'"
}
# ===========================================
# 全量部署
# ===========================================
full_deploy() {
log_step "全量部署 - $IMAGE_TAG"
run_ssh "cd /tmp/canary-release && \
IMAGE_TAG='$IMAGE_TAG' \
ACR_USERNAME='$ACR_USERNAME' \
ACR_PASSWORD='$ACR_PASSWORD' \
sh ./ci_production_deploy.sh"
}
# ===========================================
# 灰度回滚
# ===========================================
rollback_gray() {
log_error "执行灰度回滚..."
if [[ "$SKIP_ROLLBACK" == "true" ]]; then
log_warn "SKIP_ROLLBACK=true,跳过回滚"
return
fi
if run_ssh "test -f /tmp/canary-release/rollback_gray.sh"; then
run_ssh "cd /tmp/canary-release && ./rollback_gray.sh" || \
log_error "回滚脚本执行失败,请手动处理"
else
# 内联回滚逻辑
log_warn "使用内联回滚逻辑"
run_ssh '
NGINX_CONF="/etc/nginx/sites-enabled/00-xiaoxia-saas"
LATEST_BAK=$(ls -t "${NGINX_CONF}".bak.gray.* 2>/dev/null | head -1 || true)
if [[ -n "$LATEST_BAK" ]]; then
cp "$LATEST_BAK" "$NGINX_CONF"
else
sed -i "s|proxy_pass http://saas_api_backend|proxy_pass http://127.0.0.1:8001|g" "$NGINX_CONF"
sed -i "s|proxy_pass http://saas_web_backend/|proxy_pass http://127.0.0.1:3002/|g" "$NGINX_CONF"
fi
nginx -t && nginx -s reload
docker rm -f xiaoxia-api-canary xiaoxia-web-canary 2>/dev/null || true
' || log_error "回滚失败,请手动处理"
fi
}
# ===========================================
# 通知
# ===========================================
notify_status() {
local status="$1"
local message="$2"
if [ -n "${CI_NOTIFY_WEBHOOK:-}" ]; then
NOTIFY_MODE="$status" JOB_NAME="Canary Release - $message" \
python3 "$REPO_ROOT/scripts/ci_notify.py" 2>/dev/null || true
fi
}
# ===========================================
# 清理
# ===========================================
cleanup() {
log_step "清理生产服务器临时文件"
run_ssh "rm -rf /tmp/canary-release" 2>/dev/null || true
}
# ===========================================
# 主流程
# ===========================================
main() {
echo "==========================================="
echo " 🐦 金丝雀发布"
echo " 版本: $IMAGE_TAG"
echo " 阶段: $CANARY_STAGES"
echo "==========================================="
echo ""
setup_ssh
prepare_server
trap cleanup EXIT
# 解析灰度阶段
IFS=',' read -ra STAGES <<< "$CANARY_STAGES"
local total_stages=${#STAGES[@]}
local current_stage=0
# 逐阶段灰度
for stage in "${STAGES[@]}"; do
current_stage=$((current_stage + 1))
local pct=$(echo "$stage" | cut -d: -f1)
local wait_time=$(echo "$stage" | cut -d: -f2)
echo ""
echo "--- 阶段 $current_stage/$total_stages: ${pct}% 灰度 ---"
# 执行灰度发布
if ! gray_deploy "$pct"; then
log_error "灰度发布 ${pct}% 失败"
rollback_gray
notify_status "failure" "Stage ${pct}% Deploy Failed"
exit 1
fi
# 健康检查
if ! health_check "${pct}%灰度"; then
log_error "${pct}%灰度健康检查失败"
rollback_gray
notify_status "failure" "Stage ${pct}% Health Check Failed"
exit 1
fi
# 观察期
log_info "⏳ 观察期 ${wait_time}s,监控流量稳定性..."
local waited=0
local check_interval=60
while [ $waited -lt $wait_time ]; do
sleep $check_interval
waited=$((waited + check_interval))
# 每隔一段时间做一次快速健康检查
local api_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 10 \
"${PROD_API_URL}/health" 2>/dev/null || echo "000")
if [[ "$api_code" != "200" ]]; then
log_error "❌ 观察期内 API 异常 (HTTP $api_code),触发回滚"
rollback_gray
notify_status "failure" "Stage ${pct}% Watch Period Failed"
exit 1
fi
log_info " 观察中... ${waited}s/${wait_time}s (API=$api_code)"
done
log_info "${pct}%灰度阶段完成,稳定运行 ${wait_time}s"
done
# 全量部署
echo ""
echo "--- 最终阶段: 100% 全量部署 ---"
if ! full_deploy; then
log_error "全量部署失败"
notify_status "failure" "Full Deploy Failed"
exit 1
fi
# 最终健康检查
if ! health_check "全量部署" "180"; then
log_error "全量部署后健康检查失败"
notify_status "failure" "Full Deploy Health Check Failed"
exit 1
fi
# 清理 canary 容器
log_step "清理 Canary 容器"
run_ssh "docker rm -f xiaoxia-api-canary xiaoxia-web-canary 2>/dev/null || true" || true
echo ""
echo "==========================================="
echo " ✅ 金丝雀发布完成"
echo " 版本: $IMAGE_TAG"
echo " 状态: 100%全量运行"
echo "==========================================="
notify_status "success" "$IMAGE_TAG Fully Deployed"
}
main "$@"
+19
View File
@@ -0,0 +1,19 @@
"""CI ChatOps 工具包 - 飞书机器人对接 Gitea Actions
模块:
config - 配置管理环境变量
gitea_client - Gitea API 客户端封装
feishu_notify - 飞书通知失败/恢复/E2E摘要
ci_query - CI 状态查询
ci_trigger - CI 重跑触发
webhook_server - Gitea webhook 接收服务FastAPI
"""
__all__ = [
"config",
"gitea_client",
"feishu_notify",
"ci_query",
"ci_trigger",
"webhook_server",
]
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
CI 状态查询模块 - 查询 run 列表某分支/ PR CI 状态失败详情
支持查询类型:
- list_runs: 列出最近的 workflow runs
- branch_status: 某分支最新 CI 状态
- pr_status: PR CI 状态
- failure_detail: 某次 run 的失败详情
用法:
python3 scripts/ci/chatops/ci_query.py --branch develop
python3 scripts/ci/chatops/ci_query.py --pr 123
python3 scripts/ci/chatops/ci_query.py --run-id 456 --detail
设计:
- 与飞书机器人 /ci status 命令对接
- 返回结构化数据上层负责格式化输出
"""
import argparse
import sys
from . import config
from .gitea_client import GiteaClient
class CIQuery:
"""CI 状态查询器"""
def __init__(self, gitea_client=None):
self.gitea = gitea_client or GiteaClient()
# ── 查询方法 ──────────────────────────────────────
def get_branch_status(self, branch, limit=5):
"""获取指定分支最新的 CI 状态
Returns:
dict: {branch, latest_run, recent_runs, overall_status}
"""
runs, total = self.gitea.list_runs(branch=branch, limit=limit)
if not runs:
return {
"branch": branch,
"latest_run": None,
"recent_runs": [],
"overall_status": "no_runs",
"total_count": total,
}
latest = runs[0]
overall = self._derive_overall_status(runs)
return {
"branch": branch,
"latest_run": latest,
"recent_runs": runs,
"overall_status": overall,
"total_count": total,
}
def get_pr_status(self, pr_number):
"""获取指定 PR 的 CI 状态
Returns:
dict: {pr_number, pr_title, runs, overall_status}
"""
pr = self.gitea.get_pr(pr_number)
if not pr:
return {
"pr_number": pr_number,
"pr_title": "未知",
"runs": [],
"overall_status": "pr_not_found",
}
pr_title = pr.get("title", "")
runs = self.gitea.get_pr_ci_runs(pr_number, limit=10)
overall = self._derive_overall_status(runs) if runs else "no_runs"
return {
"pr_number": pr_number,
"pr_title": pr_title,
"runs": runs,
"overall_status": overall,
"head_sha": pr.get("head", {}).get("sha", ""),
}
def get_failure_detail(self, run_id):
"""获取某次 run 的失败详情
Returns:
dict: {run_info, failed_jobs, summary}
"""
run = self.gitea.get_run(run_id)
if not run:
return {"run_info": None, "failed_jobs": [], "summary": "Run not found"}
failed_jobs = self.gitea.get_failed_jobs_summary(run_id, max_lines_per_job=30)
summary_parts = []
for job in failed_jobs:
step = f"(步骤: {job['failed_step']}" if job["failed_step"] else ""
summary_parts.append(f"{job['name']}{step}")
summary = "\n".join(summary_parts) if summary_parts else "无失败 job(可能还在运行中)"
return {
"run_info": run,
"failed_jobs": failed_jobs,
"summary": summary,
"total_jobs": len(self.gitea.get_run_jobs(run_id)),
}
def list_recent_runs(self, status=None, branch=None, limit=10):
"""列出最近的 runs"""
runs, total = self.gitea.list_runs(status=status, branch=branch, limit=limit)
return {"runs": runs, "total_count": total}
# ── 辅助方法 ────────────────────────────────────
@staticmethod
def _derive_overall_status(runs):
"""根据最近 runs 推导整体状态
Returns:
success: 最近一次成功
failing: 最近一次失败连续失败
flaky: 有失败有成功最近一次失败
running: 有正在运行的
unknown: 未知
"""
if not runs:
return "no_runs"
# 检查是否有运行中的
running = [r for r in runs if r.get("status") != "completed"]
if running:
return "running"
# 看最近一次
latest = runs[0]
latest_conclusion = latest.get("conclusion", "unknown")
if latest_conclusion == "success":
return "success"
if latest_conclusion == "failure":
# 检查是否连续失败
consecutive_failures = 0
for r in runs:
if r.get("conclusion") == "failure":
consecutive_failures += 1
else:
break
# 看之前有没有成功
has_success = any(r.get("conclusion") == "success" for r in runs)
if has_success:
return "flaky"
return "failing"
return "unknown"
# ── 格式化输出 ────────────────────────────────────
@staticmethod
def format_branch_status(status_data):
"""格式化分支状态为人类可读文本"""
branch = status_data["branch"]
latest = status_data["latest_run"]
overall = status_data["overall_status"]
status_emoji = {
"success": "",
"failing": "🔴",
"flaky": "🟡",
"running": "🔄",
"no_runs": "",
"unknown": "",
}.get(overall, "")
lines = [f"**CI 状态:{branch} 分支**", f"整体状态: {status_emoji} {overall}"]
if latest:
name = latest.get("name", "Unknown")
conclusion = latest.get("conclusion", latest.get("status", "unknown"))
run_id = latest.get("id", "")
created = latest.get("created_at", "")[:16].replace("T", " ")
run_url = f"{config.GITEA_URL}/{config.GITEA_REPO}/actions/runs/{run_id}"
lines.append(f"最新: [{name} #{run_id}]({run_url}) - {conclusion} ({created})")
recent = status_data["recent_runs"]
if len(recent) > 1:
lines.append(f"\n最近 {len(recent)} 次:")
for r in recent[:5]:
c = r.get("conclusion", r.get("status", "?"))
emoji = {"success": "", "failure": "", "skipped": "⏭️"}.get(c, "🔄")
lines.append(f" {emoji} #{r.get('id', '?')} {r.get('name', '?')[:30]} - {c}")
return "\n".join(lines)
@staticmethod
def format_pr_status(status_data):
"""格式化 PR 状态为人类可读文本"""
pr_num = status_data["pr_number"]
pr_title = status_data["pr_title"]
overall = status_data["overall_status"]
status_emoji = {
"success": "",
"failing": "🔴",
"flaky": "🟡",
"running": "🔄",
"no_runs": "",
"pr_not_found": "",
"unknown": "",
}.get(overall, "")
pr_url = f"{config.GITEA_URL}/{config.GITEA_REPO}/pulls/{pr_num}"
lines = [
f"**CI 状态:PR #{pr_num}**",
f"标题: [{pr_title}]({pr_url})",
f"状态: {status_emoji} {overall}",
]
runs = status_data["runs"]
if runs:
lines.append(f"\nCI Runs ({len(runs)}):")
for r in runs[:5]:
c = r.get("conclusion", r.get("status", "?"))
emoji = {"success": "", "failure": "", "skipped": "⏭️"}.get(c, "🔄")
run_id = r.get("id", "?")
run_url = f"{config.GITEA_URL}/{config.GITEA_REPO}/actions/runs/{run_id}"
lines.append(f" {emoji} [{r.get('name', '?')[:30]} #{run_id}]({run_url}) - {c}")
return "\n".join(lines)
# ── CLI 入口 ──────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="CI 状态查询")
parser.add_argument("--branch", help="查询指定分支的 CI 状态")
parser.add_argument("--pr", type=int, help="查询指定 PR 的 CI 状态")
parser.add_argument("--run-id", help="查询指定 run 的详情")
parser.add_argument("--detail", action="store_true", help="显示失败详情")
parser.add_argument("--limit", type=int, default=5, help="返回数量限制")
parser.add_argument("--status", help="按状态过滤: success/failure/running")
args = parser.parse_args()
query = CIQuery()
if args.run_id:
if args.detail:
result = query.get_failure_detail(args.run_id)
print(f"Run #{args.run_id} 失败详情:")
print(result["summary"])
if result["failed_jobs"]:
print("\n详细日志尾部:")
for job in result["failed_jobs"]:
print(f"\n--- {job['name']} ---")
print(job["log_tail"][:500] if job["log_tail"] else "无日志")
else:
run = query.gitea.get_run(args.run_id)
if run:
print(f"Run #{args.run_id}: {run.get('name')} - {run.get('conclusion', run.get('status'))}")
print(f"分支: {run.get('head_branch', '?')}")
print(f"触发: {run.get('event', '?')}")
else:
print(f"Run {args.run_id} 不存在")
elif args.pr:
result = query.get_pr_status(args.pr)
print(CIQuery.format_pr_status(result))
elif args.branch:
result = query.get_branch_status(args.branch, limit=args.limit)
print(CIQuery.format_branch_status(result))
elif args.status:
result = query.list_recent_runs(status=args.status, limit=args.limit)
for r in result["runs"]:
print(
f"#{r.get('id')} {r.get('name')[:40]} - {r.get('conclusion', r.get('status'))} ({r.get('head_branch', '?')})"
)
else:
parser.print_help()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
CI 触发模块 - 重新运行失败 job重跑整个 workflow取消 run
支持操作:
- rerun_failed: 重跑失败的 jobs
- rerun_all: 重跑整个 workflow
- cancel: 取消运行中的 run
用法:
python3 scripts/ci/chatops/ci_trigger.py --run-id 456 --action rerun_failed
python3 scripts/ci/chatops/ci_trigger.py --run-id 456 --action rerun_all
python3 scripts/ci/chatops/ci_trigger.py --run-id 456 --action cancel
设计:
- 与飞书机器人 /ci rerun 命令对接
- 操作前自动校验 run 状态避免无效操作
"""
import argparse
import sys
from . import config
from .gitea_client import GiteaClient
class CITrigger:
"""CI 操作触发器"""
def __init__(self, gitea_client=None):
self.gitea = gitea_client or GiteaClient()
# ── 触发操作 ─────────────────────────────────────
def rerun_failed(self, run_id):
"""重跑失败的 jobs
Returns:
dict: {success, message, new_run_id?}
"""
run = self.gitea.get_run(run_id)
if not run:
return {"success": False, "message": f"Run {run_id} 不存在"}
status = run.get("status", "")
if status != "completed":
return {
"success": False,
"message": f"Run {run_id} 当前状态为 {status},仅 completed 状态才能重跑",
}
result = self.gitea.rerun_failed_jobs(run_id)
if result is None:
return {"success": False, "message": "重跑请求失败"}
# Gitea rerun 后返回的 run id 通常不变(复用原 run)
return {
"success": True,
"message": f"已触发重跑失败 jobs: Run #{run_id}",
"run_id": run_id,
"run_url": f"{config.GITEA_URL}/{config.GITEA_REPO}/actions/runs/{run_id}",
}
def rerun_all(self, run_id):
"""重跑整个 workflow run
Returns:
dict: {success, message, run_id, run_url}
"""
run = self.gitea.get_run(run_id)
if not run:
return {"success": False, "message": f"Run {run_id} 不存在"}
status = run.get("status", "")
if status == "running" or status == "pending":
return {
"success": False,
"message": f"Run {run_id} 正在运行中,无需重跑",
}
result = self.gitea.rerun_run(run_id)
if result is None:
return {"success": False, "message": "重跑请求失败"}
return {
"success": True,
"message": f"已触发完整重跑: Run #{run_id}",
"run_id": run_id,
"run_url": f"{config.GITEA_URL}/{config.GITEA_REPO}/actions/runs/{run_id}",
}
def cancel_run(self, run_id):
"""取消运行中的 run
Returns:
dict: {success, message}
"""
run = self.gitea.get_run(run_id)
if not run:
return {"success": False, "message": f"Run {run_id} 不存在"}
status = run.get("status", "")
if status == "completed":
return {
"success": False,
"message": f"Run {run_id} 已完成,无需取消",
}
result = self.gitea.cancel_run(run_id)
if result is None:
return {"success": False, "message": "取消请求失败"}
return {
"success": True,
"message": f"已取消 Run #{run_id}",
"run_id": run_id,
}
def rerun_latest_failed(self, branch="develop", workflow_id=None):
"""重跑指定分支最近一次失败的 run
用于快速恢复场景不需要先查 run_id
"""
runs, _ = self.gitea.list_runs(branch=branch, workflow_id=workflow_id, status="failure", limit=5)
if not runs:
return {"success": False, "message": f"{branch} 分支没有失败的 run"}
latest = runs[0]
run_id = latest.get("id")
return self.rerun_failed(run_id)
# ── CLI 入口 ──────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="CI 触发操作")
parser.add_argument("--run-id", required=True, help="Workflow Run ID")
parser.add_argument(
"--action",
required=True,
choices=["rerun_failed", "rerun_all", "cancel"],
help="操作类型",
)
args = parser.parse_args()
trigger = CITrigger()
if args.action == "rerun_failed":
result = trigger.rerun_failed(args.run_id)
elif args.action == "rerun_all":
result = trigger.rerun_all(args.run_id)
elif args.action == "cancel":
result = trigger.cancel_run(args.run_id)
else:
print(f"未知操作: {args.action}")
return 1
status = "" if result["success"] else ""
print(f"{status} {result['message']}")
if result.get("run_url"):
print(f" {result['run_url']}")
return 0 if result["success"] else 1
if __name__ == "__main__":
sys.exit(main())
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""
ChatOps 配置管理 - 统一从环境变量读取配置不硬编码任何敏感信息
环境变量:
GITEA_URL Gitea 地址 (默认 https://git.xiaoxiajianji.com)
GITEA_REPO 仓库路径 (默认 xiaoxia/xiaoxia-saas)
GITEA_TOKEN Gitea API Token (优先使用)
GITEA_USERNAME Gitea 用户名 (密码认证时)
GITEA_PASSWORD Gitea 密码 (密码认证时)
FEISHU_WEBHOOK_URL 飞书自定义机器人 webhook 地址
FEISHU_APP_ID 飞书应用 App ID (应用机器人模式预留)
FEISHU_APP_SECRET 飞书应用 App Secret (应用机器人模式预留)
CHATOPS_NOTIFY_BRANCHES 触发通知的分支逗号分隔 (默认 main,develop)
CHATOPS_WEBHOOK_PORT webhook 服务监听端口 (默认 8090)
CHATOPS_WEBHOOK_SECRET Gitea webhook 密钥 (校验签名可选)
"""
import os
# ── Gitea 配置 ────────────────────────────────────────
GITEA_URL = os.environ.get("GITEA_URL", "https://git.xiaoxiajianji.com").rstrip("/")
GITEA_REPO = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
GITEA_USERNAME = os.environ.get("GITEA_USERNAME", "")
GITEA_PASSWORD = os.environ.get("GITEA_PASSWORD", "")
# ── 飞书配置 ──────────────────────────────────────────
FEISHU_WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
# ── 通知配置 ──────────────────────────────────────────
NOTIFY_BRANCHES = [b.strip() for b in os.environ.get("CHATOPS_NOTIFY_BRANCHES", "main,develop").split(",") if b.strip()]
# ── Webhook 服务配置 ──────────────────────────────────
CHATOPS_WEBHOOK_PORT = int(os.environ.get("CHATOPS_WEBHOOK_PORT", "8090"))
WEBHOOK_SECRET = os.environ.get("CHATOPS_WEBHOOK_SECRET", "")
# ── 常量 ──────────────────────────────────────────────
PAGE_LIMIT = 50 # Gitea API 每页最大数量
def has_gitea_auth() -> bool:
"""检查是否配置了 Gitea 认证信息"""
if GITEA_TOKEN:
return True
if GITEA_USERNAME and GITEA_PASSWORD:
return True
return False
def has_feishu_webhook() -> bool:
"""检查是否配置了飞书 webhook"""
return bool(FEISHU_WEBHOOK_URL)
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
飞书通知模块 - CI 关键事件推送
支持通知类型:
- branch_failure: main/develop 分支 CI 失败
- branch_recovery: main/develop 分支 CI 从失败恢复绿色恢复
- e2e_failure: E2E 测试失败摘要
- pr_failure: PR CI 失败可选
用法:
# 命令行直接调用(供 CI workflow 使用)
python3 -m scripts.ci.chatops.feishu_notify --mode failure --run-id 12345
python3 scripts/ci/chatops/feishu_notify.py --mode recovery --run-id 12345
# Python 模块调用
from scripts.ci.chatops.feishu_notify import FeishuNotifier
notifier = FeishuNotifier()
notifier.notify_branch_failure(run_id=12345, branch="develop")
设计原则:
1. 通知失败永远不阻断主流程返回 0
2. 卡片信息丰富一键跳转 Gitea 详情页
3. 失败通知包含错误摘要不用点进去就能判断严重程度
"""
import argparse
import json
import sys
import urllib.request
from datetime import datetime
from . import config
from .gitea_client import GiteaClient
class FeishuNotifier:
"""飞书通知发送器"""
def __init__(self, webhook_url=None, gitea_client=None):
self.webhook_url = webhook_url or config.FEISHU_WEBHOOK_URL
self.gitea = gitea_client or GiteaClient()
def _send_card(self, card_payload):
"""发送飞书卡片消息
Returns:
True 表示发送成功飞书返回 code=0
"""
if not self.webhook_url:
print("[INFO] 未配置 FEISHU_WEBHOOK_URL,跳过飞书通知")
return False
payload = {"msg_type": "interactive", "card": card_payload}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
self.webhook_url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
resp_body = resp.read().decode("utf-8")
result = json.loads(resp_body)
if result.get("code", 0) != 0:
print(
f"[WARN] 飞书通知返回错误: {result.get('msg', resp_body)}",
file=sys.stderr,
)
return False
return True
except Exception as e:
print(f"[WARN] 飞书通知发送失败: {e}", file=sys.stderr)
return False
# ── 通知模板 ──────────────────────────────────────
def _run_url(self, run_id):
return f"{config.GITEA_URL}/{config.GITEA_REPO}/actions/runs/{run_id}"
def _pr_url(self, pr_number):
return f"{config.GITEA_URL}/{config.GITEA_REPO}/pulls/{pr_number}"
def notify_branch_failure(self, run_id, branch, run_data=None):
"""main/develop 分支 CI 失败通知
包含: 失败 job 列表错误摘要一键重跑链接
"""
run = run_data or self.gitea.get_run(run_id)
if not run:
print(f"[WARN] 无法获取 run {run_id} 详情", file=sys.stderr)
return False
workflow_name = run.get("name", "Unknown Workflow")
commit_msg = run.get("head_commit", {}).get("message", "未知").splitlines()[0][:60]
commit_sha = run.get("head_sha", "")[:8]
actor = (
run.get("trigger_event", {}).get("actor", {}).get("login", "unknown")
if isinstance(run.get("trigger_event"), dict)
else run.get("actor", "unknown")
)
run_url = self._run_url(run_id)
# 获取失败 job 摘要
failed_jobs = self.gitea.get_failed_jobs_summary(run_id, max_lines_per_job=15)
# 构建失败摘要
failure_summary = ""
if failed_jobs:
job_lines = []
for job in failed_jobs[:3]: # 最多显示 3 个
step_info = f"{job['failed_step']}" if job["failed_step"] else ""
job_lines.append(f"• **{job['name']}**{step_info}")
if job["log_tail"]:
# 取最后 3 行日志
tail_lines = job["log_tail"].strip().splitlines()[-3:]
for line in tail_lines:
clean_line = line.strip()[:100]
if clean_line:
job_lines.append(f" `{clean_line}`")
failure_summary = "\n".join(job_lines)
else:
failure_summary = "(获取失败详情中,点击查看日志)"
# 字段
fields = [
{"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**Workflow**\n{workflow_name}"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit_sha}`"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**触发者**\n{actor}"}},
{"is_short": False, "text": {"tag": "lark_md", "content": f"**提交信息**\n{commit_msg}"}},
{"is_short": False, "text": {"tag": "lark_md", "content": f"**失败详情**\n{failure_summary}"}},
]
card = {
"header": {
"title": {
"tag": "plain_text",
"content": f"❌ CI告警:{branch} 分支构建失败",
},
"status": "red",
},
"elements": [
{"tag": "div", "fields": fields},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看失败日志"},
"url": run_url,
"type": "danger",
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "重跑失败Job"},
"url": f"{run_url}/rerun-failed-jobs",
"type": "default",
},
],
},
],
}
result = self._send_card(card)
print(f"[INFO] 分支失败通知已发送: {branch} run={run_id}")
return result
def notify_branch_recovery(self, run_id, branch, previous_failure_run_id=None):
"""分支 CI 恢复通知(从失败变成功)"""
run = self.gitea.get_run(run_id)
if not run:
print(f"[WARN] 无法获取 run {run_id} 详情", file=sys.stderr)
return False
workflow_name = run.get("name", "Unknown Workflow")
commit_sha = run.get("head_sha", "")[:8]
run_url = self._run_url(run_id)
# 计算恢复耗时
duration_text = "已恢复"
if previous_failure_run_id:
prev_run = self.gitea.get_run(previous_failure_run_id)
if prev_run:
# 简单计算两个 run 的时间差
prev_time = prev_run.get("created_at", "")
cur_time = run.get("created_at", "")
if prev_time and cur_time:
try:
t1 = datetime.fromisoformat(prev_time.replace("Z", "+00:00"))
t2 = datetime.fromisoformat(cur_time.replace("Z", "+00:00"))
diff = (t2 - t1).total_seconds() / 60
duration_text = f"故障时长约 {diff:.0f} 分钟"
except Exception:
pass
fields = [
{"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**Workflow**\n{workflow_name}"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit_sha}`"}},
{"is_short": True, "text": {"tag": "lark_md", "content": "**状态**\n✅ 已恢复"}},
{"is_short": False, "text": {"tag": "lark_md", "content": f"**说明**\n{duration_text}"}},
]
card = {
"header": {
"title": {
"tag": "plain_text",
"content": f"✅ CI通知:{branch} 分支构建已恢复",
},
"status": "green",
},
"elements": [
{"tag": "div", "fields": fields},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看详情"},
"url": run_url,
"type": "primary",
}
],
},
],
}
result = self._send_card(card)
print(f"[INFO] 分支恢复通知已发送: {branch} run={run_id}")
return result
def notify_e2e_failure(self, run_id, branch="develop", pr_number=None):
"""E2E 测试失败摘要通知"""
failed_jobs = self.gitea.get_failed_jobs_summary(run_id, max_lines_per_job=50)
e2e_jobs = [j for j in failed_jobs if "e2e" in j["name"].lower() or "test" in j["name"].lower()]
if not e2e_jobs:
# 没有明确的 e2e job,取所有失败的
e2e_jobs = failed_jobs
run_url = self._run_url(run_id)
title_suffix = f"PR #{pr_number}" if pr_number else f"{branch} 分支"
# 构建失败用例摘要
case_summary = ""
for job in e2e_jobs[:3]:
case_summary += f"**{job['name']}**\n"
if job["log_tail"]:
# 尝试提取 FAIL 行
fail_lines = [
line.strip()
for line in job["log_tail"].splitlines()
if "FAIL" in line or "fail" in line.lower() or "" in line or "" in line
][:5]
if fail_lines:
for line in fail_lines:
case_summary += f"{line[:120]}\n"
else:
tail = job["log_tail"].strip().splitlines()[-5:]
for line in tail:
case_summary += f" `{line.strip()[:100]}`\n"
case_summary += "\n"
if not case_summary:
case_summary = "(点击查看完整测试报告)"
fields = [
{"is_short": True, "text": {"tag": "lark_md", "content": f"**来源**\n{title_suffix}"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**失败Job数**\n{len(e2e_jobs)}"}},
{"is_short": False, "text": {"tag": "lark_md", "content": f"**失败摘要**\n{case_summary}"}},
]
card = {
"header": {
"title": {
"tag": "plain_text",
"content": f"🧪 CI告警:E2E 测试失败 - {title_suffix}",
},
"status": "orange",
},
"elements": [
{"tag": "div", "fields": fields},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看完整报告"},
"url": run_url,
"type": "danger",
}
],
},
],
}
result = self._send_card(card)
print(f"[INFO] E2E失败通知已发送: run={run_id}")
return result
def notify_pr_failure(self, run_id, pr_number, pr_title=""):
"""PR CI 失败通知(轻量版,可选开启)"""
run_url = self._run_url(run_id)
pr_url = self._pr_url(pr_number)
failed_jobs = self.gitea.get_failed_jobs_summary(run_id, max_lines_per_job=10)
failure_names = [j["name"] for j in failed_jobs[:3]]
failure_text = "".join(failure_names) if failure_names else "未知"
fields = [
{
"is_short": False,
"text": {"tag": "lark_md", "content": f"**PR**\n[#{pr_number} {pr_title[:50]}]({pr_url})"},
},
{"is_short": False, "text": {"tag": "lark_md", "content": f"**失败任务**\n{failure_text}"}},
]
card = {
"header": {
"title": {
"tag": "plain_text",
"content": f"⚠️ CI通知:PR #{pr_number} 构建失败",
},
"status": "yellow",
},
"elements": [
{"tag": "div", "fields": fields},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看日志"},
"url": run_url,
"type": "default",
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看PR"},
"url": pr_url,
"type": "default",
},
],
},
],
}
result = self._send_card(card)
print(f"[INFO] PR失败通知已发送: PR #{pr_number} run={run_id}")
return result
# ── CLI 入口 ──────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="飞书 CI 通知")
parser.add_argument(
"--mode",
required=True,
choices=["failure", "recovery", "e2e_failure", "pr_failure"],
help="通知模式",
)
parser.add_argument("--run-id", required=True, help="Workflow Run ID")
parser.add_argument("--branch", default="develop", help="分支名")
parser.add_argument("--pr-number", type=int, help="PR 编号")
parser.add_argument("--pr-title", default="", help="PR 标题")
parser.add_argument("--prev-run-id", help="上一个失败的 run ID(恢复通知用)")
parser.add_argument("--webhook", help="飞书 webhook URL(覆盖环境变量)")
args = parser.parse_args()
notifier = FeishuNotifier(webhook_url=args.webhook)
if args.mode == "failure":
notifier.notify_branch_failure(args.run_id, args.branch)
elif args.mode == "recovery":
notifier.notify_branch_recovery(args.run_id, args.branch, previous_failure_run_id=args.prev_run_id)
elif args.mode == "e2e_failure":
notifier.notify_e2e_failure(args.run_id, branch=args.branch, pr_number=args.pr_number)
elif args.mode == "pr_failure":
notifier.notify_pr_failure(args.run_id, args.pr_number, pr_title=args.pr_title)
return 0
if __name__ == "__main__":
sys.exit(main())
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
Gitea API 客户端封装 - Actions + PR + Webhook 相关接口
基于 urllib 实现无第三方依赖 ci_dashboard.py 风格一致
支持 token basic auth 两种认证方式
"""
import base64
import json
import sys
import urllib.error
import urllib.request
from . import config
class GiteaClient:
"""Gitea API 客户端"""
def __init__(
self,
base_url=None,
repo=None,
token=None,
username=None,
password=None,
):
self.base_url = (base_url or config.GITEA_URL).rstrip("/")
self.repo = repo or config.GITEA_REPO
self.token = token or config.GITEA_TOKEN
self.username = username or config.GITEA_USERNAME
self.password = password or config.GITEA_PASSWORD
self.api_base = f"{self.base_url}/api/v1/repos/{self.repo}"
def _request(self, path, method="GET", data=None):
"""通用 HTTP 请求
Args:
path: API 路径相对于 /api/v1/repos/{repo}/
method: HTTP 方法
data: 请求体dict bytes
Returns:
解析后的 JSON 数据失败返回 None
"""
url = f"{self.api_base}/{path}"
body = None
if data is not None:
if isinstance(data, (dict, list)):
body = json.dumps(data).encode("utf-8")
else:
body = data if isinstance(data, bytes) else str(data).encode()
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
if self.token:
req.add_header("Authorization", f"token {self.token}")
elif self.username and self.password:
auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
req.add_header("Authorization", f"Basic {auth}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = resp.read().decode()
if not resp_body:
return {}
return json.loads(resp_body)
except urllib.error.HTTPError as e:
err_body = ""
try:
err_body = e.read().decode()
except Exception:
pass
print(
f"[WARN] HTTP {e.code}: {url} - {err_body[:200]}",
file=sys.stderr,
)
return None
except Exception as e:
print(f"[WARN] 请求失败 {url}: {e}", file=sys.stderr)
return None
# ── Actions: Workflow Runs ────────────────────────
def list_runs(
self,
status=None,
branch=None,
event=None,
workflow_id=None,
page=1,
limit=config.PAGE_LIMIT,
):
"""获取 workflow runs 列表
Returns:
(runs列表, 总数)
"""
params = []
if status:
params.append(f"status={status}")
if branch:
params.append(f"branch={branch}")
if event:
params.append(f"event={event}")
if workflow_id:
params.append(f"workflow_id={workflow_id}")
params.append(f"page={page}")
params.append(f"limit={limit}")
path = f"actions/runs?{'&'.join(params)}"
data = self._request(path)
if not data:
return [], 0
runs = data.get("workflow_runs", [])
total = data.get("total_count", 0)
return runs, total
def get_run(self, run_id):
"""获取单个 run 详情"""
return self._request(f"actions/runs/{run_id}")
def get_run_jobs(self, run_id):
"""获取 run 的 jobs 列表"""
data = self._request(f"actions/runs/{run_id}/jobs")
if not data:
return []
return data.get("jobs", [])
def get_job_log(self, run_id, job_id):
"""获取 job 日志(纯文本)"""
url = f"{self.api_base}/actions/runs/{run_id}/jobs/{job_id}/logs"
req = urllib.request.Request(url)
if self.token:
req.add_header("Authorization", f"token {self.token}")
elif self.username and self.password:
auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
req.add_header("Authorization", f"Basic {auth}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception as e:
print(f"[WARN] 获取日志失败 job={job_id}: {e}", file=sys.stderr)
return ""
def rerun_run(self, run_id):
"""重新运行整个 workflow run"""
return self._request(f"actions/runs/{run_id}/rerun", method="POST")
def rerun_failed_jobs(self, run_id):
"""重新运行失败的 jobs"""
return self._request(f"actions/runs/{run_id}/rerun-failed-jobs", method="POST")
def cancel_run(self, run_id):
"""取消 run"""
return self._request(f"actions/runs/{run_id}/cancel", method="POST")
# ── Actions: Workflows ────────────────────────────
def list_workflows(self):
"""获取 workflow 列表"""
data = self._request("actions/workflows")
if not data:
return []
return data.get("workflows", [])
def get_workflow(self, workflow_id):
"""获取单个 workflow 详情"""
return self._request(f"actions/workflows/{workflow_id}")
# ── Pull Requests ─────────────────────────────────
def get_pr(self, pr_number):
"""获取 PR 详情"""
return self._request(f"pulls/{pr_number}")
def get_pr_ci_runs(self, pr_number, limit=20):
"""获取 PR 关联的 CI runs(通过 head_sha 查询)"""
pr = self.get_pr(pr_number)
if not pr:
return []
head_sha = pr.get("head", {}).get("sha", "")
if not head_sha:
return []
# 用 head_sha 过滤 runs
runs, _ = self.list_runs(limit=limit)
return [r for r in runs if r.get("head_sha", "") == head_sha]
# ── 便捷方法 ──────────────────────────────────────
def get_latest_run(self, branch, workflow_id=None, status=None):
"""获取指定分支最新的 run"""
runs, _ = self.list_runs(branch=branch, workflow_id=workflow_id, status=status, limit=5)
return runs[0] if runs else None
def get_failed_jobs_summary(self, run_id, max_lines_per_job=30):
"""获取失败 job 的摘要信息(用于通知)
Returns:
list[dict]: 每个失败 job {name, conclusion, failed_step, log_tail}
"""
jobs = self.get_run_jobs(run_id)
if not jobs:
return []
failed = [j for j in jobs if j.get("status") == "completed" and j.get("conclusion") == "failure"]
if not failed:
# 运行中的也返回,方便定位
failed = [j for j in jobs if j.get("status") != "completed"]
result = []
for job in failed[:5]: # 最多取 5 个失败 job
job_id = job.get("id", "")
name = job.get("name", "Unknown")
conclusion = job.get("conclusion", job.get("status", "unknown"))
# 找失败的 step
failed_step = ""
steps = job.get("steps", [])
for step in steps:
if step.get("conclusion") == "failure":
failed_step = step.get("name", "")
break
# 取日志尾部
log_tail = ""
if job_id:
log = self.get_job_log(run_id, job_id)
if log:
lines = log.strip().splitlines()
log_tail = "\n".join(lines[-max_lines_per_job:])
result.append(
{
"name": name,
"conclusion": conclusion,
"failed_step": failed_step,
"log_tail": log_tail,
"job_id": job_id,
}
)
return result
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env python3
"""
Gitea Webhook 接收服务 - FastAPI 实现
功能:
1. 接收 Gitea Actions webhook 事件触发飞书通知
2. 接收飞书机器人回调消息处理 /ci 交互命令
3. 维护简单的状态缓存检测分支恢复等状态变化
部署:
部署到构建服务器监听 8090 端口可配置
Gitea webhook 指向: http://<server>:8090/webhook/gitea
飞书消息回调指向: http://<server>:8090/webhook/feishu
依赖:
fastapi + uvicorn可选未安装时仅模块可用服务不可启动
注意:
本文件为第一版骨架通知逻辑已实现飞书交互命令待后续完善
"""
import hashlib
import hmac
import json
import sys
import threading
import time
from typing import Optional
from . import config
from .gitea_client import GiteaClient
# FastAPI 是可选依赖,未安装时仅导出类不启动服务
try:
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
FASTAPI_AVAILABLE = True
except ImportError:
FASTAPI_AVAILABLE = False
FastAPI = None # type: ignore
# ── 状态缓存 ──────────────────────────────────────────
class StateCache:
"""简单的内存状态缓存,用于检测状态变化
记录每个分支最后一次 run 的状态用于判断:
- 是否从失败变成功恢复通知
- 是否连续失败避免重复告警
"""
def __init__(self, max_entries=100):
self._cache = {} # {branch: {last_status, last_run_id, last_notified_failure}}
self._lock = threading.Lock()
self._max = max_entries
def get(self, key):
with self._lock:
return self._cache.get(key)
def set(self, key, value):
with self._lock:
self._cache[key] = value
# 简单的淘汰策略
if len(self._cache) > self._max:
oldest_key = next(iter(self._cache))
del self._cache[oldest_key]
def check_and_update(self, branch, run_id, conclusion):
"""检查状态变化并更新缓存
Returns:
dict: {is_new_failure, is_recovery, previous_status, previous_run_id}
"""
prev = self.get(branch) or {}
prev_status = prev.get("last_status", "unknown")
prev_run_id = prev.get("last_run_id")
is_new_failure = False
is_recovery = False
if conclusion == "failure" and prev_status != "failure":
is_new_failure = True
if conclusion == "success" and prev_status == "failure":
is_recovery = True
self.set(
branch,
{
"last_status": conclusion,
"last_run_id": run_id,
"last_updated": time.time(),
"last_notified_failure": run_id if is_new_failure else prev.get("last_notified_failure"),
},
)
return {
"is_new_failure": is_new_failure,
"is_recovery": is_recovery,
"previous_status": prev_status,
"previous_run_id": prev_run_id,
}
state_cache = StateCache()
# ── Gitea Webhook 处理 ────────────────────────────────
def verify_gitea_signature(payload: bytes, signature: str) -> bool:
"""校验 Gitea webhook 签名(X-Gitea-Signature
Gitea 使用 HMAC-SHA256 签名格式: sha256=xxx
"""
if not config.WEBHOOK_SECRET:
return True # 未配置密钥则跳过校验
if not signature:
return False
try:
algo, sig_hex = signature.split("=", 1)
if algo != "sha256":
return False
expected = hmac.new(config.WEBHOOK_SECRET.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_hex)
except Exception:
return False
def handle_gitea_webhook(payload: dict, event_type: str) -> dict:
"""处理 Gitea webhook 事件
Args:
payload: webhook 请求体
event_type: X-Gitea-Event
Returns:
dict: {handled, notifications_sent, message}
"""
if event_type != "create" and event_type != "push":
# 我们主要关心 push 和 actions 事件
# Gitea Actions 的 webhook 事件类型可能是 "push" 或专门的 actions 事件
pass
# 尝试提取 run 信息
run_info = _extract_run_info(payload)
if not run_info:
return {"handled": False, "notifications_sent": 0, "message": "非 CI 事件,跳过"}
branch = run_info["branch"]
run_id = run_info["run_id"]
status = run_info["status"]
conclusion = run_info.get("conclusion", "")
# 只处理已完成的 run
if status != "completed":
return {"handled": True, "notifications_sent": 0, "message": f"Run {run_id} 仍在运行中 ({status})"}
# 检查是否在通知分支列表中
if branch not in config.NOTIFY_BRANCHES:
return {
"handled": True,
"notifications_sent": 0,
"message": f"分支 {branch} 不在通知列表中",
}
# 检查状态变化
change_info = state_cache.check_and_update(branch, run_id, conclusion)
notifications = 0
# 延迟导入,避免循环依赖
from .feishu_notify import FeishuNotifier
notifier = FeishuNotifier()
if conclusion == "failure" and change_info["is_new_failure"]:
# 新失败 → 发失败通知
notifier.notify_branch_failure(run_id, branch)
notifications += 1
# 检查是否是 E2E 失败
gitea = GiteaClient()
failed_jobs = gitea.get_failed_jobs_summary(run_id)
has_e2e = any("e2e" in j["name"].lower() for j in failed_jobs)
if has_e2e:
notifier.notify_e2e_failure(run_id, branch=branch)
notifications += 1
elif conclusion == "success" and change_info["is_recovery"]:
# 从失败恢复 → 发恢复通知
prev_run_id = change_info.get("previous_run_id")
notifier.notify_branch_recovery(run_id, branch, previous_failure_run_id=prev_run_id)
notifications += 1
return {
"handled": True,
"notifications_sent": notifications,
"message": f"分支 {branch} run {run_id} {conclusion}",
}
def _extract_run_info(payload: dict) -> Optional[dict]:
"""从 webhook payload 中提取 run 信息
Gitea Actions webhook payload 结构可能不同这里做兼容处理
如果 payload 不是 run 事件返回 None
"""
# 尝试多种可能的结构
if "workflow_run" in payload:
wr = payload["workflow_run"]
return {
"run_id": wr.get("id"),
"branch": wr.get("head_branch", ""),
"status": wr.get("status", ""),
"conclusion": wr.get("conclusion", ""),
"name": wr.get("name", ""),
}
if "action" in payload and "pull_request" in payload:
# PR 事件,暂不处理
return None
if "ref" in payload and "head_commit" in payload:
# push 事件,不是 run 事件
return None
return None
# ── 飞书消息处理 ──────────────────────────────────────
def handle_feishu_message(payload: dict) -> dict:
"""处理飞书机器人回调消息
支持命令:
/ci status [branch] - 查询分支 CI 状态
/ci rerun <run-id> - 重跑失败的 jobs
/ci help - 帮助
注意: 第一版骨架仅解析命令实际执行逻辑待完善
"""
# 飞书消息回调格式
header = payload.get("header", {})
event_type = header.get("event_type", "")
if event_type == "url_verification":
# 飞书 URL 验证
return {"challenge": payload.get("challenge", "")}
if event_type != "im.message.receive_v1":
return {"handled": False, "message": f"非消息事件: {event_type}"}
event = payload.get("event", {})
message = event.get("message", {})
content_str = message.get("content", "{}")
try:
content = json.loads(content_str)
except json.JSONDecodeError:
content = {}
text = content.get("text", "")
if not text:
return {"handled": False, "message": "空消息"}
# 解析命令
text = text.strip()
if not text.startswith("/ci"):
return {"handled": False, "message": "非 CI 命令"}
parts = text.split()
if len(parts) < 2:
return _help_response()
cmd = parts[1].lower()
if cmd == "status":
branch = parts[2] if len(parts) > 2 else "develop"
return _handle_status_command(branch)
elif cmd == "rerun":
if len(parts) < 3:
return {"text": "用法: /ci rerun <run-id> 或 /ci rerun latest [branch]"}
arg = parts[2]
if arg == "latest":
branch = parts[3] if len(parts) > 3 else "develop"
return _handle_rerun_latest(branch)
return _handle_rerun_command(arg)
elif cmd == "help":
return _help_response()
else:
return {"text": f"未知命令: {cmd}\n输入 /ci help 查看帮助"}
def _handle_status_command(branch: str) -> dict:
"""处理 /ci status 命令"""
from .ci_query import CIQuery
query = CIQuery()
result = query.get_branch_status(branch)
reply = CIQuery.format_branch_status(result)
return {"text": reply}
def _handle_rerun_command(run_id: str) -> dict:
"""处理 /ci rerun 命令"""
from .ci_trigger import CITrigger
trigger = CITrigger()
try:
result = trigger.rerun_failed(int(run_id))
except ValueError:
return {"text": f"无效的 run id: {run_id}"}
if result["success"]:
return {"text": f"{result['message']}\n{result.get('run_url', '')}"}
return {"text": f"{result['message']}"}
def _handle_rerun_latest(branch: str) -> dict:
"""处理 /ci rerun latest 命令"""
from .ci_trigger import CITrigger
trigger = CITrigger()
result = trigger.rerun_latest_failed(branch=branch)
if result["success"]:
return {"text": f"{result['message']}\n{result.get('run_url', '')}"}
return {"text": f"{result['message']}"}
def _help_response() -> dict:
"""返回帮助信息"""
help_text = """**CI ChatOps 命令帮助**
`/ci status [branch]` 查询分支 CI 状态默认 develop
`/ci rerun <run-id>` 重跑指定 run 的失败 jobs
`/ci rerun latest [branch]` 重跑分支最近一次失败的 run
`/ci help` 显示此帮助
**环境变量配置:**
`GITEA_TOKEN` / `GITEA_USERNAME + GITEA_PASSWORD`
`FEISHU_WEBHOOK_URL`
`CHATOPS_NOTIFY_BRANCHES=main,develop`
"""
return {"text": help_text}
# ── FastAPI 应用 ──────────────────────────────────────
def create_app():
"""创建 FastAPI 应用
如果 FastAPI 未安装返回 None
"""
if not FASTAPI_AVAILABLE:
print(
"[WARN] FastAPI 未安装,无法启动 webhook 服务。" " 请运行: pip install fastapi uvicorn",
file=sys.stderr,
)
return None
app = FastAPI(title="CI ChatOps Webhook", version="0.1.0")
@app.post("/webhook/gitea")
async def gitea_webhook(
request: Request,
x_gitea_event: str = Header(default=""),
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
# 签名校验
if not verify_gitea_signature(body, x_gitea_signature):
raise HTTPException(status_code=401, detail="Invalid signature")
try:
payload = json.loads(body.decode())
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail="Invalid JSON") from e
result = handle_gitea_webhook(payload, x_gitea_event)
return JSONResponse(content=result)
@app.post("/webhook/feishu")
async def feishu_webhook(request: Request):
body = await request.body()
try:
payload = json.loads(body.decode())
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail="Invalid JSON") from e
result = handle_feishu_message(payload)
return JSONResponse(content=result)
@app.get("/health")
async def health():
return {"status": "ok", "service": "ci-chatops"}
return app
# ── CLI 入口 ──────────────────────────────────────────
def main():
"""启动 webhook 服务"""
import argparse
parser = argparse.ArgumentParser(description="CI ChatOps Webhook 服务")
parser.add_argument("--port", type=int, default=config.CHATOPS_WEBHOOK_PORT, help="监听端口")
parser.add_argument("--host", default="0.0.0.0", help="监听地址")
args = parser.parse_args()
app = create_app()
if not app:
print("[ERROR] FastAPI 不可用,请先安装: pip install fastapi uvicorn")
return 1
try:
import uvicorn
except ImportError:
print("[ERROR] uvicorn 未安装,请先安装: pip install uvicorn")
return 1
print(f"[INFO] CI ChatOps Webhook 服务启动: http://{args.host}:{args.port}")
print("[INFO] Gitea webhook: POST /webhook/gitea")
print("[INFO] 飞书 webhook: POST /webhook/feishu")
print("[INFO] 健康检查: GET /health")
print(f"[INFO] 通知分支: {', '.join(config.NOTIFY_BRANCHES)}")
uvicorn.run(app, host=args.host, port=args.port)
return 0
if __name__ == "__main__":
sys.exit(main())
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
检查 Alembic migration 编号连续性
扫描 alembic/versions/ 下所有 migration 文件提取 revision down_revision
验证整条链是否完整每个 down_revision除了 baseline None都必须对应一个存在的 revision
支持两种格式:
revision: str = "001" # 旧格式(带类型注解)
revision = "038_error_retry" # 新格式(带描述后缀)
匹配策略提取 revision 名称的数字前缀 "001""038"作为唯一标识进行匹配
兼容纯数字编号和"数字_描述"两种命名风格
用法:
python3 scripts/ci/check_migration_chain.py [alembic_versions_dir]
默认目录: alembic/versions/
退出码:
0 - 链完整
1 - 有断链或其他错误
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# 匹配 revision / down_revision,支持带类型注解和不带类型注解两种格式
# revision: str = "xxx" 或 revision = "xxx"
REV_PATTERN = re.compile(
r'^\s*revision\s*(?::\s*str\s*)?=\s*["\']([^"\']+)["\']',
re.MULTILINE,
)
DOWN_PATTERN = re.compile(
r'^\s*down_revision\s*(?::\s*(?:Union\[str,\s*None\]|str\s*\|\s*None|None|str)\s*)?=\s*(["\']([^"\']+)["\']|None)',
re.MULTILINE,
)
# 提取 revision 名称的数字前缀,如 "001" 或 "038_error_retry" → "038"
NUM_PREFIX_PATTERN = re.compile(r"^(\d+)")
def num_prefix(name: str) -> str:
"""提取 revision 名称的数字前缀。"""
m = NUM_PREFIX_PATTERN.match(name)
return m.group(1) if m else name
def extract_migration_info(filepath: Path) -> tuple[str, str | None]:
"""从 migration 文件中提取 revision 和 down_revision(返回完整名称)。"""
content = filepath.read_text(encoding="utf-8")
rev_match = REV_PATTERN.search(content)
down_match = DOWN_PATTERN.search(content)
if not rev_match:
raise ValueError(f"{filepath.name}: 未找到 revision 定义")
revision = rev_match.group(1)
if not down_match:
raise ValueError(f"{filepath.name}: 未找到 down_revision 定义")
# down_match group(2) 是引号内的值,如果是 None 则 group(2) 为 None
down_revision = down_match.group(2)
return revision, down_revision
def check_chain(versions_dir: Path) -> list[str]:
"""检查 migration 链是否完整,返回错误列表。"""
errors: list[str] = []
if not versions_dir.is_dir():
return [f"目录不存在: {versions_dir}"]
py_files = sorted(versions_dir.glob("*.py"))
if not py_files:
return [f"目录下没有 migration 文件: {versions_dir}"]
# 收集所有 revision(用数字前缀做唯一标识)
revisions_by_num: dict[str, str] = {} # 数字前缀 -> 完整 revision 名
revision_files: dict[str, str] = {} # 数字前缀 -> 文件名
down_revisions: list[tuple[str, str | None]] = [] # (文件名, down_revision 数字前缀或None)
for f in py_files:
if f.name.startswith("__"):
continue
try:
rev, down = extract_migration_info(f)
except ValueError as e:
errors.append(str(e))
continue
rev_num = num_prefix(rev)
if rev_num in revisions_by_num:
errors.append(
f"编号重复: 编号 {rev_num} 同时出现在 "
f"{f.name} (revision={rev}) 和 {revision_files[rev_num]} (revision={revisions_by_num[rev_num]})"
)
else:
revisions_by_num[rev_num] = rev
revision_files[rev_num] = f.name
down_num = num_prefix(down) if down else None
down_revisions.append((f.name, down_num))
if errors:
return errors
# 检查每个 down_revision 是否存在
baselines = 0
for filename, down_num in down_revisions:
if down_num is None:
baselines += 1
continue
if down_num not in revisions_by_num:
errors.append(
f"断链: {filename} 的 down_revision 指向编号 '{down_num}',但没有任何 migration 的 revision 是这个编号"
)
if baselines == 0:
errors.append("没有找到 baseline migrationdown_revision = None 的文件)")
elif baselines > 1:
errors.append(f"发现 {baselines} 个 baseline migration,通常只能有 1 个")
# 额外检查:数字编号是否连续(只对能提取出数字的)
if revisions_by_num and not errors:
nums = sorted(int(n) for n in revisions_by_num if n.isdigit())
if nums:
expected = list(range(nums[0], nums[-1] + 1))
missing = [n for n in expected if n not in nums]
if missing:
missing_str = ", ".join(f"{n:03d}" for n in missing)
errors.append(f"编号不连续: 缺少编号 {missing_str}")
return errors
def main() -> int:
if len(sys.argv) > 1:
versions_dir = Path(sys.argv[1])
else:
versions_dir = Path("alembic/versions")
print(f"检查 migration 编号连续性: {versions_dir}")
print()
errors = check_chain(versions_dir)
py_files = [f for f in versions_dir.glob("*.py") if not f.name.startswith("__")]
if errors:
print(f"❌ Migration 链有问题(共 {len(py_files)} 个文件,{len(errors)} 个错误):")
for e in errors:
print(f" - {e}")
print()
print("请修复后再提交。常见原因:")
print(" 1. 新 migration 的 down_revision 编号写错了")
print(" 2. 多个 PR 同时加 migration,编号冲突")
print(" 3. 合并代码时漏了某个 migration 文件")
return 1
print(f"✅ Migration 链完整,共 {len(py_files)} 个版本")
return 0
if __name__ == "__main__":
sys.exit(main())
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
检查 Alembic migration 文件命名规范
规则:
1. 文件名必须以数字前缀开头3位补零 001_xxx.py052_add_table.py
2. 数字前缀必须连续递增 check_migration_chain.py 一致但只看文件名
3. 数字前缀后必须跟有描述性后缀不能只有数字
4. 文件名使用小写+下划线snake_case
5. revision 变量值必须与文件名数字前缀一致可选带描述后缀
用法:
python3 scripts/ci/check_migration_naming.py [alembic_versions_dir]
默认目录: alembic/versions/
退出码:
0 - 全部通过
1 - 有命名违规
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# 文件名格式: 3位数字_描述.py
FILE_NAME_PATTERN = re.compile(r"^(\d{3})_[a-z][a-z0-9_]*\.py$")
# 纯数字文件名(不允许)
PURE_NUM_PATTERN = re.compile(r"^\d{3}\.py$")
# revision 值的数字前缀
REV_NUM_PATTERN = re.compile(r"^(\d{3})")
# revision 变量行
REV_LINE_PATTERN = re.compile(
r'^\s*revision\s*(?::\s*str\s*)?=\s*["\']([^"\']+)["\']',
re.MULTILINE,
)
def check_naming(versions_dir: Path) -> list[str]:
"""检查 migration 文件命名,返回错误列表。"""
errors: list[str] = []
if not versions_dir.is_dir():
return [f"目录不存在: {versions_dir}"]
py_files = sorted(f for f in versions_dir.iterdir() if f.suffix == ".py")
if not py_files:
return [f"目录下没有 migration 文件: {versions_dir}"]
print(f"检查 migration 文件命名: {versions_dir}")
print(f"{len(py_files)} 个文件")
print()
# 1. 文件名格式检查
print("1. 文件名格式检查...")
file_nums: list[int] = []
for f in py_files:
name = f.name
if PURE_NUM_PATTERN.match(name):
errors.append(f"{name}: 只有数字编号,缺少描述性后缀")
continue
m = FILE_NAME_PATTERN.match(name)
if not m:
errors.append(f"{name}: 命名格式不规范,应为 NNN_description.py " f"3位数字前缀+下划线+小写描述)")
continue
file_nums.append(int(m.group(1)))
if not any("命名格式不规范" in e or "缺少描述性后缀" in e for e in errors):
print(f" ✅ 全部 {len(py_files)} 个文件名格式正确")
else:
for e in errors:
if "命名格式不规范" in e or "缺少描述性后缀" in e:
print(e)
# 2. 编号连续性检查(基于文件名数字前缀)
print()
print("2. 编号连续性检查...")
if file_nums:
expected = set(range(min(file_nums), max(file_nums) + 1))
actual = set(file_nums)
missing = sorted(expected - actual)
if missing:
errors.append(f" ❌ 编号不连续,缺少: {', '.join(f'{n:03d}' for n in missing)}")
print(f" ❌ 编号不连续,缺少 {len(missing)} 个: " f"{', '.join(f'{n:03d}' for n in missing)}")
else:
print(f" ✅ 编号连续({min(file_nums):03d} ~ {max(file_nums):03d}")
# 3. revision 变量与文件名前缀一致性检查
print()
print("3. revision变量与文件名一致性检查...")
rev_mismatch = 0
for f in py_files:
m = FILE_NAME_PATTERN.match(f.name)
if not m:
continue # 格式不对的已经报过了
file_num = m.group(1)
content = f.read_text(encoding="utf-8")
rev_match = REV_LINE_PATTERN.search(content)
if not rev_match:
errors.append(f"{f.name}: 未找到 revision 变量定义")
rev_mismatch += 1
continue
rev_value = rev_match.group(1)
rev_num_match = REV_NUM_PATTERN.match(rev_value)
if not rev_num_match or rev_num_match.group(1) != file_num:
errors.append(f"{f.name}: revision='{rev_value}' 与文件名前缀 {file_num} 不一致")
rev_mismatch += 1
if rev_mismatch == 0:
print(f" ✅ 全部 {len(py_files)} 个文件的 revision 与文件名一致")
return errors
def main() -> int:
versions_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("alembic/versions")
errors = check_naming(versions_dir)
print()
if errors:
print(f"❌ 发现 {len(errors)} 个命名问题")
print()
print("命名规范:")
print(" - 文件名格式: NNN_description.py3位数字前缀 + 下划线 + 小写描述)")
print(" - 编号必须连续,不能跳号")
print(" - revision 变量的数字前缀必须与文件名一致")
return 1
print("✅ 所有 migration 文件命名规范检查通过")
return 0
if __name__ == "__main__":
sys.exit(main())
+973
View File
@@ -0,0 +1,973 @@
#!/usr/bin/env python3
"""
CI 可观测性看板 - Gitea Actions API 拉取数据并生成 Markdown/HTML 日报
用法:
python3 scripts/ci/ci_dashboard.py --days 7
python3 scripts/ci/ci_dashboard.py --days 30 --output ci_report.md
python3 scripts/ci/ci_dashboard.py --workflow ci-cd.yml --days 7
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output dashboard.html
环境变量:
GITEA_URL Gitea 地址 (默认 https://git.xiaoxiajianji.com)
GITEA_REPO 仓库 (默认 xiaoxia/xiaoxia-saas)
GITEA_TOKEN API Token (优先) GITEA_USERNAME + GITEA_PASSWORD
"""
import argparse
import base64
import json
import math
import os
import statistics
import sys
import urllib.error
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone
# ── 配置 ──────────────────────────────────────────────
DEFAULT_GITEA_URL = "https://git.xiaoxiajianji.com"
DEFAULT_REPO = "xiaoxia/xiaoxia-saas"
DEFAULT_DAYS = 7
PAGE_LIMIT = 50 # 每页数量,最大50
# ── API 封装 ─────────────────────────────────────────
class GiteaActions:
def __init__(self, base_url, repo, token=None, username=None, password=None):
self.base_url = base_url.rstrip("/")
self.repo = repo
self.token = token
self.username = username
self.password = password
self.api_base = f"{self.base_url}/api/v1/repos/{self.repo}/actions"
def _request(self, path):
url = f"{self.api_base}/{path}"
req = urllib.request.Request(url)
if self.token:
req.add_header("Authorization", f"token {self.token}")
elif self.username and self.password:
auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
req.add_header("Authorization", f"Basic {auth}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
print(f"[WARN] HTTP {e.code}: {url}", file=sys.stderr)
return None
except Exception as e:
print(f"[WARN] 请求失败 {url}: {e}", file=sys.stderr)
return None
def list_runs(self, status=None, branch=None, event=None, page=1, limit=PAGE_LIMIT):
"""获取 workflow runs 列表"""
params = []
if status:
params.append(f"status={status}")
if branch:
params.append(f"branch={branch}")
if event:
params.append(f"event={event}")
params.append(f"page={page}")
params.append(f"limit={limit}")
path = f"runs?{'&'.join(params)}"
data = self._request(path)
if not data:
return [], 0
runs = data.get("workflow_runs", [])
total = data.get("total_count", 0)
return runs, total
def get_run_jobs(self, run_id):
"""获取 run 的所有 job"""
data = self._request(f"runs/{run_id}/jobs")
if not data:
return []
return data.get("jobs", [])
def list_workflows(self):
"""获取所有 workflow"""
data = self._request("workflows")
if not data:
return []
return data.get("workflows", [])
# ── 工具函数 ─────────────────────────────────────────
def parse_datetime(s):
"""解析 ISO 格式时间字符串"""
if not s or s.startswith("1970") or s.startswith("0001"):
return None
try:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return datetime.fromisoformat(s)
except Exception:
return None
def to_shanghai(dt):
"""转换为上海时区"""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone(timedelta(hours=8)))
def duration_seconds(start_str, end_str):
"""计算耗时(秒)"""
start = parse_datetime(start_str)
end = parse_datetime(end_str)
if not start or not end:
return None
return (end - start).total_seconds()
def fmt_duration(seconds):
"""格式化耗时显示"""
if seconds is None:
return "N/A"
seconds = int(seconds)
if seconds < 60:
return f"{seconds}s"
mins, secs = divmod(seconds, 60)
if mins < 60:
return f"{mins}m{secs:02d}s"
hours, mins = divmod(mins, 60)
return f"{hours}h{mins:02d}m"
def percentile(sorted_values, p):
"""计算百分位数"""
if not sorted_values:
return None
k = (len(sorted_values) - 1) * (p / 100)
f = math.floor(k)
c = math.ceil(k)
if f == c:
return sorted_values[int(k)]
return sorted_values[f] * (c - k) + sorted_values[c] * (k - f)
def classify_failure(job_name, step_name=None):
"""根据失败的 job/step 名称分类失败原因"""
name = f"{job_name} {step_name or ''}".lower()
if any(k in name for k in ["lint", "ruff", "flake8", "eslint", "prettier", "black", "mypy"]):
return "代码质量 / Lint"
if any(k in name for k in ["unit test", "pytest", "vitest", "jest"]):
return "单元测试失败"
if any(k in name for k in ["integration", "e2e"]):
return "集成测试 / E2E"
if any(k in name for k in ["build", "compile", "docker", "image"]):
return "构建失败"
if any(k in name for k in ["deploy", "preview", "release"]):
return "部署失败"
if any(k in name for k in ["setup", "checkout", "cache", "install", "deps"]):
return "环境 / 依赖"
if any(k in name for k in ["migrate", "migration", "schema"]):
return "数据库迁移"
return "其他"
# ── 数据收集 ─────────────────────────────────────────
def fetch_runs_in_range(ga, start_date, end_date, workflow_filter=None):
"""拉取指定日期范围内的所有 completed runs"""
all_runs = []
page = 1
print(f"[INFO] 拉取 {start_date} ~ {end_date} 的 CI runs...", file=sys.stderr)
while True:
runs, total = ga.list_runs(status="completed", page=page, limit=PAGE_LIMIT)
if not runs:
break
if workflow_filter:
runs = [r for r in runs if workflow_filter in r.get("path", "")]
in_range = []
out_range_old = False
for run in runs:
started = to_shanghai(parse_datetime(run.get("started_at")))
if not started:
continue
run_date = started.date()
if start_date <= run_date <= end_date:
in_range.append(run)
elif run_date < start_date:
out_range_old = True
all_runs.extend(in_range)
print(
f"[INFO] 第 {page} 页: {len(runs)} 条, 范围内 {len(in_range)} 条, 累计 {len(all_runs)}", file=sys.stderr
)
if out_range_old or len(runs) < PAGE_LIMIT:
break
page += 1
if page > 100:
print("[WARN] 超过100页,停止拉取", file=sys.stderr)
break
print(f"[INFO] 共获取 {len(all_runs)} 条 run 数据", file=sys.stderr)
return all_runs
def enrich_with_jobs(ga, runs, max_failures=50):
"""为 runs 补充 job 详情(失败原因分析 + runner 统计)
失败 run 按时间倒序取最近 N 避免 API 调用过多
成功 run 采样用于 runner 分布统计
"""
# 失败 run 取最近 N 个
failure_runs = [r for r in runs if r.get("conclusion") != "success"]
failure_runs = failure_runs[:max_failures] # 已经是时间倒序
print(f"[INFO] 为最近 {len(failure_runs)} 个失败 run 拉取 job 详情...", file=sys.stderr)
for i, run in enumerate(failure_runs):
jobs = ga.get_run_jobs(run["id"])
run["_jobs"] = jobs
if (i + 1) % 10 == 0:
print(f"[INFO] 已处理 {i+1}/{len(failure_runs)}", file=sys.stderr)
# 成功 run 采样用于 runner 分布
success_runs = [r for r in runs if r.get("conclusion") == "success"]
sample_size = min(50, len(success_runs))
if sample_size > 0:
sampled = success_runs[:: max(1, len(success_runs) // sample_size)]
print(f"[INFO] 采样 {len(sampled)} 个成功 run 用于 runner 统计...", file=sys.stderr)
for run in sampled:
if "_jobs" not in run:
jobs = ga.get_run_jobs(run["id"])
run["_jobs"] = jobs
return runs
# ── 统计分析 ─────────────────────────────────────────
def analyze_runs(runs):
"""对 runs 做全面统计分析"""
if not runs:
return {}
# 基础统计
total = len(runs)
success = sum(1 for r in runs if r.get("conclusion") == "success")
failure = sum(1 for r in runs if r.get("conclusion") == "failure")
cancelled = sum(1 for r in runs if r.get("conclusion") == "cancelled")
other = total - success - failure - cancelled
success_rate = (success / total * 100) if total > 0 else 0
# 耗时统计
durations = []
for r in runs:
d = duration_seconds(r.get("started_at"), r.get("completed_at"))
if d and d > 0:
durations.append(d)
durations.sort()
avg_dur = statistics.mean(durations) if durations else None
median_dur = percentile(durations, 50)
p95_dur = percentile(durations, 95)
# 按日期统计
daily_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0, "durations": []})
for r in runs:
started = to_shanghai(parse_datetime(r.get("started_at")))
if not started:
continue
day = started.date().isoformat()
daily_stats[day]["total"] += 1
if r.get("conclusion") == "success":
daily_stats[day]["success"] += 1
elif r.get("conclusion") == "failure":
daily_stats[day]["failure"] += 1
d = duration_seconds(r.get("started_at"), r.get("completed_at"))
if d and d > 0:
daily_stats[day]["durations"].append(d)
# 按 workflow 统计
wf_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0, "durations": []})
for r in runs:
path = r.get("path", "")
wf_name = path.split("@")[0] if "@" in path else path
wf_stats[wf_name]["total"] += 1
if r.get("conclusion") == "success":
wf_stats[wf_name]["success"] += 1
elif r.get("conclusion") == "failure":
wf_stats[wf_name]["failure"] += 1
d = duration_seconds(r.get("started_at"), r.get("completed_at"))
if d and d > 0:
wf_stats[wf_name]["durations"].append(d)
# 按触发事件统计
event_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0})
for r in runs:
evt = r.get("event", "unknown")
event_stats[evt]["total"] += 1
if r.get("conclusion") == "success":
event_stats[evt]["success"] += 1
elif r.get("conclusion") == "failure":
event_stats[evt]["failure"] += 1
# 失败原因 + runner + job 耗时(需要 _jobs 数据)
failure_categories = defaultdict(int)
failed_jobs_by_name = defaultdict(int)
job_success_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0})
runner_stats = defaultdict(lambda: {"jobs": 0, "success": 0, "failure": 0, "durations": []})
job_time_stats = defaultdict(list)
infra_failures = 0
business_failures = 0
other_failures_count = 0
# 基础设施关键词(与 ci_health_check.py 保持一致的分类逻辑)
infra_job_keywords = ["checkout", "build", "deploy", "cleanup", "setup", "cache", "install", "docker"]
business_job_keywords = [
"unit test",
"pytest",
"vitest",
"jest",
"lint",
"eslint",
"prettier",
"integration",
"e2e",
"validate",
"code quality",
"mypy",
"ruff",
"flake8",
]
for r in runs:
jobs = r.get("_jobs", [])
if not jobs:
continue
for job in jobs:
runner = job.get("runner_name", "unknown")
conclusion = job.get("conclusion", "unknown")
job_name = job.get("name", "unknown")
job_name_lower = job_name.lower()
runner_stats[runner]["jobs"] += 1
job_success_stats[job_name]["total"] += 1
if conclusion == "success":
runner_stats[runner]["success"] += 1
job_success_stats[job_name]["success"] += 1
elif conclusion == "failure":
runner_stats[runner]["failure"] += 1
job_success_stats[job_name]["failure"] += 1
jd = duration_seconds(job.get("started_at"), job.get("completed_at"))
if jd and jd > 0:
runner_stats[runner]["durations"].append(jd)
job_time_stats[job_name].append(jd)
if conclusion == "failure":
failed_jobs_by_name[job_name] += 1
failed_step = None
for step in job.get("steps", []):
if step.get("conclusion") == "failure":
failed_step = step.get("name")
break
category = classify_failure(job_name, failed_step)
failure_categories[category] += 1
# 基础设施 vs 业务代码分类
is_infra = any(k in job_name_lower for k in infra_job_keywords) and not any(
k in job_name_lower for k in business_job_keywords
)
is_business = any(k in job_name_lower for k in business_job_keywords)
if is_infra:
infra_failures += 1
elif is_business:
business_failures += 1
else:
other_failures_count += 1
return {
"total": total,
"success": success,
"failure": failure,
"cancelled": cancelled,
"other": other,
"success_rate": success_rate,
"avg_duration": avg_dur,
"median_duration": median_dur,
"p95_duration": p95_dur,
"durations": durations,
"daily_stats": dict(sorted(daily_stats.items())),
"workflow_stats": dict(wf_stats),
"event_stats": dict(event_stats),
"failure_categories": dict(failure_categories),
"failed_jobs_top": dict(sorted(failed_jobs_by_name.items(), key=lambda x: -x[1])[:15]),
"runner_stats": dict(runner_stats),
"job_time_stats": dict(job_time_stats),
"job_success_stats": dict(job_success_stats),
"infra_failures": infra_failures,
"business_failures": business_failures,
"other_failures_combined": other_failures_count,
}
# ── Markdown 报表生成 ────────────────────────────────
def generate_markdown(stats, start_date, end_date, repo):
"""生成 Markdown 格式的日报"""
lines = []
lines.append("# CI 运行状态看板")
lines.append("")
lines.append(f"> 统计周期: **{start_date} ~ {end_date}**")
lines.append(f"> 仓库: `{repo}`")
lines.append(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
# 概览
lines.append("## 📊 整体概览")
lines.append("")
lines.append("| 指标 | 数值 |")
lines.append("|------|------|")
lines.append(f"| 总构建次数 | **{stats['total']}** |")
lines.append(f"| ✅ 成功 | {stats['success']} |")
lines.append(f"| ❌ 失败 | {stats['failure']} |")
lines.append(f"| ⏹️ 取消 | {stats['cancelled']} |")
lines.append(f"| 📈 成功率 | **{stats['success_rate']:.1f}%** |")
lines.append(f"| ⏱️ 平均耗时 | {fmt_duration(stats['avg_duration'])} |")
lines.append(f"| ⏱️ P50 耗时 | {fmt_duration(stats['median_duration'])} |")
lines.append(f"| ⏱️ P95 耗时 | {fmt_duration(stats['p95_duration'])} |")
lines.append("")
# 每日趋势
lines.append("## 📈 每日趋势")
lines.append("")
lines.append("| 日期 | 总次数 | 成功 | 失败 | 成功率 | 平均耗时 | P95 耗时 |")
lines.append("|------|--------|------|------|--------|----------|----------|")
for day, s in stats["daily_stats"].items():
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
durations = sorted(s["durations"])
avg = statistics.mean(durations) if durations else None
p95 = percentile(durations, 95) if durations else None
lines.append(
f"| {day} | {s['total']} | {s['success']} | {s['failure']} | {rate:.1f}% | {fmt_duration(avg)} | {fmt_duration(p95)} |"
)
lines.append("")
# 成功率趋势图
lines.append("### 成功率趋势图")
lines.append("")
lines.append("```")
max_bar = 40
days = list(stats["daily_stats"].keys())
if len(days) > 14:
days = days[-14:]
for day in days:
s = stats["daily_stats"][day]
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
bar_len = int(rate / 100 * max_bar)
bar = "" * bar_len + "" * (max_bar - bar_len)
lines.append(f"{day} {bar} {rate:5.1f}% ({s['total']}次)")
lines.append("```")
lines.append("")
# 按 Workflow 统计
lines.append("## 🧩 各 Workflow 统计")
lines.append("")
wf_sorted = sorted(stats["workflow_stats"].items(), key=lambda x: -x[1]["total"])
lines.append("| Workflow | 次数 | 成功 | 失败 | 成功率 | 平均耗时 | P95 耗时 |")
lines.append("|----------|------|------|------|--------|----------|----------|")
for wf, s in wf_sorted:
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
durations = sorted(s["durations"])
avg = statistics.mean(durations) if durations else None
p95 = percentile(durations, 95) if durations else None
wf_short = wf.split("/")[-1] if "/" in wf else wf
lines.append(
f"| `{wf_short}` | {s['total']} | {s['success']} | {s['failure']} | {rate:.1f}% | {fmt_duration(avg)} | {fmt_duration(p95)} |"
)
lines.append("")
# 失败原因分析
if stats["failure_categories"]:
lines.append("## ❌ 失败原因分析")
lines.append("")
lines.append("> ⚠️ 基于最近 N 个失败 run 采样分析,用于趋势参考")
lines.append("")
lines.append("### 按分类统计")
lines.append("")
total_failures = sum(stats["failure_categories"].values())
fc_sorted = sorted(stats["failure_categories"].items(), key=lambda x: -x[1])
lines.append("| 分类 | 次数 | 占比 |")
lines.append("|------|------|------|")
for cat, cnt in fc_sorted:
pct = (cnt / total_failures * 100) if total_failures > 0 else 0
lines.append(f"| {cat} | {cnt} | {pct:.1f}% |")
lines.append("")
lines.append("### Top 失败 Job")
lines.append("")
lines.append("| Job 名称 | 失败次数 |")
lines.append("|----------|----------|")
for job, cnt in stats["failed_jobs_top"].items():
lines.append(f"| `{job}` | {cnt} |")
lines.append("")
# Runner 利用率
if stats["runner_stats"]:
lines.append("## 🏃 Runner 利用率")
lines.append("")
runner_sorted = sorted(stats["runner_stats"].items(), key=lambda x: -x[1]["jobs"])
lines.append("| Runner | Job 数 | 成功 | 失败 | 成功率 | 平均耗时 |")
lines.append("|--------|--------|------|------|--------|----------|")
for runner, s in runner_sorted:
rate = (s["success"] / s["jobs"] * 100) if s["jobs"] > 0 else 0
avg = statistics.mean(s["durations"]) if s["durations"] else None
lines.append(
f"| `{runner}` | {s['jobs']} | {s['success']} | {s['failure']} | {rate:.1f}% | {fmt_duration(avg)} |"
)
lines.append("")
# Job 耗时排行
if stats["job_time_stats"]:
lines.append("## ⏱️ Job 耗时排行 (Top 20 by P95)")
lines.append("")
job_stats = []
for name, durs in stats["job_time_stats"].items():
if not durs:
continue
durs_sorted = sorted(durs)
job_stats.append(
{
"name": name,
"count": len(durs_sorted),
"avg": statistics.mean(durs_sorted),
"p50": percentile(durs_sorted, 50),
"p95": percentile(durs_sorted, 95),
}
)
job_stats.sort(key=lambda x: -x["p95"])
top_n = min(20, len(job_stats))
lines.append("| Job 名称 | 次数 | 平均 | P50 | P95 |")
lines.append("|----------|------|------|-----|-----|")
for j in job_stats[:top_n]:
lines.append(
f"| `{j['name']}` | {j['count']} | {fmt_duration(j['avg'])} | {fmt_duration(j['p50'])} | {fmt_duration(j['p95'])} |"
)
lines.append("")
# 触发事件分布
lines.append("## 📋 触发事件分布")
lines.append("")
evt_sorted = sorted(stats["event_stats"].items(), key=lambda x: -x[1]["total"])
lines.append("| 事件类型 | 次数 | 成功 | 失败 | 成功率 |")
lines.append("|----------|------|------|------|--------|")
for evt, s in evt_sorted:
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
lines.append(f"| `{evt}` | {s['total']} | {s['success']} | {s['failure']} | {rate:.1f}% |")
lines.append("")
return "\n".join(lines)
# ── HTML 看板生成 ────────────────────────────────────
def generate_html(stats, start_date, end_date, repo):
"""生成 HTML 格式的可视化看板(内嵌 ECharts)"""
# 准备图表数据
# 1. 每日成功率趋势
daily_dates = list(stats["daily_stats"].keys())
daily_success_rates = []
daily_run_counts = []
for day in daily_dates:
s = stats["daily_stats"][day]
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
daily_success_rates.append(round(rate, 1))
daily_run_counts.append(s["total"])
# 2. 各 Workflow 耗时对比
wf_sorted = sorted(stats["workflow_stats"].items(), key=lambda x: -x[1]["total"])
wf_names = []
wf_avg_durations = []
for wf, s in wf_sorted:
wf_short = wf.split("/")[-1] if "/" in wf else wf
wf_names.append(wf_short)
avg = statistics.mean(s["durations"]) if s["durations"] else 0
wf_avg_durations.append(round(avg / 60, 1)) # 转为分钟
# 3. 失败原因分布(饼图数据 - 基础设施 vs 业务 vs 其他)
total_infra_biz = stats["infra_failures"] + stats["business_failures"] + stats["other_failures_combined"]
infra_rate = (stats["infra_failures"] / total_infra_biz * 100) if total_infra_biz > 0 else 0
failure_pie_data = [
{"value": stats["infra_failures"], "name": "基础设施问题"},
{"value": stats["business_failures"], "name": "业务代码问题"},
{"value": stats["other_failures_combined"], "name": "其他"},
]
# 4. 各 Job 成功率排行(横向柱状图,取成功率最低的 Top 15)
job_stats_list = []
for name, s in stats["job_success_stats"].items():
if s["total"] >= 3: # 至少有3次才统计
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
job_stats_list.append(
{
"name": name,
"rate": round(rate, 1),
"total": s["total"],
"success": s["success"],
}
)
job_stats_list.sort(key=lambda x: x["rate"])
job_stats_list = job_stats_list[:15] # 取成功率最低的15个
job_names = [j["name"] for j in job_stats_list]
job_rates = [j["rate"] for j in job_stats_list]
# 核心指标
total_runs = stats["total"]
success_rate = round(stats["success_rate"], 1)
avg_dur_min = round(stats["avg_duration"] / 60, 1) if stats["avg_duration"] else 0
infra_fail_rate = round(infra_rate, 1)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 序列化数据为 JSON(供 JS 使用)
data_json = json.dumps(
{
"daily_dates": daily_dates,
"daily_success_rates": daily_success_rates,
"daily_run_counts": daily_run_counts,
"wf_names": wf_names,
"wf_avg_durations": wf_avg_durations,
"failure_pie_data": failure_pie_data,
"job_names": job_names,
"job_rates": job_rates,
},
ensure_ascii=False,
)
# HTML 模板(注意:不使用 f-string,避免与 CSS/JS 的大括号冲突)
html_parts = []
html_parts.append("<!DOCTYPE html>")
html_parts.append('<html lang="zh-CN">')
html_parts.append("<head>")
html_parts.append(' <meta charset="UTF-8">')
html_parts.append(' <meta name="viewport" content="width=device-width, initial-scale=1.0">')
html_parts.append(f" <title>CI 健康度看板 - {repo}</title>")
html_parts.append(' <script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>')
html_parts.append(" <style>")
html_parts.append(" * { margin: 0; padding: 0; box-sizing: border-box; }")
html_parts.append(" body {")
html_parts.append(
' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",'
)
html_parts.append(' "Microsoft YaHei", sans-serif;')
html_parts.append(" background: #f0f2f5;")
html_parts.append(" color: #333;")
html_parts.append(" padding: 20px;")
html_parts.append(" }")
html_parts.append(" .container { max-width: 1400px; margin: 0 auto; }")
html_parts.append(" .header {")
html_parts.append(" background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);")
html_parts.append(" color: white;")
html_parts.append(" padding: 24px 32px;")
html_parts.append(" border-radius: 12px;")
html_parts.append(" margin-bottom: 20px;")
html_parts.append(" }")
html_parts.append(" .header h1 { font-size: 24px; margin-bottom: 8px; }")
html_parts.append(" .header .subtitle { font-size: 14px; opacity: 0.9; }")
html_parts.append(" .header .meta { font-size: 12px; opacity: 0.8; margin-top: 8px; }")
html_parts.append(" .metrics-row {")
html_parts.append(" display: grid;")
html_parts.append(" grid-template-columns: repeat(4, 1fr);")
html_parts.append(" gap: 16px;")
html_parts.append(" margin-bottom: 20px;")
html_parts.append(" }")
html_parts.append(" .metric-card {")
html_parts.append(" background: white;")
html_parts.append(" border-radius: 12px;")
html_parts.append(" padding: 20px;")
html_parts.append(" box-shadow: 0 2px 8px rgba(0,0,0,0.06);")
html_parts.append(" transition: transform 0.2s;")
html_parts.append(" }")
html_parts.append(
" .metric-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }"
)
html_parts.append(" .metric-card .label { font-size: 13px; color: #8c8c8c; margin-bottom: 8px; }")
html_parts.append(" .metric-card .value { font-size: 28px; font-weight: 600; }")
html_parts.append(" .metric-card .unit { font-size: 14px; color: #8c8c8c; margin-left: 4px; }")
html_parts.append(" .metric-card.success .value { color: #52c41a; }")
html_parts.append(" .metric-card.warning .value { color: #faad14; }")
html_parts.append(" .metric-card.danger .value { color: #ff4d4f; }")
html_parts.append(" .metric-card.info .value { color: #1890ff; }")
html_parts.append(" .charts-grid {")
html_parts.append(" display: grid;")
html_parts.append(" grid-template-columns: 1fr 1fr;")
html_parts.append(" gap: 16px;")
html_parts.append(" margin-bottom: 20px;")
html_parts.append(" }")
html_parts.append(" .chart-card {")
html_parts.append(" background: white;")
html_parts.append(" border-radius: 12px;")
html_parts.append(" padding: 20px;")
html_parts.append(" box-shadow: 0 2px 8px rgba(0,0,0,0.06);")
html_parts.append(" }")
html_parts.append(" .chart-card.full-width { grid-column: 1 / -1; }")
html_parts.append(" .chart-card h3 {")
html_parts.append(" font-size: 16px;")
html_parts.append(" margin-bottom: 12px;")
html_parts.append(" color: #262626;")
html_parts.append(" font-weight: 600;")
html_parts.append(" }")
html_parts.append(" .chart-container { width: 100%; height: 320px; }")
html_parts.append(" .chart-container.tall { height: 400px; }")
html_parts.append(" .footer {")
html_parts.append(" text-align: center;")
html_parts.append(" color: #8c8c8c;")
html_parts.append(" font-size: 12px;")
html_parts.append(" padding: 16px 0;")
html_parts.append(" }")
html_parts.append(" @media (max-width: 900px) {")
html_parts.append(" .metrics-row { grid-template-columns: repeat(2, 1fr); }")
html_parts.append(" .charts-grid { grid-template-columns: 1fr; }")
html_parts.append(" }")
html_parts.append(" @media (max-width: 600px) {")
html_parts.append(" .metrics-row { grid-template-columns: 1fr; }")
html_parts.append(" body { padding: 12px; }")
html_parts.append(" }")
html_parts.append(" </style>")
html_parts.append("</head>")
html_parts.append("<body>")
html_parts.append(' <div class="container">')
html_parts.append(' <div class="header">')
html_parts.append(" <h1>📊 CI 健康度看板</h1>")
html_parts.append(f' <div class="subtitle">仓库: {repo}</div>')
html_parts.append(f' <div class="meta">统计周期: {start_date} ~ {end_date} | 生成时间: {now_str}</div>')
html_parts.append(" </div>")
html_parts.append(' <div class="metrics-row">')
html_parts.append(' <div class="metric-card success">')
html_parts.append(' <div class="label">总成功率</div>')
html_parts.append(f' <div class="value">{success_rate}<span class="unit">%</span></div>')
html_parts.append(" </div>")
html_parts.append(' <div class="metric-card info">')
html_parts.append(' <div class="label">总 Run 数</div>')
html_parts.append(f' <div class="value">{total_runs}<span class="unit">次</span></div>')
html_parts.append(" </div>")
html_parts.append(' <div class="metric-card warning">')
html_parts.append(' <div class="label">平均耗时</div>')
html_parts.append(f' <div class="value">{avg_dur_min}<span class="unit">分钟</span></div>')
html_parts.append(" </div>")
html_parts.append(' <div class="metric-card danger">')
html_parts.append(' <div class="label">基础设施故障率</div>')
html_parts.append(f' <div class="value">{infra_fail_rate}<span class="unit">%</span></div>')
html_parts.append(" </div>")
html_parts.append(" </div>")
html_parts.append(' <div class="charts-grid">')
html_parts.append(' <div class="chart-card full-width">')
html_parts.append(" <h3>📈 CI 成功率趋势</h3>")
html_parts.append(' <div id="chart-success-rate" class="chart-container"></div>')
html_parts.append(" </div>")
html_parts.append(" </div>")
html_parts.append(' <div class="charts-grid">')
html_parts.append(' <div class="chart-card">')
html_parts.append(" <h3>⏱️ 各 Workflow 平均耗时</h3>")
html_parts.append(' <div id="chart-wf-duration" class="chart-container"></div>')
html_parts.append(" </div>")
html_parts.append(' <div class="chart-card">')
html_parts.append(" <h3>❌ 失败原因分布</h3>")
html_parts.append(' <div id="chart-failure-pie" class="chart-container"></div>')
html_parts.append(" </div>")
html_parts.append(" </div>")
html_parts.append(' <div class="charts-grid">')
html_parts.append(' <div class="chart-card full-width">')
html_parts.append(" <h3>📋 各 Job 成功率排行(最低 15 名)</h3>")
html_parts.append(' <div id="chart-job-success" class="chart-container tall"></div>')
html_parts.append(" </div>")
html_parts.append(" </div>")
html_parts.append(' <div class="charts-grid">')
html_parts.append(' <div class="chart-card full-width">')
html_parts.append(" <h3>📊 每日 Run 数量趋势</h3>")
html_parts.append(' <div id="chart-run-count" class="chart-container"></div>')
html_parts.append(" </div>")
html_parts.append(" </div>")
html_parts.append(' <div class="footer">')
html_parts.append(" 由 ci_dashboard.py 自动生成 | ECharts 可视化")
html_parts.append(" </div>")
html_parts.append(" </div>")
html_parts.append(" <script>")
html_parts.append(f" const DATA = {data_json};")
html_parts.append("")
# 图表 1: 成功率趋势
html_parts.append(" (function() {")
html_parts.append(' const chart = echarts.init(document.getElementById("chart-success-rate"));')
html_parts.append(" chart.setOption({")
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
html_parts.append(' const p = params[0]; return p.name + "<br/>成功率: <b>" + p.value + "%</b>";')
html_parts.append(" }},")
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },')
html_parts.append(' xAxis: { type: "category", boundaryGap: false, data: DATA.daily_dates,')
html_parts.append(" axisLabel: { rotate: 30, fontSize: 11 } },")
html_parts.append(' yAxis: { type: "value", min: 0, max: 100, axisLabel: { formatter: "{value}%" } },')
html_parts.append(
' series: [{ name: "成功率", type: "line", smooth: true, data: DATA.daily_success_rates,'
)
html_parts.append(' itemStyle: { color: "#52c41a" },')
html_parts.append(" areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
html_parts.append(' { offset: 0, color: "rgba(82, 196, 26, 0.3)" },')
html_parts.append(' { offset: 1, color: "rgba(82, 196, 26, 0.05)" }')
html_parts.append(" ])},")
html_parts.append(' markLine: { silent: true, data: [{ type: "average", name: "平均值",')
html_parts.append(' label: { formatter: "均值 {c}%" } }] }')
html_parts.append(" }]")
html_parts.append(" });")
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
html_parts.append(" })();")
html_parts.append("")
# 图表 2: Workflow 耗时对比
html_parts.append(" (function() {")
html_parts.append(' const chart = echarts.init(document.getElementById("chart-wf-duration"));')
html_parts.append(" chart.setOption({")
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
html_parts.append(
' const p = params[0]; return p.name + "<br/>平均耗时: <b>" + p.value + " 分钟</b>";'
)
html_parts.append(" }},")
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "15%", containLabel: true },')
html_parts.append(' xAxis: { type: "category", data: DATA.wf_names,')
html_parts.append(" axisLabel: { rotate: 30, fontSize: 10, interval: 0 } },")
html_parts.append(' yAxis: { type: "value", name: "分钟", axisLabel: { formatter: "{value} min" } },')
html_parts.append(' series: [{ name: "平均耗时", type: "bar", data: DATA.wf_avg_durations,')
html_parts.append(" itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
html_parts.append(' { offset: 0, color: "#1890ff" },')
html_parts.append(' { offset: 1, color: "#096dd9" }')
html_parts.append(" ]), borderRadius: [4, 4, 0, 0] },")
html_parts.append(" barMaxWidth: 40")
html_parts.append(" }]")
html_parts.append(" });")
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
html_parts.append(" })();")
html_parts.append("")
# 图表 3: 失败原因饼图
html_parts.append(" (function() {")
html_parts.append(' const chart = echarts.init(document.getElementById("chart-failure-pie"));')
html_parts.append(" chart.setOption({")
html_parts.append(' tooltip: { trigger: "item", formatter: "{b}: {c} 次 ({d}%)" },')
html_parts.append(' legend: { orient: "vertical", right: "5%", top: "center" },')
html_parts.append(
' series: [{ name: "失败原因", type: "pie", radius: ["40%", "70%"], center: ["35%", "50%"],'
)
html_parts.append(" avoidLabelOverlap: false,")
html_parts.append(' itemStyle: { borderRadius: 6, borderColor: "#fff", borderWidth: 2 },')
html_parts.append(' label: { show: false, position: "center" },')
html_parts.append(' emphasis: { label: { show: true, fontSize: 16, fontWeight: "bold" } },')
html_parts.append(" labelLine: { show: false },")
html_parts.append(" data: DATA.failure_pie_data,")
html_parts.append(' color: ["#ff4d4f", "#faad14", "#8c8c8c"]')
html_parts.append(" }]")
html_parts.append(" });")
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
html_parts.append(" })();")
html_parts.append("")
# 图表 4: Job 成功率排行(横向柱状图)
html_parts.append(" (function() {")
html_parts.append(' const chart = echarts.init(document.getElementById("chart-job-success"));')
html_parts.append(" const barData = DATA.job_rates.map(function(rate, i) {")
html_parts.append(" return { value: rate, itemStyle: {")
html_parts.append(' color: rate >= 90 ? "#52c41a" : (rate >= 70 ? "#faad14" : "#ff4d4f")')
html_parts.append(" }};")
html_parts.append(" });")
html_parts.append(" chart.setOption({")
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
html_parts.append(' const p = params[0]; return p.name + "<br/>成功率: <b>" + p.value + "%</b>";')
html_parts.append(" }},")
html_parts.append(' grid: { left: "3%", right: "8%", bottom: "3%", top: "3%", containLabel: true },')
html_parts.append(' xAxis: { type: "value", min: 0, max: 100, axisLabel: { formatter: "{value}%" } },')
html_parts.append(' yAxis: { type: "category", data: DATA.job_names, axisLabel: { fontSize: 11 } },')
html_parts.append(' series: [{ name: "成功率", type: "bar", data: barData, barWidth: "60%",')
html_parts.append(' label: { show: true, position: "right", formatter: "{c}%", fontSize: 11 }')
html_parts.append(" }]")
html_parts.append(" });")
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
html_parts.append(" })();")
html_parts.append("")
# 图表 5: 每日 Run 数量趋势(面积图)
html_parts.append(" (function() {")
html_parts.append(' const chart = echarts.init(document.getElementById("chart-run-count"));')
html_parts.append(" chart.setOption({")
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
html_parts.append(
' const p = params[0]; return p.name + "<br/>Run 数量: <b>" + p.value + " 次</b>";'
)
html_parts.append(" }},")
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },')
html_parts.append(' xAxis: { type: "category", boundaryGap: false, data: DATA.daily_dates,')
html_parts.append(" axisLabel: { rotate: 30, fontSize: 11 } },")
html_parts.append(' yAxis: { type: "value", name: "次数" },')
html_parts.append(
' series: [{ name: "Run 数量", type: "line", smooth: true, data: DATA.daily_run_counts,'
)
html_parts.append(' itemStyle: { color: "#722ed1" },')
html_parts.append(" areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
html_parts.append(' { offset: 0, color: "rgba(114, 46, 209, 0.3)" },')
html_parts.append(' { offset: 1, color: "rgba(114, 46, 209, 0.05)" }')
html_parts.append(" ])},")
html_parts.append(' markLine: { silent: true, data: [{ type: "average", name: "平均值",')
html_parts.append(' label: { formatter: "均值 {c}" } }] }')
html_parts.append(" }]")
html_parts.append(" });")
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
html_parts.append(" })();")
html_parts.append(" </script>")
html_parts.append("</body>")
html_parts.append("</html>")
return "\n".join(html_parts)
# ── 主函数 ───────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="CI 可观测性看板 - 生成 Gitea Actions 运行状态报表")
parser.add_argument("--days", type=int, default=DEFAULT_DAYS, help=f"统计最近 N 天 (默认 {DEFAULT_DAYS})")
parser.add_argument("--output", "-o", type=str, help="输出文件路径 (默认输出到 stdout)")
parser.add_argument("--workflow", type=str, help="只统计指定 workflow (如 ci-cd.yml)")
parser.add_argument("--gitea-url", type=str, default=os.environ.get("GITEA_URL", DEFAULT_GITEA_URL))
parser.add_argument("--repo", type=str, default=os.environ.get("GITEA_REPO", DEFAULT_REPO))
parser.add_argument("--token", type=str, default=os.environ.get("GITEA_TOKEN"))
parser.add_argument("--username", type=str, default=os.environ.get("GITEA_USERNAME"))
parser.add_argument("--password", type=str, default=os.environ.get("GITEA_PASSWORD"))
parser.add_argument("--no-job-detail", action="store_true", help="不拉取 job 详情")
parser.add_argument("--max-failures", type=int, default=50, help="最多分析多少个失败 run 的 job 详情 (默认 50)")
# HTML 输出相关参数
parser.add_argument("--html", action="store_true", help="生成 HTML 可视化看板")
parser.add_argument("--html-output", type=str, help="HTML 输出文件路径 (默认 ci_dashboard.html)")
args = parser.parse_args()
ga = GiteaActions(
base_url=args.gitea_url,
repo=args.repo,
token=args.token,
username=args.username,
password=args.password,
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=args.days - 1)
runs = fetch_runs_in_range(ga, start_date, end_date, args.workflow)
if not runs:
print("[ERROR] 未获取到任何数据", file=sys.stderr)
sys.exit(1)
if not args.no_job_detail:
runs = enrich_with_jobs(ga, runs, max_failures=args.max_failures)
stats = analyze_runs(runs)
# HTML 模式
if args.html:
html = generate_html(stats, start_date, end_date, args.repo)
html_output = args.html_output or args.output or "ci_dashboard.html"
with open(html_output, "w", encoding="utf-8") as f:
f.write(html)
print(f"[INFO] HTML 看板已保存到 {html_output}", file=sys.stderr)
return
# 默认 Markdown 模式(向后兼容)
md = generate_markdown(stats, start_date, end_date, args.repo)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(md)
print(f"[INFO] 报表已保存到 {args.output}", file=sys.stderr)
else:
print(md)
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# CI共享环境变量与常量定义
# 所有CI脚本source此文件获取统一的配置,避免硬编码分散
# === 共享常驻PG实例(CI_USE_SHARED_PG=true时使用)===
export CI_SHARED_PG_PORT="${CI_SHARED_PG_PORT:-5433}"
export CI_SHARED_PG_USER="${CI_SHARED_PG_USER:-postgres}"
export CI_SHARED_PG_PASSWORD="${CI_SHARED_PG_PASSWORD:-ci_pg_2026!}"
# === 本地PG默认端口(CI_USE_SHARED_PG=false时容器映射或本地PG===
export CI_LOCAL_PG_PORT="${CI_LOCAL_PG_PORT:-5432}"
# === 默认数据库名 ===
export CI_DEFAULT_DB="${CI_DEFAULT_DB:-xiaoxia_saas}"
+447
View File
@@ -0,0 +1,447 @@
#!/usr/bin/env python3
"""CI失败诊断增强脚本:自动分类失败原因 + 提取关键错误 + 给出修复建议。
# Trigger CI after auto-format fix
支持的失败类型
1. Lint/格式问题 (ruff/black/eslint/prettier)
2. 单元测试失败
3. Docker构建失败
4. 依赖安装失败 (pip/npm)
5. 超时
6. 缓存问题
7. 数据库/迁移问题
8. 网络问题
9. 其他
用法
python3 scripts/ci/ci_failure_diagnosis.py [--job-name "Job Name"] [--log-file /path/to/log]
如果不传--log-file会尝试从Gitea API获取失败job的日志
"""
import json
import os
import re
import sys
import urllib.request
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class FailureDiagnosis:
"""失败诊断结果"""
category: str # 失败分类
category_cn: str # 中文分类名
severity: str # 严重程度: high / medium / low
summary: str # 一句话摘要
error_lines: List[str] = field(default_factory=list) # 关键错误行
suggestions: List[str] = field(default_factory=list) # 修复建议
auto_fixable: bool = False # 是否可以自动修复
related_docs: str = "" # 相关文档链接
# ============================================================
# 失败模式定义
# ============================================================
FAILURE_PATTERNS = [
# ===== Lint / 格式问题 =====
{
"pattern": r"(ruff|black|isort)\b.*(error|failed|Error)",
"category": "lint_python",
"category_cn": "Python代码质量检查",
"severity": "low",
"summary_contains": ["ruff", "black", "isort"],
"suggestions": [
"本地运行 `black . && isort . && ruff check --fix .` 自动修复",
"使用 `scripts/agent-commit.sh` 提交(自动格式化)",
"如确认无误,可加 `# noqa: xxx` 忽略特定规则",
],
"auto_fixable": True,
},
{
"pattern": r"ESLint|prettier|eslint",
"category": "lint_frontend",
"category_cn": "前端代码检查",
"severity": "low",
"summary_contains": ["eslint", "prettier"],
"suggestions": [
"本地运行 `cd apps/web && npm run lint:fix` 自动修复",
"Prettier问题: `cd apps/web && npx prettier --write .`",
],
"auto_fixable": True,
},
{
"pattern": r"F\d{3}|E\d{3}|W\d{3}.*ruff|ruff.*F\d{3}",
"category": "lint_python",
"category_cn": "Python代码质量检查",
"severity": "low",
"suggestions": [
"F401: 删除未使用的import",
"F841: 删除未使用的变量或加下划线前缀",
"E501: 行超长,加 `# noqa: E501`",
"F811: 删重复import",
"运行 `ruff check --fix .` 自动修复大部分问题",
],
"auto_fixable": True,
},
# ===== 单元测试失败 =====
{
"pattern": r"FAILED|assert.*Error|AssertionError",
"category": "unit_test",
"category_cn": "单元测试失败",
"severity": "high",
"suggestions": [
"检查相关测试文件,确认是代码问题还是测试用例问题",
"本地运行对应测试:`pytest path/to/test.py -v`",
"如测试依赖外部服务,检查mock是否正确",
],
"auto_fixable": False,
},
{
"pattern": r"pytest.*failed|\d+ failed.*\d+ passed",
"category": "unit_test",
"category_cn": "单元测试失败",
"severity": "high",
"suggestions": [
"查看上方日志中的FAILED测试用例",
"检查失败断言的期望值 vs 实际值",
"新代码影响了现有测试行为,确认是预期内变更吗?",
],
"auto_fixable": False,
},
# ===== Docker 构建失败 =====
{
"pattern": r"Dockerfile.*not found|docker build.*failed|ERROR: failed to solve",
"category": "docker_build",
"category_cn": "Docker构建失败",
"severity": "high",
"suggestions": [
"检查Dockerfile语法是否正确",
"检查引用的基础镜像是否存在",
"本地运行 `docker build -f path/to/Dockerfile .` 复现",
],
"auto_fixable": False,
},
{
"pattern": r"manifest.*not found|no such image|image.*not found",
"category": "docker_build",
"category_cn": "镜像不存在",
"severity": "medium",
"suggestions": [
"检查基础镜像名称和tag是否正确",
"确认镜像仓库可访问,登录是否有效",
"如为新基础镜像,需先手动构建一次基础镜像",
],
"auto_fixable": False,
},
{
"pattern": r"ETXTBSY|text file busy",
"category": "docker_build",
"category_cn": "文件锁冲突(ETXTBSY",
"severity": "low",
"summary": "esbuild并发构建冲突,重试即可",
"suggestions": ["偶发问题,点击Rerun重新运行即可", "如频繁出现,检查是否有多个job并发写入同一文件"],
"auto_fixable": True,
},
# ===== 依赖安装失败 =====
{
"pattern": r"pip install.*error|Could not find a version|No matching distribution",
"category": "dependency",
"category_cn": "pip依赖安装失败",
"severity": "medium",
"suggestions": [
"检查requirements.txt中的版本号是否正确",
"如为新版本刚发布,可能源还没同步,稍后重试",
"检查网络连接,可尝试切换pip镜像源",
],
"auto_fixable": False,
},
{
"pattern": r"npm.*ERR|npm install.*failed|E404|ECONNREFUSED.*npm",
"category": "dependency",
"category_cn": "npm依赖安装失败",
"severity": "medium",
"suggestions": [
"检查package.json中的版本号是否存在",
"网络问题:检查npm registry是否可访问",
"国内网络建议配置npmmirror镜像源",
],
"auto_fixable": False,
},
{
"pattern": r"Connection refused|timed out|network.*unreachable",
"category": "network",
"category_cn": "网络问题",
"severity": "medium",
"summary": "网络连接失败,可能是源站问题或DNS问题",
"suggestions": [
"点击Rerun重试,网络问题通常是临时的",
"如持续失败,检查对应服务是否正常",
"检查Runner网络配置",
],
"auto_fixable": True,
},
# ===== 超时 =====
{
"pattern": r"timeout|timed out|exceeded.*time limit|job.*cancelled.*timeout",
"category": "timeout",
"category_cn": "执行超时",
"severity": "medium",
"suggestions": [
"如首次出现:重试一次,可能是临时性能波动",
"频繁出现:检查构建是否变慢了,最近是否加了新依赖",
"可适当增加timeout-minutes配置",
],
"auto_fixable": False,
},
# ===== 数据库/迁移 =====
{
"pattern": r"alembic.*error|migration.*failed|relation.*does not exist|column.*does not exist",
"category": "migration",
"category_cn": "数据库迁移失败",
"severity": "high",
"suggestions": [
"检查迁移脚本是否正确,down_revision是否对",
"确认数据库中是否有脏数据或残留表",
"迁移脚本合并冲突时,重新生成迁移文件",
],
"auto_fixable": False,
},
# ===== 缓存问题 =====
{
"pattern": r"cache.*corrupt|cache.*invalid|snapshot.*not found|failed to compute cache key",
"category": "cache",
"category_cn": "缓存损坏",
"severity": "low",
"suggestions": ["构建系统会自动清理损坏缓存并重试,通常无需干预", "如持续失败,手动清理Runner上的缓存目录"],
"auto_fixable": True,
},
# ===== Checkout 失败 =====
{
"pattern": r"Could not resolve host|fatal:.*repository|SSL.*problem",
"category": "checkout",
"category_cn": "代码拉取失败",
"severity": "low",
"suggestions": ["临时网络问题,点击Rerun重试", "如持续失败,检查Gitea服务状态"],
"auto_fixable": True,
},
]
def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis:
"""分析日志,返回诊断结果"""
lines = log_text.strip().split("\n")
# 收集所有匹配的模式
matched = []
error_lines = []
for line in lines:
line_stripped = line.strip()
# 收集ERROR/FAILED/Failed等错误行(最多20行)
if re.search(r"(ERROR|FAILED|Error|error:|FAIL:|Traceback)", line_stripped):
if len(error_lines) < 20:
error_lines.append(line_stripped)
for pattern_info in FAILURE_PATTERNS:
if re.search(pattern_info["pattern"], line_stripped, re.IGNORECASE):
matched.append(pattern_info)
break # 一行只匹配一个模式
if not matched:
# 未识别的失败类型
return FailureDiagnosis(
category="unknown",
category_cn="未知错误",
severity="medium",
summary="未识别的失败类型,需要人工查看日志",
error_lines=error_lines[:10],
suggestions=[
"点击'查看失败日志'查看完整日志",
"如为偶发问题,可先重试一次",
"常见原因:环境问题、配置问题、新增逻辑引入的bug",
],
auto_fixable=False,
)
# 选最严重、最具体的那个
severity_order = {"high": 3, "medium": 2, "low": 1}
matched.sort(key=lambda x: severity_order.get(x["severity"], 0), reverse=True)
best_match = matched[0]
# 生成摘要
if "summary" in best_match:
summary = best_match["summary"]
else:
summary = f"{best_match['category_cn']}检查失败"
if job_name:
summary = f"[{job_name}] {summary}"
# 从error_lines中过滤出与该分类相关的
relevant_errors = error_lines[:10]
return FailureDiagnosis(
category=best_match["category"],
category_cn=best_match["category_cn"],
severity=best_match["severity"],
summary=summary,
error_lines=relevant_errors,
suggestions=best_match["suggestions"],
auto_fixable=best_match.get("auto_fixable", False),
)
def fetch_failed_job_log(run_id: str, job_id: str, token: str, repo: str) -> Optional[str]:
"""从Gitea API获取失败job的日志"""
api_base = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}"
# 尝试获取job的日志
url = f"{api_base}/actions/runs/{run_id}/jobs/{job_id}/log"
req = urllib.request.Request(url)
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception as e:
print(f"获取日志失败: {e}", file=sys.stderr)
return None
def format_diagnosis_markdown(d: FailureDiagnosis, job_name: str = "", run_url: str = "") -> str:
"""将诊断结果格式化为飞书卡片markdown"""
severity_emoji = {"high": "🔴", "medium": "🟡", "low": "🟢"}
emoji = severity_emoji.get(d.severity, "")
lines = []
lines.append(f"**分类**: {emoji} {d.category_cn}")
lines.append(f"**问题**: {d.summary}")
if d.error_lines:
lines.append("")
lines.append("**关键错误行**:")
for err in d.error_lines[:5]:
# 截断过长的行
if len(err) > 150:
err = err[:147] + "..."
lines.append(f" `{err}`")
lines.append("")
lines.append("**修复建议**:")
for i, s in enumerate(d.suggestions[:5], 1):
lines.append(f" {i}. {s}")
if d.auto_fixable:
lines.append("")
lines.append("💡 **可自动修复**:如格式问题,可尝试点击Rerun让auto-fix自动处理")
if run_url:
lines.append("")
lines.append(f"[查看完整日志]({run_url})")
return "\n".join(lines)
def main():
job_name = os.environ.get("FAILED_JOB", "")
run_id = os.environ.get("GITHUB_RUN_ID", "")
repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas")
token = os.environ.get("GITHUB_TOKEN", "")
# 1. 尝试获取日志
log_text = ""
# 优先从环境变量或文件读取
log_file = os.environ.get("CI_LOG_FILE", "")
if log_file and os.path.exists(log_file):
with open(log_file) as f:
log_text = f.read()
elif run_id and token:
# 尝试从API获取(需要job_id,这里简化处理)
pass
# 如果没有日志,用job_name做粗略分类
if not log_text:
# 基于job名做初始判断
if any(k in job_name.lower() for k in ["validate", "lint", "quality"]):
d = FailureDiagnosis(
category="lint_general",
category_cn="代码质量检查",
severity="low",
summary=f"{job_name} 检查失败(日志不可用,基于job名初步诊断)",
suggestions=["点击查看日志获取具体错误信息", "格式类问题通常可自动修复"],
auto_fixable=True,
)
elif "build" in job_name.lower():
d = FailureDiagnosis(
category="build_general",
category_cn="构建失败",
severity="high",
summary=f"{job_name} 构建失败(日志不可用)",
suggestions=["点击查看日志获取具体构建错误", "常见原因:Dockerfile错误、依赖安装失败、网络问题"],
auto_fixable=False,
)
elif "test" in job_name.lower():
d = FailureDiagnosis(
category="test_general",
category_cn="测试失败",
severity="high",
summary=f"{job_name} 测试失败(日志不可用)",
suggestions=["点击查看日志获取具体失败的测试用例", "检查最近代码改动是否影响了测试"],
auto_fixable=False,
)
elif "deploy" in job_name.lower():
d = FailureDiagnosis(
category="deploy_general",
category_cn="部署失败",
severity="high",
summary=f"{job_name} 部署失败(日志不可用)",
suggestions=["检查目标服务器状态和网络", "检查镜像是否正确推送", "查看服务器上的容器日志"],
auto_fixable=False,
)
else:
d = FailureDiagnosis(
category="unknown",
category_cn="未知错误",
severity="medium",
summary=f"{job_name} 失败",
suggestions=["点击查看日志获取详细信息"],
auto_fixable=False,
)
else:
d = analyze_log(log_text, job_name)
# 输出诊断结果
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" if run_id else ""
print("=" * 60)
print(" CI 失败诊断报告")
print("=" * 60)
print()
print(format_diagnosis_markdown(d, job_name, run_url))
print()
print("=" * 60)
# 将诊断结果写入文件(供通知脚本读取)
output_file = os.environ.get("DIAGNOSIS_OUTPUT", "/tmp/ci_diagnosis.json")
result = {
"category": d.category,
"category_cn": d.category_cn,
"severity": d.severity,
"summary": d.summary,
"error_lines": d.error_lines,
"suggestions": d.suggestions,
"auto_fixable": d.auto_fixable,
}
with open(output_file, "w") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\n诊断结果已保存到: {output_file}")
if __name__ == "__main__":
main()
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
CI 健康度快速检查脚本
- 统计最近 N run 的成功率 workflow 分类
- 列出失败的 run 和失败的 job/step
- 区分基础设施问题 vs 业务代码问题
- 输出简洁的健康度报告
用法:
python3 scripts/ci/ci_health_check.py [--limit 20] [--workflow ci-pipeline.yml] [--json]
环境变量:
GITEA_TOKEN API token必需
GITEA_API_URL Gitea API 地址默认 https://git.xiaoxiajianji.com/api/v1
GITEA_REPO 仓库默认 xiaoxia/xiaoxia-saas
"""
import argparse
import json
import os
import sys
import urllib.request
from datetime import datetime, timedelta, timezone
# ---- 基础设施问题关键词(命中即判定为基础设施问题)----
INFRA_KEYWORDS = [
# 网络/连接
"Couldn't connect to server",
"Connection refused",
"Connection reset",
"Connection timed out",
"Failed to connect to",
"network is unreachable",
"TLS handshake timeout",
"SSL certificate problem",
# 容器/Runner
"No such container",
"container already exists",
"docker: not found",
"no space left on device",
"out of memory",
"OOMKilled",
"pull access denied",
"manifest unknown",
"Error response from daemon",
"runner",
"runner is not online",
"no matching runners",
# Checkout/Git
"Could not resolve host",
"fatal: unable to access",
"The remote end hung up unexpectedly",
"early EOF",
"index-pack failed",
"git fetch",
"checkout failed",
"ETXTBSY",
"text file busy",
# 镜像/环境
"No module named pip",
"pip: not found",
"command not found: python",
"python3: not found",
"node: not found",
"npm: not found",
"exec format error",
"standard_init_linux.go",
# 系统/资源
"Input/output error",
"device or resource busy",
"No space left on device",
"Disk full",
# 鉴权/配置
"401 Unauthorized",
"403 Forbidden",
"404 Not Found",
"identity_sign: private key",
"Permission denied",
]
def api_get(path: str) -> dict:
base = os.environ.get("GITEA_API_URL", "https://git.xiaoxiajianji.com/api/v1")
repo = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
token = os.environ.get("GITEA_TOKEN", "")
url = f"{base}/repos/{repo}/{path}"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def get_run_jobs(run_id: int) -> list:
return api_get(f"actions/runs/{run_id}/jobs").get("jobs", [])
def get_job_log(job_id: int) -> str:
try:
return api_get(f"actions/jobs/{job_id}/logs")
except Exception:
return ""
def classify_failure(job: dict) -> str:
"""判断失败原因类型: infra / business / unknown"""
name = job.get("name", "")
# 仅根据 job 名称做初步分类(更精确需读日志,但代价高)
infra_jobs = ["Checkout", "Build", "Deploy", "Cleanup"]
business_jobs = [
"Unit Tests",
"Integration Tests",
"Frontend Lint",
"Frontend Unit Tests",
"Staging E2E",
"E2E",
"Validate Code Quality",
]
name_lower = name.lower()
if (
any(k.lower() in name_lower for k in infra_jobs)
and "Test" not in name
and "Lint" not in name
and "Validate" not in name
):
return "infra"
if any(k.lower() in name_lower for k in business_jobs):
return "business"
return "unknown"
def analyze_with_log(job_id: int) -> str:
"""通过日志关键词精确分类"""
log = get_job_log(job_id)
log_lower = log.lower()
for kw in INFRA_KEYWORDS:
if kw.lower() in log_lower:
return "infra"
return "business"
def fmt_time(t: str) -> str:
if not t or t.startswith("1970"):
return "-"
try:
dt = datetime.fromisoformat(t.replace("Z", "+00:00"))
bj = dt.astimezone(timezone(timedelta(hours=8)))
return bj.strftime("%m-%d %H:%M")
except Exception:
return t[:16]
def main():
parser = argparse.ArgumentParser(description="CI 健康度快速检查")
parser.add_argument("--limit", type=int, default=20, help="最近多少条 run")
parser.add_argument("--workflow", type=str, default="", help="只看某个 workflow")
parser.add_argument("--json", action="store_true", help="JSON 输出")
parser.add_argument("--deep", action="store_true", help="深度检查(读日志,较慢)")
args = parser.parse_args()
if not os.environ.get("GITEA_TOKEN"):
print("错误: 请设置 GITEA_TOKEN 环境变量", file=sys.stderr)
sys.exit(1)
# 1. 获取最近 run
runs = api_get(f"actions/runs?limit={args.limit}").get("workflow_runs", [])
if args.workflow:
runs = [r for r in runs if args.workflow in r.get("path", "")]
if not runs:
print("没有找到匹配的 run")
return
# 按 workflow 分组统计
wf_stats = {}
failed_runs = []
for r in runs:
path = r.get("path", "unknown")
# 提取 workflow 文件名,兼容各种 path 格式
if ".yml" in path or ".yaml" in path:
# ci-pipeline.yml@refs/heads/develop -> ci-pipeline.yml
wf = path.split("@")[0].split("/")[-1]
else:
wf = path.split("/")[-1] if "/" in path else path
if wf not in wf_stats:
wf_stats[wf] = {"total": 0, "success": 0, "failure": 0, "cancelled": 0, "others": 0}
wf_stats[wf]["total"] += 1
status = r.get("status", "")
conc = r.get("conclusion", "")
if status != "completed":
wf_stats[wf]["others"] += 1
continue
if conc == "success":
wf_stats[wf]["success"] += 1
elif conc == "failure":
wf_stats[wf]["failure"] += 1
failed_runs.append(r)
elif conc == "cancelled":
wf_stats[wf]["cancelled"] += 1
else:
wf_stats[wf]["others"] += 1
# 2. 失败 run 详情
failed_details = []
for r in failed_runs[:10]: # 最多看10个失败的
jobs = get_run_jobs(r["id"])
failed_jobs = [j for j in jobs if j.get("conclusion") == "failure"]
job_infos = []
for j in failed_jobs:
cat = classify_failure(j)
if args.deep and cat == "unknown":
cat = analyze_with_log(j["id"])
# 找失败的 step
failed_steps = []
for step in j.get("steps", []):
if step.get("conclusion") == "failure":
failed_steps.append(step.get("name", "?"))
job_infos.append(
{
"name": j.get("name", ""),
"category": cat,
"failed_steps": failed_steps,
"runner": j.get("runner_name", ""),
}
)
failed_details.append(
{
"id": r["id"],
"title": r.get("display_title", ""),
"branch": r.get("head_branch", ""),
"time": fmt_time(r.get("updated_at", "")),
"jobs": job_infos,
}
)
# 3. 输出
if args.json:
result = {"workflows": wf_stats, "failed_runs": failed_details}
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# 文本报告
print("=" * 60)
print(" CI 健康度报告")
print("=" * 60)
print(f"统计范围: 最近 {len(runs)} 条 run")
print(f"时间: {datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')}")
print()
print("📊 各 Workflow 成功率:")
print("-" * 60)
for wf, s in sorted(wf_stats.items()):
total = s["total"]
succ = s["success"]
rate = (succ / total * 100) if total > 0 else 0
bar = "" * int(rate / 5) + "" * (20 - int(rate / 5))
icon = "🟢" if rate >= 90 else ("🟡" if rate >= 70 else "🔴")
print(f" {icon} {wf:35s} {rate:5.1f}% {bar} ({succ}/{total})")
if s["failure"]:
print(f" 失败: {s['failure']} 取消: {s['cancelled']} 进行中: {s['others']}")
if failed_details:
print()
print("❌ 失败详情:")
print("-" * 60)
for d in failed_details:
print(f" #{d['id']} [{d['time']}] {d['title'][:45]}")
print(f" 分支: {d['branch']}")
for j in d["jobs"]:
cat_icon = "🏗️" if j["category"] == "infra" else ("🐛" if j["category"] == "business" else "")
steps = ", ".join(j["failed_steps"][:3]) if j["failed_steps"] else "未知"
print(f" {cat_icon} {j['name'][:30]:30s} 失败步骤: {steps}")
if j["runner"]:
print(f" runner: {j['runner']}")
else:
print()
print("✅ 最近没有失败的 run")
# 总结
total_all = sum(s["total"] for s in wf_stats.values())
succ_all = sum(s["success"] for s in wf_stats.values())
fail_all = sum(s["failure"] for s in wf_stats.values())
infra_fail = sum(1 for d in failed_details for j in d["jobs"] if j["category"] == "infra")
biz_fail = sum(1 for d in failed_details for j in d["jobs"] if j["category"] == "business")
rate_all = (succ_all / total_all * 100) if total_all > 0 else 0
print()
print("=" * 60)
print(f" 总结: 总成功率 {rate_all:.1f}% ({succ_all}/{total_all})")
if fail_all > 0:
print(f" 失败job分类: 基础设施 {infra_fail} 个 | 业务代码 {biz_fail}")
if infra_fail > biz_fail:
print(" ⚠️ 主要是基础设施问题,建议优先排查 CI 环境")
else:
print(" 💡 主要是业务代码问题,建议关注业务侧修复")
print("=" * 60)
if __name__ == "__main__":
main()
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
CI健康度每日巡检报告脚本
- 调用ci_health_check.py获取数据
- 有失败时生成飞书卡片通知并发送
- 无失败时静默退出不打扰
- 用于每日定时巡检
用法:
python3 scripts/ci/ci_health_report.py [--limit 30] [--dry-run]
环境变量:
GITEA_TOKEN API token必需
CI_NOTIFY_WEBHOOK 飞书webhook地址必需用于发报告
GITEA_API_URL Gitea API 地址
GITEA_REPO 仓库
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.request
from datetime import datetime, timedelta, timezone
def run_health_check(limit: int) -> dict:
"""调用ci_health_check.py获取JSON结果"""
script_dir = os.path.dirname(os.path.abspath(__file__))
cmd = [
sys.executable,
os.path.join(script_dir, "ci_health_check.py"),
"--json",
"--limit",
str(limit),
]
env = os.environ.copy()
# 确保GITEA_TOKEN传递
if not env.get("GITEA_TOKEN") and env.get("GITHUB_TOKEN"):
env["GITEA_TOKEN"] = env["GITHUB_TOKEN"]
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
if result.returncode != 0:
print(f"health check failed: {result.stderr}")
return {"workflows": {}, "failed_runs": []}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
print(f"failed to parse health check output: {result.stdout[:200]}")
return {"workflows": {}, "failed_runs": []}
def build_feishu_card(data: dict) -> dict:
"""构建飞书卡片消息"""
wf_stats = data.get("workflows", {})
failed_runs = data.get("failed_runs", [])
# 统计数据
total_all = sum(s["total"] for s in wf_stats.values())
succ_all = sum(s["success"] for s in wf_stats.values())
fail_all = sum(s["failure"] for s in wf_stats.values())
rate_all = (succ_all / total_all * 100) if total_all > 0 else 0
# 失败分类
infra_fail = 0
biz_fail = 0
unknown_fail = 0
for run in failed_runs:
for job in run.get("jobs", []):
cat = job.get("category", "unknown")
if cat == "infra":
infra_fail += 1
elif cat == "business":
biz_fail += 1
else:
unknown_fail += 1
now = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M")
# 各workflow成功率行
wf_lines = []
for wf, s in sorted(wf_stats.items()):
total = s["total"]
succ = s["success"]
fail = s["failure"]
rate = (succ / total * 100) if total > 0 else 0
icon = "🟢" if rate >= 90 else ("🟡" if rate >= 70 else "🔴")
wf_name = (
wf.replace("ci-pipeline.yml", "CI Pipeline")
.replace("code-review.yml", "Code Review")
.replace("daily-check.yml", "Daily Check")
.replace("preview-deploy.yml", "Preview Deploy")
)
wf_lines.append(f"{icon} **{wf_name}**: {rate:.0f}% ({succ}/{total},失败{fail})")
# 失败详情(最多显示5条)
fail_detail_lines = []
for _i, run in enumerate(failed_runs[:5]):
run_id = run["id"]
title = run.get("title", "")[:35]
branch = run.get("branch", "")
jobs_str = ", ".join(j["name"][:15] for j in run.get("jobs", [])[:3])
fail_detail_lines.append(f"• **#{run_id}** {title}\n 分支: {branch} | 失败: {jobs_str}")
if len(failed_runs) > 5:
fail_detail_lines.append(f"... 还有 {len(failed_runs) - 5} 条失败记录")
# 整体状态
if fail_all == 0:
status_text = "✅ 全部通过"
status_color = "green"
elif infra_fail > biz_fail:
status_text = "⚠️ 基础设施问题为主"
status_color = "yellow"
else:
status_text = "🔴 存在业务失败"
status_color = "red"
card = {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": f"CI告警 - 每日健康度巡检 ({now})"},
"template": status_color,
},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": f"**统计范围**: 最近 {total_all} 条 run\n**整体状态**: {status_text}\n**总成功率**: {rate_all:.1f}% ({succ_all}/{total_all})",
},
},
{"tag": "hr"},
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**📊 各Workflow成功率**\n" + "\n".join(wf_lines) if wf_lines else "暂无数据",
},
},
],
}
# 失败分类统计
if fail_all > 0:
card["elements"].append({"tag": "hr"})
card["elements"].append(
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": f"**失败原因分类**\n🏗️ 基础设施: {infra_fail}\n🐛 业务代码: {biz_fail}\n❓ 待确认: {unknown_fail}",
},
}
)
# 失败详情
if fail_detail_lines:
card["elements"].append({"tag": "hr"})
card["elements"].append(
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**❌ 失败详情**\n" + "\n\n".join(fail_detail_lines),
},
}
)
# 查看更多
card["elements"].append({"tag": "hr"})
base_url = os.environ.get("GITEA_BASE_URL", "https://git.xiaoxiajianji.com")
repo = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
card["elements"].append(
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看CI面板"},
"type": "primary",
"url": f"{base_url}/{repo}/actions",
}
],
}
)
return {"msg_type": "interactive", "card": card}
def send_feishu(webhook: str, payload: dict) -> bool:
"""发送飞书webhook"""
data = json.dumps(payload).encode()
req = urllib.request.Request(
webhook,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode())
return result.get("code", -1) == 0 or result.get("StatusCode", -1) == 0
except Exception as e:
print(f"send feishu failed: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="CI健康度每日巡检报告")
parser.add_argument("--limit", type=int, default=30, help="统计最近N条run")
parser.add_argument("--dry-run", action="store_true", help="只打印不发送")
parser.add_argument("--always-notify", action="store_true", help="即使全部通过也发送通知")
args = parser.parse_args()
webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "")
if not webhook and not args.dry_run:
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
# 还是执行健康检查输出到日志,方便排查
data = run_health_check(args.limit)
print(f"health check done: {len(data.get('failed_runs', []))} failed")
return 0
# 执行健康检查
data = run_health_check(args.limit)
failed_count = len(data.get("failed_runs", []))
# 无失败且不强制通知 → 静默退出
if failed_count == 0 and not args.always_notify:
print("✅ 全部通过,静默退出")
return 0
# 构建并发送卡片
card = build_feishu_card(data)
if args.dry_run:
print(json.dumps(card, ensure_ascii=False, indent=2))
return 0
success = send_feishu(webhook, card)
if success:
print(f"📤 已发送健康度报告,失败 {failed_count}")
else:
print("❌ 发送飞书通知失败")
# 通知失败不阻断流程
return 0
if __name__ == "__main__":
sys.exit(main())
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""
CI重复失败检测脚本
- 扫描最近N天的CI失败
- 按job名称分组统计失败率
- 识别高失败率job系统性故障
- 飞书通知告警
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone
def get_env(name, default=None, required=False):
val = os.environ.get(name, default)
if required and not val:
print(f"❌ 缺少环境变量: {name}")
sys.exit(1)
return val
GITEA_URL = get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
GITEA_TOKEN = get_env("GITEA_API_TOKEN", required=False) or get_env("GITHUB_TOKEN", "")
REPO = get_env("GITEA_REPO", "xiaoxia/xiaoxia-saas")
DAYS = int(get_env("FAIL_CHECK_DAYS", "7"))
FAIL_THRESHOLD = int(get_env("FAIL_THRESHOLD", 3)) # 失败次数阈值
FAIL_RATE_THRESHOLD = float(get_env("FAIL_RATE_THRESHOLD", "30")) # 失败率阈值%
CONSECUTIVE_FAIL_THRESHOLD = int(get_env("CONSECUTIVE_FAIL_THRESHOLD", "3")) # 连续失败阈值
WEBHOOK = get_env("CI_NOTIFY_WEBHOOK", "")
def api_get(path):
"""调用Gitea API"""
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url)
if GITEA_TOKEN:
req.add_header("Authorization", f"token {GITEA_TOKEN}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
print(f" HTTP {e.code}: {path}")
return None
except Exception as e:
print(f" 错误: {e}")
return None
def fetch_recent_runs(days=7, per_page=50, max_pages=10):
"""获取最近N天的runs"""
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
all_runs = []
for page in range(1, max_pages + 1):
path = f"/repos/{REPO}/actions/runs?page={page}&limit={per_page}"
data = api_get(path)
if not data:
break
runs = data.get("workflow_runs", data.get("runs", []))
if not runs:
break
# 检查时间范围(Gitea用started_at,格式2026-07-22T10:58:10+08:00
oldest = None
for r in runs:
started = r.get("started_at", r.get("created_at", ""))
if started and started >= since:
all_runs.append(r)
else:
oldest = started
if oldest and oldest < since:
break
if len(runs) < per_page:
break
return all_runs
def fetch_run_jobs(run_id):
"""获取run的所有jobs"""
path = f"/repos/{REPO}/actions/runs/{run_id}/jobs"
data = api_get(path)
if not data:
return []
return data.get("jobs", [])
def analyze_failures(runs):
"""
分析失败情况
返回:
- job_stats: {job_name: {total, success, failure, skipped, failure_rate, failures: [...]}}
- consecutive_failures: {job_name: current_streak, max_streak, last_status}
"""
job_stats = defaultdict(
lambda: {
"total": 0,
"success": 0,
"failure": 0,
"error": 0,
"skipped": 0,
"cancelled": 0,
"failures": [],
}
)
# 按时间正序排列(旧→新)用于连续失败计算
sorted_runs = sorted(runs, key=lambda r: r.get("started_at", r.get("created_at", "")))
# 连续失败跟踪 {job_name: streak}
consecutive = defaultdict(lambda: {"current": 0, "max": 0, "last_run": None})
for run in sorted_runs:
run_id = run.get("id")
run.get("status", "")
run.get("conclusion", "")
run_started = run.get("started_at", run.get("created_at", ""))
event = run.get("event", "")
# 只统计pull_request和push事件的CI
if event not in ("pull_request", "push"):
continue
jobs = fetch_run_jobs(run_id)
for job in jobs:
name = job.get("name", "")
job.get("status", "")
conclusion = job.get("conclusion", "")
# 跳过非CI核心job(如AI Code Review、Preview等)
skip_prefixes = ("AI Code Review", "Preview", "PR Automation", "Auto")
if any(name.startswith(p) for p in skip_prefixes):
continue
stats = job_stats[name]
stats["total"] += 1
if conclusion == "success":
stats["success"] += 1
consecutive[name]["current"] = 0
elif conclusion == "failure":
stats["failure"] += 1
stats["failures"].append(
{
"run_id": run_id,
"time": run_started,
"event": event,
}
)
consecutive[name]["current"] += 1
if consecutive[name]["current"] > consecutive[name]["max"]:
consecutive[name]["max"] = consecutive[name]["current"]
consecutive[name]["last_run"] = run_id
elif conclusion == "error":
stats["error"] += 1
# error也算失败的一种
consecutive[name]["current"] += 1
if consecutive[name]["current"] > consecutive[name]["max"]:
consecutive[name]["max"] = consecutive[name]["current"]
elif conclusion == "skipped":
stats["skipped"] += 1
# skipped不算也不打断连续失败
elif conclusion == "cancelled":
stats["cancelled"] += 1
# cancelled不算失败也不打断
# 计算失败率
for _name, stats in job_stats.items():
total_actual = stats["total"] - stats["skipped"] - stats["cancelled"]
if total_actual > 0:
stats["failure_rate"] = round((stats["failure"] + stats["error"]) / total_actual * 100, 1)
else:
stats["failure_rate"] = 0.0
return dict(job_stats), dict(consecutive)
def find_high_failures(job_stats, consecutive):
"""
找出高风险job
告警级别
- critical: 连续失败 >= CONSECUTIVE_FAIL_THRESHOLD 失败率>=50%且失败次数>=5
- warning: 失败率>=FAIL_RATE_THRESHOLD且失败次数>=FAIL_THRESHOLD
- info: 失败次数>=2
"""
critical = []
warning = []
info = []
for name, stats in job_stats.items():
fail_count = stats["failure"] + stats["error"]
rate = stats["failure_rate"]
streak = consecutive.get(name, {}).get("current", 0)
max_streak = consecutive.get(name, {}).get("max", 0)
issue = {
"name": name,
"fail_count": fail_count,
"total": stats["total"],
"failure_rate": rate,
"current_streak": streak,
"max_streak": max_streak,
"recent_failures": stats["failures"][-5:], # 最近5次
}
if streak >= CONSECUTIVE_FAIL_THRESHOLD or (rate >= 50 and fail_count >= 5):
critical.append(issue)
elif rate >= FAIL_RATE_THRESHOLD and fail_count >= FAIL_THRESHOLD:
warning.append(issue)
elif fail_count >= 2:
info.append(issue)
# 按失败次数倒序
critical.sort(key=lambda x: x["fail_count"], reverse=True)
warning.sort(key=lambda x: x["fail_count"], reverse=True)
info.sort(key=lambda x: x["fail_count"], reverse=True)
return critical, warning, info
def generate_report(critical, warning, info, days, total_runs):
"""生成Markdown报告"""
lines = []
lines.append("# CI重复失败检测报告")
lines.append("")
lines.append(f"**统计周期**: 最近{days}")
lines.append(f"**扫描Runs**: {total_runs}")
lines.append(f"**生成时间**: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
lines.append("")
lines.append("## 概览")
lines.append("")
lines.append("| 级别 | 数量 |")
lines.append("|------|------|")
lines.append(f"| 🔴 严重 (连续失败≥{CONSECUTIVE_FAIL_THRESHOLD}次 或 失败率≥50%) | {len(critical)} |")
lines.append(f"| 🟡 警告 (失败率≥{FAIL_RATE_THRESHOLD}% 且 失败≥{FAIL_THRESHOLD}次) | {len(warning)} |")
lines.append(f"| 🔵 关注 (失败≥2次) | {len(info)} |")
lines.append("")
if critical:
lines.append("## 🔴 严重问题")
lines.append("")
for item in critical:
lines.append(f"### {item['name']}")
lines.append("")
lines.append(f"- 失败次数: **{item['fail_count']}** / {item['total']} 次运行")
lines.append(f"- 失败率: **{item['failure_rate']}%**")
lines.append(f"- 当前连续失败: **{item['current_streak']}** 次 (历史最高: {item['max_streak']} 次)")
lines.append("")
if item["recent_failures"]:
lines.append("最近失败:")
lines.append("")
for f in item["recent_failures"]:
lines.append(f"- [{f['time'][:16]}] run #{f['run_id']} ({f['event']})")
lines.append("")
if warning:
lines.append("## 🟡 警告")
lines.append("")
for item in warning:
lines.append(
f"- **{item['name']}**: {item['fail_count']}次失败 / {item['total']}次运行 ({item['failure_rate']}%)"
)
lines.append("")
if info:
lines.append("## 🔵 关注列表")
lines.append("")
lines.append("| Job名称 | 失败次数 | 总次数 | 失败率 | 当前连续 |")
lines.append("|---------|----------|--------|--------|----------|")
for item in info[:20]: # 最多显示20个
lines.append(
f"| {item['name']} | {item['fail_count']} | {item['total']} | {item['failure_rate']}% | {item['current_streak']} |"
)
lines.append("")
return "\n".join(lines)
def send_feishu_notification(critical, warning, info, days):
"""发送飞书通知"""
if not WEBHOOK:
print(" ⚠️ 未配置WEBHOOK,跳过飞书通知")
return False
total_issues = len(critical) + len(warning) + len(info)
if total_issues == 0:
print(" ✅ 无异常,不发送通知")
return True
level = "🔴 严重告警" if critical else "🟡 警告" if warning else "🔵 关注"
title = f"CI重复失败检测 - {level}"
text = f"统计周期: 最近{days}\n\n"
if critical:
text += "【严重问题】\n"
for item in critical[:5]:
text += f"{item['name']}\n"
text += f" 失败 {item['fail_count']}/{item['total']} ({item['failure_rate']}%) 连续{item['current_streak']}\n"
if len(critical) > 5:
text += f" ...还有{len(critical)-5}\n"
text += "\n"
if warning:
text += "【警告】\n"
for item in warning[:5]:
text += f"{item['name']}: {item['fail_count']}次失败 ({item['failure_rate']}%)\n"
if len(warning) > 5:
text += f" ...还有{len(warning)-5}\n"
text += "\n"
if info and not critical and not warning:
text += "【关注列表】\n"
for item in info[:10]:
text += f"{item['name']}: {item['fail_count']}次失败\n"
text += "\n"
text += f"共发现 {total_issues} 个异常job"
payload = {"msg_type": "text", "content": {"text": f"{title}\n\n{text}"}}
data = json.dumps(payload).encode()
req = urllib.request.Request(WEBHOOK, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
if result.get("code") == 0 or result.get("StatusCode") == 0:
print(" ✅ 飞书通知已发送")
return True
else:
print(f" ⚠️ 飞书返回: {result}")
return False
except Exception as e:
print(f" ❌ 飞书通知失败: {e}")
return False
def main():
print("=== CI重复失败检测 ===")
print(f"统计周期: 最近{DAYS}")
print(f"仓库: {REPO}")
print()
print("1. 获取最近的Runs...")
runs = fetch_recent_runs(days=DAYS)
print(f" 找到 {len(runs)} 个runs")
if not runs:
print("⚠️ 没有找到runs,退出")
return
print()
print("2. 分析job失败情况(可能需要点时间)...")
job_stats, consecutive = analyze_failures(runs)
print(f" 共统计 {len(job_stats)} 个job")
print()
print("3. 识别高风险job...")
critical, warning, info = find_high_failures(job_stats, consecutive)
print(f" 🔴 严重: {len(critical)}")
print(f" 🟡 警告: {len(warning)}")
print(f" 🔵 关注: {len(info)}")
print()
print("4. 生成报告...")
report = generate_report(critical, warning, info, DAYS, len(runs))
# 保存报告
report_path = os.environ.get("REPORT_PATH", f"/tmp/ci_failure_report_{int(time.time())}.md")
with open(report_path, "w") as f:
f.write(report)
print(f" 报告已保存: {report_path}")
# 打印摘要
print()
print("=== 摘要 ===")
if critical:
print("🔴 严重问题:")
for item in critical[:5]:
print(
f" {item['name']}: {item['fail_count']}次失败, {item['failure_rate']}%, 连续{item['current_streak']}"
)
if warning:
print("🟡 警告:")
for item in warning[:5]:
print(f" {item['name']}: {item['fail_count']}次失败, {item['failure_rate']}%")
print()
print("5. 发送飞书通知...")
send_feishu_notification(critical, warning, info, DAYS)
print()
print("✅ 检测完成")
# 有严重问题时退出码非零,方便workflow标记
if critical:
sys.exit(2)
elif warning:
sys.exit(1)
if __name__ == "__main__":
main()
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
CI Trace Report Script - Reports CI Trace data to AgentLoop from Gitea Actions workflows.
Usage in CI workflow jobs:
- At start: python3 scripts/ci/ci_trace_report.py --status running
- At end: python3 scripts/ci/ci_trace_report.py --status ok --start-time $CI_TRACE_START_TIME
Environment variables (built-in Gitea Actions):
GITEA_REPOSITORY / GITHUB_REPOSITORY - repository (owner/repo)
GITEA_WORKFLOW / GITHUB_WORKFLOW - workflow name
GITEA_JOB / GITHUB_JOB - job ID
GITEA_SHA / GITHUB_SHA - commit SHA
GITEA_REF_NAME / GITHUB_REF_NAME - branch name
GITEA_RUN_ID / GITHUB_RUN_ID - run ID
GITEA_ACTOR / GITHUB_ACTOR - trigger actor
GITEA_EVENT_NAME / GITHUB_EVENT_NAME - event type
PR_NUMBER / GITEA_PR_NUMBER - PR number (if PR triggered)
AgentLoop configuration (injected via Secrets):
AGENTLOOP_LICENSE_KEY - LicenseKey (required)
AGENTLOOP_ENDPOINT - Trace endpoint (optional, has default)
AGENTLOOP_PROJECT - SLS Project name (optional)
AGENTLOOP_WORKSPACE - CMS Workspace name (optional)
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
# ========== Default Configuration ==========
DEFAULT_ENDPOINT = "https://proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou.cn-hangzhou.log.aliyuncs.com/apm/trace/opentelemetry/v1/traces"
DEFAULT_PROJECT = "proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou"
DEFAULT_WORKSPACE = "agentloop-13b8d6efb7fde6e9b193eb982ade68e2"
# ========== OTLP Protobuf Manual Encoding ==========
def _encode_varint(value):
result = bytearray()
while value > 0x7F:
result.append((value & 0x7F) | 0x80)
value >>= 7
result.append(value & 0x7F)
return bytes(result)
def _encode_tag(field_number, wire_type):
return _encode_varint((field_number << 3) | wire_type)
def _encode_string_field(field_number, value):
value_bytes = value.encode("utf-8")
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
def _encode_bytes_field(field_number, value_bytes):
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
def _encode_int_field(field_number, value):
return _encode_tag(field_number, 0) + _encode_varint(value & 0xFFFFFFFFFFFFFFFF)
def _encode_message_field(field_number, message_bytes):
return _encode_tag(field_number, 2) + _encode_varint(len(message_bytes)) + message_bytes
def _encode_key_value(key, value_str):
any_value = _encode_string_field(1, value_str)
return _encode_string_field(1, key) + _encode_message_field(2, any_value)
def _encode_status(status_code, status_msg=""):
data = _encode_int_field(1, status_code)
if status_msg:
data += _encode_string_field(2, status_msg)
return data
def _encode_span(
trace_id_bytes,
span_id_bytes,
parent_span_id_bytes,
name,
start_time_unix_nano,
end_time_unix_nano,
span_kind,
attributes,
status_code,
status_msg="",
):
data = b""
data += _encode_bytes_field(1, trace_id_bytes)
data += _encode_bytes_field(2, span_id_bytes)
if parent_span_id_bytes:
data += _encode_bytes_field(3, parent_span_id_bytes)
data += _encode_string_field(4, name)
data += _encode_int_field(5, span_kind)
data += _encode_int_field(6, start_time_unix_nano)
data += _encode_int_field(7, end_time_unix_nano)
for key, value in attributes.items():
kv = _encode_key_value(key, str(value))
data += _encode_message_field(9, kv)
status = _encode_status(status_code, status_msg)
data += _encode_message_field(12, status)
return data
def _encode_resource_spans(service_name, scope_spans_bytes):
svc_kv = _encode_key_value("service.name", service_name)
resource = _encode_message_field(1, svc_kv)
data = _encode_message_field(1, resource)
data += _encode_message_field(2, scope_spans_bytes)
return data
def _encode_scope_spans(scope_name, spans_bytes_list):
scope = _encode_string_field(1, scope_name)
data = _encode_message_field(1, scope)
for span_bytes in spans_bytes_list:
data += _encode_message_field(2, span_bytes)
return data
def _encode_traces_data(resource_spans_bytes_list):
data = b""
for rs_bytes in resource_spans_bytes_list:
data += _encode_message_field(1, rs_bytes)
return data
# ========== Helper Functions ==========
def _gen_trace_id():
return uuid.uuid4().bytes
def _gen_span_id():
return uuid.uuid4().bytes[:8]
def _env(name, default=""):
"""Get env var with GITEA_/GITHUB_ prefix fallback."""
val = os.getenv(name, "")
if val:
return val
if name.startswith("GITEA_"):
alt = "GITHUB_" + name[6:]
return os.getenv(alt, default)
if name.startswith("GITHUB_"):
alt = "GITEA_" + name[7:]
return os.getenv(alt, default)
return default
def _get_pr_number():
"""Get PR number from environment or event file."""
pr = os.getenv("PR_NUMBER", "") or os.getenv("GITEA_PR_NUMBER", "")
if pr:
return pr
event_path = os.getenv("GITHUB_EVENT_PATH", "") or os.getenv("GITEA_EVENT_PATH", "")
if event_path and os.path.isfile(event_path):
try:
with open(event_path, "r") as f:
event = json.load(f)
if "pull_request" in event and "number" in event["pull_request"]:
return str(event["pull_request"]["number"])
except Exception:
pass
return ""
def _get_ci_attributes():
"""Collect attributes from CI environment variables."""
attrs = {
"ci.repo": _env("GITEA_REPOSITORY") or _env("GITHUB_REPOSITORY") or "unknown",
"ci.workflow": _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "unknown",
"ci.job": _env("GITEA_JOB") or _env("GITHUB_JOB") or "unknown",
"ci.commit_sha": _env("GITEA_SHA") or _env("GITHUB_SHA") or "unknown",
"ci.branch": _env("GITEA_REF_NAME") or _env("GITHUB_REF_NAME") or "unknown",
"ci.run_id": _env("GITEA_RUN_ID") or _env("GITHUB_RUN_ID") or "unknown",
"ci.actor": _env("GITEA_ACTOR") or _env("GITHUB_ACTOR") or "unknown",
"ci.event": _env("GITEA_EVENT_NAME") or _env("GITHUB_EVENT_NAME") or "unknown",
}
pr = _get_pr_number()
if pr:
attrs["ci.pr_number"] = pr
return attrs
# ========== Trace Building & Reporting ==========
def build_trace(service_name, trace_name, status, duration_ms, attributes=None):
"""Build an OTLP trace payload (protobuf bytes). No external dependencies."""
trace_id = _gen_trace_id()
end_time = int(time.time() * 1e9)
start_time = end_time - int(duration_ms * 1e6)
status_code = 1 if status in ("ok", "running") else 2
status_msg = "" if status in ("ok", "running") else "Job failed"
main_attrs = {
"agent.trace_name": trace_name,
"agent.service": service_name,
"ci.trace_status": status,
}
if attributes:
main_attrs.update(attributes)
main_span = _encode_span(
trace_id_bytes=trace_id,
span_id_bytes=_gen_span_id(),
parent_span_id_bytes=b"",
name=trace_name,
start_time_unix_nano=start_time,
end_time_unix_nano=end_time,
span_kind=1,
attributes=main_attrs,
status_code=status_code,
status_msg=status_msg,
)
scope_spans = _encode_scope_spans("ci-trace", [main_span])
resource_spans = _encode_resource_spans(service_name, scope_spans)
return _encode_traces_data([resource_spans])
def report_ci_trace(
service_name,
trace_name,
status="ok",
duration_ms=1000,
endpoint=None,
license_key=None,
project=None,
workspace=None,
extra_attributes=None,
):
"""
Report CI Trace data. Returns (success: bool, message: str).
Never raises exceptions; returns False on failure.
"""
try:
endpoint = endpoint or os.getenv("AGENTLOOP_ENDPOINT", DEFAULT_ENDPOINT)
license_key = license_key or os.getenv("AGENTLOOP_LICENSE_KEY", "")
project = project or os.getenv("AGENTLOOP_PROJECT", DEFAULT_PROJECT)
workspace = workspace or os.getenv("AGENTLOOP_WORKSPACE", DEFAULT_WORKSPACE)
if not license_key:
return False, "[Trace] skipped: AGENTLOOP_LICENSE_KEY not configured"
attrs = _get_ci_attributes()
if extra_attributes:
attrs.update(extra_attributes)
payload = build_trace(
service_name=service_name,
trace_name=trace_name,
status=status,
duration_ms=duration_ms,
attributes=attrs,
)
headers = {
"Content-Type": "application/x-protobuf",
"x-arms-license-key": license_key,
"x-arms-project": project,
"x-cms-workspace": workspace,
}
req = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
status_code = resp.status
resp_body = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
status_code = e.code
resp_body = e.read().decode("utf-8", errors="replace")
if status_code in (200, 202):
return True, (f"[Trace] success: {service_name} / {trace_name} " f"({status}, {duration_ms}ms)")
else:
return False, (f"[Trace] failed: HTTP {status_code} - {resp_body[:200]}")
except Exception as e:
return False, f"[Trace] error: {type(e).__name__}: {str(e)}"
def main():
parser = argparse.ArgumentParser(description="CI AgentLoop Trace Reporter")
parser.add_argument(
"--service",
dest="service_name",
default=os.getenv("TRACE_SERVICE", ""),
help="Service name (also via TRACE_SERVICE env)",
)
parser.add_argument(
"--name",
dest="trace_name",
default=os.getenv("TRACE_NAME", ""),
help="Trace name (also via TRACE_NAME env)",
)
parser.add_argument(
"--status",
default=os.getenv("TRACE_STATUS", "ok"),
choices=["ok", "error", "running"],
help="Status: ok / error / running (default ok)",
)
parser.add_argument(
"--start-time",
dest="start_time",
default=os.getenv("TRACE_START_TIME", ""),
help="Start timestamp (seconds) for duration calculation",
)
parser.add_argument(
"--duration-ms",
dest="duration_ms",
type=int,
default=0,
help="Direct duration in ms; takes precedence over --start-time",
)
parser.add_argument("--attrs", default="", help="Extra attributes (JSON string)")
args = parser.parse_args()
if not args.service_name:
print("[Trace] skipped: no service specified (--service or TRACE_SERVICE)")
sys.exit(0)
duration_ms = args.duration_ms
if duration_ms <= 0 and args.start_time:
try:
start_ts = float(args.start_time)
duration_ms = int((time.time() - start_ts) * 1000)
except (ValueError, TypeError):
duration_ms = 1000
if duration_ms <= 0:
duration_ms = 1000
extra_attrs = {}
if args.attrs:
try:
extra_attrs = json.loads(args.attrs)
except json.JSONDecodeError:
pass
trace_name = args.trace_name
if not trace_name:
wf = _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "CI"
job = _env("GITEA_JOB") or _env("GITHUB_JOB") or "job"
trace_name = f"{wf} / {job}"
success, msg = report_ci_trace(
service_name=args.service_name,
trace_name=trace_name,
status=args.status,
duration_ms=duration_ms,
extra_attributes=extra_attrs,
)
print(msg)
sys.exit(0)
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# PR构建专用:只构建不输出,验证Dockerfile能否正常构建
# 无本地缓存(12个runner不共享,反而添乱),只用ACR远程缓存
set -eu
NO_CACHE_FLAG=""
if [ "$1" = "--no-cache" ]; then
NO_CACHE_FLAG="--no-cache"
shift
fi
DOCKERFILE="$1"
IMAGE_TAG="$2"
CACHE_REF="$3"
shift 3
BUILD_ARGS=""
for arg in "$@"; do
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
done
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
else
docker buildx use "$BUILDER_NAME"
fi
docker buildx inspect --bootstrap
echo "=== PR Build: build only, no output, remote cache only ==="
echo "Dockerfile: ${DOCKERFILE}"
echo "Image tag: ${IMAGE_TAG}"
echo ""
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
.
echo ""
echo "PR build OK (build only, no output): ${IMAGE_TAG}"
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache共享)
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
set -eu
NO_CACHE_FLAG=""
if [ "$1" = "--no-cache" ]; then
NO_CACHE_FLAG="--no-cache"
shift
echo "模式: --no-cache (不使用缓存,全新构建)"
fi
DOCKERFILE="$1"
IMAGE_TAG="$2"
CACHE_REF="$3"
shift 3
BUILD_ARGS=""
for arg in "$@"; do
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
done
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
docker buildx create --use --name ci-builder --driver docker-container
echo "Created ci-builder"
else
docker buildx use ci-builder
echo "Using existing ci-builder"
fi
docker buildx inspect --bootstrap
# 从cache_ref中提取缓存名称(如 api-cache:develop -> api-cache-develop
CACHE_NAME=$(echo "$CACHE_REF" | tr '/' '_' | tr ':' '-')
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
mkdir -p "$LOCAL_CACHE_DIR"
# 缓存源:local优先(带自动修复),registry兜底读写
# 本地缓存损坏时自动清理后重试,避免snapshot not found导致构建全挂
build_with_cache_retry() {
local attempt=1
local max_attempts=2
while [ $attempt -le $max_attempts ]; do
local build_output
local exit_code
set +e
build_output=$(docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
--cache-from "type=registry,ref=${CACHE_REF}" \
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
. 2>&1)
exit_code=$?
set -e
if [ $exit_code -eq 0 ]; then
echo "$build_output"
return 0
fi
# 检测到缓存损坏类错误,清掉本地缓存重试
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
echo "$build_output"
echo ""
echo "⚠️ Local cache appears corrupted, cleaning up and retrying (attempt $attempt/$max_attempts)..."
rm -rf "${LOCAL_CACHE_DIR}"
mkdir -p "${LOCAL_CACHE_DIR}"
# 清理buildx builder的内部snapshot状态
docker buildx prune -f -a >/dev/null 2>&1 || true
attempt=$((attempt + 1))
else
# 非缓存类错误,直接输出并返回
echo "$build_output"
return $exit_code
fi
done
# 重试完还是失败,不用本地缓存最后试一次(只从registry读)
echo "⚠️ All cached attempts failed, building without local cache..."
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
.
}
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
echo "Local cache: ${LOCAL_CACHE_DIR}"
echo "Registry cache: ${CACHE_REF}"
echo ""
build_with_cache_retry
echo ""
echo "Image pushed: ${IMAGE_TAG}"
echo "Local cache updated"
echo "Registry cache updated (if supported)"
echo ""
echo "Build completed: ${IMAGE_TAG}"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
#
# CI 健康度看板一键生成脚本
# - 从 Gitea Actions API 拉取数据
# - 生成 HTML 可视化看板
# - 输出文件路径
#
# 用法:
# bash scripts/ci/generate_ci_dashboard.sh [--days 7] [--output ci_dashboard.html]
#
# 环境变量:
# GITEA_TOKEN API Token(必需)
# GITEA_URL Gitea 地址(可选,默认 https://git.xiaoxiajianji.com
# GITEA_REPO 仓库(可选,默认 xiaoxia/xiaoxia-saas
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# 默认参数
DAYS=7
OUTPUT="ci_dashboard.html"
# 解析参数
while [[ $# -gt 0 ]]; do
case "$1" in
--days)
DAYS="$2"
shift 2
;;
--output|-o)
OUTPUT="$2"
shift 2
;;
--help|-h)
echo "用法: bash scripts/ci/generate_ci_dashboard.sh [--days 7] [--output ci_dashboard.html]"
echo ""
echo "选项:"
echo " --days N 统计最近 N 天 (默认 7)"
echo " --output PATH HTML 输出路径 (默认 ci_dashboard.html)"
echo " --help 显示帮助"
echo ""
echo "环境变量:"
echo " GITEA_TOKEN API Token(必需)"
echo " GITEA_URL Gitea 地址"
echo " GITEA_REPO 仓库"
exit 0
;;
*)
echo "未知参数: $1"
exit 1
;;
esac
done
# 检查 Python
if ! command -v python3 &> /dev/null; then
echo "[ERROR] 未找到 python3,请先安装 Python 3"
exit 1
fi
# 检查 Token
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo "[ERROR] 请设置 GITEA_TOKEN 环境变量"
exit 1
fi
echo "========================================"
echo " CI 健康度看板生成器"
echo "========================================"
echo ""
echo "统计天数: ${DAYS}"
echo "输出文件: ${OUTPUT}"
echo ""
# 生成 HTML 看板
echo "[INFO] 正在拉取数据并生成看板..."
python3 "${SCRIPT_DIR}/ci_dashboard.py" \
--days "${DAYS}" \
--html \
--html-output "${OUTPUT}"
echo ""
echo "========================================"
echo " ✅ 看板生成完成!"
echo "========================================"
echo ""
echo "文件路径: $(realpath "${OUTPUT}")"
echo ""
# 如果在 macOS 上,尝试打开
if [[ "$(uname)" == "Darwin" ]]; then
echo "[INFO] 正在打开浏览器..."
open "${OUTPUT}"
fi
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# mypyå¢žé‡æ‰«æè„šæœ¬ - CI中调用
# 环境å˜é‡: SCAN_MODE, CHANGED_PY_FILES
set -e
echo "=== Installing mypy ==="
python3 -m pip install -q mypy
mypy --version
echo ""
echo "=== Running mypy type check (hard gate mode) ==="
echo "告警模å¼ï¼Œä¸Í阻断CI"
echo ""
MYPY_COMMON_ARGS="--ignore-missing-imports --no-site-packages --no-strict-optional --explicit-package-bases --exclude tests/|test_|migrations/|alembic/ --no-error-summary --incremental --cache-dir .mypy_cache"
EXIT_CODE=0
if [ "$SCAN_MODE" = "incremental" ] && [ -n "$CHANGED_PY_FILES" ]; then
echo "=== Incremental mypy scan (PR mode) ==="
echo "Changed files: $(echo $CHANGED_PY_FILES | wc -w) files"
MYPY_FILES=""
for f in $CHANGED_PY_FILES; do
case "$f" in
apps/*|packages/*)
MYPY_FILES="$MYPY_FILES $f"
;;
esac
done
if [ -n "$MYPY_FILES" ]; then
echo "Checking: $MYPY_FILES"
mypy $MYPY_FILES $MYPY_COMMON_ARGS 2>&1 | head -80 || EXIT_CODE=$?
else
echo "No mypy-checkable files changed, skipping"
fi
else
echo "=== Full mypy scan ==="
mypy apps/api/app packages $MYPY_COMMON_ARGS 2>&1 | head -60 || EXIT_CODE=$?
fi
echo ""
if [ "$EXIT_CODE" != "0" ]; then
echo "mypy å‘现类型问题(告警模å¼ï¼Œä¸Í阻断)"
echo "建议åŽç»­é€æ­¥ä¿®å¤"
else
echo "mypy 类型检查通过"
fi
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""
PR自动扫描器扫描所有open PR对CI全绿的进行自动审批/合并
作为短作业模式的兜底机制每5分钟运行一次
新增AI审查联动 - AI代码审查发现严重问题时不自动审批
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
def api_request(token, repo, endpoint, method="GET", data=None):
"""Gitea API请求"""
url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/{endpoint}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
# 跳过SSL验证
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, context=ctx)
return json.loads(resp.read().decode()), resp.status
except urllib.error.HTTPError as e:
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
def get_open_prs(token, repo, base="develop"):
"""获取所有open的PR"""
prs = []
page = 1
while True:
data, code = api_request(token, repo, f"pulls?state=open&base={base}&sort=recentupdate&per_page=50&page={page}")
if code != 200 or not isinstance(data, list) or len(data) == 0:
break
prs.extend(data)
if len(data) < 50:
break
page += 1
return prs
def get_commit_status(token, repo, sha):
"""获取commit的CI状态汇总"""
data, code = api_request(token, repo, f"commits/{sha}/status")
if code != 200:
return {}, "error"
return data, data.get("state", "unknown")
def check_required_contexts(token, repo, sha, contexts):
"""检查指定的context是否都通过"""
data, _ = get_commit_status(token, repo, sha)
statuses = {s["context"]: s["status"] for s in data.get("statuses", [])}
all_success = True
any_pending = False
any_failed = False
for ctx in contexts:
state = statuses.get(ctx, "pending")
if state != "success":
all_success = False
if state == "pending":
any_pending = True
if state in ("failure", "error"):
any_failed = True
return all_success, any_pending, any_failed, statuses
def get_pr_files(token, repo, pr_number):
"""获取PR变更文件"""
files = []
page = 1
while True:
data, code = api_request(token, repo, f"pulls/{pr_number}/files?per_page=300&page={page}")
if code != 200 or not isinstance(data, list) or len(data) == 0:
break
files.extend(data)
if len(data) < 300:
break
page += 1
return [f["filename"] for f in files]
def is_frontend_only(files):
"""判断是否纯前端改动"""
if not files:
return False
frontend_count = sum(1 for f in files if f.startswith("apps/web/"))
backend_count = len(files) - frontend_count
return backend_count == 0 and frontend_count > 0
def has_approval(token, repo, pr_number):
"""检查PR是否已有审批"""
reviews, code = api_request(token, repo, f"pulls/{pr_number}/reviews")
if code != 200:
return False
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
def get_ai_review_result(token, repo, pr_number):
"""
检查AI代码审查结果返回 (has_critical, review_body)
has_critical: 是否有严重问题需修改的问题 > 0
review_body: 最新的AI审查评论文本
"""
# AI审查评论标记
AI_REVIEW_MARKER = "AI_CODE_REVIEW_AUTO_COMMENT"
comments, code = api_request(token, repo, f"issues/{pr_number}/comments")
if code != 200:
return False, None
# 找最新的AI审查评论
ai_comments = [c for c in comments if isinstance(c, dict) and AI_REVIEW_MARKER in c.get("body", "")]
if not ai_comments:
return False, None
# 按时间排序,取最新的
latest = max(ai_comments, key=lambda c: c.get("created_at", ""))
body = latest.get("body", "")
# 解析严重问题数量
# 匹配 "严重问题数量:X 个" 或 "需修改的问题(严重)" 下的列表
critical_count = 0
# 方式1:直接匹配数字
match = re.search(r"严重问题数量[:]\s*(\d+)\s*个", body)
if match:
critical_count = int(match.group(1))
else:
# 方式2:数 "需修改的问题" 章节下的条目数
critical_section = re.search(
r"###\s*[❌⚠️].*?(?:需修改|问题).*?\n(.*?)(?=\n###|\Z)",
body,
re.DOTALL,
)
if critical_section:
section_text = critical_section.group(1)
# 数编号条目 1. 2. 3.
items = re.findall(r"^\d+\.\s+\*\*", section_text, re.MULTILINE)
critical_count = len(items)
has_critical = critical_count > 0
return has_critical, body
def approve_pr(token, repo, pr_number, reason="CI全绿,自动审批通过。"):
"""审批PR"""
# 创建review
data, code = api_request(
token,
repo,
f"pulls/{pr_number}/reviews",
method="POST",
data={"event": "PENDING", "body": reason},
)
if code not in (200, 201):
return False, f"创建review失败: HTTP {code}"
review_id = data.get("id")
if data.get("state") == "APPROVED":
return True, "直接创建APPROVED成功"
if not review_id:
return False, "未获取到review ID"
# submit为APPROVED
data2, code2 = api_request(
token,
repo,
f"pulls/{pr_number}/reviews/{review_id}/events",
method="POST",
data={"event": "APPROVED", "body": reason},
)
if code2 in (200, 201):
return True, "审批提交成功"
else:
# 尝试另一个端点
data3, code3 = api_request(
token,
repo,
f"pulls/{pr_number}/reviews/{review_id}",
method="POST",
data={"event": "APPROVED", "body": reason},
)
if code3 in (200, 201):
return True, "审批提交成功(备用端点)"
return False, f"审批提交失败: HTTP {code2}/{code3}"
def add_pr_label(token, repo, pr_number, label):
"""给PR添加标签"""
data, code = api_request(
token,
repo,
f"issues/{pr_number}/labels",
method="POST",
data={"labels": [label]},
)
return code in (200, 201)
def merge_pr(token, repo, pr_number):
"""合并PRsquash merge"""
# 等待几秒让状态同步
time.sleep(30)
# 检查PR状态
pr_data, code = api_request(token, repo, f"pulls/{pr_number}")
if code != 200:
return False, f"获取PR状态失败: HTTP {code}"
if pr_data.get("state") != "open":
return False, f"PR状态不是open: {pr_data.get('state')}"
# 执行squash merge
data, code = api_request(
token,
repo,
f"pulls/{pr_number}/merge",
method="POST",
data={
"do": "squash",
"merge_title_field": "",
"merge_message_field": "",
"delete_branch_after_merge": True,
"force_merge": False,
},
)
if code == 200:
return True, "合并成功"
elif code == 405:
return False, "合并返回405(门禁未满足或冲突)"
else:
return False, f"合并失败: HTTP {code}"
def main():
parser = argparse.ArgumentParser(description="PR自动扫描器")
parser.add_argument("--token", required=True, help="Gitea API token")
parser.add_argument("--repo", default="xiaoxia/xiaoxia-saas", help="仓库")
parser.add_argument("--base", default="develop", help="目标分支")
parser.add_argument("--approve", action="store_true", help="执行自动审批")
parser.add_argument("--merge", action="store_true", help="执行自动合并")
parser.add_argument("--dry-run", default="false", help="试运行模式")
parser.add_argument("--max-prs", type=int, default=20, help="最多处理的PR数")
parser.add_argument("--skip-ai-review", action="store_true", help="跳过AI审查检查(强制审批)")
args = parser.parse_args()
dry_run = args.dry_run.lower() == "true"
# required contexts(与分支保护一致)
REQUIRED_CONTEXTS_FULL = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
"CI/CD Pipeline / PR Build API Image (pull_request)",
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
"CI/CD Pipeline / PR Build Web Image (pull_request)",
]
REQUIRED_CONTEXTS_APPROVE = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
FRONTEND_ONLY_CONTEXT = [
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
# 获取所有open PR
print(f"获取 {args.base} 分支的open PR...")
prs = get_open_prs(args.token, args.repo, args.base)
print(f"找到 {len(prs)} 个open PR")
approved_count = 0
merged_count = 0
skipped_count = 0
ai_blocked_count = 0
for pr in prs[: args.max_prs]:
pr_num = pr["number"]
pr_title = pr["title"]
head_sha = pr["head"]["sha"]
base_ref = pr.get("base", {}).get("re", "")
# 跳过draft
if pr.get("draft"):
print(f"\n⏭️ #{pr_num} {pr_title[:50]} - draft,跳过")
skipped_count += 1
continue
# 跳过目标分支不对的
if base_ref != args.base:
skipped_count += 1
continue
print(f"\n--- #{pr_num} {pr_title[:60]} ---")
# 判断是否纯前端
files = get_pr_files(args.token, args.repo, pr_num)
frontend_only = is_frontend_only(files)
if frontend_only:
approve_contexts = FRONTEND_ONLY_CONTEXT
merge_contexts = FRONTEND_ONLY_CONTEXT
print(f" 类型: 纯前端改动 ({len(files)}个文件)")
else:
approve_contexts = REQUIRED_CONTEXTS_APPROVE
merge_contexts = REQUIRED_CONTEXTS_FULL
print(f" 类型: 全栈/后端改动 ({len(files)}个文件)")
# 检查审批用的CI状态
all_ok, pending, failed, _ = check_required_contexts(args.token, args.repo, head_sha, approve_contexts)
# === AI审查检查 ===
ai_has_critical = False
if not args.skip_ai_review and all_ok and not failed and args.approve:
ai_has_critical, ai_body = get_ai_review_result(args.token, args.repo, pr_num)
if ai_has_critical:
print(" ⚠️ AI审查发现严重问题,阻止自动审批")
ai_blocked_count += 1
# 给PR打标签便于人工识别
if not dry_run:
add_pr_label(args.token, args.repo, pr_num, "ai-review/需修改")
# === 自动审批 ===
if args.approve and all_ok and not failed and not ai_has_critical:
if has_approval(args.token, args.repo, pr_num):
print(" ✅ 已有审批,跳过")
else:
if dry_run:
print(" 🎯 [DRY-RUN] 将自动审批")
else:
print(" 🎯 执行自动审批...")
ok, msg = approve_pr(args.token, args.repo, pr_num)
if ok:
print(f" ✅ 审批成功: {msg}")
approved_count += 1
else:
print(f" ❌ 审批失败: {msg}")
elif ai_has_critical:
print(" 🚫 AI审查阻止审批(人工可手动审批覆盖)")
elif failed:
print(" ❌ CI有失败项,跳过审批")
elif pending:
print(" ⏳ CI仍在运行,跳过")
# === 自动合并 ===
if args.merge:
# 检查合并用的CI状态
merge_ok, merge_pending, merge_failed, _ = check_required_contexts(
args.token, args.repo, head_sha, merge_contexts
)
# 检查审批
approved = has_approval(args.token, args.repo, pr_num)
if merge_ok and approved and not merge_failed:
if dry_run:
print(" 🎯 [DRY-RUN] 将自动合并")
else:
print(" 🎯 执行自动合并...")
ok, msg = merge_pr(args.token, args.repo, pr_num)
if ok:
print(f" ✅ 合并成功: {msg}")
merged_count += 1
else:
print(f" ⚠️ 合并失败: {msg}")
elif merge_pending:
print(" ⏳ 合并条件未满足: CI运行中")
elif merge_failed:
print(" ❌ 合并条件未满足: CI有失败")
elif not approved:
print(" ⏳ 合并条件未满足: 无审批")
print("\n=== 扫描结果 ===")
print(f" 处理PR数: {min(len(prs), args.max_prs)}")
print(f" 自动审批: {approved_count}")
print(f" 自动合并: {merged_count}")
print(f" AI审查阻止: {ai_blocked_count}")
print(f" 跳过: {skipped_count}")
print(" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""生成预览环境PR评论内容"""
import json
import os
import sys
def generate_deploy_comment(pr_number, preview_url):
"""生成部署成功的评论内容"""
return f"""🚀 **预览环境已部署**
| 项目 | 详情 |
|------|------|
| PR号 | #{pr_number} |
| 预览链接 | [{preview_url}]({preview_url}) |
| API环境 | staging |
> 💡 预览环境使用 staging API 数据请勿在预览环境中操作重要数据
>
> 🔄 每次提交新代码后预览环境会自动更新
>
> 🗑 PR 关闭或合并后预览环境会自动清理
"""
def generate_cleanup_comment(pr_number):
"""生成清理完成的评论内容"""
return f"""🗑️ **预览环境已清理**
PR #{pr_number} 已关闭或合并,对应的预览环境已被清理。
> 如有需要可以重新打开 PR 来重新生成预览环境
"""
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "deploy"
pr_number = os.environ.get("PR_NUMBER", "")
preview_url = os.environ.get("PREVIEW_URL", "")
if mode == "deploy":
body = generate_deploy_comment(pr_number, preview_url)
elif mode == "cleanup":
body = generate_cleanup_comment(pr_number)
else:
print(f"Unknown mode: {mode}", file=sys.stderr)
sys.exit(1)
print(json.dumps({"body": body}))
if __name__ == "__main__":
main()
+264
View File
@@ -0,0 +1,264 @@
#!/bin/bash
# ============================================================
# 预览环境服务器初始化脚本
# 用途:在业务服务器上创建预览环境所需的目录和配置
# 使用方式:bash scripts/ci/preview_init_server.sh
# ============================================================
set -eu
PREVIEW_ROOT="/var/www/preview"
NGINX_CONF_PATH="/etc/nginx/conf.d/preview.conf"
DOMAIN="xiaoxiajianji.com"
STAGING_API="https://staging-api.xiaoxiajianji.com"
echo "=========================================="
echo " 预览环境服务器初始化"
echo "=========================================="
echo ""
# 1. 创建预览根目录
echo "[1/4] 创建预览根目录..."
if [ -d "$PREVIEW_ROOT" ]; then
echo " 目录已存在: $PREVIEW_ROOT"
else
mkdir -p "$PREVIEW_ROOT"
echo " 已创建: $PREVIEW_ROOT"
fi
chown -R root:root "$PREVIEW_ROOT"
chmod -R 755 "$PREVIEW_ROOT"
echo ""
# 2. 创建测试页面(验证Nginx配置用)
echo "[2/4] 创建测试页面..."
TEST_DIR="${PREVIEW_ROOT}/pr-demo"
mkdir -p "$TEST_DIR"
cat > "$TEST_DIR/index.html" <<'EOF'
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>预览环境测试页</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; padding: 40px; border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h1 { color: #2d3748; margin-top: 0; }
.success { color: #38a169; font-size: 48px; margin: 20px 0; }
p { color: #718096; line-height: 1.6; }
code { background: #edf2f7; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
</style>
</head>
<body>
<div class="card">
<div class="success">✅</div>
<h1>预览环境配置成功!</h1>
<p>如果你能看到这个页面,说明 Nginx 预览环境配置正确。</p>
<p>当前站点通过子域名 <code>pr-demo.preview</code> 路由到 <code>/var/www/preview/pr-demo/</code> 目录。</p>
</div>
</body>
</html>
EOF
echo " 测试页面已创建: $TEST_DIR/index.html"
echo ""
# 3. 检查Nginx是否安装
echo "[3/4] 检查Nginx环境..."
if command -v nginx > /dev/null 2>&1; then
echo " Nginx 已安装: $(nginx -v 2>&1)"
NGINX_INSTALLED=true
else
echo " ⚠️ Nginx 未安装,请先安装 Nginx"
NGINX_INSTALLED=false
fi
echo ""
# 4. 输出Nginx配置建议
echo "[4/4] Nginx 配置建议"
echo ""
echo "----------------------------------------"
echo " 请将以下配置保存到: $NGINX_CONF_PATH"
echo " 或复制到 Nginx 配置目录中"
echo "----------------------------------------"
echo ""
cat <<'NGINX_CONF'
# ============================================================
# 预览环境 Nginx 配置
# 支持 *.preview.xiaoxiajianji.com 通配符子域名
# ============================================================
# 从子域名中提取 PR 号(如 pr-123.preview -> pr-123
map $host $preview_pr {
default "";
~^(?<pr>pr-\d+)\.preview\.xiaoxiajianji\.com$ $pr;
}
# HTTP 服务器(80端口)
server {
listen 80;
server_name *.preview.xiaoxiajianji.com;
# 根目录根据子域名动态映射
root /var/www/preview/$preview_pr;
# 索引文件
index index.html;
# 字符集
charset utf-8;
# 访问日志
access_log /var/log/nginx/preview_access.log;
error_log /var/log/nginx/preview_error.log warn;
# 如果子域名格式不正确,返回404
if ($preview_pr = "") {
return 404;
}
# 如果预览目录不存在,返回404
if (!-d $document_root) {
return 404;
}
# API 反向代理到 staging 环境
location /api/ {
proxy_pass https://staging-api.xiaoxiajianji.com/api/;
proxy_http_version 1.1;
proxy_set_header Host staging-api.xiaoxiajianji.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# 超时设置
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# WebSocket 支持(如需要)
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
}
# 静态资源缓存
location /assets/ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
try_files $uri =404;
}
# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}
# 安全相关响应头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 禁止隐藏文件访问
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
# HTTPS 服务器(443端口)
# 注意:需要先配置 SSL 证书
# 建议使用 certbot 或手动配置证书
#
# server {
# listen 443 ssl http2;
# server_name *.preview.xiaoxiajianji.com;
#
# # SSL 证书配置(请替换为实际证书路径)
# ssl_certificate /path/to/fullchain.pem;
# ssl_certificate_key /path/to/privkey.pem;
#
# # SSL 安全配置
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
#
# # 其余配置与 HTTP 相同
# root /var/www/preview/$preview_pr;
# index index.html;
# charset utf-8;
#
# access_log /var/log/nginx/preview_ssl_access.log;
# error_log /var/log/nginx/preview_ssl_error.log warn;
#
# if ($preview_pr = "") {
# return 404;
# }
#
# if (!-d $document_root) {
# return 404;
# }
#
# location /api/ {
# proxy_pass https://staging-api.xiaoxiajianji.com/api/;
# proxy_http_version 1.1;
# proxy_set_header Host staging-api.xiaoxiajianji.com;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Forwarded-Host $host;
# proxy_connect_timeout 30s;
# proxy_send_timeout 60s;
# proxy_read_timeout 60s;
# }
#
# location /assets/ {
# expires 7d;
# add_header Cache-Control "public, max-age=604800, immutable";
# try_files $uri =404;
# }
#
# location / {
# try_files $uri $uri/ /index.html;
# }
#
# add_header X-Frame-Options "SAMEORIGIN" always;
# add_header X-Content-Type-Options "nosniff" always;
# add_header X-XSS-Protection "1; mode=block" always;
#
# location ~ /\. {
# deny all;
# access_log off;
# log_not_found off;
# }
# }
NGINX_CONF
echo ""
echo "----------------------------------------"
echo " 配置完成后的操作步骤:"
echo "----------------------------------------"
echo ""
echo "1. 将上面的 Nginx 配置保存到合适的位置(如 /etc/nginx/conf.d/preview.conf"
echo "2. 测试配置: nginx -t"
echo "3. 重载配置: nginx -s reload"
echo "4. 配置 DNS 解析: 将 *.preview.xiaoxiajianji.com 指向服务器 IP"
echo "5. 配置 SSL 证书(推荐使用 Let's Encrypt 通配符证书)"
echo ""
echo "测试方式:"
echo " 访问 http://pr-demo.preview.xiaoxiajianji.com 验证配置"
echo ""
echo "=========================================="
echo " 初始化完成"
echo "=========================================="
+226
View File
@@ -0,0 +1,226 @@
# ============================================================
# 预览环境 Nginx 配置模板
# 支持 *.preview.xiaoxiajianji.com 通配符子域名
#
# 使用方法:
# 1. 将本文件复制到 Nginx 配置目录(如 /etc/nginx/conf.d/preview.conf
# 2. 根据实际情况修改域名和 API 地址
# 3. 运行 nginx -t 测试配置
# 4. 运行 nginx -s reload 重载配置
#
# 前置条件:
# - DNS 已配置 *.preview.xiaoxiajianji.com 指向本服务器
# - 预览根目录已创建:/var/www/preview/
# - 每个 PR 的静态文件放在 /var/www/preview/pr-{N}/ 下
# ============================================================
# ---- 变量定义 ----
# 从子域名中提取 PR 号(如 pr-123.preview -> pr-123
map $host $preview_pr {
default "";
~^(?<pr>pr-\d+)\.preview\.xiaoxiajianji\.com$ $pr;
}
# ---- HTTP 服务器(80端口) ----
server {
listen 80;
server_name *.preview.xiaoxiajianji.com;
# 根目录根据子域名动态映射
root /var/www/preview/$preview_pr;
# 索引文件
index index.html;
# 字符集
charset utf-8;
# 访问日志
access_log /var/log/nginx/preview_access.log;
error_log /var/log/nginx/preview_error.log warn;
# 如果子域名格式不正确,返回404
if ($preview_pr = "") {
return 404;
}
# 如果预览目录不存在,返回404
if (!-d $document_root) {
return 404;
}
# ---- API 反向代理到 staging 环境 ----
location /api/ {
proxy_pass https://staging-api.xiaoxiajianji.com/api/;
proxy_http_version 1.1;
# 请求头设置
proxy_set_header Host staging-api.xiaoxiajianji.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# 超时设置
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# 重定向跟随
proxy_redirect off;
# WebSocket 支持(如需要,取消注释)
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
}
# ---- 生成文件代理(如需要) ----
# location /generated-files/ {
# proxy_pass https://staging-api.xiaoxiajianji.com/generated-files/;
# proxy_http_version 1.1;
# proxy_set_header Host staging-api.xiaoxiajianji.com;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# ---- 静态资源缓存 ----
location /assets/ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
try_files $uri =404;
}
# ---- SPA 路由支持 ----
location / {
try_files $uri $uri/ /index.html;
}
# ---- 安全相关响应头 ----
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ---- 禁止隐藏文件访问 ----
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# ---- 禁止敏感文件访问 ----
location ~* \.(env|log|sql|bak|swp|tmp|zip|tar|gz)$ {
deny all;
access_log off;
log_not_found off;
}
}
# ============================================================
# HTTPS 服务器配置(可选,需要 SSL 证书)
#
# 推荐使用 Let's Encrypt 通配符证书:
# certbot certonly --dns-xxx -d "*.preview.xiaoxiajianji.com"
#
# 启用方法:取消下方注释,并修改证书路径
# ============================================================
#
# server {
# listen 443 ssl http2;
# server_name *.preview.xiaoxiajianji.com;
#
# # SSL 证书配置
# ssl_certificate /etc/letsencrypt/live/preview.xiaoxiajianji.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/preview.xiaoxiajianji.com/privkey.pem;
#
# # SSL 安全配置
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
# ssl_prefer_server_ciphers off;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
# ssl_session_tickets off;
#
# # OCSP Stapling
# ssl_stapling on;
# ssl_stapling_verify on;
#
# # 根目录根据子域名动态映射
# root /var/www/preview/$preview_pr;
#
# # 索引文件
# index index.html;
#
# # 字符集
# charset utf-8;
#
# # 访问日志
# access_log /var/log/nginx/preview_ssl_access.log;
# error_log /var/log/nginx/preview_ssl_error.log warn;
#
# # 如果子域名格式不正确,返回404
# if ($preview_pr = "") {
# return 404;
# }
#
# # 如果预览目录不存在,返回404
# if (!-d $document_root) {
# return 404;
# }
#
# # API 反向代理到 staging 环境
# location /api/ {
# proxy_pass https://staging-api.xiaoxiajianji.com/api/;
# proxy_http_version 1.1;
# proxy_set_header Host staging-api.xiaoxiajianji.com;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Forwarded-Host $host;
# proxy_connect_timeout 30s;
# proxy_send_timeout 60s;
# proxy_read_timeout 60s;
# proxy_buffering on;
# proxy_buffer_size 4k;
# proxy_buffers 8 4k;
# }
#
# # 静态资源缓存
# location /assets/ {
# expires 7d;
# add_header Cache-Control "public, max-age=604800, immutable";
# try_files $uri =404;
# }
#
# # SPA 路由支持
# location / {
# try_files $uri $uri/ /index.html;
# }
#
# # 安全相关响应头
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# add_header X-Frame-Options "SAMEORIGIN" always;
# add_header X-Content-Type-Options "nosniff" always;
# add_header X-XSS-Protection "1; mode=block" always;
# add_header Referrer-Policy "strict-origin-when-cross-origin" always;
#
# # 禁止隐藏文件访问
# location ~ /\. {
# deny all;
# access_log off;
# log_not_found off;
# }
#
# # 禁止敏感文件访问
# location ~* \.(env|log|sql|bak|swp|tmp|zip|tar|gz)$ {
# deny all;
# access_log off;
# log_not_found off;
# }
# }
+347
View File
@@ -0,0 +1,347 @@
#!/bin/bash
# CI Integration Tests Job 主脚本
# 包含:依赖安装、ffmpeg安装、Redis启动、PG启动、迁移、测试、清理、覆盖率
# 支持 pytest-xdist 并行执行:每个 worker 使用独立数据库,预期加速 2-4 倍
set -eu
# 加载CI共享常量
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
# shellcheck source=ci_env.sh
source "${SCRIPT_DIR}/ci_env.sh"
echo "=== CI Integration Tests 开始 ==="
# --- 安装依赖 ---
echo ""
echo "=== 安装 Python 依赖 ==="
# pip install 带重试(网络不稳定时自动重试)
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q pytest-rerunfailures pytest-xdist && break
echo "pip install pytest-rerunfailures/pytest-xdist 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
pytest --version
echo "pytest-xdist: $(python3 -c "import xdist; print(xdist.__version__)" 2>/dev/null || echo 'not installed')"
# --- 安装 ffmpeg ---
echo ""
echo "=== 安装 ffmpeg ==="
bash scripts/ci/step_install_ffmpeg.sh
# --- DooD模式检测:确定宿主机访问地址 ---
# DooD模式下,docker run启动的容器跑在宿主机Docker上
# 需要用宿主机IP访问映射端口
# 检测策略:host.docker.internal -> docker0桥接IP -> 容器IP直连 -> 默认网关 -> 127.0.0.1
detect_docker_host() {
local test_port="${1:-${CI_LOCAL_PG_PORT}}"
# 候选IP列表
local candidates=()
# 1. host.docker.internalrunner配置了--add-host时可用)
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
candidates+=("host.docker.internal")
fi
# 2. docker0 桥接网关 (172.17.0.1)
candidates+=("172.17.0.1")
# 3. 默认网关(容器网络的网关即宿主机)
local gw=""
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
candidates+=("$gw")
fi
# 4. 宿主机可能的IP:容器同网段的.1或.254
local my_ip=""
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$my_ip" ]; then
# 尝试同网段的常见宿主机IP
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
candidates+=("${subnet}.1")
candidates+=("${subnet}.254")
fi
# 5. 127.0.0.1 最后尝试
candidates+=("127.0.0.1")
# 测试每个候选IP
for candidate in "${candidates[@]}"; do
if python3 -c "
import socket
s = socket.socket()
s.settimeout(2)
try:
s.connect(('$candidate', $test_port))
s.close()
print('ok')
except:
pass
" 2>/dev/null | grep -q ok; then
echo "$candidate"
return 0
fi
done
# 都失败则返回127.0.0.1
echo "127.0.0.1"
return 1
}
# 获取宿主机IP(先尝试用共享PG端口5433测试,再回退到其他端口)
if [ -S /var/run/docker.sock ]; then
# 先用共享PG端口5433探测
DOCKER_HOST_IP=$(detect_docker_host "${CI_SHARED_PG_PORT}")
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
# 如果共享PG端口探测失败,说明不在DooD或共享PG不可用,再试其他端口
DOCKER_HOST_IP=$(detect_docker_host 22)
fi
echo "检测到DooD模式(/var/run/docker.sock已挂载),宿主机地址: $DOCKER_HOST_IP"
else
DOCKER_HOST_IP="127.0.0.1"
echo "非DooD模式,使用 127.0.0.1"
fi
PG_HOST="$DOCKER_HOST_IP"
REDIS_HOST="$DOCKER_HOST_IP"
echo "PG host: $PG_HOST, Redis host: $REDIS_HOST"
# --- 指数退避TCP连接检查函数 ---
# 用法: wait_tcp_ready host port max_attempts
wait_tcp_ready() {
local host="$1"
local port="$2"
local max_attempts="${3:-5}"
local delay=1
local attempt=1
while [ "$attempt" -le "$max_attempts" ]; do
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
return 0
fi
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
sleep "$delay"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
return 1
}
# --- 启动 Redis ---
echo ""
echo "=== 启动 Redis ==="
REDIS_CONTAINER="ci-redis-${GITHUB_RUN_ID:-$$}"
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
docker run -d --name "$REDIS_CONTAINER" \
-P \
--health-cmd "redis-cli ping" \
--health-interval 2s \
--health-timeout 2s \
--health-retries 10 \
redis:7-alpine
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | cut -d: -f2)
echo "Redis port: $REDIS_PORT"
export REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}/0"
# 等待容器健康
for i in $(seq 1 15); do
if docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" 2>/dev/null | grep -q healthy; then
echo "Redis container is ready on port $REDIS_PORT"
break
fi
echo "Waiting for Redis container health... ($i/15)"
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" | grep -q healthy
# TCP连通性检查(指数退避)
echo "验证Redis TCP连通性 ($REDIS_HOST:$REDIS_PORT)..."
wait_tcp_ready "$REDIS_HOST" "$REDIS_PORT" 5
echo "TCP connectivity to Redis confirmed on port $REDIS_PORT"
# --- 启动/连接 PostgreSQL ---
echo ""
echo "=== 准备 PostgreSQL ==="
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
if [ "$USE_SHARED_PG" = "true" ]; then
# 使用常驻共享PG实例
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true"
SHARED_PG_HOST="$PG_HOST"
SHARED_PG_PORT="${CI_SHARED_PG_PORT}"
SHARED_PG_USER="${CI_SHARED_PG_USER}"
SHARED_PG_PASSWORD="${CI_SHARED_PG_PASSWORD}"
echo "等待共享PG连接就绪..."
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
# 创建主数据库(xdist 模式下各 worker 会创建自己的数据库,主库作为 fallback)
echo "创建主测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
cur.close()
conn.close()
"
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
PG_CONTAINER=""
else
# 使用临时PG容器
echo "使用临时PG容器模式"
PG_CONTAINER="ci-pg-${GITHUB_RUN_ID:-$$}"
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
--shm-size=256m \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=xiaoxia_saas \
-P \
--health-cmd "pg_isready -U postgres" \
--health-interval 5s \
--health-timeout 5s \
--health-retries 12 \
postgres:16
PG_PORT=$(docker port "$PG_CONTAINER" ${CI_LOCAL_PG_PORT}/tcp | cut -d: -f2)
echo "PostgreSQL port: $PG_PORT"
export DATABASE_URL="postgresql+psycopg://${CI_SHARED_PG_USER}:${CI_SHARED_PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
# 等待容器健康
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
echo "PostgreSQL container is ready on port $PG_PORT"
break
fi
echo "Waiting for PostgreSQL container health... ($i/30)"
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
# TCP连通性检查(指数退避)
echo "验证PostgreSQL TCP连通性 ($PG_HOST:$PG_PORT)..."
wait_tcp_ready "$PG_HOST" "$PG_PORT" 5
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
fi
# --- 执行迁移(主数据库,xdist worker 会各自创建自己的库并迁移) ---
echo ""
echo "=== 执行 Alembic 迁移(主数据库) ==="
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
echo "✅ 迁移完成"
# --- 运行集成测试(pytest-xdist 并行) ---
echo ""
echo "=== 运行集成测试(pytest-xdist 并行模式) ==="
echo "CPU 核数: $(nproc 2>/dev/null || echo 'unknown')"
# 集成测试使用 pytest-xdist 并行加速(coverage 由单元测试负责,并行模式下 coverage 不稳定)
# -n auto: 自动使用 CPU 核数(DooD模式下加--maxprocesses=4防止OOM
# --dist loadfile: 同一测试文件分配到同一 worker(共享 fixture 更高效)
# --maxfail=1: 遇到失败停止调度新测试(并行模式下等价于 -x)
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration \
-q --timeout=60 --maxfail=1 --reruns 3 --reruns-delay 5 \
-m "not performance" \
-n auto --maxprocesses=4 --dist loadfile \
-p no:cacheprovider
echo "✅ 集成测试通过"
# --- API 性能基线测试(仅告警,串行执行) ---
echo ""
echo "=== API 性能基线测试(仅告警) ==="
set +e
PERF_OUTPUT=$(mktemp)
# 性能测试单独串行运行(不参与并行,避免资源竞争影响测量结果)
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration/test_api_performance.py \
-v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT" \
--reruns 3 \
--reruns-delay=10
echo ""
echo "=== 性能测试摘要 ==="
grep "PERF_STATS:" "$PERF_OUTPUT" || echo "PERF_STATS: 未找到统计数据"
grep "PERF_RESULT:" "$PERF_OUTPUT" || echo "PERF_RESULT: 未找到详细结果"
TOTAL=$(grep -c "PERF_RESULT:" "$PERF_OUTPUT" || echo 0)
PASSED=$(grep "PERF_RESULT: PASS" "$PERF_OUTPUT" | wc -l)
FAILED=$(grep "PERF_RESULT: FAIL" "$PERF_OUTPUT" | wc -l)
echo ""
echo "性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标"
if [ "$FAILED" -gt 0 ]; then
echo ""
echo "⚠️ 警告: $FAILED 个接口性能未达标"
fi
rm -f "$PERF_OUTPUT"
set -e
# --- 清理 ---
echo ""
echo "=== 清理 ==="
if [ "$USE_SHARED_PG" = "true" ]; then
# 清理共享PG上的测试数据库(主库 + 可能残留的 worker 库)
echo "清理共享PG测试数据库..."
# 清理所有以 CI_DB_NAME 开头的数据库(主库 + worker 库)
PGPASSWORD="${SHARED_PG_PASSWORD}" python3 -c "
import psycopg2
conn = psycopg2.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
# 查找所有需要清理的数据库(主库 + worker 库)
cur.execute(\"SELECT datname FROM pg_database WHERE datname LIKE '$CI_DB_NAME%'\")
dbs = [row[0] for row in cur.fetchall()]
for db in dbs:
try:
# 强制断开所有连接
cur.execute(f\"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{db}' AND pid <> pg_backend_pid()\")
cur.execute(f'DROP DATABASE IF EXISTS \"{db}\" WITH (FORCE)')
print(f' 已清理: {db}')
except Exception as e:
print(f' 警告: 清理 {db} 失败: {e}')
cur.close()
conn.close()
" 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)"
echo "✅ 共享PG数据库已清理"
else
# 清理临时PG容器
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
echo "✅ PG容器已清理"
fi
# 清理Redis容器
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
echo "✅ Redis容器已清理"
# --- 覆盖率汇总 ---
echo ""
echo "=== 覆盖率汇总 ==="
set +e
python3 scripts/ci_coverage_summary.py
set -e
echo ""
echo "=== CI Integration Tests 全部通过 ✅ ==="
+163
View File
@@ -0,0 +1,163 @@
#!/bin/bash
# CI Unit Tests Job 主脚本
# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁
set -eu
# 测试环境必须的密钥变量
export JWT_SECRET_KEY=${JWT_SECRET_KEY:-test-jwt-secret-for-ci-only-2026}
JOB_NAME="${1:-Unit Tests}"
echo "=== CI Unit Tests 开始 ==="
# --- 配置 pip 国内源(加速下载,减少网络失败)---
python3 -m pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
python3 -m pip config set global.timeout 120
python3 -m pip config set global.retries 5
# --- 安装依赖 ---
echo ""
echo "=== 安装 Python 依赖 ==="
# pip install 带重试(网络不稳定时自动重试)
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-worker.txt && break
echo "pip install requirements-worker.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
pytest --version
# --- 安装 ffmpeg(视频处理相关测试依赖)---
bash scripts/ci/step_install_ffmpeg.sh
# 双保险:确保numpy已安装
python3 -m pip install -q numpy==1.26.4 || true
# --- 增量测试选择(仅PR) ---
UNIT_TEST_MODE="full"
SELECTED_TEST_FILES="tests/unit"
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
echo ""
echo "=== 增量测试选择 ==="
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
set +e
CHANGED_FILES="$CHANGED_FILES" \
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
python3 scripts/ci/select_unit_tests.py
SELECT_EXIT=$?
set -e
if [ $SELECT_EXIT -eq 0 ]; then
UNIT_TEST_MODE="incremental"
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
SELECTED_TEST_FILES="$TEST_FILES"
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
else
echo "全量模式"
fi
fi
# --- 运行单元测试 + 覆盖率 ---
echo ""
echo "=== 运行单元测试 (模式: $UNIT_TEST_MODE) ==="
if [ "$UNIT_TEST_MODE" = "incremental" ]; then
echo "=== 增量测试模式 ==="
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD/packages:$PWD" python3 -m coverage run \
--source=apps/api/app,apps/worker/worker_app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest $SELECTED_TEST_FILES -q
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
python3 -m coverage report --fail-under=10 > /dev/null || true
else
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD/packages:$PWD" python3 -m coverage run \
--source=apps/api/app,apps/worker/worker_app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest tests/unit -q
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
python3 -m coverage report --fail-under=65 > /dev/null
fi
# --- Diff 覆盖率检查(仅PR ---
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
echo ""
echo "=== Diff 覆盖率检查 ==="
BASE_BRANCH="${GITHUB_BASE_REF:-develop}"
echo "Base branch: $BASE_BRANCH"
PR_CODE_DIR="/tmp/pr-code-$$"
mkdir -p "$PR_CODE_DIR"
# 备份PR代码(含coverage.xmldiff-cover需要用到
find . -maxdepth 1 -mindepth 1 ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
rm -rf .git
git init > /dev/null 2>&1
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
git config user.email "ci@local"
git config user.name "CI"
git fetch origin "$BASE_BRANCH" --depth=200
# 先清理工作目录,避免未跟踪文件导致checkout失败
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
git checkout -b ci-pr-branch "origin/$BASE_BRANCH" > /dev/null 2>&1
# 清除base分支源码,用PR代码覆盖
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
cp -r "$PR_CODE_DIR"/. .
rm -rf "$PR_CODE_DIR"
git add -A > /dev/null 2>&1
git commit -m "ci-tmp" > /dev/null 2>&1
if [ "$UNIT_TEST_MODE" = "incremental" ]; then
THRESHOLD=40
echo "增量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
else
THRESHOLD=60
echo "全量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
fi
set +e
python3 -m diff_cover.diff_cover_tool coverage.xml \
--compare-branch="origin/$BASE_BRANCH" \
--fail-under=$THRESHOLD \
--html-report diff_coverage.html \
2>&1
DIFF_EXIT=$?
set -e
if [ $DIFF_EXIT -ne 0 ]; then
echo ""
echo "❌ 增量覆盖率未达到门槛 (${THRESHOLD}%)"
echo " 请为改动的代码添加单元测试后再提交"
echo ""
echo "=== 覆盖率报告 ==="
python3 -m diff_cover.diff_cover_tool coverage.xml \
--compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30
exit 1
fi
echo "✅ 增量覆盖率达标"
fi
echo ""
echo "=== CI Unit Tests 全部通过 ✅ ==="
+658
View File
@@ -0,0 +1,658 @@
#!/bin/bash
# CI Validate Job 主脚本:并行化代码质量检查
# 将 8 项检查分为 2 组并行执行,预计耗时从 ~1.8min 降至 ~1min
#
# 并行分组:
# Group A(独立并行):
# A1: Secret detection (detect-secrets)
# A2: Code quality checks (black/isort/ruff/compileall)
# A3: Mypy type check
# A4: Advisory checks (bandit + pip-audit + vulture + release scripts syntax)
# Group BPG 依赖,独立并行):
# B1: Alembic migrations validation(需要 PG
#
# 所有子任务同时启动,最后汇总结果。
set -eu
# 加载CI共享常量
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
# shellcheck source=ci_env.sh
source "${SCRIPT_DIR}/ci_env.sh"
echo "=== CI Validate: 并行化代码质量检查 ==="
echo ""
# ============================================================
# 配置
# ============================================================
LOG_DIR="/tmp/validate_logs"
rm -rf "$LOG_DIR"
mkdir -p "$LOG_DIR"
# 子任务结果文件(每个记录 exit code)
RESULT_FILE="$LOG_DIR/results.json"
echo '{}' > "$RESULT_FILE"
# ============================================================
# 工具函数
# ============================================================
# 记录子任务结果
# 用法: record_result <name> <exit_code> <blocking>
record_result() {
local name="$1"
local exit_code="$2"
local blocking="$3" # "yes" or "no"
# 写入独立文件,避免并发写 JSON 冲突
echo "${exit_code}" > "$LOG_DIR/exit_${name}"
echo "${blocking}" > "$LOG_DIR/blocking_${name}"
}
# ============================================================
# 子任务定义(每个子任务输出写入独立日志文件)
# ============================================================
# --- A1: Secret detection ---
task_secret_detection() {
local log="$LOG_DIR/task_secret_detection.log"
exec > "$log" 2>&1
set +e
echo "=== [A1] Secret detection (detect-secrets) ==="
python3 -m pip install -q detect-secrets
detect-secrets --version
detect-secrets scan \
--all-files \
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
--disable-plugin Base64HighEntropyString \
--disable-plugin HexHighEntropyString \
--disable-plugin BasicAuthDetector \
--disable-plugin KeywordDetector \
--disable-plugin IPPublicDetector \
> /tmp/secrets-scan.json 2>&1
FOUND=$(python3 -c "
import json
try:
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
results = data.get('results', {})
total = sum(len(v) for v in results.values())
print(total)
except Exception:
print('error')
")
echo "Secrets detected: $FOUND"
local exit_code=0
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
echo ""
echo "=== Secret details ==="
python3 -c "
import json
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
for fpath, items in data.get('results', {}).items():
for item in items:
line = item.get('line_number', '?')
stype = item.get('type', '?')
hashed = item.get('hashed_secret', '')[:16]
print(f' {fpath}:{line} [{stype}] {hashed}...')
"
echo ""
echo "ERROR: Potential secrets detected in code!"
exit_code=1
else
echo "✅ Secret scan passed"
fi
record_result "secret_detection" "$exit_code" "yes"
exit $exit_code
}
# --- A2: Code quality checks ---
task_code_quality() {
local log="$LOG_DIR/task_code_quality.log"
exec > "$log" 2>&1
set +e
echo "=== [A2] Code quality checks (black/isort/ruff/compileall) ==="
# --- 增量/全量模式判断 ---
local SCAN_MODE="full"
local CHANGED_PY_FILES=""
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "200" ]; then
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
import json, sys
try:
files = json.load(sys.stdin)
py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed']
print(' '.join(py_files))
except Exception:
print('')
")
if [ -n "$CHANGED_PY_FILES" ]; then
SCAN_MODE="incremental"
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
else
SCAN_MODE="skip_py"
echo "No Python files changed in this PR"
fi
else
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
fi
else
echo "Full scan mode (not a PR event)"
fi
local exit_code=0
if [ "$SCAN_MODE" = "incremental" ]; then
# 防御性过滤:磁盘上不存在的文件(已删除文件)不参与检查
local EXISTING_PY_FILES=""
for f in $CHANGED_PY_FILES; do
if [ -f "$f" ]; then
if [ -z "$EXISTING_PY_FILES" ]; then
EXISTING_PY_FILES="$f"
else
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
fi
fi
done
CHANGED_PY_FILES="$EXISTING_PY_FILES"
python3 -m compileall -q $CHANGED_PY_FILES || exit_code=$?
if [ $exit_code -eq 0 ]; then
python3 -m black --check --fast $CHANGED_PY_FILES || exit_code=$?
fi
if [ $exit_code -eq 0 ]; then
python3 -m isort --check-only $CHANGED_PY_FILES || exit_code=$?
fi
if [ $exit_code -eq 0 ]; then
local RUFF_FILES
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
if [ -n "$RUFF_FILES" ]; then
python3 -m ruff check $RUFF_FILES --statistics || exit_code=$?
else
echo "No ruff-checkable files changed, skipping"
fi
fi
elif [ "$SCAN_MODE" = "skip_py" ]; then
echo "No Python files changed - skipping Python lint checks"
else
echo "Full scan mode"
python3 -m compileall -q alembic apps packages tests scripts || exit_code=$?
if [ $exit_code -eq 0 ]; then
python3 -m black --check --fast alembic apps packages tests scripts || exit_code=$?
fi
if [ $exit_code -eq 0 ]; then
python3 -m isort --check-only alembic apps packages tests scripts || exit_code=$?
fi
if [ $exit_code -eq 0 ]; then
python3 -m ruff check apps packages tests --statistics || exit_code=$?
fi
fi
if [ $exit_code -eq 0 ]; then
echo "✅ Code quality checks passed"
else
echo "❌ Code quality checks FAILED"
fi
record_result "code_quality" "$exit_code" "yes"
exit $exit_code
}
# --- A3: Mypy type check ---
task_mypy() {
local log="$LOG_DIR/task_mypy.log"
exec > "$log" 2>&1
set +e
echo "=== [A3] Type check (mypy) ==="
bash scripts/ci/mypy_check.sh
local exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "✅ Mypy type check passed"
else
echo "❌ Mypy type check FAILED"
fi
record_result "mypy" "$exit_code" "yes"
exit $exit_code
}
# --- A4: Advisory checks (bandit + pip-audit + vulture + release scripts syntax) ---
task_advisory() {
local log="$LOG_DIR/task_advisory.log"
exec > "$log" 2>&1
set +e
# --- Bandit 安全扫描(仅告警) ---
echo "=== [A4a] Security scan (bandit, advisory only) ==="
bandit -r apps packages -q -ll
local BANDIT_EXIT=$?
if [ "$BANDIT_EXIT" -ne 0 ]; then
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
else
echo "✅ Bandit security scan passed"
fi
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
echo ""
echo "=== [A4b] Python dependency vulnerability scan (pip-audit, advisory only) ==="
python3 -m pip install -q pip-audit
pip-audit --version
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
if [ -f "$req_file" ]; then
echo "--- Scanning $req_file ---"
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || true
echo ""
fi
done
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
# --- Vulture 死代码检测(仅告警) ---
echo ""
echo "=== [A4c] Dead code detection (vulture, advisory only) ==="
python3 -m pip install -q vulture
vulture --version
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
echo ""
vulture apps packages scripts \
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
--min-confidence 70 \
2>&1 | sort -t'(' -k2 -rn | head -80
echo ""
echo "=== vulture scan summary ==="
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
echo "建议:定期人工审查高置信度(>=90%)条目"
# --- Release 脚本语法校验(不阻断) ---
echo ""
echo "=== [A4d] Release scripts syntax validation ==="
local syntax_exit=0
bash -n scripts/backup_postgres.sh || syntax_exit=$?
bash -n scripts/restore_postgres_plan.sh || syntax_exit=$?
bash -n scripts/init_production_env.sh || syntax_exit=$?
if [ $syntax_exit -eq 0 ]; then
echo "✅ Release scripts syntax OK"
else
echo "⚠️ Release scripts have syntax issues (advisory)"
fi
# Advisory checks never block
record_result "advisory" 0 "no"
exit 0
}
# --- B1: Alembic migrations validation (needs PG) ---
task_alembic() {
local log="$LOG_DIR/task_alembic.log"
exec > "$log" 2>&1
set +e
echo "=== [B1] Alembic migrations validation ==="
# --- DooD模式检测:确定宿主机访问地址 ---
detect_docker_host() {
local test_port="${1:-${CI_LOCAL_PG_PORT}}"
local candidates=()
# 1. host.docker.internal
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
candidates+=("host.docker.internal")
fi
# 2. docker0 桥接网关 (172.17.0.1)
candidates+=("172.17.0.1")
# 3. 默认网关
local gw=""
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
candidates+=("$gw")
fi
# 4. 宿主机可能的IP
local my_ip=""
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$my_ip" ]; then
local subnet
subnet=$(echo "$my_ip" | cut -d. -f1-3)
candidates+=("${subnet}.1")
candidates+=("${subnet}.254")
fi
# 5. 127.0.0.1
candidates+=("127.0.0.1")
for candidate in "${candidates[@]}"; do
if python3 -c "
import socket
s = socket.socket()
s.settimeout(2)
try:
s.connect(('$candidate', $test_port))
s.close()
print('ok')
except:
pass
" 2>/dev/null | grep -q ok; then
echo "$candidate"
return 0
fi
done
echo "127.0.0.1"
return 1
}
# 指数退避TCP连接检查函数
wait_tcp_ready() {
local host="$1"
local port="$2"
local max_attempts="${3:-5}"
local delay=1
local attempt=1
while [ "$attempt" -le "$max_attempts" ]; do
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
return 0
fi
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
sleep "$delay"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
return 1
}
# 获取宿主机IP
local PG_HOST
if [ -S /var/run/docker.sock ]; then
PG_HOST=$(detect_docker_host "${CI_SHARED_PG_PORT}")
if [ "$PG_HOST" = "127.0.0.1" ]; then
PG_HOST=$(detect_docker_host 22)
fi
echo "检测到DooD模式(/var/run/docker.sock已挂载),宿主机地址: $PG_HOST"
else
PG_HOST="127.0.0.1"
echo "非DooD模式,使用 127.0.0.1"
fi
echo "PG host: $PG_HOST"
local USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
local exit_code=0
if [ "$USE_SHARED_PG" = "true" ]; then
# 使用常驻共享PG实例
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true"
local SHARED_PG_HOST="$PG_HOST"
local SHARED_PG_PORT="${CI_SHARED_PG_PORT}"
local SHARED_PG_USER="${CI_SHARED_PG_USER}"
local SHARED_PG_PASSWORD="${CI_SHARED_PG_PASSWORD}"
local CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
echo "等待共享PG连接就绪..."
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
echo "创建测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
cur.close()
conn.close()
" || exit_code=$?
if [ $exit_code -eq 0 ]; then
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head || exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "✅ Alembic migrations applied successfully"
fi
# 清理数据库
echo "清理测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
cur.close()
conn.close()
" 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)"
echo "✅ 共享PG数据库已清理"
fi
else
# 使用临时PG容器
echo "使用临时PG容器模式"
local PG_CONTAINER="ci-pg-validate-${GITHUB_RUN_ID:-$$}"
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
--shm-size=256m \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=xiaoxia_saas \
-P \
--health-cmd "pg_isready -U postgres" \
--health-interval 3s \
--health-timeout 3s \
--health-retries 20 \
postgres:16-alpine || exit_code=$?
if [ $exit_code -eq 0 ]; then
local PG_PORT
PG_PORT=$(docker port "$PG_CONTAINER" ${CI_LOCAL_PG_PORT}/tcp | cut -d: -f2)
echo "PostgreSQL port: $PG_PORT"
export DATABASE_URL="postgresql+psycopg://${CI_SHARED_PG_USER}:${CI_SHARED_PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
# 等待容器健康
local i
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
echo "PostgreSQL container is healthy on port $PG_PORT"
break
fi
echo "Waiting for PostgreSQL container health... ($i/30)"
sleep 2
done
if ! docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
echo "❌ PostgreSQL container failed health check"
exit_code=1
else
# TCP连通性检查
echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..."
if wait_tcp_ready "$PG_HOST" "$PG_PORT" 5; then
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
# 执行迁移
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head || exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "✅ Alembic migrations applied successfully"
fi
else
echo "❌ TCP connectivity to PostgreSQL failed"
exit_code=1
fi
fi
# 清理
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
fi
fi
if [ $exit_code -eq 0 ]; then
echo "✅ Alembic migrations validation passed"
else
echo "❌ Alembic migrations validation FAILED"
fi
record_result "alembic" "$exit_code" "yes"
exit $exit_code
}
# ============================================================
# 主流程:并行启动所有子任务
# ============================================================
echo "启动并行检查(5 个子任务同时运行)..."
echo ""
# 记录开始时间
START_TIME=$(date +%s)
# 启动所有子任务(后台运行)
task_secret_detection &
PID_A1=$!
task_code_quality &
PID_A2=$!
task_mypy &
PID_A3=$!
task_advisory &
PID_A4=$!
task_alembic &
PID_B1=$!
echo "子任务 PID: A1=$PID_A1 A2=$PID_A2 A3=$PID_A3 A4=$PID_A4 B1=$PID_B1"
echo ""
# 等待所有后台任务完成(不因单个失败而中断)
# 使用 set +e 临时取消 errexit
set +e
wait $PID_A1; EXIT_A1=$?
wait $PID_A2; EXIT_A2=$?
wait $PID_A3; EXIT_A3=$?
wait $PID_A4; EXIT_A4=$?
wait $PID_B1; EXIT_B1=$?
set -e
# 计算耗时
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
# ============================================================
# 结果汇总
# ============================================================
echo ""
echo "============================================"
echo " CI Validate 结果汇总(耗时 ${ELAPSED}s"
echo "============================================"
echo ""
# 定义任务信息:名称 | PID | 退出码 | 描述 | 是否阻断
declare -A TASK_DESC
TASK_DESC[A1]="Secret detection"
TASK_DESC[A2]="Code quality (black/isort/ruff)"
TASK_DESC[A3]="Mypy type check"
TASK_DESC[A4]="Advisory (bandit/pip-audit/vulture/syntax)"
TASK_DESC[B1]="Alembic migrations"
declare -A TASK_PID
TASK_PID[A1]=$PID_A1
TASK_PID[A2]=$PID_A2
TASK_PID[A3]=$PID_A3
TASK_PID[A4]=$PID_A4
TASK_PID[B1]=$PID_B1
declare -A TASK_EXIT
TASK_EXIT[A1]=$EXIT_A1
TASK_EXIT[A2]=$EXIT_A2
TASK_EXIT[A3]=$EXIT_A3
TASK_EXIT[A4]=$EXIT_A4
TASK_EXIT[B1]=$EXIT_B1
declare -A TASK_LOG
TASK_LOG[A1]="task_secret_detection"
TASK_LOG[A2]="task_code_quality"
TASK_LOG[A3]="task_mypy"
TASK_LOG[A4]="task_advisory"
TASK_LOG[B1]="task_alembic"
declare -A TASK_BLOCKING
TASK_BLOCKING[A1]="yes"
TASK_BLOCKING[A2]="yes"
TASK_BLOCKING[A3]="yes"
TASK_BLOCKING[A4]="no"
TASK_BLOCKING[B1]="yes"
OVERALL_EXIT=0
FAILED_TASKS=()
# 按固定顺序打印摘要
for task_id in A1 A2 A3 A4 B1; do
local_exit=${TASK_EXIT[$task_id]}
local_desc=${TASK_DESC[$task_id]}
local_blocking=${TASK_BLOCKING[$task_id]}
if [ "$local_exit" -eq 0 ]; then
echo "$task_id: $local_desc — PASSED"
else
if [ "$local_blocking" = "yes" ]; then
echo "$task_id: $local_desc — FAILED (blocking)"
OVERALL_EXIT=1
FAILED_TASKS+=("$task_id")
else
echo " ⚠️ $task_id: $local_desc — FAILED (advisory, not blocking)"
# Advisory tasks don't cause overall failure
if [ "$local_blocking" = "no" ]; then
echo " → 告警类检查,不阻断流水线"
fi
fi
fi
done
echo ""
# 打印失败任务的完整日志
if [ ${#FAILED_TASKS[@]} -gt 0 ]; then
echo "============================================"
echo " 失败任务详细日志"
echo "============================================"
for task_id in "${FAILED_TASKS[@]}"; do
local_log="${TASK_LOG[$task_id]}"
local_desc="${TASK_DESC[$task_id]}"
echo ""
echo "--- $task_id: $local_desc ---"
if [ -f "$LOG_DIR/${local_log}.log" ]; then
cat "$LOG_DIR/${local_log}.log"
else
echo "(日志文件不存在)"
fi
echo ""
done
fi
# 最终结论
echo ""
if [ $OVERALL_EXIT -eq 0 ]; then
echo "=== CI Validate: 所有检查通过 ✅ (并行耗时 ${ELAPSED}s ==="
else
echo "=== CI Validate: 存在阻断性检查失败 ❌ (并行耗时 ${ELAPSED}s ==="
fi
exit $OVERALL_EXIT
+17
View File
@@ -0,0 +1,17 @@
"""Runner 监控告警工具包
模块:
config - 配置管理阈值检测间隔等
runner_status - Runner 在线状态巡检Gitea API
runner_metrics - 系统指标采集SSH后补
alert_manager - 告警调度阈值判断+去重+飞书通知
snapshot - Runner 状态快照生成
"""
__all__ = [
"config",
"runner_status",
"runner_metrics",
"alert_manager",
"snapshot",
]
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""
告警调度器 - 阈值判断 + 去重 + 飞书通知
功能:
1. runner_status runner_metrics 获取数据
2. 根据阈值判断是否触发告警
3. 告警去重同一问题 30 分钟内只报一次
4. 飞书卡片通知复用 chatops FeishuNotifier
5. 生成状态快照 JSON供看板用
告警规则:
P1严重:
- Runner 离线超过 5 分钟
- 磁盘使用率 > 90%
P2警告:
- 磁盘使用率 > 85%
- 内存使用率 > 90% 持续 5 分钟
- CI 队列积压 > 10 pending 超过 10 分钟
用法:
python3 scripts/ci/runner_monitor/alert_manager.py --check
python3 scripts/ci/runner_monitor/alert_manager.py --daemon # 持续运行
python3 scripts/ci/runner_monitor/alert_manager.py --snapshot
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
# 复用 chatops 的飞书通知
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CI_DIR = os.path.dirname(_SCRIPT_DIR)
if _CI_DIR not in sys.path:
sys.path.insert(0, _CI_DIR)
from runner_monitor import config # noqa: E402
from runner_monitor.runner_metrics import RunnerMetricsCollector # noqa: E402
from runner_monitor.runner_status import RunnerStatusChecker # noqa: E402
class Alert:
"""单条告警"""
def __init__(self, alert_id, level, title, description, details=None, source="runner_monitor"):
self.alert_id = alert_id # 唯一标识,用于去重
self.level = level # P1 / P2 / INFO
self.title = title
self.description = description
self.details = details or {}
self.source = source
self.timestamp = datetime.now(timezone.utc).isoformat()
def to_dict(self):
return {
"alert_id": self.alert_id,
"level": self.level,
"title": self.title,
"description": self.description,
"details": self.details,
"source": self.source,
"timestamp": self.timestamp,
}
class AlertManager:
"""告警调度器"""
def __init__(
self,
status_checker=None,
metrics_collector=None,
dedupe_window=None,
):
self.status_checker = status_checker or RunnerStatusChecker()
self.metrics = metrics_collector or RunnerMetricsCollector()
self.dedupe_window = dedupe_window or config.DEDUPE_WINDOW
# 告警历史: {alert_id: last_triggered_timestamp}
self._alert_history = {}
# 内存持续超阈值记录: {host: first_detected_timestamp}
self._mem_high_since = {}
# ── 告警检测 ──────────────────────────────────────
def detect_alerts(self):
"""执行所有检测规则,返回触发的告警列表
Returns:
list[Alert]: 新触发的告警已去重
"""
all_alerts = []
# 1. Runner 离线检测
all_alerts.extend(self._check_runner_offline())
# 2. 队列积压检测
all_alerts.extend(self._check_queue_backlog())
# 3. 系统指标检测(SSH,可能为空)
all_alerts.extend(self._check_system_metrics())
# 去重过滤
new_alerts = [a for a in all_alerts if self._should_alert(a)]
# 更新告警历史
for alert in new_alerts:
self._alert_history[alert.alert_id] = time.time()
return new_alerts
def _check_runner_offline(self):
"""检测离线 runner"""
offline = self.status_checker.get_offline_runners(offline_minutes=config.RUNNER_OFFLINE_MINUTES)
alerts = []
for runner in offline:
name = runner.get("name", "unknown")
runner_id = runner.get("id", "?")
alert_id = f"runner_offline_{runner_id}"
# Gitea API 没有心跳时间,status != online 就告警(P1
alerts.append(
Alert(
alert_id=alert_id,
level=config.P1,
title=f"Runner 离线: {name}",
description=(
f"Runner **{name}** (ID: {runner_id}) 状态为 "
f"{runner.get('status', 'unknown')},已离线\n"
f"标签: {', '.join(label.get('name') for label in runner.get('labels', [])[:5])}"
),
details={
"runner_id": runner_id,
"runner_name": name,
"status": runner.get("status"),
"labels": [label.get("name") for label in runner.get("labels", [])],
},
)
)
return alerts
def _check_queue_backlog(self):
"""检测队列积压"""
backlog = self.status_checker.get_queue_backlog(
pending_threshold=config.QUEUE_PENDING_COUNT,
duration_minutes=config.QUEUE_PENDING_MINUTES,
)
if not backlog["is_backlogged"]:
return []
count = backlog["pending_count"]
age = backlog["oldest_pending_minutes"]
alert_id = f"queue_backlog_{int(age // 30)}" # 每30分钟一个新告警id
return [
Alert(
alert_id=alert_id,
level=config.P2,
title="CI 队列积压",
description=(
f"当前有 **{count}** 个 pending run,最老的已等待 **{age:.0f} 分钟**\n"
f"阈值: >{config.QUEUE_PENDING_COUNT}个 且 超过{config.QUEUE_PENDING_MINUTES}分钟"
),
details={
"pending_count": count,
"oldest_pending_minutes": age,
},
)
]
def _check_system_metrics(self):
"""检测系统指标(磁盘/内存/CPU"""
metrics_list = self.metrics.collect_all()
if not metrics_list:
return []
alerts = []
now = time.time()
for m in metrics_list:
host = m.get("host", "unknown")
if m.get("status") != "ok":
continue
# 磁盘告警
disk_pct = m.get("disk_percent", 0)
if disk_pct and disk_pct >= config.DISK_CRIT_PERCENT:
alerts.append(
Alert(
alert_id=f"disk_crit_{host}",
level=config.P1,
title=f"磁盘使用率严重过高: {host}",
description=(
f"服务器 **{host}** 磁盘使用率 **{disk_pct:.1f}%** (P1阈值: {config.DISK_CRIT_PERCENT}%)\n"
f"已用: {m.get('disk_used_gb', '?')}G / {m.get('disk_total_gb', '?')}G"
),
details={"host": host, "disk_percent": disk_pct},
)
)
elif disk_pct and disk_pct >= config.DISK_WARN_PERCENT:
alerts.append(
Alert(
alert_id=f"disk_warn_{host}",
level=config.P2,
title=f"磁盘使用率过高: {host}",
description=(
f"服务器 **{host}** 磁盘使用率 **{disk_pct:.1f}%** (P2阈值: {config.DISK_WARN_PERCENT}%)\n"
f"已用: {m.get('disk_used_gb', '?')}G / {m.get('disk_total_gb', '?')}G"
),
details={"host": host, "disk_percent": disk_pct},
)
)
# 内存告警(持续 N 分钟)
mem_pct = m.get("mem_percent", 0)
mem_key = f"mem_high_{host}"
if mem_pct and mem_pct >= config.MEM_WARN_PERCENT:
if mem_key not in self._mem_high_since:
self._mem_high_since[mem_key] = now
else:
duration_min = (now - self._mem_high_since[mem_key]) / 60
if duration_min >= config.MEM_DURATION_MINUTES:
alerts.append(
Alert(
alert_id=f"mem_warn_{host}",
level=config.P2,
title=f"内存使用率持续过高: {host}",
description=(
f"服务器 **{host}** 内存使用率 **{mem_pct:.1f}%**"
f"已持续 **{duration_min:.0f} 分钟**\n"
f"阈值: {config.MEM_WARN_PERCENT}% 持续 {config.MEM_DURATION_MINUTES} 分钟"
),
details={"host": host, "mem_percent": mem_pct, "duration_min": duration_min},
)
)
else:
# 恢复了,清除记录
self._mem_high_since.pop(mem_key, None)
return alerts
# ── 去重 ──────────────────────────────────────────
def _should_alert(self, alert):
"""判断是否应该发送告警(去重 + 等级开关)"""
# 等级开关
if alert.level == config.P1 and not config.P1_ENABLED:
return False
if alert.level == config.P2 and not config.P2_ENABLED:
return False
# 去重窗口
last = self._alert_history.get(alert.alert_id, 0)
if time.time() - last < self.dedupe_window:
return False
return True
# ── 通知 ──────────────────────────────────────────
def send_alerts(self, alerts):
"""发送告警到飞书
复用 chatops FeishuNotifier这里直接构造卡片
不依赖 FeishuNotifier 实例方法因为告警卡片格式不同
"""
if not alerts:
return 0
# 延迟导入
# 直接用 urllib 发,走同一个 webhook
import urllib.request
from chatops.feishu_notify import FeishuNotifier # noqa: F401
webhook_url = config.__dict__.get("FEISHU_WEBHOOK_URL", "")
if not webhook_url:
# 从 chatops config 拿
from chatops import config as chatops_config
webhook_url = chatops_config.FEISHU_WEBHOOK_URL
if not webhook_url:
print("[WARN] 未配置飞书 webhook,跳过告警通知")
return 0
sent = 0
for alert in alerts:
card = self._build_alert_card(alert)
payload = json.dumps({"msg_type": "interactive", "card": card}).encode("utf-8")
req = urllib.request.Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
body = resp.read().decode()
result = json.loads(body)
if result.get("code", 0) == 0:
sent += 1
print(f"[INFO] 告警已发送: [{alert.level}] {alert.title}")
else:
print(f"[WARN] 告警发送失败: {result.get('msg', body)}", file=sys.stderr)
except Exception as e:
print(f"[WARN] 告警发送异常: {e}", file=sys.stderr)
return sent
@staticmethod
def _build_alert_card(alert):
"""构建飞书告警卡片"""
color = config.LEVEL_COLOR.get(alert.level, "blue")
emoji = config.LEVEL_EMOJI.get(alert.level, "")
fields = [
{
"is_short": True,
"text": {"tag": "lark_md", "content": f"**等级**\n{alert.level}"},
},
{
"is_short": True,
"text": {"tag": "lark_md", "content": f"**来源**\n{alert.source}"},
},
{
"is_short": False,
"text": {"tag": "lark_md", "content": f"**详情**\n{alert.description}"},
},
]
return {
"header": {
"title": {"tag": "plain_text", "content": f"{emoji} Runner监控告警: {alert.title}"},
"status": color,
},
"elements": [
{"tag": "div", "fields": fields},
{
"tag": "note",
"elements": [
{
"tag": "plain_text",
"content": f"告警ID: {alert.alert_id} | {alert.timestamp[:19].replace('T', ' ')}",
}
],
},
],
}
# ── 快照 ──────────────────────────────────────────
def generate_snapshot(self, alerts=None):
"""生成完整的监控快照
Returns:
dict: 快照数据
"""
status_result = self.status_checker.run_full_check()
metrics = self.metrics.collect_all()
if alerts is None:
alerts = self.detect_alerts()
snapshot = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"runner_summary": status_result["runner_summary"],
"offline_runners": status_result["offline_runners"],
"queue_backlog": status_result["queue_backlog"],
"system_metrics": metrics,
"active_alerts": [a.to_dict() for a in alerts],
"alert_history_count": len(self._alert_history),
}
return snapshot
def save_snapshot(self, output_dir=None):
"""保存快照到文件"""
from runner_monitor.runner_status import RunnerStatusChecker as RSC
snapshot = self.generate_snapshot()
if output_dir is None:
output_dir = config.OUTPUT_DIR
os.makedirs(output_dir, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
filepath = os.path.join(output_dir, f"monitor_snapshot_{ts}.json")
with open(filepath, "w", encoding="utf-8") as f:
json.dump(snapshot, f, indent=2, ensure_ascii=False)
# 清理旧快照
RSC._cleanup_old_snapshots(output_dir, keep=24)
return filepath
# ── 单次检查 ──────────────────────────────────────
def run_once(self):
"""执行一次完整检查 + 告警 + 快照
Returns:
dict: {alerts_count, sent_count, snapshot_path}
"""
alerts = self.detect_alerts()
sent = self.send_alerts(alerts)
snapshot_path = self.save_snapshot()
return {
"alerts_detected": len(alerts),
"alerts_sent": sent,
"snapshot_path": snapshot_path,
"alerts": [a.to_dict() for a in alerts],
}
# ── CLI 入口 ──────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Runner 监控告警调度器")
parser.add_argument("--check", action="store_true", help="执行一次检查")
parser.add_argument("--snapshot", action="store_true", help="生成快照")
parser.add_argument("--daemon", action="store_true", help="持续运行模式")
parser.add_argument("--dry-run", action="store_true", help="只检测不发通知")
parser.add_argument("--interval", type=int, help="检测间隔(秒),覆盖环境变量")
args = parser.parse_args()
if args.interval:
config.CHECK_INTERVAL = args.interval
manager = AlertManager()
if args.daemon:
print(f"[INFO] Runner 监控告警服务启动,检测间隔 {config.CHECK_INTERVAL}")
print(f"[INFO] P1告警: {'开启' if config.P1_ENABLED else '关闭'}")
print(f"[INFO] P2告警: {'开启' if config.P2_ENABLED else '关闭'}")
print(f"[INFO] 去重窗口: {config.DEDUPE_WINDOW}")
while True:
try:
result = (
manager.run_once()
if not args.dry_run
else {
"alerts_detected": len(manager.detect_alerts()),
"alerts_sent": 0,
}
)
now = time.strftime("%Y-%m-%d %H:%M:%S")
print(
f"[{now}] 检测完成 - "
f"发现 {result['alerts_detected']} 个告警, "
f"发送 {result['alerts_sent']} 条通知"
)
except Exception as e:
print(f"[ERROR] 检测异常: {e}", file=sys.stderr)
time.sleep(config.CHECK_INTERVAL)
elif args.snapshot:
path = manager.save_snapshot()
print(f"快照已保存: {path}")
elif args.check or args.dry_run:
if args.dry_run:
alerts = manager.detect_alerts()
print(f"检测到 {len(alerts)} 个告警(dry-run,不发送):")
for a in alerts:
print(f" [{a.level}] {a.title}")
print(f" {a.description[:100]}")
else:
result = manager.run_once()
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
parser.print_help()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

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