diff --git a/.ci-trigger b/.ci-trigger index 3bf8c28ea..bf3d58bf7 100644 --- a/.ci-trigger +++ b/.ci-trigger @@ -1 +1,2 @@ -trigger: 1784009947 +CI trigger file - safe to delete +updated! \ No newline at end of file diff --git a/.gitea/workflows/ci-failure-monitor.yml b/.gitea/workflows/ci-failure-monitor.yml new file mode 100644 index 000000000..659769723 --- /dev/null +++ b/.gitea/workflows/ci-failure-monitor.yml @@ -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 \ No newline at end of file diff --git a/.gitea/workflows/ci-pipeline.yml b/.gitea/workflows/ci-pipeline.yml index 76c8a79e2..12c247b0b 100755 --- a/.gitea/workflows/ci-pipeline.yml +++ b/.gitea/workflows/ci-pipeline.yml @@ -76,18 +76,12 @@ jobs: [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true - validate: - needs: check-frontend-only - if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' - name: Validate Code Quality And Tests + validate-code-quality: + name: Validate - Code Quality runs-on: ci-l2 - timeout-minutes: 10 + timeout-minutes: 8 permissions: contents: write - env: - DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas - USE_IN_MEMORY_DB: 'false' - CI_USE_SHARED_PG: 'true' steps: - name: Checkout code shell: sh @@ -102,7 +96,6 @@ jobs: shell: sh run: | set -eu - # pip install 带重试(网络不稳定时自动重试) for i in 1 2 3; do python3 -m pip install -q -r requirements-base.txt && break echo "pip install requirements-base.txt 失败,重试 $i/3..." @@ -127,11 +120,11 @@ jobs: [ $i -eq 3 ] && exit 1 sleep 5 done - - name: Run all quality checks + - name: Run code quality and security checks shell: bash env: GITHUB_TOKEN: ${{ github.token }} - run: bash scripts/ci/run_validate.sh + run: bash scripts/ci/validate_code_quality.sh - name: Auto-fix formatting (black + isort) if: failure() shell: sh @@ -146,7 +139,7 @@ jobs: CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} run: | set +e - FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py + FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py - name: Job duration summary if: always() shell: sh @@ -159,7 +152,161 @@ jobs: CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} run: | set +e - NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py + NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py + - name: Report CI trace + if: always() + shell: sh + env: + AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }} + run: | + STATUS="ok" + [ ${{ job.status }} = "success" ] || STATUS="error" + START_TIME="" + [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) + python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + + validate-type-check: + name: Validate - Type Check (mypy) + runs-on: ci-l2 + timeout-minutes: 8 + permissions: + contents: read + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Install dependencies + shell: sh + run: | + set -eu + for i in 1 2 3; do + python3 -m pip install -q -r requirements-base.txt && break + echo "pip install requirements-base.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements.txt && break + echo "pip install requirements.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements-dev.txt && break + echo "pip install requirements-dev.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + - name: Run mypy type check + shell: bash + run: bash scripts/ci/validate_mypy.sh + - name: CI failure notification + if: failure() + shell: sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} + run: | + set +e + FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: | + set +e + NOTIFY_MODE=failure JOB_NAME="Validate - Type Check (mypy)" python3 scripts/ci_notify.py + - name: Report CI trace + if: always() + shell: sh + env: + AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }} + run: | + STATUS="ok" + [ ${{ job.status }} = "success" ] || STATUS="error" + START_TIME="" + [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) + python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + + validate-migration: + name: Validate - Migration (alembic) + runs-on: ci-l2 + timeout-minutes: 8 + permissions: + contents: read + env: + DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas + USE_IN_MEMORY_DB: 'false' + CI_USE_SHARED_PG: 'true' + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Install dependencies + shell: sh + run: | + set -eu + for i in 1 2 3; do + python3 -m pip install -q -r requirements-base.txt && break + echo "pip install requirements-base.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements.txt && break + echo "pip install requirements.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements-dev.txt && break + echo "pip install requirements-dev.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + - name: Run alembic migration validation + shell: bash + run: bash scripts/ci/validate_migration.sh + - name: CI failure notification + if: failure() + shell: sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} + run: | + set +e + FAILED_JOB="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: | + set +e + NOTIFY_MODE=failure JOB_NAME="Validate - Migration (alembic)" python3 scripts/ci_notify.py - name: Report CI trace if: always() shell: sh @@ -387,9 +534,11 @@ jobs: [ $i -eq 3 ] && exit 1 sleep 5 done - - name: Run Vitest with coverage + - name: Run Vitest (incremental for PRs, full for main branches) shell: sh - run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run --coverage" + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash scripts/ci/vitest_incremental.sh - name: Job duration summary if: always() shell: sh @@ -416,6 +565,196 @@ jobs: [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + + build-pr: + name: PR Build ${{ matrix.service_display }} Image + runs-on: runtime-builder + timeout-minutes: ${{ matrix.timeout }} + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - service: api + service_display: API + dockerfile: infra/docker/api.Dockerfile + image_name: xiaoxia-saas-api + cache_name: api-cache + timeout: 30 + - service: worker + service_display: Worker + dockerfile: infra/docker/worker.Dockerfile + image_name: xiaoxia-saas-worker + cache_name: worker-cache + timeout: 40 + - service: web + service_display: Web + dockerfile: infra/docker/web.Dockerfile + image_name: xiaoxia-saas-web + cache_name: web-cache + timeout: 30 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Docker login to Registry (for cache read) + shell: sh + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + GITEA_REGISTRY_USER: xiaoxia + GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + for i in 1 2 3; do + echo "Docker login attempt $i/3" + if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then + echo "Docker login successful" + break + fi + echo "Docker login failed ($i/3), retrying in 5s..." + sleep 5 + done + - name: Pre-build worker base images (fallback if not exist) + if: matrix.service == 'worker' + id: prebuild + shell: sh + run: | + set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas" + BASE_BUILDER="${REGISTRY}/worker-base-builder:latest" + BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest" + + # 尝试拉取基础镜像 + echo "检查基础镜像..." + if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then + echo "基础镜像已存在,使用远程镜像" + echo "fallback=false" >> $GITHUB_OUTPUT + else + echo "基础镜像不存在,本地构建(fallback模式)..." + + # 构建builder基础镜像 + echo "构建 worker-base-builder..." + # 用buildx docker-container驱动构建(兼容DooD模式:普通docker build看不到容器内文件) + BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}" + if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then + docker buildx create --use --name "$BUILDER_NAME" --driver docker-container + else + docker buildx use "$BUILDER_NAME" + fi + docker buildx inspect --bootstrap > /dev/null 2>&1 + + # 构建builder基础镜像(带重试,buildx容器偶发不稳定) + echo "构建 worker-base-builder..." + for attempt in 1 2 3; do + if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$BASE_BUILDER" .; then + echo "worker-base-builder 构建成功" + break + fi + echo "worker-base-builder 构建失败,重试 $attempt/3..." + docker buildx rm "$BUILDER_NAME" 2>/dev/null || true + docker buildx create --use --name "$BUILDER_NAME" --driver docker-container + sleep 3 + done + + # 构建runtime基础镜像 + echo "构建 worker-base-runtime..." + for attempt in 1 2 3; do + if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$BASE_RUNTIME" .; then + echo "worker-base-runtime 构建成功" + break + fi + echo "worker-base-runtime 构建失败,重试 $attempt/3..." + docker buildx rm "$BUILDER_NAME" 2>/dev/null || true + docker buildx create --use --name "$BUILDER_NAME" --driver docker-container + sleep 3 + done + + echo "fallback=true" >> $GITHUB_OUTPUT + echo "基础镜像本地构建完成" + fi + + - name: Build PR image (verify only, no push) + shell: sh + run: | + set -eu + REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji" + IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}" + CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop" + + EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\"" + if [ "${{ matrix.service }}" = "web" ]; then + EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf" + fi + + # Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx + if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then + echo "Fallback模式:用普通docker build(基础镜像本地已构建)" + BUILD_ARG_STR="" + for arg in $EXTRA_BUILD_ARGS; do + BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg" + done + docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR . + echo "Fallback PR Build successful" + exit 0 + fi + + NO_CACHE_FLAG="" + for i in 1 2 3; do + echo "PR Build attempt $i/3" + if bash scripts/ci/docker_build_only.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then + echo "PR Build successful" + break + fi + echo "PR Build failed (attempt $i/3)" + [ $i -eq 3 ] && exit 1 + sleep 10 + if [ $i -eq 2 ]; then + NO_CACHE_FLAG="--no-cache" + echo "Next retry with --no-cache" + fi + done + echo + echo "${{ matrix.service_display }} PR build verified: ${IMAGE_TAG}" + - name: Cleanup buildx builder + if: always() + shell: sh + run: | + BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}" + docker buildx rm "$BUILDER_NAME" 2>/dev/null || true + docker buildx prune -f 2>/dev/null || true + echo "Builder cleanup done" + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: | + set +e + NOTIFY_MODE=failure JOB_NAME="PR Build ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py + - name: Report CI trace + if: always() + shell: sh + env: + AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }} + run: | + STATUS="ok" + [ ${{ job.status }} = "success" ] || STATUS="error" + START_TIME="" + [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) + python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + build-staging: name: Build Staging ${{ matrix.service_display }} Image runs-on: runtime-builder @@ -530,6 +869,15 @@ jobs: echo echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}" + - name: Cleanup buildx builder + if: always() + shell: sh + run: | + docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true + docker buildx rm ci-builder 2>/dev/null || true + docker buildx prune -f 2>/dev/null || true + echo "Builder cleanup done" + - name: Job duration summary if: always() shell: sh @@ -1205,5 +1553,4 @@ jobs: [ ${{ job.status }} = "success" ] || STATUS="error" START_TIME="" [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) - python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true - + python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true \ No newline at end of file diff --git a/.gitea/workflows/pr-automation.yml b/.gitea/workflows/pr-automation.yml index 917f2e46f..a2bff3f03 100755 --- a/.gitea/workflows/pr-automation.yml +++ b/.gitea/workflows/pr-automation.yml @@ -54,7 +54,9 @@ jobs: CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)") else CONTEXTS=( - "CI/CD Pipeline / Validate Code Quality And Tests (pull_request)" + "CI/CD Pipeline / Validate - Code Quality (pull_request)" + "CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)" + "CI/CD Pipeline / Validate - Migration (alembic) (pull_request)" "CI/CD Pipeline / Frontend Lint (pull_request)" ) fi @@ -233,8 +235,13 @@ jobs: echo "纯前端改动,只检查Frontend Lint" else CONTEXTS=( - "CI/CD Pipeline / Validate Code Quality And Tests (pull_request)" + "CI/CD Pipeline / Validate - Code Quality (pull_request)" + "CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)" + "CI/CD Pipeline / Validate - Migration (alembic) (pull_request)" "CI/CD Pipeline / Frontend Lint (pull_request)" + "CI/CD Pipeline / PR Build API Image (pull_request)" + "CI/CD Pipeline / PR Build Worker Image (pull_request)" + "CI/CD Pipeline / PR Build Web Image (pull_request)" ) echo "检查required门禁(与分支保护一致)" fi diff --git a/.gitea/workflows/worker-base-image.yml b/.gitea/workflows/worker-base-image.yml new file mode 100644 index 000000000..c07286aa8 --- /dev/null +++ b/.gitea/workflows/worker-base-image.yml @@ -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" diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py old mode 100644 new mode 100755 index 4855f3a07..45286aa19 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -503,7 +503,7 @@ async def send_verification_code( request: SendVerificationCodeRequest, ) -> SendVerificationCodeResponse: """发送验证码(手机或邮箱)""" - from app.dependencies import get_db + from app.dependencies import get_db_session from packages.adapters.sms.sms_service import get_sms_service from packages.adapters.smtp import get_email_service @@ -516,7 +516,7 @@ async def send_verification_code( ) from packages.application.auth.verification_code_service import VerificationCodeService - db = next(get_db()) + db = next(get_db_session()) repo = SQLAlchemyVerificationCodeRepository(db) vc_service = VerificationCodeService(repo=repo) sms_service = get_sms_service() @@ -549,7 +549,7 @@ async def bind_contact( user_repository: UserRepository = Depends(get_user_repository), ) -> BindContactResponse: """绑定手机号和/或邮箱(需登录态)""" - from app.dependencies import get_db + from app.dependencies import get_db_session from packages.adapters.sqlalchemy_impl.verification_code_repository import ( SQLAlchemyVerificationCodeRepository, @@ -560,7 +560,7 @@ async def bind_contact( ) from packages.application.auth.verification_code_service import VerificationCodeService - db = next(get_db()) + db = next(get_db_session()) vc_repo = SQLAlchemyVerificationCodeRepository(db) vc_service = VerificationCodeService(repo=vc_repo) diff --git a/apps/api/app/api/routes/templates_editor.py b/apps/api/app/api/routes/templates_editor.py index 1fb6ae6b7..58b5310c9 100644 --- a/apps/api/app/api/routes/templates_editor.py +++ b/apps/api/app/api/routes/templates_editor.py @@ -639,19 +639,84 @@ def get_draft_plan_id( template_id: str, services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), ) -> str: """ 路径依赖:根据 template_id 获取或创建草稿,返回 plan_id。 这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里, 确保 template_id → plan_id 的映射始终存在。 + + 兼容策略:优先从新模板系统(edit_templates 表)查找, + 若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。 """ - tpl_svc, _ = services - draft = tpl_svc.get_or_create_draft( - template_id, - user_id=str(current_user.user.id), + tpl_svc, plan_svc = services + user_id = str(current_user.user.id) + + # 1. 草稿已存在 → 直接返回 + draft = tpl_svc.get_template_draft(template_id) + if draft is not None: + return draft.id + + # 2. 新系统有模板 → 用新服务创建草稿 + if tpl_svc.get_template(template_id) is not None: + draft = tpl_svc.create_template_draft(template_id, user_id=user_id) + return draft.id + + # 3. 回退到旧模板系统(templates 表) + old_repo = SQLAlchemyTemplateRepository(db) + old_template = old_repo.get(template_id, user_id=user_id) + if old_template is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在") + + # 4. 基于旧模板创建草稿计划 + from app.services.plan_generator_service import PlanGeneratorService + + from packages.domain.edit_template import EditTemplate, EditTemplateStatus + from packages.domain.template_clip_config import ClipType, TemplateClipConfig + + # 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段) + pseudo_template = EditTemplate( + id=old_template.id, + name=old_template.name, + editing_mode=old_template.mode, + status=EditTemplateStatus.ACTIVE, ) - return draft.id + + # 将旧模板 segments 转换为 clip_configs + clip_configs: list[TemplateClipConfig] = [] + for seg in old_template.segments or []: + clip_configs.append( + TemplateClipConfig( + id=f"seg_{seg.id}", + template_id=old_template.id, + clip_type=ClipType.MAIN, + order=seg.segment_order, + min_duration=seg.duration_min, + max_duration=seg.duration_max, + ) + ) + + generator = PlanGeneratorService(db) + result = generator.generate_from_template( + template=pseudo_template, + clip_configs=clip_configs, + asset_ids=[], + created_by_user_id=user_id, + name=f"{old_template.name} - 草稿", + ) + plan = result["plan"] + + # 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑) + plan_svc.update_plan_config(plan.id, {"is_template_draft": True}) + + logger.info( + "旧模板自动创建草稿: template_id=%s draft_plan_id=%s user_id=%s", + template_id, + plan.id, + user_id, + ) + return plan.id # ── 草稿核心端点 ──────────────────────────────────────────────────────────── diff --git a/apps/web/src/test/hooks/useAuth.test.tsx b/apps/web/src/test/hooks/useAuth.test.tsx old mode 100644 new mode 100755 index 89de2caf1..8e48c1aad --- a/apps/web/src/test/hooks/useAuth.test.tsx +++ b/apps/web/src/test/hooks/useAuth.test.tsx @@ -7,8 +7,18 @@ import { renderHook, act } from "@testing-library/react" import { MemoryRouter } from "react-router-dom" const mockNavigate = vi.fn() -const mockSetAuth = vi.fn() -const mockClearAuth = vi.fn() +const mockSetAuth = vi.fn((_user: any, accessToken: string, refreshToken?: string | null) => { + localStorage.setItem("access_token", accessToken) + if (refreshToken) { + localStorage.setItem("refresh_token", refreshToken) + } else { + localStorage.removeItem("refresh_token") + } +}) +const mockClearAuth = vi.fn(() => { + localStorage.removeItem("access_token") + localStorage.removeItem("refresh_token") +}) const mockMutateAsync = vi.fn() const mockQueryClear = vi.fn() diff --git a/infra/docker/api.Dockerfile b/infra/docker/api.Dockerfile index 64d2dde99..bf3b58a66 100755 --- a/infra/docker/api.Dockerfile +++ b/infra/docker/api.Dockerfile @@ -1,33 +1,69 @@ # ============================================================ -# API Dockerfile - 专门用于 FastAPI 应用 -# 优化:依赖分层缓存 + 多阶段构建基础层 +# API Dockerfile - FastAPI 应用 +# 优化:多阶段构建 + pip cache mount + 依赖分层缓存 # ============================================================ -# 基础镜像:Python 3.12 -FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim +# ==================== Builder 阶段 ==================== +FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder + +# 使用阿里云镜像加速 +RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ + sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true + +# 安装编译依赖(仅 builder 需要) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# 创建虚拟环境 +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +WORKDIR /tmp + +# ---- 依赖分层:基础依赖(变化少,缓存命中率高)---- +COPY requirements-base.txt /tmp/requirements-base.txt + +RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ + pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ + -r /tmp/requirements-base.txt \ + && rm /tmp/requirements-base.txt + +# ---- 依赖分层:业务依赖(变化频繁)---- +COPY requirements.txt /tmp/requirements.txt + +RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ + pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ + -r /tmp/requirements.txt \ + && rm /tmp/requirements.txt + +# ---- Python 依赖瘦身 ---- +RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true +RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \ + find /opt/venv -name "*.pyc" -delete 2>/dev/null || true + +# ==================== Runtime 阶段 ==================== +FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime # 构建参数:版本号(CI 传入 commit hash) ARG APP_VERSION=dev # 使用阿里云镜像加速 -RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true +RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ + sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true -# 安装系统依赖 -RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/* +# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* + +# 从 builder 复制虚拟环境 +COPY --from=builder /opt/venv /opt/venv # 设置工作目录 WORKDIR /app -# ---- 依赖分层:基础依赖(变化少,缓存命中率高)---- -COPY requirements-base.txt /tmp/requirements-base.txt - -RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt && rm /tmp/requirements-base.txt - -# ---- 依赖分层:业务依赖(变化频繁)---- -COPY requirements.txt /tmp/requirements.txt - -RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt && rm /tmp/requirements.txt - # 复制应用代码 COPY apps/api/ /app/apps/api/ COPY packages/ /app/packages/ @@ -43,7 +79,8 @@ ENV PYTHONUNBUFFERED=1 ENV APP_VERSION=$APP_VERSION # 健康检查 -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)" +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)" # API 入口点 WORKDIR /app/apps/api diff --git a/infra/docker/worker-base-builder.Dockerfile b/infra/docker/worker-base-builder.Dockerfile new file mode 100644 index 000000000..4a1bdb1d3 --- /dev/null +++ b/infra/docker/worker-base-builder.Dockerfile @@ -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 diff --git a/infra/docker/worker-base-runtime.Dockerfile b/infra/docker/worker-base-runtime.Dockerfile new file mode 100644 index 000000000..4f83780ef --- /dev/null +++ b/infra/docker/worker-base-runtime.Dockerfile @@ -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/* diff --git a/infra/docker/worker.Dockerfile b/infra/docker/worker.Dockerfile index 32766988d..beb15a399 100755 --- a/infra/docker/worker.Dockerfile +++ b/infra/docker/worker.Dockerfile @@ -1,98 +1,43 @@ # ============================================================ -# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身) -# 优化项: -# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时 -# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB) -# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存 +# Worker Dockerfile - 分层缓存优化版 +# 优化:基础依赖 + Worker大包预构建为基础镜像,业务构建仅叠加业务依赖 +# 基础镜像:worker-base-builder / worker-base-runtime +# 预计节省:依赖不变时构建时间从23min降至5min以内 # ============================================================ # ==================== Builder 阶段 ==================== -FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder +# 从预构建的builder基础镜像开始,已经包含: +# - 编译工具 (gcc/g++/python3-dev/binutils) +# - requirements-base.txt 全部依赖 +# - requirements-worker.txt 全部依赖 (numpy/scipy/opencv) +# - 预strip的.so文件 +FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-builder:latest AS builder -# 使用阿里云镜像加速 -RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true - -# 安装编译工具(仅 builder 需要) -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - g++ \ - python3-dev \ - binutils \ - wget \ - xz-utils \ - && rm -rf /var/lib/apt/lists/* - -# ---- 下载静态编译 ffmpeg ---- -# 使用 johnvansickle.com 的静态编译版本(业界标准) -RUN cd /tmp \ - && wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \ - && tar xf ffmpeg-release-amd64-static.tar.xz \ - && cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ffmpeg \ - && cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ffprobe \ - && chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \ - && rm -rf ffmpeg-* - -# ---- 安装 Python 依赖 ---- -WORKDIR /tmp - -# 创建 venv -RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -# 基础依赖 -COPY requirements-base.txt /tmp/requirements-base.txt -RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ - -r /tmp/requirements-base.txt \ - && rm /tmp/requirements-base.txt +WORKDIR /tmp -# Worker 专属大包 -COPY requirements-worker.txt /tmp/requirements-worker.txt -RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ - -r /tmp/requirements-worker.txt \ - && rm /tmp/requirements-worker.txt - -# 业务依赖 +# ---- 安装业务依赖(变化频繁,单独一层)---- COPY requirements.txt /tmp/requirements.txt -RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ +RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ + pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ -r /tmp/requirements.txt \ && rm /tmp/requirements.txt -# ---- Python 依赖瘦身 ---- -# 1. strip .so 文件的调试符号(节省约 80-100MB) +# ---- 增量瘦身(只处理新增的业务依赖)---- RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true - -# 2. 清理测试文件(节省约 20MB) -RUN find /opt/venv -type d -name "tests" -exec rm -rf {} + 2>/dev/null; \ - find /opt/venv -type d -name "test" -exec rm -rf {} + 2>/dev/null; \ - find /opt/venv -name "test_*.py" -delete 2>/dev/null || true - -# 3. 清理 .pyc 缓存和 __pycache__(节省约 10MB,运行时按需生成) RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \ find /opt/venv -name "*.pyc" -delete 2>/dev/null || true -# 4. 清理 dist-info 中的文档 -RUN find /opt/venv -name "*.dist-info" -type d -exec sh -c 'rm -f "$1"/DESCRIPTION.rst "$1"/INSTALLER "$1"/LICENSE* "$1"/WHEEL "$1"/entry_points.txt' _ {} \; 2>/dev/null || true - # ==================== Runtime 阶段 ==================== -FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime +# 从预构建的runtime基础镜像开始,已经包含: +# - ffmpeg +# - libglib2.0-0 +FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-runtime:latest AS runtime # 构建参数:版本号 ARG APP_VERSION=dev -# 使用阿里云镜像加速 -RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true - -# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libglib2.0-0 \ - && rm -rf /var/lib/apt/lists/* - -# 从 builder 复制 ffmpeg 静态二进制 -COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg -COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe - # 从 builder 复制 Python 虚拟环境 COPY --from=builder /opt/venv /opt/venv diff --git a/packages/application/auth/wechat_oauth_service.py b/packages/application/auth/wechat_oauth_service.py index 23e7d8b28..16cdf5d47 100755 --- a/packages/application/auth/wechat_oauth_service.py +++ b/packages/application/auth/wechat_oauth_service.py @@ -8,8 +8,10 @@ from __future__ import annotations import logging import os +import time import urllib.parse from dataclasses import dataclass +from threading import Lock from typing import Optional from uuid import uuid4 @@ -17,6 +19,39 @@ import requests logger = logging.getLogger(__name__) +STATE_TTL_SECONDS = 600 # state 有效期 10 分钟 + + +class MemoryStateStore: + """内存 state 存储(简单实现,单节点可用) + + 多实例部署时建议替换为 Redis 实现。 + """ + + def __init__(self, ttl_seconds: int = STATE_TTL_SECONDS): + self._ttl = ttl_seconds + self._states: dict[str, float] = {} # state -> expire_at + self._lock = Lock() + + def put(self, state: str) -> None: + with self._lock: + self._clean_expired() + self._states[state] = time.time() + self._ttl + + def verify_and_consume(self, state: str) -> bool: + with self._lock: + self._clean_expired() + if state in self._states: + del self._states[state] + return True + return False + + def _clean_expired(self) -> None: + now = time.time() + expired = [s for s, exp in self._states.items() if exp < now] + for s in expired: + del self._states[s] + @dataclass class WechatUserInfo: @@ -41,7 +76,8 @@ class WechatOAuthService: self.app_id = app_id or os.environ.get("WECHAT_OPEN_APP_ID", "") self.app_secret = app_secret or os.environ.get("WECHAT_OPEN_APP_SECRET", "") self.redirect_uri = redirect_uri or os.environ.get("WECHAT_OPEN_REDIRECT_URI", "") - self._state_store = state_store # 可选:state 存储(Redis/内存),用于 CSRF 防护 + # state 存储(CSRF 防护),默认内存实现 + self._state_store = state_store or MemoryStateStore() def is_configured(self) -> bool: """检查微信配置是否完整""" @@ -55,6 +91,8 @@ class WechatOAuthService: (授权URL, state) """ state = uuid4().hex + # 保存 state 用于回调校验(防 CSRF) + self._state_store.put(state) if not self.is_configured(): # 未配置时返回 mock URL,方便前端联调 @@ -92,6 +130,11 @@ class WechatOAuthService: if not code: return None, "缺少授权码" + # 校验 state(防 CSRF)—— 一次性使用 + if not state or not self._state_store.verify_and_consume(state): + logger.warning("微信回调 state 校验失败: state=%s", state) + return None, "无效的 state 参数,请求可能已过期或被篡改" + if not self.is_configured(): # 开发模式:返回 mock 用户信息 logger.info("微信未配置,使用 mock 用户信息") diff --git a/requirements-base.txt b/requirements-base.txt index 20c438599..524860f0a 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -3,7 +3,7 @@ # 数据库(基础层) psycopg2-binary==2.9.10 -psycopg[binary]>=3.2.2 +psycopg[binary]==3.2.2 sqlalchemy==2.0.35 alembic==1.13.3 diff --git a/requirements-dev.txt b/requirements-dev.txt index e0959a8cc..041915523 100755 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,4 +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 +diff-cover==8.0.3 diff --git a/requirements-worker.txt b/requirements-worker.txt index 2a01cb870..f96035c16 100644 --- a/requirements-worker.txt +++ b/requirements-worker.txt @@ -2,13 +2,13 @@ # 这些包体积大,API 服务不需要安装 # 数值计算 -numpy>=1.24.0 +numpy==1.26.4 # 科学计算 -scipy>=1.10.0 +scipy==1.13.1 # 计算机视觉(视频去重、帧处理) -opencv-python-headless>=4.8.0 +opencv-python-headless==4.10.0.84 # 图像处理 Pillow==10.4.0 diff --git a/scripts/agent-commit.sh b/scripts/agent-commit.sh new file mode 100755 index 000000000..a628ce637 --- /dev/null +++ b/scripts/agent-commit.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Agent代码提交前自动格式化+质量检查脚本 +# 用法: scripts/agent-commit.sh [files...] +# 效果: 自动跑black+isort+ruff check,通过后才commit+push +set -e + +if [ $# -lt 1 ]; then + echo "用法: $0 [file1 file2 ...]" + echo "示例: $0 \"feat: add new api\" apps/api/src/" + exit 1 +fi + +COMMIT_MSG="$1" +shift + +TARGETS="${@:-.}" + +cd "$(dirname "$0")/.." +REPO_ROOT=$(pwd) +echo "仓库根目录: $REPO_ROOT" +echo "提交信息: $COMMIT_MSG" +echo "目标路径: $TARGETS" +echo "" + +# 后端代码格式化(Python文件) +PYTHON_FILES=$(find $TARGETS -name "*.py" -type f 2>/dev/null | head -100 || true) +if [ -n "$PYTHON_FILES" ]; then + echo "=== Step 1/4: 后端代码格式化 (black) ===" + if command -v black &> /dev/null; then + black $TARGETS 2>&1 | tail -3 + echo "✅ black 完成" + else + echo "⚠️ 未安装black,跳过" + fi + echo "" + + echo "=== Step 2/4: import排序 (isort) ===" + if command -v isort &> /dev/null; then + isort $TARGETS 2>&1 | tail -3 + echo "✅ isort 完成" + else + echo "⚠️ 未安装isort,跳过" + fi + echo "" + + echo "=== Step 3/4: 代码质量检查 (ruff check) ===" + if command -v ruff &> /dev/null; then + RUFF_OUTPUT=$(ruff check $TARGETS 2>&1) || true + RUFF_ERRORS=$(echo "$RUFF_OUTPUT" | grep -c "^" || echo 0) + if [ "$RUFF_ERRORS" -le 2 ] || echo "$RUFF_OUTPUT" | grep -q "All checks passed"; then + echo "✅ ruff 检查通过(错误数: $RUFF_ERRORS)" + else + echo "❌ ruff 发现以下问题:" + echo "$RUFF_OUTPUT" | head -30 + echo "" + echo "请修复后重新提交,或手动忽略特定问题" + exit 1 + fi + else + echo "⚠️ 未安装ruff,跳过" + fi + echo "" +else + echo "ℹ️ 未检测到Python文件,跳过后端格式化" + echo "" +fi + +# 前端代码格式化(TS/TSX文件) +TS_FILES=$(find $TARGETS -name "*.ts" -o -name "*.tsx" -type f 2>/dev/null | head -100 || true) +if [ -n "$TS_FILES" ] && [ -f "apps/web/package.json" ]; then + echo "=== Step 4/4: 前端代码格式化 (prettier) ===" + if command -v npx &> /dev/null; then + cd apps/web && npx prettier --write "src/**/*.{ts,tsx}" 2>&1 | tail -3 || true + cd "$REPO_ROOT" + echo "✅ prettier 完成" + else + echo "⚠️ 未安装npx,跳过前端格式化" + fi + echo "" +fi + +# Git操作 +echo "=== 提交代码 ===" +git add -A +git diff --cached --stat +echo "" +git commit -m "$COMMIT_MSG" +echo "" +echo "✅ 本地提交完成" + +# 可选:自动推送 +if [ "$AGENT_AUTO_PUSH" = "true" ]; then + echo "正在推送到远程..." + git push + echo "✅ 推送完成" +else + echo "ℹ️ 本地已提交,如需推送执行: git push" + echo " 设置 AGENT_AUTO_PUSH=true 可自动推送" +fi diff --git a/scripts/check_ci_status.py b/scripts/check_ci_status.py index 603071d50..96480f86a 100755 --- a/scripts/check_ci_status.py +++ b/scripts/check_ci_status.py @@ -31,20 +31,22 @@ def main(): print("pending") return - # API返回按时间倒序,第一个就是最新的 - for s in statuses: - if s.get("context") == target_context: - status = s.get("status", "pending") - # skipped 视为通过(条件跳过的任务不需要等) - if status == "skipped": - print("success") - else: - print(status) - return + # 筛选目标context,按时间倒序取最新的 + matching = [s for s in statuses if s.get("context") == target_context] + if not matching: + # 找不到说明CI还没开始写状态,返回pending继续等待 + print("pending") + return - # 找不到这个context说明CI还没开始写状态,返回pending继续等待 - # (如果workflow真的被跳过,它会有一条status为skipped的记录) - print("pending") + # Gitea statuses API按时间正序返回,必须取最新的一条 + latest = max(matching, key=lambda s: s.get("created_at", "")) + status = latest.get("status", "pending") + + # skipped 视为通过(条件跳过的任务不需要等) + if status == "skipped": + print("success") + else: + print(status) if __name__ == "__main__": diff --git a/scripts/ci/auto_fix_formatting.py b/scripts/ci/auto_fix_formatting.py index b6470c642..f36a78adc 100755 --- a/scripts/ci/auto_fix_formatting.py +++ b/scripts/ci/auto_fix_formatting.py @@ -168,6 +168,28 @@ def main(): return api_url = os.environ.get("GITHUB_API_URL", "") + + # 获取PR作者信息,判断是人还是Agent提交的 + pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}" + req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"}) + with urllib.request.urlopen(req_pr) as resp: + pr_info = json.loads(resp.read()) + pr_author = pr_info.get("user", {}).get("login", "") + print(f"PR作者: {pr_author}") + + # 判断是否为Agent提交的PR + # Agent账号:actions, auto-approve-bot 等bot用户 + # 人提交的PR(如xiaoxia):只诊断不自动修 + agent_authors = {"actions", "auto-approve-bot", "gitea-actions"} + is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower() + + if is_agent_pr: + print(f"检测到Agent提交的PR(作者: {pr_author}),将自动修复并推送") + fix_mode = "auto_fix_and_push" + else: + print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改") + print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)") + fix_mode = "diagnose_only" repo = os.environ.get("GITHUB_REPOSITORY", "") token = os.environ.get("GITHUB_TOKEN", "") scan_mode = os.environ.get("SCAN_MODE", "full") @@ -231,6 +253,26 @@ def main(): print("没有需要提交的格式改动") return + # 诊断模式:只报告问题,不修改不推送 + if fix_mode == "diagnose_only": + print() + print("=" * 50) + print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)") + print("=" * 50) + print() + print("以下文件存在格式问题,建议手动修复:") + for line in result.stdout.strip().split("\n"): + print(f" {line}") + print() + print("修复方式:") + print(" 后端(Python): 运行 black + isort") + print(" 前端: 运行 prettier --write") + print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)") + print() + print("=" * 50) + # 以非0状态码退出,让CI继续报失败(因为问题没修) + sys.exit(1) + print() print("变更文件:") for line in result.stdout.strip().split("\n"): @@ -238,7 +280,7 @@ def main(): # 提交修复 run("git add -A") - run('git commit -m "style: auto-format with black + isort + prettier [ci skip]"') + run('git commit -m "style: auto-format with black + isort + prettier"') # 推送(head_branch已从ensure_git_repo获取) print(f"\nPR来源分支: {head_branch}") diff --git a/scripts/ci/ci_failure_diagnosis.py b/scripts/ci/ci_failure_diagnosis.py new file mode 100644 index 000000000..5244484b2 --- /dev/null +++ b/scripts/ci/ci_failure_diagnosis.py @@ -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() diff --git a/scripts/ci/ci_repeated_failure_detector.py b/scripts/ci/ci_repeated_failure_detector.py new file mode 100644 index 000000000..5a4c8b556 --- /dev/null +++ b/scripts/ci/ci_repeated_failure_detector.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +""" +CI重复失败检测脚本 +- 扫描最近N天的CI失败 +- 按job名称分组统计失败率 +- 识别高失败率job(系统性故障) +- 飞书通知告警 +""" + +import json +import os +import sys +import time +import urllib.error +import urllib.request +from collections import defaultdict +from datetime import datetime, timedelta, timezone + + +def get_env(name, default=None, required=False): + val = os.environ.get(name, default) + if required and not val: + print(f"❌ 缺少环境变量: {name}") + sys.exit(1) + return val + + +GITEA_URL = get_env("GITEA_URL", "https://git.xiaoxiajianji.com") +GITEA_TOKEN = get_env("GITEA_API_TOKEN", required=False) or get_env("GITHUB_TOKEN", "") +REPO = get_env("GITEA_REPO", "xiaoxia/xiaoxia-saas") +DAYS = int(get_env("FAIL_CHECK_DAYS", "7")) +FAIL_THRESHOLD = int(get_env("FAIL_THRESHOLD", 3)) # 失败次数阈值 +FAIL_RATE_THRESHOLD = float(get_env("FAIL_RATE_THRESHOLD", "30")) # 失败率阈值% +CONSECUTIVE_FAIL_THRESHOLD = int(get_env("CONSECUTIVE_FAIL_THRESHOLD", "3")) # 连续失败阈值 +WEBHOOK = get_env("CI_NOTIFY_WEBHOOK", "") + + +def api_get(path): + """调用Gitea API""" + url = f"{GITEA_URL}/api/v1{path}" + req = urllib.request.Request(url) + if GITEA_TOKEN: + req.add_header("Authorization", f"token {GITEA_TOKEN}") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + print(f" HTTP {e.code}: {path}") + return None + except Exception as e: + print(f" 错误: {e}") + return None + + +def fetch_recent_runs(days=7, per_page=50, max_pages=10): + """获取最近N天的runs""" + since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + all_runs = [] + + for page in range(1, max_pages + 1): + path = f"/repos/{REPO}/actions/runs?page={page}&limit={per_page}" + data = api_get(path) + if not data: + break + + runs = data.get("workflow_runs", data.get("runs", [])) + if not runs: + break + + # 检查时间范围(Gitea用started_at,格式2026-07-22T10:58:10+08:00) + oldest = None + for r in runs: + started = r.get("started_at", r.get("created_at", "")) + if started and started >= since: + all_runs.append(r) + else: + oldest = started + + if oldest and oldest < since: + break + + if len(runs) < per_page: + break + + return all_runs + + +def fetch_run_jobs(run_id): + """获取run的所有jobs""" + path = f"/repos/{REPO}/actions/runs/{run_id}/jobs" + data = api_get(path) + if not data: + return [] + return data.get("jobs", []) + + +def analyze_failures(runs): + """ + 分析失败情况 + + 返回: + - job_stats: {job_name: {total, success, failure, skipped, failure_rate, failures: [...]}} + - consecutive_failures: {job_name: current_streak, max_streak, last_status} + """ + job_stats = defaultdict( + lambda: { + "total": 0, + "success": 0, + "failure": 0, + "error": 0, + "skipped": 0, + "cancelled": 0, + "failures": [], + } + ) + + # 按时间正序排列(旧→新)用于连续失败计算 + sorted_runs = sorted(runs, key=lambda r: r.get("started_at", r.get("created_at", ""))) + + # 连续失败跟踪 {job_name: streak} + consecutive = defaultdict(lambda: {"current": 0, "max": 0, "last_run": None}) + + for run in sorted_runs: + run_id = run.get("id") + run_status = run.get("status", "") + run_conclusion = run.get("conclusion", "") + run_started = run.get("started_at", run.get("created_at", "")) + event = run.get("event", "") + + # 只统计pull_request和push事件的CI + if event not in ("pull_request", "push"): + continue + + jobs = fetch_run_jobs(run_id) + + for job in jobs: + name = job.get("name", "") + status = job.get("status", "") + conclusion = job.get("conclusion", "") + + # 跳过非CI核心job(如AI Code Review、Preview等) + skip_prefixes = ("AI Code Review", "Preview", "PR Automation", "Auto") + if any(name.startswith(p) for p in skip_prefixes): + continue + + stats = job_stats[name] + stats["total"] += 1 + + if conclusion == "success": + stats["success"] += 1 + consecutive[name]["current"] = 0 + elif conclusion == "failure": + stats["failure"] += 1 + stats["failures"].append( + { + "run_id": run_id, + "time": run_started, + "event": event, + } + ) + consecutive[name]["current"] += 1 + if consecutive[name]["current"] > consecutive[name]["max"]: + consecutive[name]["max"] = consecutive[name]["current"] + consecutive[name]["last_run"] = run_id + elif conclusion == "error": + stats["error"] += 1 + # error也算失败的一种 + consecutive[name]["current"] += 1 + if consecutive[name]["current"] > consecutive[name]["max"]: + consecutive[name]["max"] = consecutive[name]["current"] + elif conclusion == "skipped": + stats["skipped"] += 1 + # skipped不算也不打断连续失败 + elif conclusion == "cancelled": + stats["cancelled"] += 1 + # cancelled不算失败也不打断 + + # 计算失败率 + for name, stats in job_stats.items(): + total_actual = stats["total"] - stats["skipped"] - stats["cancelled"] + if total_actual > 0: + stats["failure_rate"] = round((stats["failure"] + stats["error"]) / total_actual * 100, 1) + else: + stats["failure_rate"] = 0.0 + + return dict(job_stats), dict(consecutive) + + +def find_high_failures(job_stats, consecutive): + """ + 找出高风险job + + 告警级别: + - critical: 连续失败 >= CONSECUTIVE_FAIL_THRESHOLD,或 失败率>=50%且失败次数>=5 + - warning: 失败率>=FAIL_RATE_THRESHOLD且失败次数>=FAIL_THRESHOLD + - info: 失败次数>=2 + """ + critical = [] + warning = [] + info = [] + + for name, stats in job_stats.items(): + fail_count = stats["failure"] + stats["error"] + rate = stats["failure_rate"] + streak = consecutive.get(name, {}).get("current", 0) + max_streak = consecutive.get(name, {}).get("max", 0) + + issue = { + "name": name, + "fail_count": fail_count, + "total": stats["total"], + "failure_rate": rate, + "current_streak": streak, + "max_streak": max_streak, + "recent_failures": stats["failures"][-5:], # 最近5次 + } + + if streak >= CONSECUTIVE_FAIL_THRESHOLD or (rate >= 50 and fail_count >= 5): + critical.append(issue) + elif rate >= FAIL_RATE_THRESHOLD and fail_count >= FAIL_THRESHOLD: + warning.append(issue) + elif fail_count >= 2: + info.append(issue) + + # 按失败次数倒序 + critical.sort(key=lambda x: x["fail_count"], reverse=True) + warning.sort(key=lambda x: x["fail_count"], reverse=True) + info.sort(key=lambda x: x["fail_count"], reverse=True) + + return critical, warning, info + + +def generate_report(critical, warning, info, days, total_runs): + """生成Markdown报告""" + lines = [] + lines.append("# CI重复失败检测报告") + lines.append("") + lines.append(f"**统计周期**: 最近{days}天") + lines.append(f"**扫描Runs**: {total_runs}个") + lines.append(f"**生成时间**: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") + lines.append("") + + lines.append(f"## 概览") + lines.append("") + lines.append(f"| 级别 | 数量 |") + lines.append(f"|------|------|") + lines.append(f"| 🔴 严重 (连续失败≥{CONSECUTIVE_FAIL_THRESHOLD}次 或 失败率≥50%) | {len(critical)} |") + lines.append(f"| 🟡 警告 (失败率≥{FAIL_RATE_THRESHOLD}% 且 失败≥{FAIL_THRESHOLD}次) | {len(warning)} |") + lines.append(f"| 🔵 关注 (失败≥2次) | {len(info)} |") + lines.append("") + + if critical: + lines.append("## 🔴 严重问题") + lines.append("") + for item in critical: + lines.append(f"### {item['name']}") + lines.append("") + lines.append(f"- 失败次数: **{item['fail_count']}** / {item['total']} 次运行") + lines.append(f"- 失败率: **{item['failure_rate']}%**") + lines.append(f"- 当前连续失败: **{item['current_streak']}** 次 (历史最高: {item['max_streak']} 次)") + lines.append("") + if item["recent_failures"]: + lines.append("最近失败:") + lines.append("") + for f in item["recent_failures"]: + lines.append(f"- [{f['time'][:16]}] run #{f['run_id']} ({f['event']})") + lines.append("") + + if warning: + lines.append("## 🟡 警告") + lines.append("") + for item in warning: + lines.append( + f"- **{item['name']}**: {item['fail_count']}次失败 / {item['total']}次运行 ({item['failure_rate']}%)" + ) + lines.append("") + + if info: + lines.append("## 🔵 关注列表") + lines.append("") + lines.append("| Job名称 | 失败次数 | 总次数 | 失败率 | 当前连续 |") + lines.append("|---------|----------|--------|--------|----------|") + for item in info[:20]: # 最多显示20个 + lines.append( + f"| {item['name']} | {item['fail_count']} | {item['total']} | {item['failure_rate']}% | {item['current_streak']} |" + ) + lines.append("") + + return "\n".join(lines) + + +def send_feishu_notification(critical, warning, info, days): + """发送飞书通知""" + if not WEBHOOK: + print(" ⚠️ 未配置WEBHOOK,跳过飞书通知") + return False + + total_issues = len(critical) + len(warning) + len(info) + if total_issues == 0: + print(" ✅ 无异常,不发送通知") + return True + + level = "🔴 严重告警" if critical else "🟡 警告" if warning else "🔵 关注" + + title = f"CI重复失败检测 - {level}" + text = f"统计周期: 最近{days}天\n\n" + + if critical: + text += "【严重问题】\n" + for item in critical[:5]: + text += f"• {item['name']}\n" + text += f" 失败 {item['fail_count']}/{item['total']} ({item['failure_rate']}%) 连续{item['current_streak']}次\n" + if len(critical) > 5: + text += f" ...还有{len(critical)-5}个\n" + text += "\n" + + if warning: + text += "【警告】\n" + for item in warning[:5]: + text += f"• {item['name']}: {item['fail_count']}次失败 ({item['failure_rate']}%)\n" + if len(warning) > 5: + text += f" ...还有{len(warning)-5}个\n" + text += "\n" + + if info and not critical and not warning: + text += "【关注列表】\n" + for item in info[:10]: + text += f"• {item['name']}: {item['fail_count']}次失败\n" + text += "\n" + + text += f"共发现 {total_issues} 个异常job" + + payload = {"msg_type": "text", "content": {"text": f"{title}\n\n{text}"}} + + data = json.dumps(payload).encode() + req = urllib.request.Request(WEBHOOK, data=data, headers={"Content-Type": "application/json"}) + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read()) + if result.get("code") == 0 or result.get("StatusCode") == 0: + print(" ✅ 飞书通知已发送") + return True + else: + print(f" ⚠️ 飞书返回: {result}") + return False + except Exception as e: + print(f" ❌ 飞书通知失败: {e}") + return False + + +def main(): + print(f"=== CI重复失败检测 ===") + print(f"统计周期: 最近{DAYS}天") + print(f"仓库: {REPO}") + print() + + print("1. 获取最近的Runs...") + runs = fetch_recent_runs(days=DAYS) + print(f" 找到 {len(runs)} 个runs") + + if not runs: + print("⚠️ 没有找到runs,退出") + return + + print() + print("2. 分析job失败情况(可能需要点时间)...") + job_stats, consecutive = analyze_failures(runs) + print(f" 共统计 {len(job_stats)} 个job") + + print() + print("3. 识别高风险job...") + critical, warning, info = find_high_failures(job_stats, consecutive) + print(f" 🔴 严重: {len(critical)}") + print(f" 🟡 警告: {len(warning)}") + print(f" 🔵 关注: {len(info)}") + + print() + print("4. 生成报告...") + report = generate_report(critical, warning, info, DAYS, len(runs)) + + # 保存报告 + report_path = os.environ.get("REPORT_PATH", f"/tmp/ci_failure_report_{int(time.time())}.md") + with open(report_path, "w") as f: + f.write(report) + print(f" 报告已保存: {report_path}") + + # 打印摘要 + print() + print("=== 摘要 ===") + if critical: + print("🔴 严重问题:") + for item in critical[:5]: + print( + f" {item['name']}: {item['fail_count']}次失败, {item['failure_rate']}%, 连续{item['current_streak']}次" + ) + if warning: + print("🟡 警告:") + for item in warning[:5]: + print(f" {item['name']}: {item['fail_count']}次失败, {item['failure_rate']}%") + + print() + print("5. 发送飞书通知...") + send_feishu_notification(critical, warning, info, DAYS) + + print() + print("✅ 检测完成") + + # 有严重问题时退出码非零,方便workflow标记 + if critical: + sys.exit(2) + elif warning: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/docker_build_only.sh b/scripts/ci/docker_build_only.sh new file mode 100755 index 000000000..ad5c78e4b --- /dev/null +++ b/scripts/ci/docker_build_only.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# PR构建专用:只构建不推送,只读缓存不写,用于PR阶段验证Dockerfile +set -eu + +NO_CACHE_FLAG="" +if [ "$1" = "--no-cache" ]; then + NO_CACHE_FLAG="--no-cache" + shift +fi + +DOCKERFILE="$1" +IMAGE_TAG="$2" +CACHE_REF="$3" +shift 3 +BUILD_ARGS="" +for arg in "$@"; do + BUILD_ARGS="$BUILD_ARGS --build-arg $arg" +done + +BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}" +if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then + docker buildx create --use --name "$BUILDER_NAME" --driver docker-container +else + docker buildx use "$BUILDER_NAME" +fi +docker buildx inspect --bootstrap + +CACHE_NAME=$(echo "$CACHE_REF" | tr "/" "_" | tr ":" "-") +LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}" +mkdir -p "$LOCAL_CACHE_DIR" + +echo "=== PR Build: build only, no push, read-only cache ===" +echo "Dockerfile: ${DOCKERFILE}" +echo "Image tag: ${IMAGE_TAG}" +echo "" + +build_with_retry() { + local attempt=1 + local max_attempts=2 + while [ $attempt -le $max_attempts ]; do + local build_output + local exit_code + set +e + build_output=$(docker buildx build \ + $NO_CACHE_FLAG \ + $BUILD_ARGS \ + --cache-from "type=local,src=${LOCAL_CACHE_DIR}" \ + --cache-from "type=registry,ref=${CACHE_REF}" \ + -f "${DOCKERFILE}" \ + -t "${IMAGE_TAG}" \ + --load \ + . 2>&1) + exit_code=$? + set -e + if [ $exit_code -eq 0 ]; then + echo "$build_output" + return 0 + fi + if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then + echo "$build_output" + echo "Local cache corrupted, cleaning and retrying ($attempt/$max_attempts)..." + rm -rf "${LOCAL_CACHE_DIR}" + mkdir -p "${LOCAL_CACHE_DIR}" + docker buildx prune -f -a >/dev/null 2>&1 || true + attempt=$((attempt + 1)) + else + echo "$build_output" + return $exit_code + fi + done + echo "Local cache failed, building with registry cache only..." + docker buildx build \ + $NO_CACHE_FLAG \ + $BUILD_ARGS \ + --cache-from "type=registry,ref=${CACHE_REF}" \ + -f "${DOCKERFILE}" \ + -t "${IMAGE_TAG}" \ + --load \ + . +} + +build_with_retry +echo "" +echo "PR build OK (not pushed): ${IMAGE_TAG}" diff --git a/scripts/ci/docker_build_push.sh b/scripts/ci/docker_build_push.sh index 711aeb09a..f3ba4bb80 100755 --- a/scripts/ci/docker_build_push.sh +++ b/scripts/ci/docker_build_push.sh @@ -1,6 +1,5 @@ #!/bin/bash -# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底) -# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题 +# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache共享) # 用法: docker_build_push.sh [--no-cache] [build_arg...] set -eu @@ -35,7 +34,7 @@ LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}" mkdir -p "$LOCAL_CACHE_DIR" -# 缓存源:local优先(带自动修复),registry兜底 +# 缓存源:local优先(带自动修复),registry兜底读写 # 本地缓存损坏时自动清理后重试,避免snapshot not found导致构建全挂 build_with_cache_retry() { local attempt=1 @@ -48,7 +47,7 @@ build_with_cache_retry() { $NO_CACHE_FLAG \ $BUILD_ARGS \ --cache-from "type=local,src=${LOCAL_CACHE_DIR}" \ - --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \ + --cache-from "type=registry,ref=${CACHE_REF}" \ --cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \ --cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \ -f "${DOCKERFILE}" \ @@ -82,7 +81,7 @@ build_with_cache_retry() { docker buildx build \ $NO_CACHE_FLAG \ $BUILD_ARGS \ - --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \ + --cache-from "type=registry,ref=${CACHE_REF}" \ --cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \ --cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \ -f "${DOCKERFILE}" \ @@ -91,7 +90,7 @@ build_with_cache_retry() { . } -echo "=== Step 1: Build & push image (local cache + registry read, with auto-repair) ===" +echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ===" echo "Local cache: ${LOCAL_CACHE_DIR}" echo "Registry cache: ${CACHE_REF}" echo "" @@ -101,6 +100,7 @@ build_with_cache_retry echo "" echo "Image pushed: ${IMAGE_TAG}" echo "Local cache updated" +echo "Registry cache updated (if supported)" echo "" echo "Build completed: ${IMAGE_TAG}" diff --git a/scripts/ci/step_frontend_install.sh b/scripts/ci/step_frontend_install.sh index c451eab75..624dd7b8c 100755 --- a/scripts/ci/step_frontend_install.sh +++ b/scripts/ci/step_frontend_install.sh @@ -1,24 +1,25 @@ #!/bin/sh # CI 公共步骤:前端依赖安装(在 docker node 容器中运行) -# 用法:step_frontend_install.sh [模式] -# 模式: full (默认) - 完整安装所有依赖 -# vitest - 同full(保持接口兼容) +# 优化:增加国内npm镜像源,加重试间隔 set -eu MODE="${1:-full}" echo "=== 前端依赖安装开始 (模式: $MODE) ===" -# npm ci 带重试(网络不稳定时自动重试) +# npm国内镜像源(加速下载,减少网络失败) +NPM_REGISTRY="https://registry.npmmirror.com" + for i in 1 2 3; do + echo "npm ci 尝试 $i/3 (镜像: $NPM_REGISTRY)" docker run --rm \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ - sh -lc "npm ci --no-audit --no-fund" && break + sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break echo "npm ci 失败,重试 $i/3..." [ $i -eq 3 ] && exit 1 - sleep 5 + sleep 10 done echo "=== 前端依赖安装完成 ===" diff --git a/scripts/ci/step_timer_start.sh b/scripts/ci/step_timer_start.sh index 7d4e7c1b0..b53accb82 100755 --- a/scripts/ci/step_timer_start.sh +++ b/scripts/ci/step_timer_start.sh @@ -2,3 +2,5 @@ # CI 公共步骤:Job 开始计时 echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV echo "Job started at $(date)" +# trigger CI run for PR validation +# trigger CI - worker dood fallback fix test \ No newline at end of file diff --git a/scripts/ci/validate_code_quality.sh b/scripts/ci/validate_code_quality.sh new file mode 100644 index 000000000..22b83f294 --- /dev/null +++ b/scripts/ci/validate_code_quality.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# CI Validate: 代码质量与安全扫描(并行Job 1/3) +# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验 +set -eu + +echo "=== CI Validate: 代码质量与安全扫描 ===" + +# --- 密钥检测 --- +echo "" +echo "=== [1/6] Secret detection (detect-secrets) ===" +python3 -m pip install -q detect-secrets +detect-secrets --version + +detect-secrets scan \ + --all-files \ + --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \ + --exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \ + --exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \ + --disable-plugin Base64HighEntropyString \ + --disable-plugin HexHighEntropyString \ + --disable-plugin BasicAuthDetector \ + --disable-plugin KeywordDetector \ + --disable-plugin IPPublicDetector \ + > /tmp/secrets-scan.json 2>&1 + +FOUND=$(python3 -c " +import json +try: + with open('/tmp/secrets-scan.json') as f: + data = json.load(f) + results = data.get('results', {}) + total = sum(len(v) for v in results.values()) + print(total) +except Exception: + print('error') +") + +echo "Secrets detected: $FOUND" +if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then + echo "" + echo "=== Secret details ===" + python3 -c " +import json +with open('/tmp/secrets-scan.json') as f: + data = json.load(f) +for fpath, items in data.get('results', {}).items(): + for item in items: + line = item.get('line_number', '?') + stype = item.get('type', '?') + hashed = item.get('hashed_secret', '')[:16] + print(f' {fpath}:{line} [{stype}] {hashed}...') +" + echo "" + echo "ERROR: Potential secrets detected in code!" + exit 1 +fi +echo "✅ Secret scan passed" + +# --- 增量/全量模式判断 --- +echo "" +echo "=== [2/6] Code quality checks ===" +SCAN_MODE="full" +CHANGED_PY_FILES="" + +if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100" + set +e + RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL") + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + set -e + if [ "$HTTP_CODE" = "200" ]; then + CHANGED_PY_FILES=$(echo "$BODY" | python3 -c " +import json, sys +try: + files = json.load(sys.stdin) + py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed'] + print(' '.join(py_files)) +except Exception: + print('') +") + if [ -n "$CHANGED_PY_FILES" ]; then + SCAN_MODE="incremental" + echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed" + else + SCAN_MODE="skip_py" + echo "No Python files changed in this PR" + fi + else + echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan" + fi +else + echo "Full scan mode (not a PR event)" +fi + +if [ "$SCAN_MODE" = "incremental" ]; then + # 防御性过滤 + EXISTING_PY_FILES="" + for f in $CHANGED_PY_FILES; do + if [ -f "$f" ]; then + if [ -z "$EXISTING_PY_FILES" ]; then + EXISTING_PY_FILES="$f" + else + EXISTING_PY_FILES="$EXISTING_PY_FILES $f" + fi + fi + done + CHANGED_PY_FILES="$EXISTING_PY_FILES" + + python3 -m compileall -q $CHANGED_PY_FILES + python3 -m black --check --fast $CHANGED_PY_FILES + python3 -m isort --check-only $CHANGED_PY_FILES + RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs) + if [ -n "$RUFF_FILES" ]; then + python3 -m ruff check $RUFF_FILES --statistics + else + echo "No ruff-checkable files changed, skipping" + fi +elif [ "$SCAN_MODE" = "skip_py" ]; then + echo "No Python files changed - skipping Python lint checks" +else + echo "Full scan mode" + python3 -m compileall -q alembic apps packages tests scripts + python3 -m black --check --fast alembic apps packages tests scripts + python3 -m isort --check-only alembic apps packages tests scripts + python3 -m ruff check apps packages tests --statistics +fi +echo "✅ Code quality checks passed" + +# --- Bandit 安全扫描(仅告警) --- +echo "" +echo "=== [3/6] Security scan (bandit, advisory only) ===" +set +e +bandit -r apps packages -q -ll +BANDIT_EXIT=$? +set -e +if [ "$BANDIT_EXIT" -ne 0 ]; then + echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)" +else + echo "✅ Bandit security scan passed" +fi + +# --- Pip-audit 依赖漏洞扫描(仅告警) --- +echo "" +echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ===" +python3 -m pip install -q pip-audit +pip-audit --version +EXIT_CODE=0 +for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do + if [ -f "$req_file" ]; then + echo "--- Scanning $req_file ---" + pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$? + echo "" + fi +done +echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)" + +# --- Vulture 死代码检测(仅告警) --- +echo "" +echo "=== [5/6] Dead code detection (vulture, advisory only) ===" +set +e +python3 -m pip install -q vulture +vulture --version +echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。" +echo "" +vulture apps packages scripts \ + --exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \ + --min-confidence 70 \ + 2>&1 | sort -t'(' -k2 -rn | head -80 +echo "" +echo "=== vulture scan summary ===" +echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)" +echo "建议:定期人工审查高置信度(>=90%)条目" +set -e + +# --- Release 脚本语法校验 --- +echo "" +echo "=== [6/6] Release scripts syntax validation ===" +bash -n scripts/backup_postgres.sh +bash -n scripts/restore_postgres_plan.sh +bash -n scripts/init_production_env.sh +echo "✅ Release scripts syntax OK" + +echo "" +echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ===" diff --git a/scripts/ci/validate_migration.sh b/scripts/ci/validate_migration.sh new file mode 100644 index 000000000..998dc1651 --- /dev/null +++ b/scripts/ci/validate_migration.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# CI Validate: Alembic迁移验证(并行Job 3/3) +# 需要PostgreSQL数据库 +set -eu + +echo "=== CI Validate: Alembic迁移验证 ===" + +# --- DooD模式检测:确定宿主机访问地址 --- +detect_docker_host() { + local test_port="${1:-5432}" + + local candidates=() + + # 1. host.docker.internal + if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then + candidates+=("host.docker.internal") + fi + + # 2. docker0 桥接网关 + candidates+=("172.17.0.1") + + # 3. 默认网关 + local gw="" + gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1) + if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then + candidates+=("$gw") + fi + + # 4. 宿主机同网段的.1或.254 + local my_ip="" + my_ip=$(hostname -I 2>/dev/null | awk '{print $1}') + if [ -n "$my_ip" ]; then + local subnet=$(echo "$my_ip" | cut -d. -f1-3) + candidates+=("${subnet}.1") + candidates+=("${subnet}.254") + fi + + # 5. 127.0.0.1 最后尝试 + candidates+=("127.0.0.1") + + for candidate in "${candidates[@]}"; do + if python3 -c " +import socket +s = socket.socket() +s.settimeout(2) +try: + s.connect(('$candidate', $test_port)) + s.close() + print('ok') +except: + pass +" 2>/dev/null | grep -q ok; then + echo "$candidate" + return 0 + fi + done + + echo "127.0.0.1" + return 1 +} + +# 获取宿主机IP +if [ -S /var/run/docker.sock ]; then + DOCKER_HOST_IP=$(detect_docker_host 5433) + if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then + DOCKER_HOST_IP=$(detect_docker_host 22) + fi + echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP" +else + DOCKER_HOST_IP="127.0.0.1" + echo "非DooD模式,使用 127.0.0.1" +fi +PG_HOST="$DOCKER_HOST_IP" +echo "PG host: $PG_HOST" + +# 指数退避TCP连接检查 +wait_tcp_ready() { + local host="$1" + local port="$2" + local max_attempts="${3:-5}" + local delay=1 + local attempt=1 + while [ "$attempt" -le "$max_attempts" ]; do + if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then + return 0 + fi + echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..." + sleep "$delay" + delay=$((delay * 2)) + attempt=$((attempt + 1)) + done + return 1 +} + +USE_SHARED_PG="${CI_USE_SHARED_PG:-false}" + +if [ "$USE_SHARED_PG" = "true" ]; then + # 使用常驻共享PG实例 + echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)" + SHARED_PG_HOST="$PG_HOST" + SHARED_PG_PORT="5433" + SHARED_PG_USER="postgres" + SHARED_PG_PASSWORD="ci_pg_2026!" + CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}" + + echo "等待共享PG连接就绪..." + wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5 + + echo "创建测试数据库: $CI_DB_NAME" + PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c " +import psycopg2 +conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres') +conn.autocommit = True +cur = conn.cursor() +cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"') +cur.close() +conn.close() +" + export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}" + echo "✅ 共享PG数据库已创建: $CI_DB_NAME" + + # 执行迁移 + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head + echo "✅ Alembic migrations applied successfully" + + # 清理数据库 + echo "清理测试数据库: $CI_DB_NAME" + PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c " +import psycopg2 +conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres') +conn.autocommit = True +cur = conn.cursor() +cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)') +cur.close() +conn.close() +" 2>/dev/null || echo "WARN: 数据库清理失败" + echo "✅ 共享PG数据库已清理" +else + # 使用临时PG容器(默认模式) + echo "使用临时PG容器模式" + PG_CONTAINER=ci-pg-validate-migration-${GITHUB_RUN_ID:-$$} + docker rm -f "$PG_CONTAINER" 2>/dev/null || true + docker run -d --name "$PG_CONTAINER" \ + --shm-size=256m \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=xiaoxia_saas \ + -P \ + --health-cmd "pg_isready -U postgres" \ + --health-interval 3s \ + --health-timeout 3s \ + --health-retries 20 \ + postgres:16-alpine + PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2) + echo "PostgreSQL port: $PG_PORT" + export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas" + + # 等待容器健康 + for i in $(seq 1 30); do + if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then + echo "PostgreSQL container is healthy on port $PG_PORT" + break + fi + echo "Waiting for PostgreSQL container health... ($i/30)" + sleep 2 + done + docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy + + # TCP连通性检查 + echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..." + wait_tcp_ready "$PG_HOST" "$PG_PORT" 5 + echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT" + + # 执行迁移 + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head + echo "✅ Alembic migrations applied successfully" + + docker rm -f "$PG_CONTAINER" 2>/dev/null || true +fi + +echo "" +echo "=== CI Validate: Alembic迁移验证 通过 ✅ ===" diff --git a/scripts/ci/validate_mypy.sh b/scripts/ci/validate_mypy.sh new file mode 100644 index 000000000..645776828 --- /dev/null +++ b/scripts/ci/validate_mypy.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# CI Validate: Mypy类型检查(并行Job 2/3) +set -eu + +echo "=== CI Validate: Mypy类型检查 ===" + +bash scripts/ci/mypy_check.sh + +echo "" +echo "=== CI Validate: Mypy类型检查 通过 ✅ ===" diff --git a/scripts/ci/vitest_incremental.sh b/scripts/ci/vitest_incremental.sh new file mode 100644 index 000000000..d367a07f4 --- /dev/null +++ b/scripts/ci/vitest_incremental.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Vitest 增量执行脚本 +# PR模式下只跑与改动文件相关的测试,大幅节省时间 +# 用法: bash scripts/ci/vitest_incremental.sh +set -eu + +cd apps/web + +# 如果不是PR事件,直接全量跑 +if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then + echo "非PR模式,全量执行Vitest" + npx --no-install vitest run --coverage + exit $? +fi + +# 获取PR改动的文件列表 +PR_NUMBER=$(echo "${GITHUB_REF:-}" | sed 's|refs/pull/||; s|/.*||') +if [ -z "$PR_NUMBER" ]; then + echo "无法获取PR编号,全量执行Vitest" + npx --no-install vitest run --coverage + exit $? +fi + +API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100" +CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c " +import json, sys +try: + files = json.load(sys.stdin) + web_files = [] + for f in files: + fname = f['filename'] + # 只关注前端源码文件 + if fname.startswith('apps/web/src/') and fname.endswith(('.ts', '.tsx', '.js', '.jsx')) and f['status'] != 'removed': + # 去掉apps/web/前缀,变成相对路径 + web_files.append(fname.replace('apps/web/', '')) + print(' '.join(web_files)) +except Exception as e: + print('') +") + +if [ -z "$CHANGED_FILES" ]; then + echo "PR未改动前端源码文件,跳过Vitest" + echo "(如果配置了前端单测门禁,请确保至少有一个相关测试)" + exit 0 +fi + +FILE_COUNT=$(echo "$CHANGED_FILES" | wc -w) +echo "PR改动了 $FILE_COUNT 个前端文件" +echo "改动文件: $CHANGED_FILES" + +# 如果改动文件太多(超过30个),全量跑更可靠 +if [ "$FILE_COUNT" -gt 30 ]; then + echo "改动文件较多(>$FILE_COUNT),降级为全量执行以确保覆盖" + npx --no-install vitest run --coverage + exit $? +fi + +# 使用vitest --related 跑增量测试 +echo "" +echo "=== 增量执行 Vitest(只跑相关测试)===" +echo "相关源文件: $CHANGED_FILES" +echo "" + +set +e +npx --no-install vitest run --related $CHANGED_FILES +VITEST_EXIT=$? +set -e + +if [ "$VITEST_EXIT" -eq 0 ]; then + echo "" + echo "✅ 增量测试通过" + echo "(仅覆盖与改动相关的测试用例)" + exit 0 +else + echo "" + echo "❌ 增量测试失败" + exit $VITEST_EXIT +fi diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py index a8be3de03..d7b620c8d 100755 --- a/scripts/ci_notify_failure.py +++ b/scripts/ci_notify_failure.py @@ -1,17 +1,57 @@ #!/usr/bin/env python3 -"""发送 CI 失败通知到飞书/项目群 webhook。""" +"""发送 CI 失败通知到飞书/项目群 webhook(增强版:带失败诊断)。 + +诊断功能:自动分析失败原因,给出分类和修复建议。 +""" import json import os +import subprocess import sys import urllib.request +def run_diagnosis() -> dict: + """运行失败诊断脚本,返回诊断结果""" + diag_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ci/ci_failure_diagnosis.py") + if not os.path.exists(diag_script): + diag_script = "scripts/ci/ci_failure_diagnosis.py" + + result = { + "category": "unknown", + "category_cn": "未知", + "severity": "medium", + "summary": "", + "error_lines": [], + "suggestions": [], + "auto_fixable": False, + } + + try: + # 运行诊断脚本 + env = os.environ.copy() + env["DIAGNOSIS_OUTPUT"] = "/tmp/ci_diagnosis_result.json" + + proc = subprocess.run([sys.executable, diag_script], capture_output=True, text=True, timeout=30, env=env) + + # 尝试读取结果文件 + output_file = "/tmp/ci_diagnosis_result.json" + if os.path.exists(output_file): + with open(output_file) as f: + result = json.load(f) + elif proc.stdout: + # 从stdout解析 + pass + except Exception as e: + print(f"诊断脚本执行失败: {e}", file=sys.stderr) + + return result + + def main() -> int: webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "") if not webhook: print("未配置 CI_NOTIFY_WEBHOOK,跳过通知") - print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK") return 0 failed_job = os.environ.get("FAILED_JOB", "Unknown Job") @@ -21,6 +61,86 @@ def main() -> int: run_id = os.environ.get("GITHUB_RUN_ID", "unknown") repo = os.environ.get("GITHUB_REPOSITORY", "unknown") run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" + pr_number = os.environ.get("PR_NUMBER", "") + + # 运行诊断 + diagnosis = run_diagnosis() + + # 构建卡片内容 + severity_color = {"high": "red", "medium": "orange", "low": "blue"} + card_status = severity_color.get(diagnosis.get("severity", "medium"), "red") + + # 标题 + title = f"❌ CI失败 - {diagnosis.get('category_cn', '未知')}" + + # 诊断部分 + diag_lines = [] + diag_lines.append(f"**任务**: {failed_job}") + diag_lines.append(f"**分类**: {diagnosis.get('category_cn', '未知')}") + if diagnosis.get("summary"): + diag_lines.append(f"**问题**: {diagnosis['summary']}") + + # 错误行 + error_lines = diagnosis.get("error_lines", []) + if error_lines: + diag_lines.append("") + diag_lines.append("**关键错误**:") + for err in error_lines[:3]: + if len(err) > 100: + err = err[:97] + "..." + diag_lines.append(f"`{err}`") + + # 修复建议 + suggestions = diagnosis.get("suggestions", []) + if suggestions: + diag_lines.append("") + diag_lines.append("**修复建议**:") + for i, s in enumerate(suggestions[:3], 1): + diag_lines.append(f"{i}. {s}") + + if diagnosis.get("auto_fixable"): + diag_lines.append("") + diag_lines.append("💡 *可自动修复的问题,试试Rerun*") + + # 基本信息 + info_lines = [ + f"**分支**: {branch}", + f"**提交**: `{commit}`", + f"**提交者**: {actor}", + ] + if pr_number: + info_lines.append(f"**PR**: #{pr_number}") + + elements = [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "\n".join(diag_lines), + }, + }, + { + "tag": "hr", + }, + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "\n".join(info_lines), + }, + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "查看失败日志"}, + "url": run_url, + "type": "danger", + }, + ], + }, + ] payload = { "msg_type": "interactive", @@ -28,36 +148,11 @@ def main() -> int: "header": { "title": { "tag": "plain_text", - "content": "❌ CI 构建失败", + "content": title, }, - "status": "red", + "status": card_status, }, - "elements": [ - { - "tag": "div", - "text": { - "tag": "lark_md", - "content": ( - f"**任务**: {failed_job}\n" - f"**分支**: {branch}\n" - f"**提交**: {commit}\n" - f"**提交者**: {actor}\n" - f"**Run ID**: {run_id}" - ), - }, - }, - { - "tag": "action", - "actions": [ - { - "tag": "button", - "text": {"tag": "plain_text", "content": "查看失败日志"}, - "url": run_url, - "type": "danger", - } - ], - }, - ], + "elements": elements, }, } @@ -71,7 +166,7 @@ def main() -> int: try: with urllib.request.urlopen(req, timeout=10) as resp: resp.read() - print("通知已发送") + print("通知已发送(带诊断信息)") except Exception as e: print(f"通知发送失败: {e}", file=sys.stderr) return 1 @@ -81,3 +176,5 @@ def main() -> int: if __name__ == "__main__": sys.exit(main()) + +# trigger CI - bypass [ci skip] bug diff --git a/scripts/ci_staging_deploy.sh b/scripts/ci_staging_deploy.sh index 80a0337b4..71444545f 100755 --- a/scripts/ci_staging_deploy.sh +++ b/scripts/ci_staging_deploy.sh @@ -1,22 +1,9 @@ #!/bin/sh # =========================================== -# Staging 部署脚本(SSH 模式,支持自动回滚) +# Staging 部署脚本(SSH 模式,并行优化版) # =========================================== -# 通过 SSH 在 staging 服务器上执行 -# -# 环境变量: -# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名) -# REGISTRY_TOKEN - Registry 访问令牌 -# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji) -# REGISTRY_USER - Registry 用户名(默认 xiaoxia) -# ENV_FILE - 环境变量文件路径 -# GENERATED_DIR - 生成文件目录 -# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false) -# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false) - set -eu -# ---- 重试工具函数 ---- retry_cmd() { local max_attempts=$1 local backoff=$2 @@ -73,10 +60,9 @@ mkdir -p "$GENERATED_DIR" mkdir -p "$LEGACY_ASSETS_DIR" echo "===========================================" -echo " Staging 部署 - $IMAGE_TAG" +echo " Staging 部署 - $IMAGE_TAG (并行优化版)" echo "===========================================" -# ---- 记录当前运行的镜像版本(用于回滚) ---- echo "Recording current image versions for rollback..." PREV_API_IMAGE="" PREV_WORKER_IMAGE="" @@ -95,7 +81,6 @@ for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do fi done -# ---- 回滚函数 ---- rollback() { echo "" echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" @@ -108,7 +93,6 @@ rollback() { exit 1 fi - # 停止当前(失败的)新容器 echo "Stopping new containers..." docker rm -f xiaoxia-api-staging 2>/dev/null || true docker rm -f xiaoxia-worker-staging 2>/dev/null || true @@ -116,7 +100,6 @@ rollback() { LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3" - # 恢复 API if [ -n "$PREV_API_IMAGE" ]; then echo "Rolling back API to: $PREV_API_IMAGE" docker run -d \ @@ -137,12 +120,9 @@ rollback() { --health-retries 3 \ --health-start-period 40s \ $LOG_OPTS \ - "$PREV_API_IMAGE" - else - echo "No previous API image to roll back to" + "$PREV_API_IMAGE" & fi - # 恢复 Worker if [ -n "$PREV_WORKER_IMAGE" ]; then echo "Rolling back Worker to: $PREV_WORKER_IMAGE" docker run -d \ @@ -164,12 +144,9 @@ rollback() { --health-retries 3 \ --health-start-period 30s \ $LOG_OPTS \ - "$PREV_WORKER_IMAGE" - else - echo "No previous Worker image to roll back to" + "$PREV_WORKER_IMAGE" & fi - # 恢复 Web if [ -n "$PREV_WEB_IMAGE" ]; then echo "Rolling back Web to: $PREV_WEB_IMAGE" LEGACY_VOLUME="" @@ -187,12 +164,11 @@ rollback() { --health-timeout 5s \ --health-retries 3 \ $LOG_OPTS \ - "$PREV_WEB_IMAGE" - else - echo "No previous Web image to roll back to" + "$PREV_WEB_IMAGE" & fi - # 等待 API 回滚后恢复健康 + wait + if [ -n "$PREV_API_IMAGE" ]; then echo "Waiting for rolled-back API to become healthy..." i=0 @@ -224,7 +200,6 @@ rollback() { exit 1 } -# ---- 登录 Registry ---- if [ -n "$REGISTRY_TOKEN" ]; then echo "==========================================" echo " Login to Registry (with retries)" @@ -233,28 +208,64 @@ if [ -n "$REGISTRY_TOKEN" ]; then retry_docker_login fi -# ---- Pull 新版本镜像 ---- +# ---- 并行 Pull 三个镜像 ---- REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" echo "==========================================" -echo " Pull images (with retries)" +echo " Pull images (parallel, up to 3 retries each)" echo "==========================================" -retry_docker_pull "$REGISTRY_API" -retry_docker_pull "$REGISTRY_WORKER" -retry_docker_pull "$REGISTRY_WEB" +PULL_LOG_DIR="/tmp/staging-pull-$$" +mkdir -p "$PULL_LOG_DIR" + +retry_docker_pull "$REGISTRY_API" > "$PULL_LOG_DIR/api.log" 2>&1 & +PID_API=$! +retry_docker_pull "$REGISTRY_WORKER" > "$PULL_LOG_DIR/worker.log" 2>&1 & +PID_WORKER=$! +retry_docker_pull "$REGISTRY_WEB" > "$PULL_LOG_DIR/web.log" 2>&1 & +PID_WEB=$! + +wait $PID_API $PID_WORKER $PID_WEB + +echo "" +echo "Pull 结果:" +PULL_FAILED=0 +for svc in api worker web; do + if tail -1 "$PULL_LOG_DIR/$svc.log" 2>/dev/null | grep -qE "Status:|Downloaded|already exists|is up to date"; then + echo " OK $svc" + elif grep -qE "Digest:|Status: Downloaded" "$PULL_LOG_DIR/$svc.log" 2>/dev/null; then + echo " OK $svc" + else + # 检查docker pull返回值不直接,用镜像是否存在来判断 + img_var="REGISTRY_$(echo $svc | tr '[:lower:]' '[:upper:]')" + img_val=$(eval echo "\$$img_var") + if docker image inspect "$img_val" >/dev/null 2>&1; then + echo " OK $svc" + else + echo " FAIL $svc" + tail -5 "$PULL_LOG_DIR/$svc.log" 2>/dev/null || true + PULL_FAILED=$((PULL_FAILED + 1)) + fi + fi +done + +rm -rf "$PULL_LOG_DIR" + +if [ "$PULL_FAILED" -gt 0 ]; then + echo "" + echo "ERROR: $PULL_FAILED 个镜像 pull 失败" + exit 1 +fi echo "All images pulled." -# ---- 备份 legacy assets ---- echo "Backing up legacy assets from current web container..." if docker inspect xiaoxia-web-staging >/dev/null 2>&1; then _tmpdir="/tmp/legacy-assets-$$" rm -rf "$_tmpdir" mkdir -p "$_tmpdir" docker cp xiaoxia-web-staging:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true - # 只有目录非空才拷贝,避免覆盖有内容的 legacy assets if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files" @@ -264,13 +275,11 @@ else echo "No existing web container, skipping legacy assets backup" fi -# 清理 7 天前的 legacy assets if [ -d "$LEGACY_ASSETS_DIR" ]; then find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true echo "Legacy assets cleanup done (retain 7 days)" fi -# ---- 检查基础设施容器 ---- echo "Checking infrastructure containers..." for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do if ! docker inspect "$c" >/dev/null 2>&1; then @@ -284,10 +293,8 @@ for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do fi done -# ---- 创建网络(不存在则创建) ---- docker network create xiaoxia-net-staging 2>/dev/null || true -# ---- 数据库迁移 ---- if [ "$SKIP_MIGRATION" != "true" ]; then echo "Running database migrations..." docker run --rm \ @@ -296,8 +303,6 @@ if [ "$SKIP_MIGRATION" != "true" ]; then -e APP_ENV=staging \ "$REGISTRY_API" sh -c "cd /app && alembic upgrade head" || { echo "ERROR: Database migration failed" - echo "Note: Migration failures are NOT automatically rolled back (data safety)" - echo "Please manually check and fix the migration, then redeploy" exit 1 } echo "Migrations completed." @@ -305,7 +310,6 @@ else echo "Skipping migrations (SKIP_MIGRATION=true)" fi -# ---- 停止旧容器 ---- echo "Stopping old containers..." docker rm -f xiaoxia-api-staging 2>/dev/null || true docker rm -f xiaoxia-worker-staging 2>/dev/null || true @@ -313,8 +317,14 @@ docker rm -f xiaoxia-web-staging 2>/dev/null || true LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3" -# ---- 启动 API ---- -echo "Starting API container..." +# ---- 并行启动三个容器 ---- +echo "Starting all containers (parallel)..." + +LEGACY_VOLUME="" +if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then + LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro" +fi + docker run -d \ --name xiaoxia-api-staging \ --env-file "$ENV_FILE" \ @@ -333,10 +343,9 @@ docker run -d \ --health-retries 3 \ --health-start-period 40s \ $LOG_OPTS \ - "$REGISTRY_API" || rollback + "$REGISTRY_API" & +PID_API_START=$! -# ---- 启动 Worker ---- -echo "Starting Worker container..." docker run -d \ --name xiaoxia-worker-staging \ --env-file "$ENV_FILE" \ @@ -356,18 +365,9 @@ docker run -d \ --health-retries 3 \ --health-start-period 30s \ $LOG_OPTS \ - "$REGISTRY_WORKER" || rollback + "$REGISTRY_WORKER" & +PID_WORKER_START=$! -# ---- 启动 Web ---- -LEGACY_VOLUME="" -if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then - LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro" - echo "Web container: legacy assets mounted (fallback)" -else - echo "Web container: no legacy assets to mount" -fi - -echo "Starting Web container..." docker run -d \ --name xiaoxia-web-staging \ --network xiaoxia-net-staging \ @@ -379,53 +379,111 @@ docker run -d \ --health-timeout 5s \ --health-retries 3 \ $LOG_OPTS \ - "$REGISTRY_WEB" || rollback + "$REGISTRY_WEB" & +PID_WEB_START=$! -# ---- 等待 API 健康 ---- -echo "Waiting for API to become healthy..." -i=0 -while [ "$i" -lt 40 ]; do - if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then - echo "API is healthy!" - break +wait $PID_API_START $PID_WORKER_START $PID_WEB_START + +START_FAILED=0 +for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do + if ! docker inspect "$c" >/dev/null 2>&1; then + echo " FAIL $c: not created" + START_FAILED=$((START_FAILED + 1)) + else + state=$(docker inspect -f '{{.State.Status}}' "$c") + if [ "$state" = "running" ] || [ "$state" = "starting" ]; then + echo " OK $c: $state" + else + echo " FAIL $c: $state" + docker logs --tail 20 "$c" 2>/dev/null || true + START_FAILED=$((START_FAILED + 1)) + fi fi - i=$((i + 1)) - echo " Waiting... ($i/40)" - sleep 3 done -if [ "$i" -ge 40 ]; then - echo "ERROR: API did not become healthy within 120s" +if [ "$START_FAILED" -gt 0 ]; then + echo "ERROR: $START_FAILED 个容器启动失败" + rollback +fi + +# ---- 并行等待 API 和 Web 健康 ---- +echo "" +echo "Waiting for API + Web health (parallel)..." + +HEALTH_LOG_DIR="/tmp/staging-health-$$" +mkdir -p "$HEALTH_LOG_DIR" + +( + i=0 + while [ "$i" -lt 40 ]; do + if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then + echo "API healthy after $((i * 3))s" + exit 0 + fi + i=$((i + 1)) + sleep 3 + done + echo "API FAILED after 120s" + exit 1 +) > "$HEALTH_LOG_DIR/api.log" 2>&1 & +PID_API_HEALTH=$! + +( + i=0 + while [ "$i" -lt 15 ]; do + if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then + echo "Web healthy after $((i * 2))s" + exit 0 + fi + i=$((i + 1)) + sleep 2 + done + echo "Web FAILED after 30s" + exit 1 +) > "$HEALTH_LOG_DIR/web.log" 2>&1 & +PID_WEB_HEALTH=$! + +set +e +wait $PID_API_HEALTH +API_EXIT=$? +wait $PID_WEB_HEALTH +WEB_EXIT=$? +set -e + +echo "" +echo "健康检查结果:" +API_OK=0 +WEB_OK=0 +if [ "$API_EXIT" -eq 0 ]; then + echo " OK API: $(cat "$HEALTH_LOG_DIR/api.log")" + API_OK=1 +else + echo " FAIL API: 120s未就绪" docker logs --tail 50 xiaoxia-api-staging - rollback fi -# ---- 等待 Web 健康 ---- -echo "Waiting for Web to become healthy..." -i=0 -while [ "$i" -lt 15 ]; do - if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then - echo "Web is healthy!" - break - fi - i=$((i + 1)) - echo " Waiting... ($i/15)" - sleep 2 -done - -if [ "$i" -ge 15 ]; then - echo "ERROR: Web did not become healthy within 30s" +if [ "$WEB_EXIT" -eq 0 ]; then + echo " OK Web: $(cat "$HEALTH_LOG_DIR/web.log")" + WEB_OK=1 +else + echo " FAIL Web: 30s未就绪" docker logs --tail 30 xiaoxia-web-staging +fi + +rm -rf "$HEALTH_LOG_DIR" + +if [ "$API_OK" -eq 0 ] || [ "$WEB_OK" -eq 0 ]; then + echo "" + echo "ERROR: 健康检查失败" rollback fi -# ---- 清理旧镜像 ---- echo "Cleaning up old images..." docker image prune -af --filter "until=168h" 2>/dev/null || true docker builder prune -af --filter "until=168h" 2>/dev/null || true echo "" -echo "=== Staging deployment complete ===" +echo "=== Staging deployment complete (并行优化版) ===" echo "API: http://127.0.0.1:8000" echo "Web: http://127.0.0.1:3001" echo "Version: $IMAGE_TAG" diff --git a/tests/unit/test_classification_domain.py b/tests/unit/test_classification_domain.py new file mode 100755 index 000000000..9b1edaa19 --- /dev/null +++ b/tests/unit/test_classification_domain.py @@ -0,0 +1,139 @@ +"""classification 模块单元测试.""" + +import pytest +from domain.classification import ( + AssetClassification, + AssetLibraryKind, + ClassificationJob, + ClassificationJobStatus, + IngestJobStatus, +) + + +class TestAssetLibraryKind: + """AssetLibraryKind 枚举测试.""" + + def test_values(self): + assert AssetLibraryKind.VIDEO == "video" + assert AssetLibraryKind.VOICE == "voice" + + +class TestIngestJobStatus: + """IngestJobStatus 枚举测试.""" + + def test_values(self): + assert IngestJobStatus.PENDING == "pending" + assert IngestJobStatus.PROCESSING == "processing" + assert IngestJobStatus.COMPLETED == "completed" + assert IngestJobStatus.FAILED == "failed" + + +class TestClassificationJobStatus: + """ClassificationJobStatus 枚举测试.""" + + def test_values(self): + assert ClassificationJobStatus.PENDING == "pending" + assert ClassificationJobStatus.PROCESSING == "processing" + assert ClassificationJobStatus.COMPLETED == "completed" + assert ClassificationJobStatus.FAILED == "failed" + + +class TestAssetClassification: + """AssetClassification 枚举测试.""" + + def test_values(self): + assert AssetClassification.SCENIC == "scenic" + assert AssetClassification.PRODUCT == "product" + assert AssetClassification.PERSON == "person" + assert AssetClassification.ANIMAL == "animal" + assert AssetClassification.FOOD == "food" + assert AssetClassification.TECH == "tech" + assert AssetClassification.SPORT == "sport" + assert AssetClassification.MUSIC == "music" + assert AssetClassification.OTHER == "other" + + +class TestClassificationJobCreate: + """ClassificationJob.create 工厂方法测试.""" + + def test_create_with_valid_params(self): + job = ClassificationJob.create(project_id="proj_001", asset_id="asset_001") + assert job.id + assert len(job.id) == 32 + assert job.project_id == "proj_001" + assert job.asset_id == "asset_001" + assert job.status == ClassificationJobStatus.PENDING + assert job.classification == "" + assert job.confidence == 0.0 + assert job.error_message == "" + assert job.created_at is not None + assert job.updated_at is not None + + def test_create_strips_strings(self): + job = ClassificationJob.create( + project_id=" proj_002 ", + asset_id=" asset_002 ", + ) + assert job.project_id == "proj_002" + assert job.asset_id == "asset_002" + + def test_create_empty_project_id_raises(self): + with pytest.raises(ValueError, match="project_id"): + ClassificationJob.create(project_id="", asset_id="a") + + def test_create_whitespace_project_id_raises(self): + with pytest.raises(ValueError, match="project_id"): + ClassificationJob.create(project_id=" ", asset_id="a") + + def test_create_empty_asset_id_raises(self): + with pytest.raises(ValueError, match="asset_id"): + ClassificationJob.create(project_id="p", asset_id="") + + def test_create_whitespace_asset_id_raises(self): + with pytest.raises(ValueError, match="asset_id"): + ClassificationJob.create(project_id="p", asset_id=" ") + + def test_create_ids_are_unique(self): + j1 = ClassificationJob.create(project_id="p", asset_id="a") + j2 = ClassificationJob.create(project_id="p", asset_id="b") + assert j1.id != j2.id + + def test_create_timestamps_are_utc(self): + job = ClassificationJob.create(project_id="p", asset_id="a") + assert job.created_at.tzinfo is not None + assert job.updated_at.tzinfo is not None + + +class TestClassificationJobState: + """ClassificationJob 状态操作测试""" + + def test_set_processing(self): + job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") + job.status = ClassificationJobStatus.PROCESSING + assert job.status == ClassificationJobStatus.PROCESSING + + def test_set_completed_with_result(self): + job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") + job.status = ClassificationJobStatus.COMPLETED + job.classification = AssetClassification.SCENIC + job.confidence = 0.95 + assert job.status == ClassificationJobStatus.COMPLETED + assert job.classification == "scenic" + assert job.confidence == pytest.approx(0.95) + + def test_set_failed_with_error(self): + job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") + job.status = ClassificationJobStatus.FAILED + job.error_message = "model timeout" + assert job.status == ClassificationJobStatus.FAILED + assert job.error_message == "model timeout" + + def test_confidence_range_zero(self): + job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") + job.confidence = 0.0 + assert job.confidence == 0.0 + + def test_confidence_range_one(self): + job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") + job.confidence = 1.0 + assert job.confidence == 1.0 diff --git a/tests/unit/test_edit_plan_clip_domain.py b/tests/unit/test_edit_plan_clip_domain.py new file mode 100755 index 000000000..4e993a143 --- /dev/null +++ b/tests/unit/test_edit_plan_clip_domain.py @@ -0,0 +1,262 @@ +"""edit_plan_clip 领域模型单元测试.""" + +from datetime import datetime, timezone + +import pytest +from domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus + + +class TestEditPlanClipStatus: + """EditPlanClipStatus 枚举测试.""" + + def test_values(self): + assert EditPlanClipStatus.PENDING == "pending" + assert EditPlanClipStatus.READY == "ready" + assert EditPlanClipStatus.RENDERED == "rendered" + assert EditPlanClipStatus.FAILED == "failed" + + +class TestEditPlanClipCreate: + """EditPlanClip.create 工厂方法测试.""" + + def test_create_with_required_fields(self): + clip = EditPlanClip.create(plan_id="plan_001", clip_type="video", order=1) + assert clip.id # 自动生成的 UUID + assert len(clip.id) == 32 # hex 格式 + assert clip.plan_id == "plan_001" + assert clip.clip_type == "video" + assert clip.order == 1 + assert clip.status == EditPlanClipStatus.PENDING + assert clip.start_time == 0.0 + assert clip.duration == 0.0 + assert clip.transition_effect == "cut" + assert clip.playback_speed == 1.0 + assert clip.config == {} + + def test_create_with_all_fields(self): + clip = EditPlanClip.create( + plan_id="plan_002", + clip_type="audio", + order=2, + template_clip_config_id="tpl_001", + asset_id="asset_001", + text_content="测试文案", + start_time=5.0, + duration=10.0, + transition_effect="fade", + transition_duration=0.5, + playback_speed=1.5, + config={"key": "value"}, + ) + assert clip.plan_id == "plan_002" + assert clip.clip_type == "audio" + assert clip.order == 2 + assert clip.template_clip_config_id == "tpl_001" + assert clip.asset_id == "asset_001" + assert clip.text_content == "测试文案" + assert clip.start_time == 5.0 + assert clip.duration == 10.0 + assert clip.transition_effect == "fade" + assert clip.transition_duration == 0.5 + assert clip.playback_speed == 1.5 + assert clip.config == {"key": "value"} + + def test_create_strips_strings(self): + clip = EditPlanClip.create( + plan_id=" plan_003 ", + clip_type=" video ", + order=1, + asset_id=" asset_001 ", + template_clip_config_id=" tpl_001 ", + text_content=" 测试 ", + transition_effect=" fade ", + ) + assert clip.plan_id == "plan_003" + assert clip.clip_type == "video" + assert clip.asset_id == "asset_001" + assert clip.template_clip_config_id == "tpl_001" + assert clip.text_content == "测试" + assert clip.transition_effect == "fade" + + def test_create_empty_plan_id_raises(self): + with pytest.raises(ValueError, match="plan_id"): + EditPlanClip.create(plan_id="", clip_type="video", order=1) + + def test_create_whitespace_plan_id_raises(self): + with pytest.raises(ValueError, match="plan_id"): + EditPlanClip.create(plan_id=" ", clip_type="video", order=1) + + def test_create_empty_clip_type_raises(self): + with pytest.raises(ValueError, match="clip_type"): + EditPlanClip.create(plan_id="plan_001", clip_type="", order=1) + + def test_create_negative_start_time_raises(self): + with pytest.raises(ValueError, match="start_time"): + EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=-1.0) + + def test_create_negative_duration_raises(self): + with pytest.raises(ValueError, match="duration"): + EditPlanClip.create(plan_id="p", clip_type="v", order=1, duration=-5.0) + + def test_create_zero_speed_clamps_to_1(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.0) + assert clip.playback_speed == 1.0 + + def test_create_negative_speed_clamps_to_1(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=-1.0) + assert clip.playback_speed == 1.0 + + def test_create_low_speed_clamps_to_min(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.1) + assert clip.playback_speed == 0.25 + + def test_create_high_speed_clamps_to_max(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=5.0) + assert clip.playback_speed == 4.0 + + def test_create_speed_at_boundary_values(self): + # 边界值应该保持不变 + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.25) + assert clip.playback_speed == 0.25 + + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=4.0) + assert clip.playback_speed == 4.0 + + def test_create_negative_transition_duration_clamps_to_0(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, transition_duration=-1.0) + assert clip.transition_duration == 0.0 + + def test_create_empty_transition_effect_defaults_to_cut(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, transition_effect="") + assert clip.transition_effect == "cut" + + def test_create_empty_asset_id_stays_empty(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="") + assert clip.asset_id == "" + + def test_create_none_config_defaults_to_empty_dict(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, config=None) + assert clip.config == {} + + def test_create_ids_are_unique(self): + c1 = EditPlanClip.create(plan_id="p", clip_type="v", order=1) + c2 = EditPlanClip.create(plan_id="p", clip_type="v", order=2) + assert c1.id != c2.id + + def test_create_timestamps_are_utc(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1) + assert clip.created_at.tzinfo is not None + assert clip.updated_at.tzinfo is not None + + +class TestEditPlanClipStateMachine: + """状态机流转测试.""" + + @pytest.fixture + def pending_clip(self): + return EditPlanClip.create(plan_id="plan_001", clip_type="video", order=1) + + def test_initial_status_is_pending(self, pending_clip): + assert pending_clip.status == EditPlanClipStatus.PENDING + + def test_pending_to_ready(self, pending_clip): + pending_clip.mark_ready() + assert pending_clip.status == EditPlanClipStatus.READY + + def test_pending_cannot_mark_rendered(self, pending_clip): + with pytest.raises(ValueError, match="只有 ready"): + pending_clip.mark_rendered() + + def test_pending_cannot_mark_failed(self, pending_clip): + with pytest.raises(ValueError, match="只有 ready"): + pending_clip.mark_failed() + + def test_ready_to_rendered(self, pending_clip): + pending_clip.mark_ready() + pending_clip.mark_rendered() + assert pending_clip.status == EditPlanClipStatus.RENDERED + + def test_ready_to_failed(self, pending_clip): + pending_clip.mark_ready() + pending_clip.mark_failed() + assert pending_clip.status == EditPlanClipStatus.FAILED + + def test_rendered_cannot_mark_ready_again(self, pending_clip): + pending_clip.mark_ready() + pending_clip.mark_rendered() + with pytest.raises(ValueError): + pending_clip.mark_ready() + + def test_failed_cannot_mark_ready_again(self, pending_clip): + pending_clip.mark_ready() + pending_clip.mark_failed() + with pytest.raises(ValueError): + pending_clip.mark_ready() + + def test_state_transition_updates_updated_at(self, pending_clip): + old_updated = pending_clip.updated_at + # 确保时间不同 + import time + + time.sleep(0.001) + pending_clip.mark_ready() + assert pending_clip.updated_at > old_updated + + +class TestEditPlanClipAssignAsset: + """assign_asset 方法测试.""" + + def test_assign_asset(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1) + assert not clip.has_asset + clip.assign_asset("asset_001") + assert clip.asset_id == "asset_001" + assert clip.has_asset + + def test_assign_asset_strips_whitespace(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1) + clip.assign_asset(" asset_001 ") + assert clip.asset_id == "asset_001" + + def test_assign_empty_asset_raises(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1) + with pytest.raises(ValueError, match="asset_id"): + clip.assign_asset("") + + def test_assign_whitespace_asset_raises(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1) + with pytest.raises(ValueError, match="asset_id"): + clip.assign_asset(" ") + + def test_assign_updates_updated_at(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1) + old_updated = clip.updated_at + import time + + time.sleep(0.001) + clip.assign_asset("asset_001") + assert clip.updated_at > old_updated + + +class TestEditPlanClipProperties: + """属性方法测试.""" + + def test_end_time(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=5.0, duration=10.0) + assert clip.end_time == 15.0 + + def test_end_time_zero_duration(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=3.0, duration=0.0) + assert clip.end_time == 3.0 + + def test_has_asset_true(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="a001") + assert clip.has_asset is True + + def test_has_asset_false(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1) + assert clip.has_asset is False + + def test_has_asset_empty_string(self): + clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="") + assert clip.has_asset is False diff --git a/tests/unit/test_edit_plan_domain.py b/tests/unit/test_edit_plan_domain.py new file mode 100755 index 000000000..474ac2cef --- /dev/null +++ b/tests/unit/test_edit_plan_domain.py @@ -0,0 +1,237 @@ +"""剪辑计划领域模型单元测试.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from packages.domain.edit_plan import EditPlan, EditPlanStatus + + +class TestEditPlanStatus: + """EditPlanStatus 枚举测试.""" + + def test_status_values(self): + assert EditPlanStatus.DRAFT.value == "draft" + assert EditPlanStatus.EDITING.value == "editing" + assert EditPlanStatus.RENDERING.value == "rendering" + assert EditPlanStatus.COMPLETED.value == "completed" + assert EditPlanStatus.FAILED.value == "failed" + + def test_status_is_str(self): + assert isinstance(EditPlanStatus.DRAFT, str) + assert EditPlanStatus.DRAFT == "draft" + + +class TestEditPlanCreate: + """创建剪辑计划测试.""" + + def test_create_basic(self): + plan = EditPlan.create(template_id="tpl_001", name="测试计划") + assert plan.id + assert len(plan.id) == 32 # uuid4 hex + assert plan.template_id == "tpl_001" + assert plan.name == "测试计划" + assert plan.status == EditPlanStatus.DRAFT + assert plan.total_duration == 0.0 + assert plan.config == {} + assert plan.source_edit_plan_id == "" + assert plan.project_id == "" + assert plan.created_by_user_id == "" + + def test_create_with_all_fields(self): + plan = EditPlan.create( + template_id="tpl_001", + name="完整测试计划", + config={"key": "value"}, + total_duration=60.5, + source_edit_plan_id="src_001", + project_id="proj_001", + created_by_user_id="user_001", + ) + assert plan.name == "完整测试计划" + assert plan.total_duration == 60.5 + assert plan.config == {"key": "value"} + assert plan.source_edit_plan_id == "src_001" + assert plan.project_id == "proj_001" + assert plan.created_by_user_id == "user_001" + + def test_create_empty_name_raises(self): + with pytest.raises(ValueError, match="名称不能为空"): + EditPlan.create(template_id="tpl_001", name="") + + def test_create_whitespace_name_raises(self): + with pytest.raises(ValueError, match="名称不能为空"): + EditPlan.create(template_id="tpl_001", name=" ") + + def test_create_empty_template_id_raises(self): + with pytest.raises(ValueError, match="template_id 不能为空"): + EditPlan.create(template_id="", name="测试") + + def test_create_whitespace_template_id_raises(self): + with pytest.raises(ValueError, match="template_id 不能为空"): + EditPlan.create(template_id=" ", name="测试") + + def test_create_name_stripped(self): + plan = EditPlan.create(template_id="tpl_001", name=" 我的计划 ") + assert plan.name == "我的计划" + + def test_create_template_id_stripped(self): + plan = EditPlan.create(template_id=" tpl_001 ", name="测试") + assert plan.template_id == "tpl_001" + + def test_create_timestamps_set(self): + before = datetime.now(timezone.utc) + plan = EditPlan.create(template_id="tpl_001", name="测试") + after = datetime.now(timezone.utc) + assert before <= plan.created_at <= after + assert before <= plan.updated_at <= after + + def test_create_config_none_defaults_to_empty(self): + plan = EditPlan.create(template_id="tpl_001", name="测试", config=None) + assert plan.config == {} + + +class TestEditPlanStateMachine: + """状态机流转测试.""" + + def _make_plan(self, status: EditPlanStatus) -> EditPlan: + return EditPlan( + id="test_id", + template_id="tpl_001", + name="测试计划", + status=status, + ) + + def test_draft_to_editing(self): + plan = self._make_plan(EditPlanStatus.DRAFT) + plan.start_editing() + assert plan.status == EditPlanStatus.EDITING + assert plan.updated_at > plan.created_at + + def test_editing_to_rendering(self): + plan = self._make_plan(EditPlanStatus.EDITING) + plan.start_rendering() + assert plan.status == EditPlanStatus.RENDERING + + def test_rendering_to_completed(self): + plan = self._make_plan(EditPlanStatus.RENDERING) + plan.mark_completed() + assert plan.status == EditPlanStatus.COMPLETED + + def test_rendering_to_failed(self): + plan = self._make_plan(EditPlanStatus.RENDERING) + plan.mark_failed() + assert plan.status == EditPlanStatus.FAILED + + def test_completed_to_editing_resume(self): + plan = self._make_plan(EditPlanStatus.COMPLETED) + plan.resume_editing() + assert plan.status == EditPlanStatus.EDITING + + def test_failed_to_editing_resume(self): + plan = self._make_plan(EditPlanStatus.FAILED) + plan.resume_editing() + assert plan.status == EditPlanStatus.EDITING + + def test_failed_to_draft_reset(self): + plan = self._make_plan(EditPlanStatus.FAILED) + plan.reset_to_draft() + assert plan.status == EditPlanStatus.DRAFT + + def test_invalid_start_editing_from_editing(self): + plan = self._make_plan(EditPlanStatus.EDITING) + with pytest.raises(ValueError, match="只有 draft 状态"): + plan.start_editing() + + def test_invalid_start_editing_from_rendering(self): + plan = self._make_plan(EditPlanStatus.RENDERING) + with pytest.raises(ValueError): + plan.start_editing() + + def test_invalid_start_rendering_from_draft(self): + plan = self._make_plan(EditPlanStatus.DRAFT) + with pytest.raises(ValueError, match="只有 editing 状态"): + plan.start_rendering() + + def test_invalid_start_rendering_from_completed(self): + plan = self._make_plan(EditPlanStatus.COMPLETED) + with pytest.raises(ValueError): + plan.start_rendering() + + def test_invalid_mark_completed_from_draft(self): + plan = self._make_plan(EditPlanStatus.DRAFT) + with pytest.raises(ValueError, match="只有 rendering 状态"): + plan.mark_completed() + + def test_invalid_mark_failed_from_editing(self): + plan = self._make_plan(EditPlanStatus.EDITING) + with pytest.raises(ValueError): + plan.mark_failed() + + def test_invalid_resume_editing_from_draft(self): + plan = self._make_plan(EditPlanStatus.DRAFT) + with pytest.raises(ValueError, match="只有 completed/failed 状态"): + plan.resume_editing() + + def test_invalid_resume_editing_from_rendering(self): + plan = self._make_plan(EditPlanStatus.RENDERING) + with pytest.raises(ValueError): + plan.resume_editing() + + def test_invalid_reset_to_draft_from_draft(self): + plan = self._make_plan(EditPlanStatus.DRAFT) + with pytest.raises(ValueError, match="只有 failed 状态"): + plan.reset_to_draft() + + def test_invalid_reset_to_draft_from_completed(self): + plan = self._make_plan(EditPlanStatus.COMPLETED) + with pytest.raises(ValueError): + plan.reset_to_draft() + + def test_state_transition_updates_updated_at(self): + plan = self._make_plan(EditPlanStatus.DRAFT) + old_updated = plan.updated_at + plan.start_editing() + assert plan.updated_at >= old_updated + + +class TestEditPlanDataclass: + """数据类属性测试.""" + + def test_slots_prevents_dynamic_attributes(self): + plan = EditPlan(id="1", template_id="t1", name="test") + with pytest.raises(AttributeError): + plan.new_field = "value" + + def test_full_flow_draft_editing_rendering_completed(self): + """完整流程:草稿 → 编辑 → 渲染 → 完成.""" + plan = EditPlan.create(template_id="tpl_001", name="完整流程") + assert plan.status == EditPlanStatus.DRAFT + + plan.start_editing() + assert plan.status == EditPlanStatus.EDITING + + plan.start_rendering() + assert plan.status == EditPlanStatus.RENDERING + + plan.mark_completed() + assert plan.status == EditPlanStatus.COMPLETED + + def test_full_flow_draft_editing_rendering_failed_reset(self): + """完整流程:草稿 → 编辑 → 渲染 → 失败 → 重置 → 编辑 → 渲染 → 完成.""" + plan = EditPlan.create(template_id="tpl_001", name="失败重试流程") + + plan.start_editing() + plan.start_rendering() + plan.mark_failed() + assert plan.status == EditPlanStatus.FAILED + + plan.reset_to_draft() + assert plan.status == EditPlanStatus.DRAFT + + plan.start_editing() + plan.start_rendering() + plan.mark_completed() + assert plan.status == EditPlanStatus.COMPLETED diff --git a/tests/unit/test_editing_mode_domain.py b/tests/unit/test_editing_mode_domain.py new file mode 100755 index 000000000..63e5cb08d --- /dev/null +++ b/tests/unit/test_editing_mode_domain.py @@ -0,0 +1,40 @@ +""" +EditingMode 剪辑模式枚举单元测试 +""" + +from packages.domain.editing_mode import EditingMode + + +class TestEditingMode: + """EditingMode 枚举测试""" + + def test_all_modes_exist(self): + assert EditingMode.ONE_TAKE == "one_take" + assert EditingMode.PIP == "pip" + assert EditingMode.VOICE_OVER == "voice_over" + assert EditingMode.VOICE_PIP == "voice_pip" + + def test_total_count(self): + assert len(EditingMode) == 4 + + def test_is_string_type(self): + for mode in EditingMode: + assert isinstance(mode.value, str) + assert isinstance(mode, str) + + def test_mode_descriptions(self): + """验证模式值有意义""" + assert "one" in EditingMode.ONE_TAKE + assert "pip" in EditingMode.PIP + assert "voice" in EditingMode.VOICE_OVER + assert "voice" in EditingMode.VOICE_PIP + + def test_usage_in_comparison(self): + mode = EditingMode.ONE_TAKE + assert mode == "one_take" + assert mode != "pip" + + def test_iterable(self): + modes = list(EditingMode) + assert len(modes) == 4 + assert EditingMode.ONE_TAKE in modes diff --git a/tests/unit/test_filter_presets_domain.py b/tests/unit/test_filter_presets_domain.py new file mode 100755 index 000000000..2a41d7751 --- /dev/null +++ b/tests/unit/test_filter_presets_domain.py @@ -0,0 +1,230 @@ +"""filter_presets 模块单元测试.""" + +from dataclasses import FrozenInstanceError + +import pytest +from domain.filter_presets import ( + FILTER_PRESET_LIBRARY, + FilterPreset, + build_ffmpeg_filter, + get_filter_preset, + list_filter_presets, +) + + +class TestFilterPreset: + """FilterPreset 数据类测试.""" + + def test_create_required_fields(self): + f = FilterPreset(id="test_001", name="测试滤镜", category="basic") + assert f.id == "test_001" + assert f.name == "测试滤镜" + assert f.category == "basic" + # 默认值 + assert f.description == "" + assert f.tags == [] + assert f.brightness == 0.0 + assert f.contrast == 1.0 + assert f.saturation == 1.0 + assert f.gamma == 1.0 + assert f.gamma_r == 1.0 + assert f.gamma_g == 1.0 + assert f.gamma_b == 1.0 + assert f.hue == 0.0 + assert f.lut_url == "" + + def test_create_all_fields(self): + f = FilterPreset( + id="test_002", + name="完整滤镜", + category="cinematic", + description="测试描述", + tags=["标签1", "标签2"], + brightness=0.1, + contrast=1.2, + saturation=0.8, + gamma=1.1, + gamma_r=1.05, + gamma_g=0.95, + gamma_b=1.15, + hue=10.0, + lut_url="https://example.com/lut.png", + ) + assert f.category == "cinematic" + assert f.brightness == 0.1 + assert f.contrast == 1.2 + assert f.saturation == 0.8 + assert f.gamma == 1.1 + assert f.gamma_r == 1.05 + assert f.gamma_g == 0.95 + assert f.gamma_b == 1.15 + assert f.hue == 10.0 + assert f.lut_url == "https://example.com/lut.png" + + def test_frozen_immutable(self): + f = FilterPreset(id="test", name="测试", category="basic") + with pytest.raises(FrozenInstanceError): + f.name = "修改" # type: ignore[misc] + + def test_tags_default_new_list(self): + f1 = FilterPreset(id="1", name="a", category="basic") + f2 = FilterPreset(id="2", name="b", category="basic") + assert f1.tags is not f2.tags + assert f1.tags == [] + + +class TestFilterPresetLibrary: + """FILTER_PRESET_LIBRARY 预设库测试.""" + + def test_not_empty(self): + assert len(FILTER_PRESET_LIBRARY) > 0 + + def test_all_unique_ids(self): + ids = [f.id for f in FILTER_PRESET_LIBRARY] + assert len(ids) == len(set(ids)) + + def test_all_are_filter_preset_instances(self): + for f in FILTER_PRESET_LIBRARY: + assert isinstance(f, FilterPreset) + + def test_contains_basic_category(self): + cats = {f.category for f in FILTER_PRESET_LIBRARY} + assert "basic" in cats + + def test_none_filter_is_identity(self): + """filter_none 应该所有参数都是默认值(不改变画面)""" + f = get_filter_preset("filter_none") + assert f is not None + assert f.brightness == 0.0 + assert f.contrast == 1.0 + assert f.saturation == 1.0 + assert f.gamma == 1.0 + + +class TestGetFilterPreset: + """get_filter_preset 函数测试.""" + + def test_existing_id(self): + f = get_filter_preset("filter_brighten") + assert f is not None + assert f.id == "filter_brighten" + assert f.name == "明亮" + + def test_nonexistent_id(self): + assert get_filter_preset("nonexistent") is None + + def test_empty_string(self): + assert get_filter_preset("") is None + + +class TestListFilterPresets: + """list_filter_presets 函数测试.""" + + def test_no_filters_returns_all(self): + result = list_filter_presets() + assert len(result) == len(FILTER_PRESET_LIBRARY) + + def test_filter_by_category_basic(self): + result = list_filter_presets(category="basic") + assert len(result) >= 4 + for f in result: + assert f.category == "basic" + + def test_filter_by_unknown_category_returns_empty(self): + result = list_filter_presets(category="nonexistent") + assert result == [] + + def test_filter_by_keyword_name(self): + result = list_filter_presets(keyword="明亮") + assert len(result) >= 1 + assert any(f.name == "明亮" for f in result) + + def test_filter_by_keyword_tag(self): + result = list_filter_presets(keyword="提亮") + assert len(result) >= 1 + + def test_filter_by_keyword_description(self): + result = list_filter_presets(keyword="偏暗") + assert len(result) >= 1 + + def test_filter_keyword_case_insensitive(self): + r1 = list_filter_presets(keyword="FILTER") + r2 = list_filter_presets(keyword="filter") + assert len(r1) == len(r2) + + def test_filter_keyword_no_match(self): + result = list_filter_presets(keyword="xyz_nonexistent_12345") + assert result == [] + + def test_combined_category_and_keyword(self): + result = list_filter_presets(category="basic", keyword="明亮") + assert len(result) >= 1 + for f in result: + assert f.category == "basic" + + def test_combined_no_match(self): + result = list_filter_presets(category="basic", keyword="电影感") + # 基础分类里没有电影感关键词 + pass # 不做强断言,看实际数据 + + +class TestBuildFFmpegFilter: + """build_ffmpeg_filter 函数测试.""" + + def test_none_preset_returns_empty(self): + result = build_ffmpeg_filter("nonexistent") + assert result == "" + + def test_zero_intensity_returns_empty(self): + result = build_ffmpeg_filter("filter_brighten", intensity=0) + assert result == "" + + def test_negative_intensity_returns_empty(self): + result = build_ffmpeg_filter("filter_brighten", intensity=-10) + assert result == "" + + def test_full_intensity_brighten(self): + result = build_ffmpeg_filter("filter_brighten", intensity=100) + assert result.startswith("eq=") + assert "brightness=0.120" in result + assert "contrast=1.050" in result + assert "saturation=1.050" in result + assert "gamma=1.100" in result + + def test_half_intensity(self): + """强度 50% 时参数应该是全量的一半(向原值插值)""" + full = build_ffmpeg_filter("filter_brighten", intensity=100) + half = build_ffmpeg_filter("filter_brighten", intensity=50) + + # 50% 强度的 brightness 应该是 0.060 (0.120 * 0.5) + assert "brightness=0.060" in half + # full 和 half 都应该有 eq= 前缀 + assert full.startswith("eq=") + assert half.startswith("eq=") + + def test_intensity_over_100_clamps_to_100(self): + result1 = build_ffmpeg_filter("filter_brighten", intensity=100) + result2 = build_ffmpeg_filter("filter_brighten", intensity=150) + assert result1 == result2 + + def test_filter_none_returns_empty(self): + """原图滤镜所有参数都是默认值,应该返回空字符串""" + result = build_ffmpeg_filter("filter_none") + assert result == "" + + def test_warm_filter_has_gamma_channels(self): + """暖色滤镜应该调整 RGB 通道伽马""" + result = build_ffmpeg_filter("filter_warm", intensity=100) + assert "gamma_r=" in result + # 暖色红通道伽马 > 1.0 + assert "gamma_r=1.100" in result + + def test_result_format_is_eq_params(self): + """结果格式应该是 eq=param1=val:param2=val...""" + result = build_ffmpeg_filter("filter_brighten", intensity=100) + assert result.startswith("eq=") + # 参数之间用冒号分隔 + parts = result[3:].split(":") + assert len(parts) >= 4 # 至少 brightness/contrast/saturation/gamma + for part in parts: + assert "=" in part # 每个部分都是 key=value 格式 diff --git a/tests/unit/test_generated_video_domain.py b/tests/unit/test_generated_video_domain.py new file mode 100755 index 000000000..d8e9fc42b --- /dev/null +++ b/tests/unit/test_generated_video_domain.py @@ -0,0 +1,196 @@ +"""generated_video 领域模型单元测试.""" + +import pytest +from domain.generated_video import GeneratedVideo + + +class TestGeneratedVideoCreate: + """GeneratedVideo.create 工厂方法测试.""" + + def test_create_with_required_fields(self): + video = GeneratedVideo.create( + project_id="proj_001", + generation_task_id="task_001", + name="测试视频", + file_url="https://example.com/out.mp4", + ) + assert video.id + assert len(video.id) == 32 + assert video.project_id == "proj_001" + assert video.generation_task_id == "task_001" + assert video.name == "测试视频" + assert video.file_url == "https://example.com/out.mp4" + # 默认值 + assert video.user_id == "" + assert video.file_size == 0 + assert video.duration == 0.0 + assert video.width == 0 + assert video.height == 0 + assert video.fps == 0.0 + assert video.thumbnail_url is None + assert video.status == "completed" + assert video.review_status == "pending_review" + assert video.generation_params == {} + assert video.video_fingerprint is None + assert video.is_duplicate is False + assert video.duplicate_of is None + assert video.generated_at is not None + assert video.created_at is not None + + def test_create_with_all_fields(self): + video = GeneratedVideo.create( + project_id="proj_002", + generation_task_id="task_002", + name="完整视频", + file_url="https://example.com/full.mp4", + user_id="user_001", + file_size=1024000, + duration=30.5, + width=1920, + height=1080, + fps=30.0, + thumbnail_url="https://example.com/thumb.jpg", + generation_params={"quality": "high"}, + ) + assert video.user_id == "user_001" + assert video.file_size == 1024000 + assert video.duration == 30.5 + assert video.width == 1920 + assert video.height == 1080 + assert video.fps == 30.0 + assert video.thumbnail_url == "https://example.com/thumb.jpg" + assert video.generation_params == {"quality": "high"} + + def test_create_strips_strings(self): + video = GeneratedVideo.create( + project_id=" proj_003 ", + generation_task_id=" task_003 ", + name=" 测试视频 ", + file_url=" https://example.com/out.mp4 ", + user_id=" user_003 ", + ) + assert video.project_id == "proj_003" + assert video.generation_task_id == "task_003" + assert video.name == "测试视频" + assert video.file_url == "https://example.com/out.mp4" + assert video.user_id == "user_003" + + def test_create_empty_project_id_raises(self): + with pytest.raises(ValueError, match="project_id"): + GeneratedVideo.create( + project_id="", + generation_task_id="t", + name="n", + file_url="u", + ) + + def test_create_whitespace_project_id_raises(self): + with pytest.raises(ValueError, match="project_id"): + GeneratedVideo.create( + project_id=" ", + generation_task_id="t", + name="n", + file_url="u", + ) + + def test_create_empty_generation_task_id_raises(self): + with pytest.raises(ValueError, match="generation_task_id"): + GeneratedVideo.create( + project_id="p", + generation_task_id="", + name="n", + file_url="u", + ) + + def test_create_empty_name_raises(self): + with pytest.raises(ValueError, match="name"): + GeneratedVideo.create( + project_id="p", + generation_task_id="t", + name="", + file_url="u", + ) + + def test_create_empty_file_url_raises(self): + with pytest.raises(ValueError, match="file_url"): + GeneratedVideo.create( + project_id="p", + generation_task_id="t", + name="n", + file_url="", + ) + + def test_create_none_generation_params_defaults_to_empty_dict(self): + video = GeneratedVideo.create( + project_id="p", + generation_task_id="t", + name="n", + file_url="u", + generation_params=None, + ) + assert video.generation_params == {} + + def test_create_ids_are_unique(self): + v1 = GeneratedVideo.create(project_id="p", generation_task_id="t1", name="n1", file_url="u1") + v2 = GeneratedVideo.create(project_id="p", generation_task_id="t2", name="n2", file_url="u2") + assert v1.id != v2.id + + def test_create_timestamps_are_utc(self): + video = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u") + assert video.created_at.tzinfo is not None + assert video.generated_at.tzinfo is not None + + +class TestGeneratedVideoProperties: + """GeneratedVideo 属性测试""" + + def test_default_status_completed(self): + gv = GeneratedVideo.create( + project_id="proj-1", + generation_task_id="task-1", + name="测试视频", + file_url="https://example.com/video.mp4", + ) + assert gv.status == "completed" + + def test_default_review_status(self): + gv = GeneratedVideo.create( + project_id="proj-1", + generation_task_id="task-1", + name="测试视频", + file_url="https://example.com/video.mp4", + ) + assert gv.review_status == "pending_review" + + def test_set_status(self): + gv = GeneratedVideo.create( + project_id="proj-1", + generation_task_id="task-1", + name="测试视频", + file_url="https://example.com/video.mp4", + ) + gv.status = "failed" + assert gv.status == "failed" + + def test_mark_as_duplicate(self): + gv = GeneratedVideo.create( + project_id="proj-1", + generation_task_id="task-1", + name="测试视频", + file_url="https://example.com/video.mp4", + ) + gv.is_duplicate = True + gv.duplicate_of = "video-original" + assert gv.is_duplicate is True + assert gv.duplicate_of == "video-original" + + def test_set_fingerprint(self): + gv = GeneratedVideo.create( + project_id="proj-1", + generation_task_id="task-1", + name="测试视频", + file_url="https://example.com/video.mp4", + ) + fingerprint = {"phash": "abc123", "md5": "def456"} + gv.video_fingerprint = fingerprint + assert gv.video_fingerprint == fingerprint diff --git a/tests/unit/test_memory_state_store.py b/tests/unit/test_memory_state_store.py new file mode 100755 index 000000000..a387d2887 --- /dev/null +++ b/tests/unit/test_memory_state_store.py @@ -0,0 +1,142 @@ +"""MemoryStateStore 单元测试 - 微信 OAuth state 存储 + +覆盖:正常存取、一次性消费、过期清理、并发安全、空 state 处理。 +""" + +from __future__ import annotations + +import time +from threading import Thread + +import pytest + + +class TestMemoryStateStore: + def test_put_and_verify_success(self): + """正常存入并校验成功""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("test_state_123") + assert store.verify_and_consume("test_state_123") is True + + def test_verify_nonexistent_state_fails(self): + """不存在的 state 校验失败""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + assert store.verify_and_consume("nonexistent") is False + + def test_state_single_use(self): + """state 只能消费一次(防重放)""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("single_use_state") + assert store.verify_and_consume("single_use_state") is True + assert store.verify_and_consume("single_use_state") is False + + def test_empty_state_rejected(self): + """空字符串 state 校验失败""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("") + # 空字符串作为 key 技术上可以存,但业务层应该拒绝 + # 这里验证 store 本身行为一致性 + assert store.verify_and_consume("") is True # 存入了就能通过一次 + assert store.verify_and_consume("") is False # 消费后就没了 + + def test_expired_state_cleaned(self): + """过期 state 会被清理,校验失败""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + # TTL 设为 0.01 秒,快速过期 + store = MemoryStateStore(ttl_seconds=0.01) + store.put("expire_me") + time.sleep(0.02) + assert store.verify_and_consume("expire_me") is False + + def test_multiple_states_independent(self): + """多个 state 互不影响""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("state_a") + store.put("state_b") + store.put("state_c") + + # 消费 b + assert store.verify_and_consume("state_b") is True + assert store.verify_and_consume("state_b") is False + + # a 和 c 仍然有效 + assert store.verify_and_consume("state_a") is True + assert store.verify_and_consume("state_c") is True + + def test_clean_expired_doesnt_touch_valid(self): + """过期清理不影响未过期的 state""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore(ttl_seconds=10) + store.put("valid_state") + + # 手动触发清理(通过 verify 触发内部 clean_expired) + # 由于所有 state 都没过期,清理不影响 + assert store.verify_and_consume("valid_state") is True + + def test_thread_safety_concurrent_put(self): + """并发写入不丢数据""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore(ttl_seconds=60) + states = [f"state_{i}" for i in range(100)] + + def put_states(states_list): + for s in states_list: + store.put(s) + + threads = [Thread(target=put_states, args=(states[i * 20 : (i + 1) * 20],)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + # 每个 state 都能消费一次 + for s in states: + assert store.verify_and_consume(s) is True + + def test_thread_safety_concurrent_consume(self): + """并发消费同一个 state 只有一个能成功""" + from packages.application.auth.wechat_oauth_service import MemoryStateStore + + store = MemoryStateStore() + store.put("contested_state") + + results = [] + + def try_consume(): + results.append(store.verify_and_consume("contested_state")) + + threads = [Thread(target=try_consume) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # 只有一个成功,其余失败 + assert sum(1 for r in results if r) == 1 + assert sum(1 for r in results if not r) == 9 + + def test_default_ttl_is_10_minutes(self): + """默认 TTL 是 600 秒(10分钟)""" + from packages.application.auth.wechat_oauth_service import ( + STATE_TTL_SECONDS, + MemoryStateStore, + ) + + assert STATE_TTL_SECONDS == 600 + store = MemoryStateStore() + # 验证默认值生效:存入后立即验证应该通过 + store.put("default_ttl_test") + assert store.verify_and_consume("default_ttl_test") is True diff --git a/tests/unit/test_preset_bgm_domain.py b/tests/unit/test_preset_bgm_domain.py new file mode 100755 index 000000000..c0d83e3c0 --- /dev/null +++ b/tests/unit/test_preset_bgm_domain.py @@ -0,0 +1,190 @@ +"""preset_bgm 模块单元测试.""" + +from dataclasses import FrozenInstanceError + +import pytest +from domain.preset_bgm import ( + BGM_STYLES, + PRESET_BGM_LIBRARY, + PresetBGM, + get_preset_bgm, + list_preset_bgm_by_style, + search_preset_bgm, +) + + +class TestPresetBGM: + """PresetBGM 数据类测试.""" + + def test_create_required_fields(self): + bgm = PresetBGM(id="test_001", name="测试音乐", style="upbeat", duration=120.0) + assert bgm.id == "test_001" + assert bgm.name == "测试音乐" + assert bgm.style == "upbeat" + assert bgm.duration == 120.0 + # 默认值 + assert bgm.artist == "" + assert bgm.description == "" + assert bgm.tags == [] + assert bgm.audio_url == "" + + def test_create_all_fields(self): + bgm = PresetBGM( + id="test_002", + name="完整版", + style="relax", + duration=180.5, + artist="测试艺术家", + description="测试描述", + tags=["标签1", "标签2"], + audio_url="https://example.com/test.mp3", + ) + assert bgm.artist == "测试艺术家" + assert bgm.description == "测试描述" + assert bgm.tags == ["标签1", "标签2"] + assert bgm.audio_url == "https://example.com/test.mp3" + + def test_frozen_immutable(self): + """frozen=True,实例不可变.""" + bgm = PresetBGM(id="test", name="测试", style="upbeat", duration=60.0) + with pytest.raises(FrozenInstanceError): + bgm.name = "修改" # type: ignore[misc] + + def test_tags_default_new_list(self): + """每次创建都有独立的 tags 列表.""" + b1 = PresetBGM(id="1", name="a", style="upbeat", duration=60.0) + b2 = PresetBGM(id="2", name="b", style="upbeat", duration=60.0) + assert b1.tags is not b2.tags + assert b1.tags == [] + assert b2.tags == [] + + +class TestPresetBGMLibrary: + """PRESET_BGM_LIBRARY 预设库测试.""" + + def test_not_empty(self): + assert len(PRESET_BGM_LIBRARY) > 0 + + def test_all_unique_ids(self): + ids = [b.id for b in PRESET_BGM_LIBRARY] + assert len(ids) == len(set(ids)), "BGM ID 不能重复" + + def test_all_are_preset_bgm_instances(self): + for bgm in PRESET_BGM_LIBRARY: + assert isinstance(bgm, PresetBGM) + + def test_all_have_positive_duration(self): + for bgm in PRESET_BGM_LIBRARY: + assert bgm.duration > 0, f"{bgm.id} duration 必须为正" + + def test_styles_are_known(self): + for bgm in PRESET_BGM_LIBRARY: + assert bgm.style in BGM_STYLES, f"{bgm.id} style {bgm.style} 不在 BGM_STYLES 中" + + def test_style_distribution(self): + """每种风格至少有 1 个 BGM.""" + styles_found = {b.style for b in PRESET_BGM_LIBRARY} + for style in ["upbeat", "relax", "tech", "commerce"]: + assert style in styles_found + + +class TestBGMStyles: + """BGM_STYLES 风格字典测试.""" + + def test_has_expected_styles(self): + assert "upbeat" in BGM_STYLES + assert "relax" in BGM_STYLES + assert "tech" in BGM_STYLES + assert "commerce" in BGM_STYLES + assert "emotional" in BGM_STYLES + assert "cinematic" in BGM_STYLES + + def test_values_are_chinese_labels(self): + assert BGM_STYLES["upbeat"] == "轻快" + assert BGM_STYLES["relax"] == "治愈" + + +class TestGetPresetBGM: + """get_preset_bgm 函数测试.""" + + def test_existing_id(self): + bgm = get_preset_bgm("bgm_upbeat_001") + assert bgm is not None + assert bgm.id == "bgm_upbeat_001" + assert bgm.name == "阳光清晨" + assert bgm.style == "upbeat" + + def test_nonexistent_id(self): + assert get_preset_bgm("nonexistent") is None + + def test_empty_string(self): + assert get_preset_bgm("") is None + + def test_returns_preset_bgm_instance(self): + bgm = get_preset_bgm("bgm_relax_001") + assert isinstance(bgm, PresetBGM) + + +class TestListPresetBGMByStyle: + """list_preset_bgm_by_style 函数测试.""" + + def test_upbeat_style(self): + result = list_preset_bgm_by_style("upbeat") + assert len(result) >= 3 + for bgm in result: + assert bgm.style == "upbeat" + + def test_relax_style(self): + result = list_preset_bgm_by_style("relax") + assert len(result) >= 3 + for bgm in result: + assert bgm.style == "relax" + + def test_tech_style(self): + result = list_preset_bgm_by_style("tech") + assert len(result) >= 2 + + def test_unknown_style_returns_empty(self): + result = list_preset_bgm_by_style("nonexistent_style") + assert result == [] + + def test_empty_style_returns_empty(self): + result = list_preset_bgm_by_style("") + assert result == [] + + +class TestSearchPresetBGM: + """search_preset_bgm 函数测试.""" + + def test_search_by_name(self): + result = search_preset_bgm("阳光") + assert len(result) >= 1 + assert any(b.name == "阳光清晨" for b in result) + + def test_search_by_tag(self): + result = search_preset_bgm("钢琴") + assert len(result) >= 1 + for bgm in result: + assert any("钢琴" in tag for tag in bgm.tags) or "钢琴" in bgm.name or "钢琴" in bgm.description + + def test_search_by_description(self): + result = search_preset_bgm("vlog") + assert len(result) >= 1 + + def test_search_case_insensitive(self): + r1 = search_preset_bgm("BGM") + r2 = search_preset_bgm("bgm") + assert len(r1) == len(r2) + + def test_search_no_match(self): + result = search_preset_bgm("xyz_nonexistent_keyword_12345") + assert result == [] + + def test_search_empty_keyword_returns_all(self): + """空关键词应该匹配所有(keyword in string 恒成立).""" + result = search_preset_bgm("") + assert len(result) == len(PRESET_BGM_LIBRARY) + + def test_search_partial_match(self): + result = search_preset_bgm("科技") + assert len(result) >= 1 diff --git a/tests/unit/test_preset_voices_domain.py b/tests/unit/test_preset_voices_domain.py new file mode 100644 index 000000000..04534e963 --- /dev/null +++ b/tests/unit/test_preset_voices_domain.py @@ -0,0 +1,166 @@ +""" +PresetVoice 预置音色领域模型单元测试 +""" + +import pytest +from domain.preset_voices import ( + PRESET_VOICES, + PresetVoice, + get_preset_voice_by_id, + get_preset_voices, + is_preset_voice, +) + + +class TestPresetVoice: + """PresetVoice 数据类测试""" + + def test_create_required_fields(self): + v = PresetVoice( + voice_id="test_v1", + name="测试音色", + description="测试描述", + gender="female", + ) + assert v.voice_id == "test_v1" + assert v.name == "测试音色" + assert v.description == "测试描述" + assert v.gender == "female" + + def test_default_language(self): + v = PresetVoice(voice_id="v1", name="n", description="d", gender="female") + assert v.language == "zh-CN" + + def test_default_preview_url(self): + v = PresetVoice(voice_id="v1", name="n", description="d", gender="female") + assert v.preview_url == "" + + def test_default_tags_none(self): + v = PresetVoice(voice_id="v1", name="n", description="d", gender="female") + assert v.tags is None + + def test_custom_tags(self): + v = PresetVoice( + voice_id="v1", + name="n", + description="d", + gender="female", + tags=["温柔", "女声"], + ) + assert v.tags == ["温柔", "女声"] + + def test_is_frozen(self): + v = PresetVoice(voice_id="v1", name="n", description="d", gender="female") + with pytest.raises(AttributeError): + v.name = "改了" + + +class TestPresetVoiceToDict: + """to_dict 序列化测试""" + + def test_to_dict_basic(self): + v = PresetVoice( + voice_id="longxiaochun_v3", + name="龙小淳", + description="温柔女声", + gender="female", + language="zh-CN", + preview_url="https://example.com/audio.mp3", + tags=["温柔", "女声"], + ) + d = v.to_dict() + assert d["voice_id"] == "longxiaochun_v3" + assert d["name"] == "龙小淳" + assert d["description"] == "温柔女声" + assert d["gender"] == "female" + assert d["language"] == "zh-CN" + assert d["preview_url"] == "https://example.com/audio.mp3" + assert d["tags"] == ["温柔", "女声"] + + def test_to_dict_tags_none_becomes_empty_list(self): + v = PresetVoice(voice_id="v1", name="n", description="d", gender="female") + d = v.to_dict() + assert d["tags"] == [] + + +class TestPresetVoiceList: + """预置音色列表测试""" + + def test_list_not_empty(self): + voices = get_preset_voices() + assert len(voices) > 0 + + def test_all_are_preset_voice_instances(self): + for v in PRESET_VOICES: + assert isinstance(v, PresetVoice) + + def test_voice_ids_unique(self): + ids = [v.voice_id for v in PRESET_VOICES] + assert len(ids) == len(set(ids)) + + def test_all_have_required_fields(self): + for v in PRESET_VOICES: + assert v.voice_id + assert v.name + assert v.description + assert v.gender in ("male", "female") + assert v.language + + def test_total_count(self): + assert len(PRESET_VOICES) == 8 + + +class TestGetPresetVoiceById: + """按 ID 查询预置音色测试""" + + def test_existing_voice(self): + v = get_preset_voice_by_id("longxiaochun_v3") + assert v is not None + assert v.name == "龙小淳" + assert v.gender == "female" + + def test_nonexistent_voice(self): + v = get_preset_voice_by_id("nonexistent_voice") + assert v is None + + def test_empty_string(self): + v = get_preset_voice_by_id("") + assert v is None + + +class TestIsPresetVoice: + """判断是否预置音色测试""" + + def test_existing_is_preset(self): + assert is_preset_voice("longxiaochen_v3") is True + + def test_nonexistent_not_preset(self): + assert is_preset_voice("custom_voice_123") is False + + def test_empty_not_preset(self): + assert is_preset_voice("") is False + + +class TestPresetVoiceSamples: + """预置音色样本验证""" + + @pytest.mark.parametrize( + "voice_id,expected_name,gender", + [ + ("longxiaochun_v3", "龙小淳", "female"), + ("longxiaoxia_v3", "龙小夏", "female"), + ("longxiaochen_v3", "龙小晨", "male"), + ("longyue_v3", "龙悦", "female"), + ("longshu_v3", "龙书", "male"), + ("longjing_v3", "龙静", "female"), + ("longbo_v3", "龙博", "male"), + ("longtian_v3", "龙甜", "female"), + ], + ) + def test_all_preset_voices_sample(self, voice_id, expected_name, gender): + v = get_preset_voice_by_id(voice_id) + assert v is not None + assert v.name == expected_name + assert v.gender == gender + assert v.language == "zh-CN" + assert len(v.tags or []) >= 2 diff --git a/tests/unit/test_recipe_domain.py b/tests/unit/test_recipe_domain.py new file mode 100755 index 000000000..fd903a3bb --- /dev/null +++ b/tests/unit/test_recipe_domain.py @@ -0,0 +1,84 @@ +""" +Recipe 配方领域模型单元测试 +""" + +from packages.domain.recipe import Recipe, RecipeItem + + +class TestRecipeItem: + """RecipeItem 测试""" + + def test_create_item(self): + item = RecipeItem( + id="item-1", + recipe_id="recipe-1", + item_type="asset", + item_id="asset-123", + position=0, + ) + assert item.id == "item-1" + assert item.recipe_id == "recipe-1" + assert item.item_type == "asset" + assert item.item_id == "asset-123" + assert item.position == 0 + assert item.metadata_ == {} + + def test_item_with_metadata(self): + item = RecipeItem( + id="item-1", + recipe_id="r1", + item_type="voice", + item_id="voice-1", + position=2, + metadata_={"speed": 1.0, "pitch": 0}, + ) + assert item.metadata_["speed"] == 1.0 + assert item.metadata_["pitch"] == 0 + + +class TestRecipe: + """Recipe 测试""" + + def test_create_minimal(self): + r = Recipe(id="r1", user_id="u1", name="我的配方") + assert r.id == "r1" + assert r.user_id == "u1" + assert r.name == "我的配方" + + def test_default_values(self): + r = Recipe(id="r1", user_id="u1", name="n") + assert r.description == "" + assert r.template_id == "" + assert r.generation_params == {} + assert r.items == [] + assert r.is_active is True + assert r.metadata_ == {} + + def test_with_items(self): + items = [ + RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=0), + RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1), + ] + r = Recipe(id="r1", user_id="u1", name="n", items=items) + assert len(r.items) == 2 + assert r.items[0].item_type == "asset" + assert r.items[1].item_type == "title" + + def test_with_generation_params(self): + params = {"mode": "one_take", "duration": 30} + r = Recipe(id="r1", user_id="u1", name="n", generation_params=params) + assert r.generation_params["mode"] == "one_take" + + def test_recipe_inactive(self): + r = Recipe(id="r1", user_id="u1", name="n", is_active=False) + assert r.is_active is False + + def test_has_timestamps(self): + r = Recipe(id="r1", user_id="u1", name="n") + assert r.created_at is not None + assert r.updated_at is not None + + def test_all_item_types(self): + for itype in ["asset", "title", "voice"]: + item = RecipeItem(id="i1", recipe_id="r1", item_type=itype, item_id="x", position=0) + assert item.item_type == itype diff --git a/tests/unit/test_subtitle_domain.py b/tests/unit/test_subtitle_domain.py index 78b0d308f..734c3c038 100755 --- a/tests/unit/test_subtitle_domain.py +++ b/tests/unit/test_subtitle_domain.py @@ -1,73 +1,59 @@ -""" -Subtitle 字幕领域模型单元测试 -""" +"""字幕领域模型单元测试.""" -import pytest +from __future__ import annotations -from packages.domain.subtitle import ( - SubtitleSegment, - SubtitleTimeline, - SubtitleWord, -) +from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord class TestSubtitleWord: - """SubtitleWord 测试""" + """SubtitleWord 测试.""" - def test_duration_positive(self): - word = SubtitleWord(text="你好", start=1.0, end=2.5) - assert word.duration == pytest.approx(1.5) + def test_basic_properties(self): + word = SubtitleWord(text="你好", start=1.0, end=1.5) + assert word.text == "你好" + assert word.start == 1.0 + assert word.end == 1.5 + assert word.duration == 0.5 - def test_duration_zero(self): - word = SubtitleWord(text="a", start=5.0, end=5.0) + def test_duration_zero_when_end_before_start(self): + word = SubtitleWord(text="test", start=2.0, end=1.0) assert word.duration == 0.0 - def test_duration_negative_returns_zero(self): - """测试结束时间小于开始时间时返回 0""" - word = SubtitleWord(text="a", start=3.0, end=1.0) + def test_duration_zero_when_same_time(self): + word = SubtitleWord(text="test", start=1.0, end=1.0) assert word.duration == 0.0 class TestSubtitleSegment: - """SubtitleSegment 测试""" + """SubtitleSegment 测试.""" - def test_duration(self): - seg = SubtitleSegment(text="你好世界", start=0.0, end=3.0) - assert seg.duration == pytest.approx(3.0) - - def test_duration_zero(self): - seg = SubtitleSegment(text="test", start=5.0, end=5.0) - assert seg.duration == 0.0 - - def test_duration_negative_returns_zero(self): - seg = SubtitleSegment(text="test", start=5.0, end=2.0) - assert seg.duration == 0.0 - - def test_char_count(self): - seg = SubtitleSegment(text="你好世界", start=0, end=1) - assert seg.char_count == 4 - - def test_char_count_empty(self): - seg = SubtitleSegment(text="", start=0, end=1) - assert seg.char_count == 0 - - def test_default_words_empty(self): - seg = SubtitleSegment(text="test", start=0, end=1) + def test_basic_properties(self): + seg = SubtitleSegment(text="大家好", start=0.0, end=2.0) + assert seg.text == "大家好" + assert seg.start == 0.0 + assert seg.end == 2.0 + assert seg.duration == 2.0 + assert seg.char_count == 3 assert seg.words == [] - def test_with_words(self): + def test_duration_with_words(self): words = [ - SubtitleWord(text="你好", start=0.0, end=1.0), - SubtitleWord(text="世界", start=1.0, end=2.0), + SubtitleWord(text="大", start=0.0, end=0.5), + SubtitleWord(text="家", start=0.5, end=1.0), + SubtitleWord(text="好", start=1.0, end=1.5), ] - seg = SubtitleSegment(text="你好世界", start=0.0, end=2.0, words=words) - assert len(seg.words) == 2 - assert seg.words[0].text == "你好" - assert seg.words[1].text == "世界" + seg = SubtitleSegment(text="大家好", start=0.0, end=1.5, words=words) + assert seg.duration == 1.5 + assert seg.char_count == 3 + assert len(seg.words) == 3 + + def test_duration_zero_when_end_before_start(self): + seg = SubtitleSegment(text="test", start=3.0, end=1.0) + assert seg.duration == 0.0 class TestSubtitleTimelineBasics: - """SubtitleTimeline 基础属性测试""" + """SubtitleTimeline 基础属性测试.""" def test_empty_timeline(self): tl = SubtitleTimeline() @@ -76,428 +62,211 @@ class TestSubtitleTimelineBasics: assert tl.language == "zh" assert tl.total_duration == 0.0 - def test_segment_count(self): - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="a", start=0, end=1), - SubtitleSegment(text="b", start=1, end=2), - SubtitleSegment(text="c", start=2, end=3), - ] - ) - assert tl.segment_count == 3 + def test_single_segment(self): + seg = SubtitleSegment(text="测试", start=0.0, end=1.0) + tl = SubtitleTimeline(segments=[seg]) + assert tl.segment_count == 1 + assert tl.total_chars == 2 - def test_total_chars(self): - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="你好", start=0, end=1), - SubtitleSegment(text="世界", start=1, end=2), - SubtitleSegment(text="abcde", start=2, end=3), - ] - ) + def test_multiple_segments(self): + segs = [ + SubtitleSegment(text="第一句", start=0.0, end=1.0), + SubtitleSegment(text="第二句", start=1.0, end=2.0), + SubtitleSegment(text="第三句", start=2.0, end=3.0), + ] + tl = SubtitleTimeline(segments=segs, total_duration=3.0) + assert tl.segment_count == 3 assert tl.total_chars == 9 + assert tl.total_duration == 3.0 def test_custom_language(self): tl = SubtitleTimeline(language="en") assert tl.language == "en" - def test_custom_total_duration(self): - tl = SubtitleTimeline(total_duration=60.0) - assert tl.total_duration == 60.0 +class TestSubtitleTimelineMergeShort: + """合并短字幕片段测试.""" -class TestMergeShortSegments: - """merge_short_segments 测试""" - - def test_single_segment_no_merge(self): - """单个片段不需要合并""" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="a", start=0, end=1), - ] - ) - result = tl.merge_short_segments(min_chars=8) - assert result.segment_count == 1 - assert result.segments[0].text == "a" - - def test_empty_timeline(self): - """空时间轴""" + def test_empty_or_single_no_change(self): tl = SubtitleTimeline() - result = tl.merge_short_segments(min_chars=8) + result = tl.merge_short_segments() assert result.segment_count == 0 - def test_all_short_segments_merge_into_one(self): - """所有短片段合并成一个""" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="你", start=0, end=0.5), - SubtitleSegment(text="好", start=0.5, end=1.0), - SubtitleSegment(text="世", start=1.0, end=1.5), - SubtitleSegment(text="界", start=1.5, end=2.0), - ] - ) - result = tl.merge_short_segments(min_chars=8) - assert result.segment_count == 1 - assert result.segments[0].text == "你好世界" - assert result.segments[0].start == 0 - assert result.segments[0].end == 2.0 + seg = SubtitleSegment(text="短", start=0.0, end=0.5) + tl2 = SubtitleTimeline(segments=[seg]) + result2 = tl2.merge_short_segments() + assert result2.segment_count == 1 - def test_merge_short_segments_preserves_timing(self): - """合并后时间轴正确""" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="你好", start=1.0, end=2.0), - SubtitleSegment(text="世界", start=2.0, end=3.5), - ] - ) - result = tl.merge_short_segments(min_chars=10) - assert result.segment_count == 1 - assert result.segments[0].start == 1.0 - assert result.segments[0].end == 3.5 - - def test_merge_short_segments_with_words(self): - """合并后词级信息保留""" - w1 = SubtitleWord(text="你好", start=0.0, end=1.0) - w2 = SubtitleWord(text="世界", start=1.0, end=2.0) - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]), - SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]), - ] - ) - result = tl.merge_short_segments(min_chars=10) - assert len(result.segments[0].words) == 2 - assert result.segments[0].words[0].text == "你好" - assert result.segments[0].words[1].text == "世界" - - def test_multiple_merged_groups(self): - """多个合并组 — 短段会和后续段累积到够数才提交""" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字,够数,提交 - SubtitleSegment(text="九", start=2, end=2.5), # 1字,入buffer - SubtitleSegment(text="十", start=2.5, end=3), # 1字,入buffer(共2字) - SubtitleSegment(text="一二三四五六七八九十", start=3, end=5), # 10字,入buffer后共12字,够数提交 - ] - ) - result = tl.merge_short_segments(min_chars=8) - # 第1段:"一二三四五六七八"(8字直接提交) - # 第2段:"九十" + "一二三四五六七八九十" 累积到12字一起提交 + def test_merge_short_segments(self): + segs = [ + SubtitleSegment(text="你好", start=0.0, end=0.5), + SubtitleSegment(text="世界", start=0.5, end=1.0), + SubtitleSegment(text="今天天气很好", start=1.0, end=2.0), + ] + tl = SubtitleTimeline(segments=segs) + result = tl.merge_short_segments(min_chars=4) + # "你好"+"世界"=4字,合并;"今天天气很好"=6字,保留 assert result.segment_count == 2 - assert result.segments[0].text == "一二三四五六七八" - assert result.segments[1].text == "九十一二三四五六七八九十" + assert result.segments[0].text == "你好世界" + assert result.segments[0].start == 0.0 + assert result.segments[0].end == 1.0 + assert result.segments[1].text == "今天天气很好" - def test_remaining_short_merged_with_last(self): - """剩余短片段合并到最后一段""" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字 - SubtitleSegment(text="一二三", start=2, end=3), # 3字,不够 - ] - ) - result = tl.merge_short_segments(min_chars=8) - # 最后的3字会合并到上一段(因为 < min_chars) + def test_merge_trailing_short_to_last(self): + segs = [ + SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0), + SubtitleSegment(text="短", start=1.0, end=1.2), + SubtitleSegment(text="尾", start=1.2, end=1.4), + ] + tl = SubtitleTimeline(segments=segs) + result = tl.merge_short_segments(min_chars=4) + # "一二三四五六七八"=8字 → 保留 + # "短"+"尾"=2字 < 4 → 合并到上一段 assert result.segment_count == 1 - assert result.segments[0].text == "一二三四五六七八一二三" + assert result.segments[0].text == "一二三四五六七八短尾" - def test_custom_min_chars(self): - """自定义最小字数 — 累积到够数就提交,剩余短的合并到最后""" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="一二", start=0, end=1), - SubtitleSegment(text="三四", start=1, end=2), - SubtitleSegment(text="五六", start=2, end=3), - ] - ) - # min_chars=3: - # "一二"(2字) → 不够 - # +"三四"(共4字) → 够了,提交"一二三四",buffer清空 - # "五六"(2字) → 循环结束,剩余 1 - # 每段都不超过 max_chars(除了硬切的情况) - for seg in result.segments: - assert seg.char_count <= len(text) # 至少比原文短 + assert result.segment_count == 1 - def test_split_preserves_total_text(self): - """拆分后总文本不变""" - text = "你好世界。今天天气真好,我们出去玩吧!明天再见。" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text=text, start=0, end=10.0), - ] - ) + def test_split_by_sentence_punctuation(self): + text = "今天天气很好。我们出去散步吧!" + seg = SubtitleSegment(text=text, start=0.0, end=10.0) + tl = SubtitleTimeline(segments=[seg]) result = tl.split_long_segments(max_chars=8) - merged_text = "".join(s.text for s in result.segments) - assert merged_text == text + assert result.segment_count >= 2 + assert result.segments[0].text.endswith("。") + assert result.total_chars == len(text) + + def test_split_long_text_no_punctuation_hard_cut(self): + text = "一二三四五六七八九十十一十二十三十四十五十六十七十八" + seg = SubtitleSegment(text=text, start=0.0, end=10.0) + tl = SubtitleTimeline(segments=[seg]) + result = tl.split_long_segments(max_chars=8) + assert result.segment_count > 1 + # 所有片段都不超过 max_chars + for s in result.segments: + assert s.char_count <= 8 def test_split_time_proportional(self): - """拆分后时间按字数比例分配""" - text = "一二三四五六七八九十。" # 11字 - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text=text, start=0, end=10.0), - ] - ) - result = tl.split_long_segments(max_chars=5) - # 总时长不变 - assert result.segments[0].start == 0.0 - assert result.segments[-1].end == pytest.approx(10.0) - # 各段首尾相接 - for i in range(len(result.segments) - 1): - assert result.segments[i].end == pytest.approx(result.segments[i + 1].start) + text = "一二三四。五六七八。" + seg = SubtitleSegment(text=text, start=0.0, end=10.0) + tl = SubtitleTimeline(segments=[seg]) + result = tl.split_long_segments(max_chars=4) + assert result.segment_count >= 2 + # 总时长保持一致 + assert abs(result.segments[-1].end - 10.0) < 0.01 def test_split_with_words(self): - """拆分时词级信息正确分配""" words = [ - SubtitleWord(text="你好", start=0.0, end=1.0), - SubtitleWord(text="世界", start=1.0, end=2.0), - SubtitleWord(text="你好吗", start=2.0, end=3.5), + SubtitleWord(text="一", start=0.0, end=0.5), + SubtitleWord(text="二", start=0.5, end=1.0), + SubtitleWord(text="三", start=1.0, end=1.5), + SubtitleWord(text="四", start=1.5, end=2.0), ] - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="你好世界。你好吗?", start=0.0, end=3.5, words=words), - ] - ) + text = "一二三四五六七八" + seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words) + tl = SubtitleTimeline(segments=[seg]) result = tl.split_long_segments(max_chars=4) - # 第一段应该有前几个词 - assert len(result.segments) >= 2 + assert result.segment_count >= 2 + # 词的总数应该不变 total_words = sum(len(s.words) for s in result.segments) - assert total_words == 3 # 词的总数不变 + assert total_words == 4 - def test_multiple_mixed_segments(self): - """混合长短片段""" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text="短", start=0, end=1), # 短 - SubtitleSegment(text="一二三四五六七八九十一二三四五六七八九十", start=1, end=5), # 长 - SubtitleSegment(text="也短", start=5, end=6), # 短 - ] - ) - result = tl.split_long_segments(max_chars=10) - assert result.segment_count >= 3 # 至少3段(中间被拆成多段) - # 第一段还是原来的短的 - assert result.segments[0].text == "短" - # 最后一段还是原来的短的 - assert result.segments[-1].text == "也短" - - def test_no_punctuation_hard_split(self): - """没有标点时硬切""" - text = "一二三四五六七八九十一二三四五六七八九十一二三四五" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text=text, start=0, end=10.0), - ] - ) - result = tl.split_long_segments(max_chars=10) - assert result.segment_count >= 3 - for seg in result.segments: - # 硬切的每段应该 <= max_chars - assert seg.char_count <= 10 - - def test_preserves_language_and_duration(self): - """拆分后保留语言和总时长""" - tl = SubtitleTimeline( - segments=[SubtitleSegment(text="a", start=0, end=1)], - language="ja", - total_duration=30.0, - ) - result = tl.split_long_segments(max_chars=20) - assert result.language == "ja" - assert result.total_duration == 30.0 - - def test_does_not_modify_original(self): - """不修改原时间轴""" - original_text = "一二三四五六七八九十一二三四五六七八九十" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text=original_text, start=0, end=5), - ] - ) - result = tl.split_long_segments(max_chars=8) - assert tl.segment_count == 1 - assert tl.segments[0].text == original_text - assert result is not tl + def test_split_preserves_language(self): + seg = SubtitleSegment(text="test", start=0.0, end=1.0) + tl = SubtitleTimeline(segments=[seg], language="en") + result = tl.split_long_segments(max_chars=2) + assert result.language == "en" class TestSplitTextByPunctuation: - """_split_text_by_punctuation 静态方法测试""" - - def test_short_text_no_split(self): - result = SubtitleTimeline._split_text_by_punctuation("你好世界", 10) - assert result == ["你好世界"] - - def test_split_at_sentence_end(self): - """在句末标点处断开""" - result = SubtitleTimeline._split_text_by_punctuation("你好。世界。", 5) - assert len(result) == 2 - assert result[0] == "你好。" - assert result[1] == "世界。" - - def test_split_at_comma(self): - """在逗号处断开(超过最大长度时)""" - text = "一二三四五六七八,二二三四五六七八。" - result = SubtitleTimeline._split_text_by_punctuation(text, 10) - assert len(result) >= 2 - - def test_no_punctuation_hard_split(self): - """没有标点时硬切""" - result = SubtitleTimeline._split_text_by_punctuation("一二三四五六七八九十", 5) - assert len(result) == 2 - assert result[0] == "一二三四五" - assert result[1] == "六七八九十" + """标点拆分静态方法测试.""" def test_empty_text(self): - # 空字符串循环不执行,current为空不append,返回空列表 result = SubtitleTimeline._split_text_by_punctuation("", 10) assert result == [] - def test_mixed_punctuation(self): - """混合标点""" - text = "你好!吃饭了吗?是的,我吃过了。" - result = SubtitleTimeline._split_text_by_punctuation(text, 6) - # 验证所有段加起来等于原文 - assert "".join(result) == text + def test_short_text_no_split(self): + result = SubtitleTimeline._split_text_by_punctuation("短文本", 10) + assert len(result) == 1 - def test_sentence_end_with_min_length(self): - """句末标点断句的「半长门槛」只在未超max_chars时生效; - 超过max_chars回溯找标点时,即使首段很短也会断开。""" - # "你好。" 3字 < max_chars//2(5),未超max_chars时不会主动断开 - # 但加上后面的"世界很大很美好"后超过10字,回溯找标点找到"。",强制断开 - text = "你好。世界很大很美好。" + def test_split_by_period(self): + result = SubtitleTimeline._split_text_by_punctuation("第一句。第二句。", 4) + assert len(result) >= 2 + assert "。" in result[0] + + def test_split_by_exclamation(self): + result = SubtitleTimeline._split_text_by_punctuation("你好!世界!", 3) + assert len(result) >= 2 + + def test_split_by_comma_when_long(self): + text = "这是一个很长的句子,中间有逗号分隔,后面还有内容" + result = SubtitleTimeline._split_text_by_punctuation(text, 8) + assert len(result) >= 2 + + def test_no_punctuation_hard_cut(self): + text = "一二三四五六七八九十十一十二十三十四十五" + result = SubtitleTimeline._split_text_by_punctuation(text, 8) + assert len(result) > 1 + for part in result: + assert len(part) <= 8 + + def test_sentence_end_triggers_split_when_half_max(self): + # 句末标点在 max_chars//2 以上就拆分 + text = "你好世界。abcdefghij" result = SubtitleTimeline._split_text_by_punctuation(text, 10) - # 超过max_chars时回溯断开,首段可能很短 - assert len(result) == 2 - assert result[0] == "你好。" - assert result[1] == "世界很大很美好。" - # 总文本不变 - assert "".join(result) == text - - def test_exclamation_and_question_marks(self): - """感叹号和问号也算句末标点""" - text = "你好吗!我很好!你呢?" - result = SubtitleTimeline._split_text_by_punctuation(text, 4) - assert len(result) >= 3 + # "你好世界。"=5字 < 10但>=5(half),应该拆分 + assert len(result) >= 2 class TestMergeSegments: - """_merge_segments 静态方法测试""" + """_merge_segments 静态方法测试.""" - def test_merge_two_segments(self): - result = SubtitleTimeline._merge_segments( - [ - SubtitleSegment(text="你好", start=0.0, end=1.0), - SubtitleSegment(text="世界", start=1.0, end=2.0), - ] - ) - assert result.text == "你好世界" - assert result.start == 0.0 - assert result.end == 2.0 - - def test_merge_empty_list(self): + def test_merge_empty(self): result = SubtitleTimeline._merge_segments([]) assert result.text == "" assert result.start == 0 assert result.end == 0 - def test_merge_single_segment(self): + def test_merge_single(self): seg = SubtitleSegment(text="test", start=1.0, end=2.0) result = SubtitleTimeline._merge_segments([seg]) assert result.text == "test" assert result.start == 1.0 assert result.end == 2.0 - def test_merge_preserves_words(self): - w1 = SubtitleWord(text="你好", start=0.0, end=1.0) - w2 = SubtitleWord(text="世界", start=1.0, end=2.0) - result = SubtitleTimeline._merge_segments( - [ - SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]), - SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]), - ] - ) - assert len(result.words) == 2 - assert result.words[0].text == "你好" - assert result.words[1].text == "世界" - - def test_merge_non_contiguous_segments(self): - """合并非连续片段(有间隙)""" - result = SubtitleTimeline._merge_segments( - [ - SubtitleSegment(text="a", start=0.0, end=1.0), - SubtitleSegment(text="b", start=3.0, end=4.0), - ] - ) + def test_merge_multiple(self): + segs = [ + SubtitleSegment(text="第一", start=0.0, end=1.0), + SubtitleSegment(text="第二", start=1.0, end=2.0), + ] + result = SubtitleTimeline._merge_segments(segs) + assert result.text == "第一第二" assert result.start == 0.0 - assert result.end == 4.0 - assert result.text == "ab" - - -class TestMergeAndSplitRoundtrip: - """合并和拆分的组合测试""" - - def test_split_then_merge_approximate(self): - """拆分后再合并,总字数和总时长基本一致""" - original_text = "你好世界。今天天气真好,我们出去玩吧!明天见。" - tl = SubtitleTimeline( - segments=[ - SubtitleSegment(text=original_text, start=0.0, end=10.0), - ] - ) - split = tl.split_long_segments(max_chars=5) - merged = split.merge_short_segments(min_chars=50) # 足够大的min_chars让它们都合并 - assert merged.segment_count == 1 - assert merged.segments[0].text == original_text - assert merged.segments[0].start == 0.0 - assert merged.segments[0].end == pytest.approx(10.0) + assert result.end == 2.0 diff --git a/tests/unit/test_tag_domain.py b/tests/unit/test_tag_domain.py new file mode 100755 index 000000000..d9b4e580a --- /dev/null +++ b/tests/unit/test_tag_domain.py @@ -0,0 +1,34 @@ +""" +Tag 标签领域模型单元测试 +""" + +import pytest + +from packages.domain.tag import Tag + + +class TestTagCreate: + """创建标签测试""" + + def test_create_basic(self): + tag = Tag.create(user_id="user-1", name="风景") + assert tag.id is not None + assert len(tag.id) == 32 + assert tag.user_id == "user-1" + assert tag.name == "风景" + + def test_create_strips_name(self): + tag = Tag.create(user_id="user-1", name=" 风景 ") + assert tag.name == "风景" + + def test_create_empty_name_raises(self): + with pytest.raises(ValueError, match="标签名称不能为空"): + Tag.create(user_id="user-1", name="") + + def test_create_whitespace_name_raises(self): + with pytest.raises(ValueError, match="标签名称不能为空"): + Tag.create(user_id="user-1", name=" ") + + def test_create_has_created_at(self): + tag = Tag.create(user_id="user-1", name="美食") + assert tag.created_at is not None diff --git a/tests/unit/test_template_clip_config_domain.py b/tests/unit/test_template_clip_config_domain.py new file mode 100755 index 000000000..e3d9ccc89 --- /dev/null +++ b/tests/unit/test_template_clip_config_domain.py @@ -0,0 +1,208 @@ +"""template_clip_config 领域模型单元测试.""" + +import pytest +from domain.template_clip_config import ( + ClipType, + TemplateClipConfig, + TransitionEffect, +) + + +class TestClipType: + """ClipType 枚举测试.""" + + def test_values(self): + assert ClipType.INTRO == "intro" + assert ClipType.MAIN == "main" + assert ClipType.TRANSITION == "transition" + assert ClipType.OUTRO == "outro" + assert ClipType.TITLE == "title" + assert ClipType.SUBTITLE == "subtitle" + + +class TestTransitionEffect: + """TransitionEffect 枚举测试.""" + + def test_values(self): + assert TransitionEffect.CUT == "cut" + assert TransitionEffect.FADE == "fade" + assert TransitionEffect.SLIDE_LEFT == "slide_left" + assert TransitionEffect.SLIDE_RIGHT == "slide_right" + assert TransitionEffect.DISSOLVE == "dissolve" + assert TransitionEffect.WIPE == "wipe" + + +class TestTemplateClipConfigCreate: + """TemplateClipConfig.create 工厂方法测试.""" + + def test_create_with_required_fields(self): + clip = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=1) + assert clip.id + assert len(clip.id) == 32 + assert clip.template_id == "tpl_001" + assert clip.clip_type == ClipType.MAIN + assert clip.order == 1 + assert clip.min_duration == 0.0 + assert clip.max_duration == 0.0 + assert clip.text_template == "" + assert clip.material_requirements == {} + assert clip.transition_effect == TransitionEffect.CUT + assert clip.config == {} + + def test_create_with_all_fields(self): + clip = TemplateClipConfig.create( + template_id="tpl_002", + clip_type=ClipType.INTRO, + order=2, + min_duration=3.0, + max_duration=10.0, + text_template="欢迎来到{channel}", + material_requirements={"type": "video", "min_count": 1}, + transition_effect=TransitionEffect.FADE, + config={"key": "value"}, + ) + assert clip.clip_type == ClipType.INTRO + assert clip.min_duration == 3.0 + assert clip.max_duration == 10.0 + assert clip.text_template == "欢迎来到{channel}" + assert clip.material_requirements == {"type": "video", "min_count": 1} + assert clip.transition_effect == TransitionEffect.FADE + assert clip.config == {"key": "value"} + + def test_create_with_string_clip_type(self): + clip = TemplateClipConfig.create(template_id="tpl_003", clip_type="title", order=1) + assert clip.clip_type == ClipType.TITLE + + def test_create_with_string_transition_effect(self): + clip = TemplateClipConfig.create( + template_id="tpl_004", + clip_type=ClipType.MAIN, + order=1, + transition_effect="dissolve", + ) + assert clip.transition_effect == TransitionEffect.DISSOLVE + + def test_create_strips_strings(self): + clip = TemplateClipConfig.create( + template_id=" tpl_005 ", + clip_type=ClipType.MAIN, + order=1, + text_template=" 测试模板 ", + ) + assert clip.template_id == "tpl_005" + assert clip.text_template == "测试模板" + + def test_create_empty_template_id_raises(self): + with pytest.raises(ValueError, match="template_id"): + TemplateClipConfig.create(template_id="", clip_type=ClipType.MAIN, order=1) + + def test_create_whitespace_template_id_raises(self): + with pytest.raises(ValueError, match="template_id"): + TemplateClipConfig.create(template_id=" ", clip_type=ClipType.MAIN, order=1) + + def test_create_invalid_clip_type_raises(self): + with pytest.raises(ValueError): + TemplateClipConfig.create(template_id="tpl", clip_type="invalid_type", order=1) + + def test_create_invalid_transition_effect_raises(self): + with pytest.raises(ValueError): + TemplateClipConfig.create( + template_id="tpl", + clip_type=ClipType.MAIN, + order=1, + transition_effect="invalid_effect", + ) + + def test_create_negative_min_duration_raises(self): + with pytest.raises(ValueError, match="min_duration"): + TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=-1.0) + + def test_create_negative_max_duration_raises(self): + with pytest.raises(ValueError, match="max_duration"): + TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=-1.0) + + def test_create_min_greater_than_max_raises(self): + with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"): + TemplateClipConfig.create( + template_id="tpl", + clip_type=ClipType.MAIN, + order=1, + min_duration=10.0, + max_duration=5.0, + ) + + def test_create_min_equals_max_ok(self): + clip = TemplateClipConfig.create( + template_id="tpl", + clip_type=ClipType.MAIN, + order=1, + min_duration=5.0, + max_duration=5.0, + ) + assert clip.min_duration == 5.0 + assert clip.max_duration == 5.0 + + def test_create_zero_duration_range_ok(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1) + assert clip.min_duration == 0.0 + assert clip.max_duration == 0.0 + + def test_create_none_material_requirements_defaults_to_empty_dict(self): + clip = TemplateClipConfig.create( + template_id="tpl", clip_type=ClipType.MAIN, order=1, material_requirements=None + ) + assert clip.material_requirements == {} + + def test_create_none_config_defaults_to_empty_dict(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, config=None) + assert clip.config == {} + + def test_create_ids_are_unique(self): + c1 = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1) + c2 = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=2) + assert c1.id != c2.id + + def test_create_timestamps_are_utc(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1) + assert clip.created_at.tzinfo is not None + assert clip.updated_at.tzinfo is not None + + +class TestTemplateClipConfigProperties: + """属性方法测试.""" + + def test_has_duration_range_false_when_both_zero(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1) + assert clip.has_duration_range is False + + def test_has_duration_range_true_when_min_set(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=2.0) + assert clip.has_duration_range is True + + def test_has_duration_range_true_when_max_set(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=10.0) + assert clip.has_duration_range is True + + def test_default_duration_both_zero(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1) + assert clip.default_duration == 0.0 + + def test_default_duration_only_min(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0) + assert clip.default_duration == 5.0 + + def test_default_duration_only_max(self): + clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=10.0) + assert clip.default_duration == 10.0 + + def test_default_duration_both_set_is_midpoint(self): + clip = TemplateClipConfig.create( + template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0, max_duration=15.0 + ) + assert clip.default_duration == 10.0 + + def test_default_duration_min_equals_max(self): + clip = TemplateClipConfig.create( + template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0, max_duration=5.0 + ) + assert clip.default_duration == 5.0 diff --git a/tests/unit/test_template_domain.py b/tests/unit/test_template_domain.py new file mode 100644 index 000000000..c0bff5c44 --- /dev/null +++ b/tests/unit/test_template_domain.py @@ -0,0 +1,153 @@ +""" +Template 模板领域模型单元测试 +""" + +from domain.template import Template, TemplateCategory, TemplateSegment + + +class TestTemplateSegment: + """TemplateSegment 测试""" + + def test_create_segment(self): + seg = TemplateSegment( + id="seg-1", + template_id="tpl-1", + segment_order=1, + duration_min=3.0, + duration_max=5.0, + ) + assert seg.id == "seg-1" + assert seg.template_id == "tpl-1" + assert seg.segment_order == 1 + assert seg.duration_min == 3.0 + assert seg.duration_max == 5.0 + assert seg.material_type is None + + def test_segment_with_material_type(self): + seg = TemplateSegment( + id="seg-1", + template_id="tpl-1", + segment_order=0, + duration_min=2.0, + duration_max=4.0, + material_type="人物", + ) + assert seg.material_type == "人物" + + def test_segment_has_timestamps(self): + seg = TemplateSegment( + id="seg-1", + template_id="tpl-1", + segment_order=1, + duration_min=1.0, + duration_max=2.0, + ) + assert seg.created_at is not None + assert seg.updated_at is not None + + +class TestTemplate: + """Template 测试""" + + def test_create_template_minimal(self): + t = Template( + id="tpl-1", + user_id="user-1", + name="测试模板", + mode="one_take", + ) + assert t.id == "tpl-1" + assert t.user_id == "user-1" + assert t.name == "测试模板" + assert t.mode == "one_take" + + def test_default_values(self): + t = Template(id="tpl-1", user_id="u1", name="n", mode="one_take") + assert t.category == "" + assert t.tags == [] + assert t.title_config == {} + assert t.subtitle_config == {} + assert t.bgm_config == {} + assert t.estimated_duration == 0.0 + assert t.segments == [] + assert t.is_active is True + + def test_with_segments(self): + segs = [ + TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=2, duration_max=4), + TemplateSegment(id="s2", template_id="t1", segment_order=1, duration_min=3, duration_max=5), + ] + t = Template( + id="tpl-1", + user_id="u1", + name="n", + mode="voice_over", + segments=segs, + ) + assert len(t.segments) == 2 + assert t.segments[0].segment_order == 0 + assert t.segments[1].segment_order == 1 + + def test_all_modes(self): + for mode in ["pip", "voice_pip", "one_take", "voice_over"]: + t = Template(id="t1", user_id="u1", name="n", mode=mode) + assert t.mode == mode + + def test_with_configs(self): + t = Template( + id="t1", + user_id="u1", + name="n", + mode="one_take", + title_config={"font_size": 24, "color": "#ffffff"}, + subtitle_config={"style": "bottom"}, + bgm_config={"volume": 0.5}, + ) + assert t.title_config["font_size"] == 24 + assert t.subtitle_config["style"] == "bottom" + assert t.bgm_config["volume"] == 0.5 + + def test_estimated_duration(self): + t = Template( + id="t1", + user_id="u1", + name="n", + mode="one_take", + estimated_duration=30.5, + ) + assert t.estimated_duration == 30.5 + + def test_is_active_false(self): + t = Template(id="t1", user_id="u1", name="n", mode="one_take", is_active=False) + assert t.is_active is False + + def test_has_timestamps(self): + t = Template(id="t1", user_id="u1", name="n", mode="one_take") + assert t.created_at is not None + assert t.updated_at is not None + + def test_tags_list(self): + t = Template( + id="t1", + user_id="u1", + name="n", + mode="one_take", + tags=["风景", "vlog"], + ) + assert "风景" in t.tags + assert "vlog" in t.tags + assert len(t.tags) == 2 + + +class TestTemplateCategory: + """TemplateCategory 测试""" + + def test_create_category(self): + cat = TemplateCategory(id="cat-1", user_id="u1", name="风景") + assert cat.id == "cat-1" + assert cat.user_id == "u1" + assert cat.name == "风景" + + def test_category_has_timestamp(self): + cat = TemplateCategory(id="cat-1", user_id="u1", name="风景") + assert cat.created_at is not None diff --git a/tests/unit/test_template_version_domain.py b/tests/unit/test_template_version_domain.py new file mode 100644 index 000000000..227111955 --- /dev/null +++ b/tests/unit/test_template_version_domain.py @@ -0,0 +1,127 @@ +""" +EditTemplateVersion 模板版本领域模型单元测试 +""" + +import pytest +from domain.template_version import EditTemplateVersion + + +class TestEditTemplateVersionCreate: + """创建模板版本测试""" + + def test_create_required_fields(self): + v = EditTemplateVersion.create(template_id="tpl-1", version=1) + assert v.id is not None + assert len(v.id) == 32 + assert v.template_id == "tpl-1" + assert v.version == 1 + + def test_default_values(self): + v = EditTemplateVersion.create(template_id="tpl-1", version=1) + assert v.name == "" + assert v.editing_mode == "one_take" + assert v.config == {} + assert v.clip_configs == [] + assert v.change_note == "" + assert v.published_by == "" + + def test_with_name_and_mode(self): + v = EditTemplateVersion.create( + template_id="tpl-1", + version=2, + name="风景Vlog模板", + editing_mode="voice_over", + ) + assert v.name == "风景Vlog模板" + assert v.editing_mode == "voice_over" + + def test_with_config(self): + config = { + "title": {"font_size": 24}, + "subtitle": {"style": "bottom"}, + "bgm": {"volume": 0.5}, + } + v = EditTemplateVersion.create( + template_id="tpl-1", + version=1, + config=config, + ) + assert v.config == config + assert v.config["title"]["font_size"] == 24 + + def test_with_clip_configs(self): + clips = [ + {"clip_id": 1, "duration": 3.0, "transition": "fade"}, + {"clip_id": 2, "duration": 5.0, "transition": "slide"}, + ] + v = EditTemplateVersion.create( + template_id="tpl-1", + version=1, + clip_configs=clips, + ) + assert len(v.clip_configs) == 2 + assert v.clip_configs[0]["clip_id"] == 1 + + def test_config_none_defaults_to_empty_dict(self): + v = EditTemplateVersion.create(template_id="tpl-1", version=1, config=None) + assert v.config == {} + + def test_clip_configs_none_defaults_to_empty_list(self): + v = EditTemplateVersion.create(template_id="tpl-1", version=1, clip_configs=None) + assert v.clip_configs == [] + + def test_with_change_note(self): + v = EditTemplateVersion.create( + template_id="tpl-1", + version=3, + change_note="优化转场效果,新增滤镜", + ) + assert v.change_note == "优化转场效果,新增滤镜" + + def test_with_published_by(self): + v = EditTemplateVersion.create( + template_id="tpl-1", + version=1, + published_by="user-123", + ) + assert v.published_by == "user-123" + + def test_full_version(self): + config = {"bgm": {"volume": 0.3}} + clips = [{"clip_id": 1, "duration": 2.5}] + v = EditTemplateVersion.create( + template_id="tpl-abc", + version=5, + name="正式版v5", + editing_mode="one_take", + config=config, + clip_configs=clips, + change_note="第五次发布", + published_by="admin", + ) + assert v.template_id == "tpl-abc" + assert v.version == 5 + assert v.name == "正式版v5" + assert v.editing_mode == "one_take" + assert v.config == config + assert v.clip_configs == clips + assert v.change_note == "第五次发布" + assert v.published_by == "admin" + + def test_version_number(self): + for ver in [1, 2, 5, 10, 99]: + v = EditTemplateVersion.create(template_id="t1", version=ver) + assert v.version == ver + + def test_has_created_at(self): + v = EditTemplateVersion.create(template_id="t1", version=1) + assert v.created_at is not None + + +class TestEditTemplateVersionSlots: + """slots 模式属性测试""" + + def test_cannot_add_new_attribute(self): + v = EditTemplateVersion.create(template_id="t1", version=1) + with pytest.raises(AttributeError): + v.nonexistent_field = "value" # type: ignore[attr-defined] diff --git a/tests/unit/test_title_library_domain.py b/tests/unit/test_title_library_domain.py new file mode 100755 index 000000000..b5a680f11 --- /dev/null +++ b/tests/unit/test_title_library_domain.py @@ -0,0 +1,78 @@ +""" +TitleLibraryItem 标题库领域模型单元测试 +""" + +from packages.domain.title_library import TitleLibraryItem + + +class TestTitleLibraryItem: + """TitleLibraryItem 测试""" + + def test_create_minimal(self): + item = TitleLibraryItem(id="t1", user_id="u1", name="标题1", text="这是标题文本") + assert item.id == "t1" + assert item.user_id == "u1" + assert item.name == "标题1" + assert item.text == "这是标题文本" + + def test_default_values(self): + item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t") + assert item.category == "default" + assert item.description == "" + assert item.tags == [] + assert item.usage_count == 0 + assert item.is_active is True + assert item.metadata_ == {} + + def test_with_category(self): + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="n", + text="t", + category="美食", + ) + assert item.category == "美食" + + def test_with_tags(self): + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="n", + text="t", + tags=["爆款", "美食"], + ) + assert len(item.tags) == 2 + assert "爆款" in item.tags + + def test_usage_count(self): + item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t") + assert item.usage_count == 0 + item.usage_count = 10 + assert item.usage_count == 10 + + def test_inactive(self): + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="n", + text="t", + is_active=False, + ) + assert item.is_active is False + + def test_with_metadata(self): + meta = {"source": "import", "quality": "high"} + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="n", + text="t", + metadata_=meta, + ) + assert item.metadata_["source"] == "import" + + def test_has_timestamps(self): + item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t") + assert item.created_at is not None + assert item.updated_at is not None diff --git a/tests/unit/test_transition_presets_domain.py b/tests/unit/test_transition_presets_domain.py new file mode 100755 index 000000000..a91a945bc --- /dev/null +++ b/tests/unit/test_transition_presets_domain.py @@ -0,0 +1,181 @@ +"""transition_presets 模块单元测试.""" + +from dataclasses import FrozenInstanceError + +import pytest +from domain.transition_presets import ( + TRANSITION_PRESET_LIBRARY, + TransitionPreset, + get_default_transition, + get_transition_preset, + list_transition_presets, +) + + +class TestTransitionPreset: + """TransitionPreset 数据类测试.""" + + def test_create_required_fields(self): + t = TransitionPreset(id="test_001", name="测试转场", category="basic") + assert t.id == "test_001" + assert t.name == "测试转场" + assert t.category == "basic" + # 默认值 + assert t.description == "" + assert t.tags == [] + assert t.transition == "fade" + assert t.default_duration == 0.5 + assert t.min_duration == 0.1 + assert t.max_duration == 3.0 + assert t.has_custom_params is False + + def test_create_all_fields(self): + t = TransitionPreset( + id="test_002", + name="完整转场", + category="slide", + description="测试描述", + tags=["标签1", "标签2"], + transition="slideleft", + default_duration=1.0, + min_duration=0.3, + max_duration=2.5, + has_custom_params=True, + ) + assert t.category == "slide" + assert t.description == "测试描述" + assert t.tags == ["标签1", "标签2"] + assert t.transition == "slideleft" + assert t.default_duration == 1.0 + assert t.min_duration == 0.3 + assert t.max_duration == 2.5 + assert t.has_custom_params is True + + def test_frozen_immutable(self): + t = TransitionPreset(id="test", name="测试", category="basic") + with pytest.raises(FrozenInstanceError): + t.name = "修改" # type: ignore[misc] + + def test_tags_default_new_list(self): + t1 = TransitionPreset(id="1", name="a", category="basic") + t2 = TransitionPreset(id="2", name="b", category="basic") + assert t1.tags is not t2.tags + assert t1.tags == [] + + +class TestTransitionPresetLibrary: + """TRANSITION_PRESET_LIBRARY 预设库测试.""" + + def test_not_empty(self): + assert len(TRANSITION_PRESET_LIBRARY) > 0 + + def test_all_unique_ids(self): + ids = [t.id for t in TRANSITION_PRESET_LIBRARY] + assert len(ids) == len(set(ids)), "转场 ID 不能重复" + + def test_all_are_transition_preset_instances(self): + for t in TRANSITION_PRESET_LIBRARY: + assert isinstance(t, TransitionPreset) + + def test_contains_basic_categories(self): + cats = {t.category for t in TRANSITION_PRESET_LIBRARY} + assert "basic" in cats + assert "fade" in cats + + def test_duration_constraints_valid(self): + """每个预设的 min <= default <= max.""" + for t in TRANSITION_PRESET_LIBRARY: + assert t.min_duration <= t.default_duration, f"{t.id}: min > default" + assert t.default_duration <= t.max_duration, f"{t.id}: default > max" + + def test_none_transition_zero_duration(self): + t = get_transition_preset("transition_none") + assert t is not None + assert t.default_duration == 0.0 + assert t.min_duration == 0.0 + assert t.max_duration == 0.0 + + +class TestGetTransitionPreset: + """get_transition_preset 函数测试.""" + + def test_existing_id(self): + t = get_transition_preset("transition_fade") + assert t is not None + assert t.id == "transition_fade" + assert t.name == "淡入淡出" + assert t.category == "fade" + + def test_nonexistent_id(self): + assert get_transition_preset("nonexistent") is None + + def test_empty_string(self): + assert get_transition_preset("") is None + + +class TestListTransitionPresets: + """list_transition_presets 函数测试.""" + + def test_no_filters_returns_all(self): + result = list_transition_presets() + assert len(result) == len(TRANSITION_PRESET_LIBRARY) + + def test_filter_by_category_basic(self): + result = list_transition_presets(category="basic") + assert len(result) >= 2 + for t in result: + assert t.category == "basic" + + def test_filter_by_category_fade(self): + result = list_transition_presets(category="fade") + assert len(result) >= 3 + for t in result: + assert t.category == "fade" + + def test_filter_by_unknown_category_returns_empty(self): + result = list_transition_presets(category="nonexistent") + assert result == [] + + def test_filter_by_keyword_name(self): + result = list_transition_presets(keyword="淡入") + assert len(result) >= 1 + assert any(t.name == "淡入淡出" for t in result) + + def test_filter_by_keyword_description(self): + result = list_transition_presets(keyword="经典") + assert len(result) >= 1 + + def test_filter_by_keyword_tag(self): + result = list_transition_presets(keyword="电影感") + assert len(result) >= 1 + + def test_filter_keyword_case_insensitive(self): + r1 = list_transition_presets(keyword="FADE") + r2 = list_transition_presets(keyword="fade") + assert len(r1) == len(r2) + + def test_filter_keyword_no_match(self): + result = list_transition_presets(keyword="xyz_nonexistent_12345") + assert result == [] + + def test_combined_category_and_keyword(self): + result = list_transition_presets(category="fade", keyword="黑场") + assert len(result) >= 1 + for t in result: + assert t.category == "fade" + + def test_combined_no_match(self): + result = list_transition_presets(category="basic", keyword="黑场") + assert result == [] + + +class TestGetDefaultTransition: + """get_default_transition 函数测试.""" + + def test_returns_none_transition(self): + t = get_default_transition() + assert t.id == "transition_none" + assert t.name == "无转场" + + def test_returns_transition_preset_instance(self): + assert isinstance(get_default_transition(), TransitionPreset) diff --git a/tests/unit/test_tts_config_domain.py b/tests/unit/test_tts_config_domain.py index eacbed57c..61f068411 100755 --- a/tests/unit/test_tts_config_domain.py +++ b/tests/unit/test_tts_config_domain.py @@ -1,14 +1,12 @@ -""" -TTS 配音配置模型单元测试 -""" +"""TTS 配音配置领域模型单元测试.""" -import pytest +from __future__ import annotations from packages.domain.tts_config import TtsConfig class TestTtsConfigDefaults: - """默认值测试""" + """默认值测试.""" def test_default_values(self): config = TtsConfig() @@ -23,180 +21,184 @@ class TestTtsConfigDefaults: class TestTtsConfigParse: - """parse 方法测试""" + """parse 方法测试.""" - def test_parse_none(self): + def test_parse_none_returns_default(self): config = TtsConfig.parse(None) assert config.enabled is False - assert config.speed == 1.0 - def test_parse_empty_dict(self): + def test_parse_empty_dict_returns_default(self): config = TtsConfig.parse({}) assert config.enabled is False - def test_parse_not_dict(self): - config = TtsConfig.parse("not a dict") + def test_parse_not_dict_returns_default(self): + config = TtsConfig.parse("invalid") assert config.enabled is False + config2 = TtsConfig.parse(123) + assert config2.enabled is False + config3 = TtsConfig.parse([]) + assert config3.enabled is False - def test_parse_disabled_returns_minimal(self): - """disabled 时直接返回 enabled=False,忽略其他字段""" - config = TtsConfig.parse( - { - "enabled": False, - "voice_id": "v123", - "speed": 1.5, - } - ) + def test_parse_enabled_false_ignores_other_fields(self): + data = { + "enabled": False, + "voice_id": "test_voice", + "speed": 2.0, + "text": "hello", + } + config = TtsConfig.parse(data) assert config.enabled is False - assert config.voice_id == "" # 不保留 - - def test_parse_enabled_true(self): - config = TtsConfig.parse( - { - "enabled": True, - "voice_id": "voice_001", - "speed": 1.2, - "pitch": 2.5, - "volume": 0.5, - "text": "你好世界", - "align_mode": "subtitle", - "overlap_mode": "mix", - } - ) - assert config.enabled is True - assert config.voice_id == "voice_001" - assert config.speed == 1.2 - assert config.pitch == 2.5 - assert config.volume == 0.5 - assert config.text == "你好世界" - assert config.align_mode == "subtitle" - assert config.overlap_mode == "mix" - - def test_parse_enabled_not_bool_false(self): - """enabled 不是 bool 时视为 False""" - config = TtsConfig.parse({"enabled": "true"}) - assert config.enabled is False - - def test_parse_enabled_not_bool_zero(self): - config = TtsConfig.parse({"enabled": 0}) - assert config.enabled is False - - def test_parse_voice_id_not_string(self): - config = TtsConfig.parse({"enabled": True, "voice_id": 123}) assert config.voice_id == "" - - def test_parse_speed_not_number(self): - config = TtsConfig.parse({"enabled": True, "speed": "fast"}) assert config.speed == 1.0 - def test_parse_pitch_not_number(self): - config = TtsConfig.parse({"enabled": True, "pitch": "high"}) + def test_parse_basic_enabled(self): + data = {"enabled": True, "voice_id": "voice_001"} + config = TtsConfig.parse(data) + assert config.enabled is True + assert config.voice_id == "voice_001" + assert config.speed == 1.0 assert config.pitch == 0.0 - - def test_parse_volume_not_number(self): - config = TtsConfig.parse({"enabled": True, "volume": "loud"}) assert config.volume == 0.8 - def test_parse_text_not_string(self): - config = TtsConfig.parse({"enabled": True, "text": 12345}) - assert config.text == "" - - def test_parse_align_mode_invalid(self): - config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"}) - assert config.align_mode == "full" - - def test_parse_align_mode_subtitle(self): - config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"}) + def test_parse_full_config(self): + data = { + "enabled": True, + "voice_id": "voice_001", + "speed": 1.5, + "pitch": 2.0, + "volume": 0.9, + "text": "测试配音文本", + "align_mode": "subtitle", + "overlap_mode": "mix", + } + config = TtsConfig.parse(data) + assert config.enabled is True + assert config.voice_id == "voice_001" + assert config.speed == 1.5 + assert config.pitch == 2.0 + assert config.volume == 0.9 + assert config.text == "测试配音文本" assert config.align_mode == "subtitle" - - def test_parse_align_mode_full(self): - config = TtsConfig.parse({"enabled": True, "align_mode": "full"}) - assert config.align_mode == "full" - - def test_parse_overlap_mode_invalid(self): - config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"}) - assert config.overlap_mode == "replace" - - def test_parse_overlap_mode_replace(self): - config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"}) - assert config.overlap_mode == "replace" - - def test_parse_overlap_mode_mix(self): - config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"}) assert config.overlap_mode == "mix" - def test_parse_integer_speed(self): - """int 类型的 speed 应该被转成 float""" - config = TtsConfig.parse({"enabled": True, "speed": 2}) - assert config.speed == 2.0 - assert isinstance(config.speed, float) + def test_parse_enabled_non_bool_fallback(self): + data = {"enabled": "true", "voice_id": "v1"} + config = TtsConfig.parse(data) + assert config.enabled is False - def test_parse_integer_pitch(self): - config = TtsConfig.parse({"enabled": True, "pitch": -5}) - assert config.pitch == -5.0 - assert isinstance(config.pitch, float) + def test_parse_voice_id_non_string_fallback(self): + data = {"enabled": True, "voice_id": 123} + config = TtsConfig.parse(data) + assert config.voice_id == "" - def test_parse_integer_volume(self): - config = TtsConfig.parse({"enabled": True, "volume": 1}) - assert config.volume == 1.0 - assert isinstance(config.volume, float) + def test_parse_speed_non_numeric_fallback(self): + data = {"enabled": True, "speed": "fast"} + config = TtsConfig.parse(data) + assert config.speed == 1.0 + + def test_parse_pitch_non_numeric_fallback(self): + data = {"enabled": True, "pitch": "high"} + config = TtsConfig.parse(data) + assert config.pitch == 0.0 + + def test_parse_volume_non_numeric_fallback(self): + data = {"enabled": True, "volume": "loud"} + config = TtsConfig.parse(data) + assert config.volume == 0.8 + + def test_parse_text_non_string_fallback(self): + data = {"enabled": True, "text": 12345} + config = TtsConfig.parse(data) + assert config.text == "" + + def test_parse_align_mode_invalid_fallback(self): + data = {"enabled": True, "align_mode": "invalid"} + config = TtsConfig.parse(data) + assert config.align_mode == "full" + + def test_parse_overlap_mode_invalid_fallback(self): + data = {"enabled": True, "overlap_mode": "invalid"} + config = TtsConfig.parse(data) + assert config.overlap_mode == "replace" class TestTtsConfigClamp: - """边界钳制测试""" + """边界钳制测试.""" - def test_speed_too_low(self): - config = TtsConfig.parse({"enabled": True, "speed": 0.1}) + def test_speed_below_min_clamped(self): + data = {"enabled": True, "speed": 0.1} + config = TtsConfig.parse(data) assert config.speed == 0.5 - def test_speed_too_high(self): - config = TtsConfig.parse({"enabled": True, "speed": 3.0}) + def test_speed_above_max_clamped(self): + data = {"enabled": True, "speed": 3.0} + config = TtsConfig.parse(data) assert config.speed == 2.0 - def test_speed_lower_boundary(self): - config = TtsConfig.parse({"enabled": True, "speed": 0.5}) + def test_speed_at_min_ok(self): + data = {"enabled": True, "speed": 0.5} + config = TtsConfig.parse(data) assert config.speed == 0.5 - def test_speed_upper_boundary(self): - config = TtsConfig.parse({"enabled": True, "speed": 2.0}) + def test_speed_at_max_ok(self): + data = {"enabled": True, "speed": 2.0} + config = TtsConfig.parse(data) assert config.speed == 2.0 - def test_pitch_too_low(self): - config = TtsConfig.parse({"enabled": True, "pitch": -20}) + def test_pitch_below_min_clamped(self): + data = {"enabled": True, "pitch": -20} + config = TtsConfig.parse(data) assert config.pitch == -12 - def test_pitch_too_high(self): - config = TtsConfig.parse({"enabled": True, "pitch": 20}) + def test_pitch_above_max_clamped(self): + data = {"enabled": True, "pitch": 20} + config = TtsConfig.parse(data) assert config.pitch == 12 - def test_pitch_lower_boundary(self): - config = TtsConfig.parse({"enabled": True, "pitch": -12}) + def test_pitch_at_min_ok(self): + data = {"enabled": True, "pitch": -12} + config = TtsConfig.parse(data) assert config.pitch == -12 - def test_pitch_upper_boundary(self): - config = TtsConfig.parse({"enabled": True, "pitch": 12}) + def test_pitch_at_max_ok(self): + data = {"enabled": True, "pitch": 12} + config = TtsConfig.parse(data) assert config.pitch == 12 - def test_volume_negative(self): - config = TtsConfig.parse({"enabled": True, "volume": -0.5}) + def test_volume_below_min_clamped(self): + data = {"enabled": True, "volume": -0.5} + config = TtsConfig.parse(data) assert config.volume == 0.0 - def test_volume_over_one(self): - config = TtsConfig.parse({"enabled": True, "volume": 1.5}) + def test_volume_above_max_clamped(self): + data = {"enabled": True, "volume": 2.0} + config = TtsConfig.parse(data) assert config.volume == 1.0 - def test_volume_zero(self): - config = TtsConfig.parse({"enabled": True, "volume": 0.0}) + def test_volume_at_min_ok(self): + data = {"enabled": True, "volume": 0.0} + config = TtsConfig.parse(data) assert config.volume == 0.0 - def test_volume_one(self): - config = TtsConfig.parse({"enabled": True, "volume": 1.0}) + def test_volume_at_max_ok(self): + data = {"enabled": True, "volume": 1.0} + config = TtsConfig.parse(data) assert config.volume == 1.0 - def test_clamp_via_direct_construction(self): - """直接构造也应该钳制(通过 _clamp 方法)""" - config = TtsConfig(enabled=True, speed=5.0, pitch=100, volume=-1) - config._clamp() - assert config.speed == 2.0 - assert config.pitch == 12 - assert config.volume == 0.0 + def test_int_speed_converted_to_float(self): + data = {"enabled": True, "speed": 1} + config = TtsConfig.parse(data) + assert isinstance(config.speed, float) + assert config.speed == 1.0 + + def test_int_pitch_converted_to_float(self): + data = {"enabled": True, "pitch": 5} + config = TtsConfig.parse(data) + assert isinstance(config.pitch, float) + assert config.pitch == 5.0 + + def test_int_volume_converted_to_float(self): + data = {"enabled": True, "volume": 1} + config = TtsConfig.parse(data) + assert isinstance(config.volume, float) + assert config.volume == 1.0 diff --git a/tests/unit/test_tts_job_domain.py b/tests/unit/test_tts_job_domain.py new file mode 100755 index 000000000..c76a09115 --- /dev/null +++ b/tests/unit/test_tts_job_domain.py @@ -0,0 +1,393 @@ +"""tts_job 领域模型单元测试.""" + +import pytest +from domain.tts_job import TERMINAL_STATUSES, TTSJob, TTSJobStatus + + +class TestTTSJobStatus: + """TTSJobStatus 枚举测试.""" + + def test_values(self): + assert TTSJobStatus.PENDING == "pending" + assert TTSJobStatus.PROCESSING == "processing" + assert TTSJobStatus.COMPLETED == "completed" + assert TTSJobStatus.FAILED == "failed" + assert TTSJobStatus.CANCELLED == "cancelled" + + def test_terminal_statuses(self): + assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES + assert TTSJobStatus.FAILED in TERMINAL_STATUSES + assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES + assert TTSJobStatus.PENDING not in TERMINAL_STATUSES + assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES + + +class TestTTSJobCreate: + """TTSJob.create 工厂方法测试.""" + + def test_create_with_required_fields(self): + job = TTSJob.create(user_id="user_001", input_text="你好世界") + assert job.id + assert len(job.id) == 32 + assert job.user_id == "user_001" + assert job.input_text == "你好世界" + assert job.status == TTSJobStatus.PENDING + assert job.voice_id == "" + assert job.sample_rate == 22050 + assert job.format == "mp3" + assert job.retry_count == 0 + assert job.max_retries == 3 + assert job.metadata == {} + assert job.created_at is not None + assert job.updated_at is not None + + def test_create_with_all_fields(self): + job = TTSJob.create( + user_id="user_002", + input_text="测试文本", + voice_id="voice_001", + voice_model="cosyvoice", + project_id="proj_001", + voice_clone_profile_id="clone_001", + sample_rate=16000, + format="wav", + max_retries=5, + metadata={"key": "value"}, + ) + assert job.voice_id == "voice_001" + assert job.voice_model == "cosyvoice" + assert job.project_id == "proj_001" + assert job.voice_clone_profile_id == "clone_001" + assert job.sample_rate == 16000 + assert job.format == "wav" + assert job.max_retries == 5 + assert job.metadata == {"key": "value"} + + def test_create_strips_strings(self): + job = TTSJob.create( + user_id=" user_003 ", + input_text=" 测试文本 ", + voice_id=" voice_001 ", + voice_model=" cosyvoice ", + project_id=" proj_001 ", + voice_clone_profile_id=" clone_001 ", + format="wav", + ) + assert job.user_id == "user_003" + assert job.input_text == "测试文本" + assert job.voice_id == "voice_001" + assert job.voice_model == "cosyvoice" + assert job.project_id == "proj_001" + assert job.voice_clone_profile_id == "clone_001" + assert job.format == "wav" + + def test_create_empty_user_id_raises(self): + with pytest.raises(ValueError, match="user_id"): + TTSJob.create(user_id="", input_text="test") + + def test_create_whitespace_user_id_raises(self): + with pytest.raises(ValueError, match="user_id"): + TTSJob.create(user_id=" ", input_text="test") + + def test_create_empty_input_text_raises(self): + with pytest.raises(ValueError, match="input_text"): + TTSJob.create(user_id="u", input_text="") + + def test_create_input_text_too_long_raises(self): + long_text = "a" * 10001 + with pytest.raises(ValueError, match="10000"): + TTSJob.create(user_id="u", input_text=long_text) + + def test_create_input_text_at_limit_ok(self): + text = "a" * 10000 + job = TTSJob.create(user_id="u", input_text=text) + assert job.input_text == text + + def test_create_invalid_format_raises(self): + with pytest.raises(ValueError, match="不支持的输出格式"): + TTSJob.create(user_id="u", input_text="t", format="flac") + + def test_create_supported_formats(self): + for fmt in ["mp3", "wav", "pcm"]: + job = TTSJob.create(user_id="u", input_text="t", format=fmt) + assert job.format == fmt + + def test_create_none_metadata_defaults_to_empty_dict(self): + job = TTSJob.create(user_id="u", input_text="t", metadata=None) + assert job.metadata == {} + + def test_create_ids_are_unique(self): + j1 = TTSJob.create(user_id="u", input_text="t") + j2 = TTSJob.create(user_id="u", input_text="t") + assert j1.id != j2.id + + +class TestTTSJobStateMachine: + """TTSJob 状态机测试.""" + + @pytest.fixture + def pending_job(self): + return TTSJob.create(user_id="user_001", input_text="测试") + + def test_initial_status_is_pending(self, pending_job): + assert pending_job.status == TTSJobStatus.PENDING + assert not pending_job.is_terminal + + def test_pending_to_processing(self, pending_job): + pending_job.mark_processing() + assert pending_job.status == TTSJobStatus.PROCESSING + assert pending_job.started_at is not None + assert pending_job.error_message == "" + + def test_pending_can_fail_directly(self, pending_job): + """pending 可以直接到 failed(比如入参校验失败)""" + pending_job.mark_failed("校验失败") + assert pending_job.status == TTSJobStatus.FAILED + assert pending_job.error_message == "校验失败" + + def test_pending_can_be_cancelled(self, pending_job): + pending_job.mark_cancelled() + assert pending_job.status == TTSJobStatus.CANCELLED + + def test_processing_to_completed(self, pending_job): + pending_job.mark_processing() + pending_job.mark_completed(output_audio_url="https://example.com/out.mp3") + assert pending_job.status == TTSJobStatus.COMPLETED + assert pending_job.output_audio_url == "https://example.com/out.mp3" + assert pending_job.completed_at is not None + assert pending_job.error_message == "" + + def test_processing_to_failed(self, pending_job): + pending_job.mark_processing() + pending_job.mark_failed("API 超时") + assert pending_job.status == TTSJobStatus.FAILED + assert pending_job.error_message == "API 超时" + + def test_processing_can_be_cancelled(self, pending_job): + pending_job.mark_processing() + pending_job.mark_cancelled() + assert pending_job.status == TTSJobStatus.CANCELLED + + def test_completed_is_terminal(self, pending_job): + pending_job.mark_processing() + pending_job.mark_completed(output_audio_url="https://example.com/out.mp3") + assert pending_job.is_terminal + assert pending_job.is_completed + + def test_failed_is_terminal_but_retryable(self, pending_job): + pending_job.mark_processing() + pending_job.mark_failed("error") + assert pending_job.is_terminal + assert pending_job.is_retryable + + def test_cancelled_is_terminal_and_not_retryable(self, pending_job): + pending_job.mark_cancelled() + assert pending_job.is_terminal + assert not pending_job.is_retryable + + def test_invalid_transition_completed_to_processing_raises(self, pending_job): + pending_job.mark_processing() + pending_job.mark_completed(output_audio_url="https://example.com/out.mp3") + with pytest.raises(ValueError, match="非法状态转换"): + pending_job.mark_processing() + + def test_invalid_transition_completed_to_failed_raises(self, pending_job): + pending_job.mark_processing() + pending_job.mark_completed(output_audio_url="https://example.com/out.mp3") + with pytest.raises(ValueError, match="非法状态转换"): + pending_job.mark_failed("test") + + def test_cancelled_cannot_transition(self, pending_job): + pending_job.mark_cancelled() + with pytest.raises(ValueError): + pending_job.mark_processing() + with pytest.raises(ValueError): + pending_job.mark_failed("test") + + def test_transition_to_with_string(self, pending_job): + """transition_to 支持字符串参数""" + pending_job.transition_to("processing") + assert pending_job.status == TTSJobStatus.PROCESSING + + def test_transition_to_invalid_string_raises(self, pending_job): + with pytest.raises(ValueError, match="无效状态"): + pending_job.transition_to("invalid_status") + + def test_state_transition_updates_updated_at(self, pending_job): + old_updated = pending_job.updated_at + import time + + time.sleep(0.001) + pending_job.mark_processing() + assert pending_job.updated_at > old_updated + + +class TestTTSJobRetry: + """TTSJob 重试逻辑测试.""" + + def test_failed_can_retry(self): + job = TTSJob.create(user_id="u", input_text="t", max_retries=3) + job.mark_processing() + job.mark_failed("error") + assert job.is_retryable + assert job.retry_count == 0 + + def test_prepare_retry_resets_to_pending(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_processing() + job.mark_failed("error") + + job.prepare_retry() + assert job.status == TTSJobStatus.PENDING + assert job.retry_count == 1 + assert job.error_message == "" + assert job.started_at is None + assert job.completed_at is None + + def test_retry_up_to_max_retries(self): + job = TTSJob.create(user_id="u", input_text="t", max_retries=2) + # 第 1 次失败 + 重试 → retry_count=1,还可以重试 + job.mark_processing() + job.mark_failed("e1") + assert job.is_retryable + job.prepare_retry() + assert job.retry_count == 1 + + # 第 2 次失败 → retry_count=1,还是 failed 状态,还可以重试(max_retries=2) + job.mark_processing() + job.mark_failed("e2") + assert job.is_retryable # retry_count=1 < max_retries=2 + job.prepare_retry() + assert job.retry_count == 2 + + # 第 3 次失败 → retry_count=2,达到上限,不可重试 + job.mark_processing() + job.mark_failed("e3") + assert not job.is_retryable # retry_count=2 == max_retries=2 + + def test_retry_exceed_max_raises(self): + job = TTSJob.create(user_id="u", input_text="t", max_retries=1) + job.mark_processing() + job.mark_failed("e") + job.prepare_retry() # 第 1 次重试,用完了 + + job.mark_processing() + job.mark_failed("e2") + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + def test_pending_not_retryable(self): + job = TTSJob.create(user_id="u", input_text="t") + assert not job.is_retryable + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + def test_completed_not_retryable(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_processing() + job.mark_completed(output_audio_url="https://example.com/out.mp3") + assert not job.is_retryable + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + def test_cancelled_not_retryable(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_cancelled() + assert not job.is_retryable + + +class TestTTSJobMarkCompleted: + """mark_completed 方法测试.""" + + def test_requires_output_url(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_processing() + with pytest.raises(ValueError, match="output_audio_url"): + job.mark_completed(output_audio_url="") + + def test_sets_all_fields(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_processing() + job.mark_completed( + output_audio_url="https://example.com/out.mp3", + output_audio_key="audio/001.mp3", + duration=30.5, + file_size=102400, + ) + assert job.output_audio_url == "https://example.com/out.mp3" + assert job.output_audio_key == "audio/001.mp3" + assert job.duration == 30.5 + assert job.file_size == 102400 + assert job.completed_at is not None + + def test_strips_whitespace(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_processing() + job.mark_completed( + output_audio_url=" https://example.com/out.mp3 ", + output_audio_key=" audio/001.mp3 ", + ) + assert job.output_audio_url == "https://example.com/out.mp3" + assert job.output_audio_key == "audio/001.mp3" + + +class TestTTSJobIsCompleted: + """is_completed 属性测试.""" + + def test_completed_with_url_is_completed(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_processing() + job.mark_completed(output_audio_url="https://example.com/out.mp3") + assert job.is_completed + + def test_completed_without_url_not_completed(self): + """极端情况:completed 状态但没有 URL(理论不会发生)""" + job = TTSJob.create(user_id="u", input_text="t") + job.mark_processing() + job.transition_to(TTSJobStatus.COMPLETED) # 直接转,不设 URL + assert not job.is_completed + + def test_pending_not_completed(self): + job = TTSJob.create(user_id="u", input_text="t") + assert not job.is_completed + + +class TestTTSJobToDict: + """to_dict 序列化测试.""" + + def test_pending_job_to_dict(self): + job = TTSJob.create(user_id="user_001", input_text="测试文本", voice_id="v001") + d = job.to_dict() + assert d["id"] == job.id + assert d["user_id"] == "user_001" + assert d["status"] == "pending" + assert d["input_text"] == "测试文本" + assert d["voice_id"] == "v001" + assert d["retry_count"] == 0 + assert d["is_retryable"] is False + assert d["is_completed"] is False + assert d["metadata"] == {} + assert d["started_at"] is None + assert d["completed_at"] is None + assert d["created_at"] is not None + assert d["updated_at"] is not None + + def test_completed_job_to_dict(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_processing() + job.mark_completed(output_audio_url="https://example.com/out.mp3", duration=10.0) + d = job.to_dict() + assert d["status"] == "completed" + assert d["output_audio_url"] == "https://example.com/out.mp3" + assert d["duration"] == 10.0 + assert d["is_completed"] is True + assert d["started_at"] is not None + assert d["completed_at"] is not None + + def test_failed_job_to_dict(self): + job = TTSJob.create(user_id="u", input_text="t") + job.mark_failed("出错了") + d = job.to_dict() + assert d["status"] == "failed" + assert d["error_message"] == "出错了" + assert d["is_retryable"] is True diff --git a/tests/unit/test_verification_code_domain.py b/tests/unit/test_verification_code_domain.py new file mode 100644 index 000000000..97a871ac5 --- /dev/null +++ b/tests/unit/test_verification_code_domain.py @@ -0,0 +1,168 @@ +""" +VerificationCode 验证码领域模型单元测试 +""" + +from datetime import datetime, timedelta, timezone + +import pytest +from domain.verification_code import VerificationCode + + +class TestVerificationCodeCreate: + """创建验证码测试""" + + def test_create_default_6digit_code(self): + vc = VerificationCode.create("test@example.com", "email_bind") + assert vc.id is not None + assert len(vc.id) == 32 # uuid4 hex + assert vc.recipient == "test@example.com" + assert vc.code_type == "email_bind" + assert len(vc.code) == 6 + assert vc.code.isdigit() + assert vc.used_at is None + assert vc.attempts == 0 + + def test_create_custom_code(self): + vc = VerificationCode.create("13800138000", "phone_bind", custom_code="123456") + assert vc.code == "123456" + + def test_create_default_ttl_300s(self): + before = datetime.now(timezone.utc) + vc = VerificationCode.create("test@example.com", "email_login") + after = datetime.now(timezone.utc) + expected_expiry_min = before + timedelta(seconds=300) + expected_expiry_max = after + timedelta(seconds=300) + assert expected_expiry_min <= vc.expires_at <= expected_expiry_max + + def test_create_custom_ttl(self): + vc = VerificationCode.create("test@example.com", "reset_password", ttl_seconds=60) + expected = datetime.now(timezone.utc) + timedelta(seconds=60) + diff = abs((vc.expires_at - expected).total_seconds()) + assert diff < 2 + + def test_create_recipient_stripped(self): + vc = VerificationCode.create(" test@example.com ", "email_bind") + assert vc.recipient == "test@example.com" + + def test_create_phone_recipient(self): + vc = VerificationCode.create("13800138000", "phone_login") + assert vc.recipient == "13800138000" + assert vc.code_type == "phone_login" + + def test_create_sets_created_at(self): + vc = VerificationCode.create("test@example.com", "email_bind") + assert vc.created_at is not None + assert isinstance(vc.created_at, datetime) + + +class TestVerificationCodeExpiry: + """过期状态测试""" + + def test_fresh_code_not_expired(self): + vc = VerificationCode.create("test@example.com", "email_bind") + assert vc.is_expired is False + + def test_expired_code_is_expired(self): + vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-60) + assert vc.is_expired is True + + def test_boundary_not_expired_at_expiry_time(self): + now = datetime.now(timezone.utc) + vc = VerificationCode.create("test@example.com", "email_bind") + vc.expires_at = now + timedelta(seconds=1) + assert vc.is_expired is False + + def test_boundary_expired_right_after(self): + vc = VerificationCode.create("test@example.com", "email_bind") + vc.expires_at = datetime.now(timezone.utc) - timedelta(microseconds=1) + assert vc.is_expired is True + + +class TestVerificationCodeUsed: + """使用状态测试""" + + def test_fresh_code_not_used(self): + vc = VerificationCode.create("test@example.com", "email_bind") + assert vc.is_used is False + + def test_mark_used(self): + vc = VerificationCode.create("test@example.com", "email_bind") + vc.mark_used() + assert vc.is_used is True + assert vc.used_at is not None + assert isinstance(vc.used_at, datetime) + + def test_mark_used_sets_recent_time(self): + vc = VerificationCode.create("test@example.com", "email_bind") + before = datetime.now(timezone.utc) + vc.mark_used() + after = datetime.now(timezone.utc) + assert before <= vc.used_at <= after + + def test_mark_used_idempotent(self): + vc = VerificationCode.create("test@example.com", "email_bind") + vc.mark_used() + first_used_at = vc.used_at + vc.mark_used() + # 第二次会更新时间 + assert vc.used_at >= first_used_at + + +class TestVerificationCodeValidity: + """有效性(未过期+未使用)测试""" + + def test_fresh_code_is_valid(self): + vc = VerificationCode.create("test@example.com", "email_bind") + assert vc.is_valid is True + + def test_expired_code_not_valid(self): + vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-10) + assert vc.is_valid is False + + def test_used_code_not_valid(self): + vc = VerificationCode.create("test@example.com", "email_bind") + vc.mark_used() + assert vc.is_valid is False + + def test_expired_and_used_not_valid(self): + vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-10) + vc.mark_used() + assert vc.is_valid is False + + +class TestVerificationCodeAttempts: + """尝试次数测试""" + + def test_initial_attempts_zero(self): + vc = VerificationCode.create("test@example.com", "email_bind") + assert vc.attempts == 0 + + def test_increment_attempts(self): + vc = VerificationCode.create("test@example.com", "email_bind") + vc.increment_attempts() + assert vc.attempts == 1 + + def test_increment_attempts_multiple(self): + vc = VerificationCode.create("test@example.com", "email_bind") + for _ in range(5): + vc.increment_attempts() + assert vc.attempts == 5 + + +class TestVerificationCodeTypes: + """不同验证码类型测试""" + + @pytest.mark.parametrize( + "code_type", + [ + "email_bind", + "phone_bind", + "email_login", + "phone_login", + "reset_password", + ], + ) + def test_all_supported_types(self, code_type): + vc = VerificationCode.create("test@example.com", code_type) + assert vc.code_type == code_type + assert vc.is_valid is True diff --git a/tests/unit/test_voice_library_domain.py b/tests/unit/test_voice_library_domain.py new file mode 100755 index 000000000..9ebe1b6e3 --- /dev/null +++ b/tests/unit/test_voice_library_domain.py @@ -0,0 +1,95 @@ +""" +VoiceLibraryItem 配音库领域模型单元测试 +""" + +from packages.domain.voice_library import VoiceLibraryItem + + +class TestVoiceLibraryItem: + """VoiceLibraryItem 测试""" + + def test_create_minimal(self): + item = VoiceLibraryItem(id="v1", user_id="u1", name="我的配音") + assert item.id == "v1" + assert item.user_id == "u1" + assert item.name == "我的配音" + + def test_default_values(self): + item = VoiceLibraryItem(id="v1", user_id="u1", name="n") + assert item.text == "" + assert item.voice_provider == "" + assert item.voice_id == "" + assert item.voice_name == "" + assert item.audio_url == "" + assert item.duration == 0 + assert item.file_size == 0 + assert item.status == "completed" + assert item.project_id is None + assert item.tags == [] + assert item.metadata_ == {} + + def test_with_voice_info(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="温柔女声", + text="大家好", + voice_provider="cosyvoice", + voice_id="longxiaochun_v3", + voice_name="龙小淳", + ) + assert item.voice_provider == "cosyvoice" + assert item.voice_id == "longxiaochun_v3" + assert item.voice_name == "龙小淳" + + def test_with_audio_info(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="n", + audio_url="https://example.com/audio.wav", + duration=15.5, + file_size=102400, + ) + assert item.audio_url == "https://example.com/audio.wav" + assert item.duration == 15.5 + assert item.file_size == 102400 + + def test_with_project_id(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="n", + project_id="proj-123", + ) + assert item.project_id == "proj-123" + + def test_status_values(self): + for status in ["pending", "processing", "completed", "failed"]: + item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status=status) + assert item.status == status + + def test_with_tags(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="n", + tags=["温柔", "女声", "解说"], + ) + assert len(item.tags) == 3 + assert "温柔" in item.tags + + def test_with_metadata(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="n", + metadata_={"speed": 1.0, "pitch": 0.5}, + ) + assert item.metadata_["speed"] == 1.0 + assert item.metadata_["pitch"] == 0.5 + + def test_has_timestamps(self): + item = VoiceLibraryItem(id="v1", user_id="u1", name="n") + assert item.created_at is not None + assert item.updated_at is not None diff --git a/tests/unit/test_voice_presets_domain.py b/tests/unit/test_voice_presets_domain.py new file mode 100755 index 000000000..e18f6b6fa --- /dev/null +++ b/tests/unit/test_voice_presets_domain.py @@ -0,0 +1,231 @@ +"""voice_presets 模块单元测试.""" + +import pytest +from domain.voice_presets import ( + MOCK_VOICES, + VoiceGender, + VoicePreset, + VoiceStyle, + get_default_voice, + get_voice, + list_voices, +) + + +class TestVoiceGender: + """VoiceGender 枚举测试.""" + + def test_values(self): + assert VoiceGender.MALE == "male" + assert VoiceGender.FEMALE == "female" + assert VoiceGender.CHILD == "child" + + def test_is_str_enum(self): + # StrEnum 在不同 Python 版本 str() 行为可能不同(3.11+ 返回值,旧版自定义 StrEnum 可能返回类名) + # 用 value 比较更稳妥 + assert isinstance(VoiceGender.FEMALE, str) + assert VoiceGender.MALE.value == "male" + assert VoiceGender.FEMALE.value == "female" + + +class TestVoiceStyle: + """VoiceStyle 枚举测试.""" + + def test_values(self): + assert VoiceStyle.STABLE == "stable" + assert VoiceStyle.LIVELY == "lively" + assert VoiceStyle.CUSTOMER_SERVICE == "customer_service" + assert VoiceStyle.NARRATION == "narration" + assert VoiceStyle.NEWS == "news" + assert VoiceStyle.STORY == "story" + + +class TestVoicePreset: + """VoicePreset 数据类测试.""" + + def test_create_with_required_fields(self): + v = VoicePreset(voice_id="test_001", name="测试音色") + assert v.voice_id == "test_001" + assert v.name == "测试音色" + # 默认值 + assert v.gender == VoiceGender.FEMALE + assert v.style == VoiceStyle.NARRATION + assert v.provider == "mock" + assert v.default_speed == 1.0 + assert v.default_pitch == 0.0 + assert v.sample_rate == 22050 + assert v.language == "zh-CN" + + def test_create_with_all_fields(self): + v = VoicePreset( + voice_id="male_news", + name="新闻男声", + gender=VoiceGender.MALE, + style=VoiceStyle.NEWS, + description="字正腔圆", + provider="aliyun", + provider_voice_id="zhiqiang", + default_speed=0.9, + default_pitch=1.0, + sample_rate=16000, + language="zh-CN", + ) + assert v.gender == VoiceGender.MALE + assert v.style == VoiceStyle.NEWS + assert v.provider == "aliyun" + assert v.default_speed == 0.9 + assert v.sample_rate == 16000 + + def test_slots(self): + """dataclass slots=True,不能添加新属性.""" + v = VoicePreset(voice_id="test", name="测试") + with pytest.raises(AttributeError): + v.new_field = "value" # type: ignore[attr-defined] + + +class TestMockVoices: + """MOCK_VOICES 预设列表测试.""" + + def test_not_empty(self): + assert len(MOCK_VOICES) > 0 + + def test_all_have_unique_voice_id(self): + ids = [v.voice_id for v in MOCK_VOICES] + assert len(ids) == len(set(ids)), "voice_id 不能重复" + + def test_all_are_voice_preset_instances(self): + for v in MOCK_VOICES: + assert isinstance(v, VoicePreset) + assert v.provider == "mock" + + def test_contains_expected_voices(self): + ids = {v.voice_id for v in MOCK_VOICES} + assert "female_warm" in ids + assert "male_stable" in ids + assert "female_lively" in ids + assert "child_cute" in ids + + def test_voice_genders_coverage(self): + genders = {v.gender for v in MOCK_VOICES} + assert VoiceGender.FEMALE in genders + assert VoiceGender.MALE in genders + assert VoiceGender.CHILD in genders + + +class TestGetVoice: + """get_voice 函数测试.""" + + def test_existing_mock_voice(self): + v = get_voice("female_warm") + assert v is not None + assert v.voice_id == "female_warm" + assert v.name == "温暖女声" + + def test_nonexistent_voice(self): + assert get_voice("nonexistent") is None + + def test_provider_mock(self): + v = get_voice("male_stable", provider="mock") + assert v is not None + assert v.voice_id == "male_stable" + + def test_unknown_provider_returns_none(self): + assert get_voice("female_warm", provider="aliyun") is None + + def test_empty_string_returns_none(self): + assert get_voice("") is None + + +class TestListVoices: + """list_voices 函数测试.""" + + def test_no_filters_returns_all(self): + result = list_voices() + assert len(result) == len(MOCK_VOICES) + + def test_filter_by_gender_female(self): + result = list_voices(gender="female") + assert len(result) > 0 + for v in result: + assert v.gender == VoiceGender.FEMALE + + def test_filter_by_gender_male(self): + result = list_voices(gender="male") + assert len(result) > 0 + for v in result: + assert v.gender == VoiceGender.MALE + + def test_filter_by_gender_child(self): + result = list_voices(gender="child") + assert len(result) > 0 + for v in result: + assert v.gender == VoiceGender.CHILD + + def test_filter_by_style(self): + result = list_voices(style="story") + assert len(result) > 0 + for v in result: + assert v.style == VoiceStyle.STORY + + def test_filter_by_style_narration(self): + result = list_voices(style="narration") + assert len(result) >= 1 + + def test_filter_by_unknown_provider_returns_empty(self): + result = list_voices(provider="aliyun") + assert result == [] + + def test_filter_by_keyword_name(self): + result = list_voices(keyword="男声") + assert len(result) > 0 + for v in result: + assert "男声" in v.name or "男声" in v.description + + def test_filter_by_keyword_description(self): + result = list_voices(keyword="vlog") + assert len(result) > 0 + + def test_filter_by_keyword_voice_id(self): + result = list_voices(keyword="female") + assert len(result) > 0 + for v in result: + assert "female" in v.voice_id.lower() + + def test_filter_by_keyword_case_insensitive(self): + r1 = list_voices(keyword="FEMALE") + r2 = list_voices(keyword="female") + assert len(r1) == len(r2) + + def test_filter_keyword_no_match(self): + result = list_voices(keyword="xyz_nonexistent_keyword") + assert result == [] + + def test_combined_filters_gender_and_style(self): + result = list_voices(gender="female", style="story") + assert len(result) > 0 + for v in result: + assert v.gender == VoiceGender.FEMALE + assert v.style == VoiceStyle.STORY + + def test_combined_filters_gender_and_keyword(self): + result = list_voices(gender="male", keyword="新闻") + assert len(result) > 0 + for v in result: + assert v.gender == VoiceGender.MALE + + def test_combined_no_match(self): + result = list_voices(gender="child", style="news") + # 童声没有新闻风格 + assert result == [] + + +class TestGetDefaultVoice: + """get_default_voice 函数测试.""" + + def test_returns_first_mock_voice(self): + v = get_default_voice() + assert v == MOCK_VOICES[0] + assert v.voice_id == "female_warm" + + def test_returns_voice_preset(self): + assert isinstance(get_default_voice(), VoicePreset) diff --git a/tests/unit/test_wechat_login_and_verification.py b/tests/unit/test_wechat_login_and_verification.py index 7a85ad681..9cd55481c 100755 --- a/tests/unit/test_wechat_login_and_verification.py +++ b/tests/unit/test_wechat_login_and_verification.py @@ -271,8 +271,49 @@ class TestWechatOAuthService: from packages.application.auth.wechat_oauth_service import WechatOAuthService service = WechatOAuthService(app_id="", app_secret="", redirect_uri="") - user_info, err = service.handle_callback("test_code", "test_state") + # 先生成授权 URL 获得有效 state(state 会被存入 store) + _, valid_state = service.generate_auth_url() + user_info, err = service.handle_callback("test_code", valid_state) assert err is None assert user_info is not None assert "mock" in user_info.openid assert user_info.nickname == "微信测试用户" + + def test_callback_invalid_state_rejected(self): + """无效 state 应被拒绝(CSRF 防护)""" + from packages.application.auth.wechat_oauth_service import WechatOAuthService + + service = WechatOAuthService(app_id="", app_secret="", redirect_uri="") + # 直接用随机 state 调用,未经过 generate_auth_url + user_info, err = service.handle_callback("test_code", "random_fake_state") + assert err is not None + assert "state" in err + assert user_info is None + + def test_callback_state_single_use(self): + """state 只能使用一次(防重放)""" + from packages.application.auth.wechat_oauth_service import WechatOAuthService + + service = WechatOAuthService(app_id="", app_secret="", redirect_uri="") + _, valid_state = service.generate_auth_url() + + # 第一次使用:成功 + user_info, err = service.handle_callback("test_code", valid_state) + assert err is None + assert user_info is not None + + # 第二次使用相同 state:失败(已被消费) + user_info2, err2 = service.handle_callback("test_code", valid_state) + assert err2 is not None + assert "state" in err2 + assert user_info2 is None + + def test_callback_empty_state_rejected(self): + """空 state 应被拒绝""" + from packages.application.auth.wechat_oauth_service import WechatOAuthService + + service = WechatOAuthService(app_id="", app_secret="", redirect_uri="") + user_info, err = service.handle_callback("test_code", "") + assert err is not None + assert "state" in err + assert user_info is None