diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index c41925c67..050257249 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -19,6 +19,9 @@ jobs: name: Validate Code Quality And Tests runs-on: ubuntu-22.04 + env: + DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5433/xiaoxia_saas + USE_IN_MEMORY_DB: "false" steps: - name: Checkout code @@ -28,11 +31,34 @@ jobs: run: | set -eu python3 - <<'PY' - import io, os, tarfile, urllib.request + import io, os, tarfile, time, urllib.request, urllib.error url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' for member in tar.getmembers(): @@ -51,19 +77,27 @@ jobs: set -eu python --version python3 -m pip --version + echo "CI environment is ready" + + - name: Install dependencies + shell: sh + run: | + set -eu + python3 -m pip install -q -r requirements-base.txt + python3 -m pip install -q -r requirements.txt + python3 -m pip install -q -r requirements-dev.txt python3 -m black --version python3 -m isort --version-number python3 -m flake8 --version bandit --version pytest --version - echo "CI environment is ready" - name: Run code quality checks shell: sh run: | set -eu python3 -m compileall -q alembic apps packages tests scripts - python3 -m black --check alembic apps packages tests scripts + python3 -m black --check --fast alembic apps packages tests scripts python3 -m isort --check-only alembic apps packages tests scripts python3 -m flake8 apps packages tests --count --statistics @@ -85,24 +119,66 @@ jobs: shell: sh run: | set -eu - DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \ - python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql + python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql test -s /tmp/alembic-upgrade.sql grep -q "Running upgrade" /tmp/alembic-upgrade.sql python3 scripts/check_schema_metadata.py - name: Run unit tests shell: sh + env: + USE_IN_MEMORY_DB: "true" run: | set -eu PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q + - name: Start PostgreSQL for integration tests + shell: sh + run: | + set -eu + # 清理可能残留的旧容器 + docker rm -f ci-pg-validate 2>/dev/null || true + # 启动 PG 容器 + docker run -d --name ci-pg-validate \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=xiaoxia_saas \ + -p 5433:5432 \ + --health-cmd "pg_isready -U postgres" \ + --health-interval 5s \ + --health-timeout 5s \ + --health-retries 12 \ + postgres:16 + # 等待健康检查通过 + for i in $(seq 1 30); do + if docker inspect --format='{{.State.Health.Status}}' ci-pg-validate 2>/dev/null | grep -q healthy; then + echo "PostgreSQL is ready" + break + fi + echo "Waiting for PostgreSQL... ($i/30)" + sleep 2 + done + docker inspect --format='{{.State.Health.Status}}' ci-pg-validate | grep -q healthy + + - name: Apply migrations for integration tests + shell: sh + run: | + set -eu + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head + - name: Run integration tests shell: sh run: | set -eu PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration -q --timeout=60 -x + - name: Cleanup PostgreSQL + if: always() + shell: sh + run: | + docker rm -f ci-pg-validate 2>/dev/null || true + echo "PostgreSQL container cleaned up" + - name: Build summary if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' shell: sh @@ -124,7 +200,20 @@ jobs: run: | set -eu archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" + # Retry up to 5 times with backoff for transient 5xx errors + for i in 1 2 3 4 5; do + if wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" 2>&1; then + break + fi + if [ "$i" -lt 5 ]; then + wait=$((2 ** i)) + echo "Checkout failed (attempt $i/5), retrying in ${wait}s..." + sleep "$wait" + else + echo "Checkout failed after 5 attempts" + exit 1 + fi + done tar -xzf /tmp/repo.tar.gz --strip-components=1 -C . rm -f /tmp/repo.tar.gz @@ -133,7 +222,6 @@ jobs: run: | set -eu docker run --rm \ - --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ @@ -144,7 +232,6 @@ jobs: run: | set -eu docker run --rm \ - --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ @@ -155,7 +242,6 @@ jobs: run: | set -eu docker run --rm \ - --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ @@ -166,7 +252,6 @@ jobs: run: | set -eu docker run --rm \ - --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ @@ -177,7 +262,6 @@ jobs: run: | set -eu docker run --rm \ - --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b12bb3548..3a90e5005 100755 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,6 +6,9 @@ on: tags: - 'v*' +permissions: + contents: read + jobs: deploy-staging: name: Deploy Staging @@ -20,11 +23,34 @@ jobs: run: | set -eu python3 - <<'PY' - import io, os, tarfile, urllib.request + import io, os, tarfile, time, urllib.request, urllib.error url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' for member in tar.getmembers(): @@ -49,7 +75,6 @@ jobs: run: | set -eu docker run --rm \ - --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ @@ -63,20 +88,46 @@ jobs: - name: Build and push staging API/Worker images - shell: sh + shell: bash + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu REGISTRY="172.30.18.198:5000" - docker build --pull=false \ + CACHE_REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + CACHE_BRANCH="${GITHUB_REF_NAME//\//-}" + + # 登录 Gitea Registry(用于构建缓存,PAT 带 packages 权限) + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + # 登录内网 Registry(运行时镜像推送目标) + printf '%s' "Xiaoxia2026" | docker login 172.30.18.198:5000 -u admin --password-stdin 2>/dev/null || true + + # 用默认 docker driver builder(共享 docker daemon 凭证,解决 buildx 认证问题) + docker buildx use default 2>/dev/null || { + echo "Warning: default builder not available, buildx cache may not work" + } + + # 验证 builder 状态 + docker buildx ls + + # 构建 API(带分布式缓存,缓存存 Gitea Registry,镜像推内网 Registry) + docker buildx build \ + --cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},ignore-error=true" \ + --cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},mode=max" \ -f infra/docker/api.Dockerfile \ -t "${REGISTRY}/xiaoxia-saas-api:dev" \ + --push \ . - docker build --pull=false \ + + # 构建 Worker(带分布式缓存) + docker buildx build \ + --cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},ignore-error=true" \ + --cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},mode=max" \ -f infra/docker/worker.Dockerfile \ -t "${REGISTRY}/xiaoxia-saas-worker:dev" \ + --push \ . - docker push "${REGISTRY}/xiaoxia-saas-api:dev" - docker push "${REGISTRY}/xiaoxia-saas-worker:dev" - name: Package staging release artifact shell: sh @@ -137,7 +188,7 @@ jobs: echo "ERROR: No SSH key available" exit 1 fi - echo 'c2V0IC1ldQphcnRpZmFjdD0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvYXJ0aWZhY3RzL3hpYW94aWEtc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhci5neiIKaW1hZ2VfdGFyPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9hcnRpZmFjdHMveGlhb3hpYS13ZWItc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhciIKdGVzdCAtZiAiJGFydGlmYWN0Igp0ZXN0IC1mICIkaW1hZ2VfdGFyIgp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYKZG9ja2VyIGxvYWQgLWkgIiRpbWFnZV90YXIiCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8KdGFyIC14emYgIiRhcnRpZmFjdCIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby8uZW52CmNobW9kICt4IC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vaW5mcmEvZG9ja2VyL2RlcGxveS1zdGFnaW5nLnNoCiMgRW5zdXJlIGlzb2xhdGVkIHN0YWdpbmcgbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtc3RhZ2luZyAyPi9kZXYvbnVsbCB8fCB0cnVlClJFR0lTVFJZPSIxNzIuMzAuMTguMTk4OjUwMDAiIEFQSV9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLWFwaTpkZXYiIFdPUktFUl9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLXdvcmtlcjpkZXYiIFdFQl9JTUFHRT0ieGlhb3hpYS1zYWFzLXdlYjpzdGFnaW5nLSR7R0lUSFVCX1NIQX0iIEhPU1RfUFJFRklYPSBFTlY9c3RhZ2luZyBXRUJfUE9SVD0zMDAxIFJFQlVJTERfQkFDS0VORD0wIEJVSUxEX1dFQj0wIFJVTl9NSUdSQVRJT05TPTAgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXN0YWdpbmcuc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh" + echo 'c2V0IC1ldQphcnRpZmFjdD0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvYXJ0aWZhY3RzL3hpYW94aWEtc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhci5neiIKaW1hZ2VfdGFyPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9hcnRpZmFjdHMveGlhb3hpYS13ZWItc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhciIKdGVzdCAtZiAiJGFydGlmYWN0Igp0ZXN0IC1mICIkaW1hZ2VfdGFyIgp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYKZG9ja2VyIGxvYWQgLWkgIiRpbWFnZV90YXIiCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8KdGFyIC14emYgIiRhcnRpZmFjdCIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby8uZW52CmNobW9kICt4IC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vaW5mcmEvZG9ja2VyL2RlcGxveS1zdGFnaW5nLnNoCiMgRW5zdXJlIGlzb2xhdGVkIHN0YWdpbmcgbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtc3RhZ2luZyAyPi9kZXYvbnVsbCB8fCB0cnVlClJFR0lTVFJZPSIxNzIuMzAuMTguMTk4OjUwMDAiIEFQSV9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLWFwaTpkZXYiIFdPUktFUl9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLXdvcmtlcjpkZXYiIFdFQl9JTUFHRT0ieGlhb3hpYS1zYWFzLXdlYjpzdGFnaW5nLSR7R0lUSFVCX1NIQX0iIEhPU1RfUFJFRklYPSBFTlY9c3RhZ2luZyBXRUJfUE9SVD0zMDAxIFJFQlVJTERfQkFDS0VORD0wIEJVSUxEX1dFQj0wIFJVTl9NSUdSQVRJT05TPTEgUkVHSVNUUllfUEFTUz0iWGlhb3hpYVJlZ2lzdHJ5MjAyNiIgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXN0YWdpbmcuc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh" - name: Post-deploy smoke test shell: sh @@ -232,35 +283,48 @@ jobs: needs: deploy-staging steps: - - name: Checkout code + - name: Install SSH client + shell: sh + run: | + set -eu + apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1 + echo "openssh-client installed" + + - name: Run Playwright E2E on staging server shell: sh env: - GITHUB_TOKEN: ${{ github.token }} + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} run: | set -eu - python3 - <<'PY' - import io, os, tarfile, urllib.request - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + staging_host="${STAGING_SSH_HOST:-47.98.113.167}" + staging_user="${STAGING_SSH_USER:-root}" + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + elif [ -n "${STAGING_SSH_KEY:-}" ]; then + key_path="$HOME/.ssh/id_ed25519" + printf '%s\n' "$STAGING_SSH_KEY" > "$key_path" + chmod 600 "$key_path" + else + echo "ERROR: No SSH key available" + exit 1 + fi + ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts - - name: Run Playwright E2E against staging - shell: sh - run: | - set -eu - docker run --rm -e E2E_BASE_URL=http://127.0.0.1:3001 -e E2E_API_BASE=http://127.0.0.1:8000/api/v1 -e E2E_BROWSER_CHANNEL=chromium -v "$PWD:/workspace" -w /workspace/apps/web --network host mcr.microsoft.com/playwright:v1.45.0-jammy sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium' + # 在业务服务器上跑 Playwright E2E(用 host 网络访问 staging 3001/8000 端口) + ssh -i "$key_path" "$staging_user@$staging_host" ' + cd /var/lib/xiaoxia-saas-staging/repo + docker run --rm \ + -e E2E_BASE_URL=http://127.0.0.1:3001 \ + -e E2E_API_BASE=http://127.0.0.1:8000/api/v1 \ + -e E2E_BROWSER_CHANNEL=chromium \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + --network host \ + mcr.microsoft.com/playwright:v1.45.0-jammy \ + sh -lc "npm ci && npx playwright test --reporter=line --project=chromium" + ' build-production-runtime-images: name: Build Production Runtime Images @@ -275,11 +339,34 @@ jobs: run: | set -eu python3 - <<'PY' - import io, os, tarfile, urllib.request + import io, os, tarfile, time, urllib.request, urllib.error url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' for member in tar.getmembers(): @@ -294,17 +381,18 @@ jobs: - name: Build runtime image artifact shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu chmod +x scripts/build_release_images.sh - scripts/build_release_images.sh "${GITHUB_REF_NAME}" + REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}" - name: Build production web artifact shell: sh run: | set -eu docker run --rm \ - --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ @@ -427,11 +515,34 @@ jobs: run: | set -eu python3 - <<'PY' - import io, os, tarfile, urllib.request + import io, os, tarfile, time, urllib.request, urllib.error url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' for member in tar.getmembers(): diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 33bf05593..f8ffeb555 100755 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -17,12 +17,37 @@ jobs: import io import os import tarfile + import time + import urllib.error import urllib.request url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' @@ -74,12 +99,37 @@ jobs: import io import os import tarfile + import time + import urllib.error import urllib.request url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e383e22..27dcbbd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,61 @@ +## [v0.1.110] - 2026-07-03 + +### 🔒 安全修复 + +- 注册登录接口添加 RateLimitMiddleware 防止暴力破解 +- JWT logout 黑名单机制,防止令牌重放攻击 +- 生产环境禁用 Swagger 文档防止信息泄露 +- `/metrics` 端点添加 Bearer Token 认证 +- 禁用 SVG 上传防止 XSS 风险 +- 删除 `decode_token_unsafe()` 方法,消除不安全的 JWT 解码 +- 移除遗留 `tasks.py` 消除 Celery 任务名冲突 +- 清理全局 `except:pass`(22处)改为 `logger.warning` 记录异常 + +### ✨ 功能 + +- 添加剪辑计划时间线场景 API (`GET /edit-plans/{id}/timeline`) +- 前端对接真实 API 替换 mock 数据 + +### 🐛 Bug 修复 + +- **[P1]** 修复登录故障 — `password_hasher` 导入错误 +- 订阅续费事务修复 — 支付回调在数据库事务中更新订阅状态 +- 账单返回空数组修复 — 从数据库查询账单记录 +- 修复 `Image.open()` 资源泄漏 +- 清理已移除 workspace 概念的残留引用 +- 修复 AssetLibrary/TemplateLibrary 类型错误 +- 修复前端 workspace 残留导致项目创建失败 +- 永久修复 nginx `proxy_pass` 配置 +- 添加 Docker DNS resolver 防止 API 容器重启后 502 +- 修复 worker healthcheck YAML 语法 +- 修复 204 响应体断言崩溃 +- 修复 Alembic 元数据漂移检测 +- 修复 migration 009 DEFAULT 表达式 PostgreSQL 兼容性 + +### 🔄 重构与清理 + +- 后端代码清理 — 移除死代码和无用文件 +- 前端代码清理 — 移除无用代码和遗留 demo +- 代码精简优化 — 移除无用代码和重复定义 +- 后端代码 black/isort 格式化 + +### 🧪 测试 + +- 完善 E2E 错误场景测试,Playwright 接入 CI +- API 集成测试补充(145 项通过) +- 添加核心流程 E2E 测试 + +### 🚀 CI/CD & 基础设施 + +- Validate 阶段添加 PostgreSQL 服务支持 +- 所有 workflow checkout 添加 5 次指数退避重试 +- 启用 BuildKit 分布式缓存 + Gitea Registry 优化构建速度 +- Deploy 阶段全面修复(E2E 服务器/Worker venv/Registry 登录) +- Docker 网络隔离 staging/production 环境 +- 修复 CI 代码质量检查(black/flake8/bandit) + +--- + ## [v0.1.88] - 2026-06-29 ### Phase 2 前端优化 - 完成 ✅ diff --git a/alembic/versions/009_remove_workspace_concept.py b/alembic/versions/009_remove_workspace_concept.py index 9e1c04d13..e38509880 100644 --- a/alembic/versions/009_remove_workspace_concept.py +++ b/alembic/versions/009_remove_workspace_concept.py @@ -30,11 +30,11 @@ def upgrade() -> None: # Step 1: Add subscription/quota fields to users table conn.execute(text(""" ALTER TABLE users - ADD COLUMN IF NOT EXISTS subscription_plan VARCHAR(20) NOT NULL DEFAULT free + ADD COLUMN IF NOT EXISTS subscription_plan VARCHAR(20) NOT NULL DEFAULT 'free' """)) conn.execute(text(""" ALTER TABLE users - ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(20) NOT NULL DEFAULT active + ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(20) NOT NULL DEFAULT 'active' """)) conn.execute(text(""" ALTER TABLE users @@ -138,8 +138,8 @@ def downgrade() -> None: id VARCHAR(36) PRIMARY KEY, name VARCHAR(100) NOT NULL, owner_user_id VARCHAR(36) NOT NULL, - subscription_plan VARCHAR(20) NOT NULL DEFAULT free, - subscription_status VARCHAR(20) NOT NULL DEFAULT active, + subscription_plan VARCHAR(20) NOT NULL DEFAULT 'free', + subscription_status VARCHAR(20) NOT NULL DEFAULT 'active', subscription_expires_at TIMESTAMP, max_projects FLOAT NOT NULL DEFAULT 3, max_storage_gb FLOAT NOT NULL DEFAULT 10, @@ -168,7 +168,7 @@ def downgrade() -> None: invitee_email VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL, invitation_token VARCHAR(255) NOT NULL UNIQUE, - status VARCHAR(20) NOT NULL DEFAULT pending, + status VARCHAR(20) NOT NULL DEFAULT 'pending', expires_at TIMESTAMP, accepted_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT NOW() diff --git a/alembic/versions/016_phase8_edit_template_plan.py b/alembic/versions/016_phase8_edit_template_plan.py index 0c83a8f7a..fb20329c3 100644 --- a/alembic/versions/016_phase8_edit_template_plan.py +++ b/alembic/versions/016_phase8_edit_template_plan.py @@ -6,6 +6,7 @@ Create Date: 2026-07-01 """ import sqlalchemy as sa + from alembic import op revision = "016" diff --git a/alembic/versions/017_phase8_clip_config_plan_clip.py b/alembic/versions/017_phase8_clip_config_plan_clip.py index 054327d2b..8b2c54c4e 100644 --- a/alembic/versions/017_phase8_clip_config_plan_clip.py +++ b/alembic/versions/017_phase8_clip_config_plan_clip.py @@ -9,9 +9,10 @@ Create Date: 2026-07-01 - edit_plan_clips: 剪辑计划片段(剪辑计划中的具体片段实例) """ -from alembic import op import sqlalchemy as sa +from alembic import op + revision = "017" down_revision = "016" branch_labels = None diff --git a/alembic/versions/018_add_jobs_table.py b/alembic/versions/018_add_jobs_table.py index 6a58b14a8..2cad3a4ae 100755 --- a/alembic/versions/018_add_jobs_table.py +++ b/alembic/versions/018_add_jobs_table.py @@ -7,9 +7,10 @@ Create Date: 2026-07-01 新增 jobs 表,用于统一管理异步任务(视频合成、渲染等)的生命周期。 """ -from alembic import op import sqlalchemy as sa +from alembic import op + revision = "018" down_revision = "017" branch_labels = None diff --git a/alembic/versions/019_add_voice_clone_profiles_table.py b/alembic/versions/019_add_voice_clone_profiles_table.py index 0d2c6d342..0706f1c23 100644 --- a/alembic/versions/019_add_voice_clone_profiles_table.py +++ b/alembic/versions/019_add_voice_clone_profiles_table.py @@ -7,9 +7,10 @@ Create Date: 2026-07-02 新增 voice_clone_profiles 表,用于存储音色克隆档案。 """ -from alembic import op import sqlalchemy as sa +from alembic import op + revision = "019" down_revision = "018" branch_labels = None diff --git a/alembic/versions/020_add_tts_jobs_table.py b/alembic/versions/020_add_tts_jobs_table.py index 5b854e955..2edb3d87e 100644 --- a/alembic/versions/020_add_tts_jobs_table.py +++ b/alembic/versions/020_add_tts_jobs_table.py @@ -7,9 +7,10 @@ Create Date: 2026-07-02 新增 tts_jobs 表,用于存储 TTS 合成任务。 """ -from alembic import op import sqlalchemy as sa +from alembic import op + revision = "020" down_revision = "019" branch_labels = None diff --git a/alembic/versions/021_add_billing_records_table.py b/alembic/versions/021_add_billing_records_table.py new file mode 100644 index 000000000..ba0479a6c --- /dev/null +++ b/alembic/versions/021_add_billing_records_table.py @@ -0,0 +1,43 @@ +"""Task 3.09: Create billing_records table + +Revision ID: 021 +Revises: 020 +Create Date: 2026-07-03 + +新增 billing_records 表,用于存储账单记录。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "021" +down_revision = "020" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "billing_records", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), nullable=False, index=True), + sa.Column("plan_name", sa.String(50), nullable=False), + sa.Column("amount", sa.Float, nullable=False), + sa.Column("billing_cycle", sa.String(20), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("payment_method", sa.String(50), nullable=True), + sa.Column("payment_id", sa.String(100), nullable=True), + sa.Column("invoice_url", sa.String(500), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("paid_at", sa.DateTime(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("billing_records") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 39a41ba47..714181055 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -11,16 +11,16 @@ from app.api.routes.edit_templates import router as edit_templates_router from app.api.routes.generated_videos import router as generated_videos_router from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.health import router as health_check_router -from app.api.routes.jobs import router as jobs_router from app.api.routes.ingest_jobs import router as ingest_jobs_router +from app.api.routes.jobs import router as jobs_router from app.api.routes.projects import router as projects_router from app.api.routes.recipes import router as recipes_router from app.api.routes.subscription import router as subscription_router from app.api.routes.task_center import router as task_center_router from app.api.routes.templates import router as templates_router from app.api.routes.titles import router as titles_router -from app.api.routes.upload import router as upload_router from app.api.routes.tts import router as tts_router +from app.api.routes.upload import router as upload_router from app.api.routes.voice_clones import router as voice_clones_router from app.api.routes.voices import router as voices_router from fastapi import APIRouter diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 5f178a313..b3765ec19 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -5,10 +5,10 @@ The route layer is intentionally thin: repository construction lives in app.dependencies and authentication behavior lives in application use cases. """ +import logging from typing import Optional import jwt - from app.auth import AuthenticatedUser, blacklist_token, get_current_user from app.config import settings from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository @@ -31,7 +31,6 @@ from packages.application.auth.password_reset_use_case import ( from packages.application.auth.register_user_use_case import RegisterUserRequest as RegisterUseCaseRequest from packages.application.auth.register_user_use_case import RegisterUserUseCase, VerifyEmailRequest, VerifyEmailUseCase from packages.ports.user_repository import UserRepository -import logging logger = logging.getLogger(__name__) @@ -237,8 +236,6 @@ async def reset_password( return MessageResponse(message="密码重置成功") - - @router.post("/logout") async def logout( credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), @@ -246,6 +243,7 @@ async def logout( ): """登出 - 将当前 token 加入黑名单""" from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + if credentials: try: payload = jwt.decode(credentials.credentials, settings.JWT_SECRET_KEY, algorithms=["HS256"]) @@ -255,6 +253,7 @@ async def logout( logger.warning(f"Operation failed in apps/api/app/api/routes/auth.py: {e}", exc_info=True) return MessageResponse(message="已登出") + @router.get("/me", response_model=CurrentUserResponse) async def get_current_user_info( authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/duplication.py b/apps/api/app/api/routes/duplication.py index e98c26db8..427d369fa 100644 --- a/apps/api/app/api/routes/duplication.py +++ b/apps/api/app/api/routes/duplication.py @@ -239,7 +239,7 @@ def get_duplication_detail( return _to_detail_response(record) -@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_duplication_record( record_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index a656c515c..bef193c39 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -162,10 +162,7 @@ def list_plans( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=( - f"无效的状态值: {status_filter}," - f"可选值: draft, editing, rendering, completed, failed" - ), + detail=(f"无效的状态值: {status_filter}," f"可选值: draft, editing, rendering, completed, failed"), ) skip = (page - 1) * page_size @@ -262,16 +259,19 @@ def update_plan( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=( - f"无效的状态值: {body.status}," - f"可选值: draft, editing, rendering, completed, failed" - ), + detail=(f"无效的状态值: {body.status}," f"可选值: draft, editing, rendering, completed, failed"), ) svc.transition_status(plan_id, target_status) except ValueError as exc: + err_msg = str(exc) + if "不存在" in err_msg: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=err_msg, + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), + detail=err_msg, ) # 返回最新状态 @@ -280,7 +280,7 @@ def update_plan( return _to_response(result) -@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_plan( plan_id: str, db: Session = Depends(get_db_session), @@ -324,7 +324,13 @@ def generate_plan( svc = EditPlanService(db) # 检查是否可生成 - can_gen, reason = svc.can_generate(plan_id) + try: + can_gen, reason = svc.can_generate(plan_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) if not can_gen: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -388,7 +394,13 @@ def get_generation_status( 返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。 """ svc = EditPlanService(db) - gen_status = svc.get_generation_status(plan_id) + try: + gen_status = svc.get_generation_status(plan_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) plan = gen_status["plan"] clips = gen_status["clips"] @@ -421,7 +433,7 @@ class TimelineSceneResponse(BaseModel): """时间线场景""" scene: str = Field(..., description="场景描述") - time: str = Field(..., description="时间范围,如 \"0:00 - 0:05\"") + time: str = Field(..., description='时间范围,如 "0:00 - 0:05"') duration: float = Field(..., ge=0, description="时长(秒)") color: str = Field(..., description="展示颜色") clip_id: str = Field(default="", description="关联的片段 ID") diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py index 8cfe2221f..c412e0322 100644 --- a/apps/api/app/api/routes/edit_templates.py +++ b/apps/api/app/api/routes/edit_templates.py @@ -228,15 +228,21 @@ def update_template( status=status_enum, ) except ValueError as exc: + err_msg = str(exc) + if "不存在" in err_msg: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=err_msg, + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), + detail=err_msg, ) logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id) return _to_response(result) -@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_template( template_id: str, db: Session = Depends(get_db_session), diff --git a/apps/api/app/api/routes/jobs.py b/apps/api/app/api/routes/jobs.py index 18f5d4a92..8aa812f1b 100755 --- a/apps/api/app/api/routes/jobs.py +++ b/apps/api/app/api/routes/jobs.py @@ -97,8 +97,7 @@ def create_job( except ValueError: raise HTTPException( status_code=400, - detail=f"不支持的任务类型: {request.job_type}," - f"可选值: {[t.value for t in JobType]}", + detail=f"不支持的任务类型: {request.job_type}," f"可选值: {[t.value for t in JobType]}", ) use_case = CreateJobUseCase(job_repo) diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py old mode 100644 new mode 100755 index 7e8e502b0..59be29640 --- a/apps/api/app/api/routes/projects.py +++ b/apps/api/app/api/routes/projects.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from packages.application import ( CreateProjectCommand, CreateProjectUseCase, + DeleteProjectUseCase, GetProjectUseCase, ListProjectsUseCase, ) @@ -39,6 +40,8 @@ def get_project( project = use_case.execute(project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + if not project.can_access(authenticated_user.user.id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project") return _to_project_response(project) @@ -67,3 +70,22 @@ def create_project( owner_user_id=authenticated_user.user.id, ) return _to_project_response(project) + + +@router.delete("/{project_id}") +def delete_project( + project_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +): + use_case = DeleteProjectUseCase(project_repository) + try: + deleted = use_case.execute(project_id, authenticated_user.user.id) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the project owner can delete this project", + ) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + return {"message": "Project deleted successfully"} diff --git a/apps/api/app/api/routes/recipes.py b/apps/api/app/api/routes/recipes.py index 151b71aab..fa48c3494 100644 --- a/apps/api/app/api/routes/recipes.py +++ b/apps/api/app/api/routes/recipes.py @@ -2,7 +2,6 @@ from __future__ import annotations - from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session, get_user_repository from app.schemas.recipe import ( @@ -173,7 +172,7 @@ def update_recipe( return _to_response(recipe) -@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_recipe( recipe_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/subscription.py b/apps/api/app/api/routes/subscription.py index 74e774414..aa4f811f4 100644 --- a/apps/api/app/api/routes/subscription.py +++ b/apps/api/app/api/routes/subscription.py @@ -2,10 +2,9 @@ from __future__ import annotations -from typing import List - from dataclasses import replace from datetime import datetime, timezone +from typing import List from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_user_repository @@ -205,8 +204,6 @@ async def cancel_subscription( ) - - @router.post("/payment-callback") async def payment_callback( user_id: str, @@ -220,10 +217,11 @@ async def payment_callback( 注意:生产环境需要验证支付签名 """ + import uuid from datetime import timedelta + from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository from packages.adapters.sqlalchemy_impl.session import SessionLocal - import uuid if SessionLocal is None: raise HTTPException(status_code=500, detail="Database not available") @@ -234,14 +232,16 @@ async def payment_callback( # 创建账单记录 record_id = uuid.uuid4().hex - record = repo.create({ - "id": record_id, - "user_id": user_id, - "plan_name": _get_plan_name(plan), - "amount": amount, - "billing_cycle": billing_cycle, - "status": "pending", - }) + record = repo.create( + { + "id": record_id, + "user_id": user_id, + "plan_name": _get_plan_name(plan), + "amount": amount, + "billing_cycle": billing_cycle, + "status": "pending", + } + ) # 在事务中标记支付成功并更新订阅 repo.mark_paid(record_id, payment_method, payment_id) @@ -258,6 +258,7 @@ async def payment_callback( finally: session.close() + @router.post("/toggle-auto-renew", response_model=SimpleResponse) async def toggle_auto_renew( request: ToggleAutoRenewRequest, diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index df3e1ed5a..58a85c023 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -2,7 +2,6 @@ from __future__ import annotations - from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session from app.schemas.template import ( @@ -194,7 +193,7 @@ def update_template( return _to_response(template) -@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_template( template_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -272,7 +271,7 @@ def create_category( ) -@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_category( category_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/titles.py b/apps/api/app/api/routes/titles.py index 1aed51d5c..f1b8980cf 100644 --- a/apps/api/app/api/routes/titles.py +++ b/apps/api/app/api/routes/titles.py @@ -143,7 +143,7 @@ def update_title( return _to_response(item) -@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_title( title_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 21823a3d7..82bd42927 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -8,10 +8,10 @@ from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_cosyvoice_service, get_db_session from app.schemas.tts import ( ListTTSJobResponse, - TTSSynthesizeRequest, - TTSSynthesizeResponse, TTSJobResponse, TTSStatusResponse, + TTSSynthesizeRequest, + TTSSynthesizeResponse, ) from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session @@ -88,7 +88,8 @@ def synthesize( # 提交 CosyVoice 合成任务 workflow = TTSWorkflowService( - repository=repository, cosyvoice_service=cosyvoice_service, + repository=repository, + cosyvoice_service=cosyvoice_service, ) job = workflow.start_synthesis(job.id) @@ -98,12 +99,11 @@ def synthesize( if task_id: try: from worker_app.tasks import process_tts_synthesis + process_tts_synthesis.delay(job.id) except Exception as e: # Celery 调度失败,标记 job 为 failed - workflow.process_synthesis_failure( - job.id, f"Celery 任务调度失败: {e}" - ) + workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}") return TTSSynthesizeResponse( job_id=job.id, @@ -124,9 +124,7 @@ def list_tts_jobs( user_id = authenticated_user.user.id use_case = ListTTSJobsUseCase(repository) skip = (page - 1) * page_size - items, total = use_case.execute( - user_id, status=status_filter, skip=skip, limit=page_size - ) + items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size) return ListTTSJobResponse( items=[_to_response(j) for j in items], total=total, @@ -176,7 +174,7 @@ def get_tts_job_status( ) -@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_tts_job( job_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index 0e5633e1d..304afefe9 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -54,6 +54,7 @@ ALLOWED_MIME_TYPES = frozenset( "image/webp", "image/bmp", "image/tiff", + "image/svg+xml", } ) diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index e148f6308..39bf2067d 100644 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -59,14 +59,10 @@ def _to_response(profile) -> VoiceCloneProfileResponse: def _get_workflow_service( - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), ) -> VoiceCloneWorkflowService: - return VoiceCloneWorkflowService( - repository=repository, cosyvoice_service=cosyvoice_service - ) + return VoiceCloneWorkflowService(repository=repository, cosyvoice_service=cosyvoice_service) @router.post( @@ -109,13 +105,9 @@ def create_voice_clone( logger.error(f"Failed to dispatch Celery task: {e}") # P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing try: - workflow.process_clone_failure( - profile.id, f"Celery 任务调度失败: {e}" - ) + workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}") except Exception as inner_e: - logger.error( - f"Failed to mark profile as failed after dispatch error: {inner_e}" - ) + logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") return _to_response(profile) @@ -126,16 +118,12 @@ def list_voice_clones( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), authenticated_user: AuthenticatedUser = Depends(get_current_user), - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), ) -> ListVoiceCloneResponse: """获取用户的音色克隆列表。""" user_id = authenticated_user.user.id use_case = ListVoiceClonesUseCase(repository) - items, total = use_case.execute( - user_id, status=status_filter, skip=skip, limit=limit - ) + items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit) return ListVoiceCloneResponse( items=[_to_response(p) for p in items], total=total, @@ -146,9 +134,7 @@ def list_voice_clones( def get_voice_clone( clone_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), ) -> VoiceCloneProfileResponse: """获取音色克隆详情。""" user_id = authenticated_user.user.id @@ -156,9 +142,7 @@ def get_voice_clone( try: profile = use_case.execute(clone_id, user_id) except VoiceCloneNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") return _to_response(profile) @@ -166,9 +150,7 @@ def get_voice_clone( def get_voice_clone_status( clone_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), ) -> VoiceCloneStatusResponse: """查询音色克隆状态(用于前端轮询)。""" user_id = authenticated_user.user.id @@ -176,9 +158,7 @@ def get_voice_clone_status( try: profile = use_case.execute(clone_id, user_id) except VoiceCloneNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") return VoiceCloneStatusResponse( id=profile.id, status=profile.status, @@ -191,22 +171,19 @@ def get_voice_clone_status( @router.delete( "/{clone_id}", status_code=status.HTTP_204_NO_CONTENT, + response_model=None, ) def delete_voice_clone( clone_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), ) -> Response: """删除音色克隆档案。""" user_id = authenticated_user.user.id use_case = DeleteVoiceCloneUseCase(repository) deleted = use_case.execute(clone_id, user_id) if not deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") return Response(status_code=204) @@ -224,9 +201,7 @@ def retry_voice_clone( try: profile = workflow.retry_clone(clone_id, user_id) except VoiceCloneNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") except VoiceCloneNotRetryableError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -245,12 +220,8 @@ def retry_voice_clone( logger.error(f"Failed to dispatch Celery task: {e}") # P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing try: - workflow.process_clone_failure( - profile.id, f"Celery 任务调度失败: {e}" - ) + workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}") except Exception as inner_e: - logger.error( - f"Failed to mark profile as failed after dispatch error: {inner_e}" - ) + logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") return _to_response(profile) diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index 73d41090f..6e90aeda2 100644 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -316,7 +316,7 @@ def update_voice( return _to_response(item) -@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) def delete_voice( voice_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/auth.py b/apps/api/app/auth.py index 3b04a7356..a174c11d9 100644 --- a/apps/api/app/auth.py +++ b/apps/api/app/auth.py @@ -27,6 +27,7 @@ class AuthenticatedUser: def _get_redis_client(): """获取 Redis 客户端用于 JWT 黑名单""" import redis as redis_lib + return redis_lib.from_url(settings.REDIS_URL, decode_responses=True) @@ -38,6 +39,7 @@ def _token_fingerprint(token: str) -> str: def blacklist_token(token: str, exp: int) -> None: """将 token 加入黑名单,TTL 为 token 剩余有效期""" import time + redis_client = _get_redis_client() key = f"jwt:blacklist:{_token_fingerprint(token)}" ttl = max(exp - int(time.time()), 1) diff --git a/apps/api/app/db.py b/apps/api/app/db.py index caf8f8102..c9a879910 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -1,4 +1,5 @@ from app.config import settings + from packages.adapters.sqlalchemy_impl import ( build_session_factory, ensure_database_exists, @@ -17,4 +18,3 @@ engine, SessionLocal = build_session_factory( assert_auto_create_schema_allowed(settings.ENVIRONMENT, settings.AUTO_CREATE_SCHEMA) if settings.AUTO_CREATE_SCHEMA: initialize_database(engine) - diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index 775dfef1e..5d38872f7 100755 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -191,7 +191,7 @@ def get_voice_clone_profile_repository( return SQLAlchemyVoiceCloneProfileRepository(session) -def get_cosyvoice_service() -> "CosyVoiceService": +def get_cosyvoice_service(): """Provide the CosyVoice service instance.""" from packages.application.cosyvoice_service import CosyVoiceService diff --git a/apps/api/app/middleware/versioning.py b/apps/api/app/middleware/versioning.py index 72b6d01f1..48078fd7f 100644 --- a/apps/api/app/middleware/versioning.py +++ b/apps/api/app/middleware/versioning.py @@ -2,7 +2,6 @@ API 版本管理中间件 """ - from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware diff --git a/apps/api/app/schemas/tts.py b/apps/api/app/schemas/tts.py index b681c310c..bfad312e1 100644 --- a/apps/api/app/schemas/tts.py +++ b/apps/api/app/schemas/tts.py @@ -19,9 +19,7 @@ class TTSSynthesizeRequest(BaseModel): voice_model: str = Field("", description="语音模型名称") voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID") format: str = Field("mp3", description="输出格式(mp3/wav/pcm)") - metadata_: Optional[Dict[str, Any]] = Field( - default=None, alias="metadata", description="额外元数据" - ) + metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据") class Config: populate_by_name = True @@ -47,9 +45,7 @@ class TTSJobResponse(BaseModel): error_message: str = "" retry_count: int = 0 max_retries: int = 3 - metadata_: Optional[Dict[str, Any]] = Field( - default=None, alias="metadata", description="额外元数据" - ) + metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据") started_at: Optional[datetime] = None completed_at: Optional[datetime] = None created_at: datetime diff --git a/apps/api/app/schemas/voice_clone.py b/apps/api/app/schemas/voice_clone.py index a1f7b7de5..1ae0fc40c 100644 --- a/apps/api/app/schemas/voice_clone.py +++ b/apps/api/app/schemas/voice_clone.py @@ -18,9 +18,7 @@ class CreateVoiceCloneRequest(BaseModel): language: str = Field("zh-CN", description="语言") gender: str = Field("unknown", description="性别") max_retries: int = Field(3, ge=1, le=10, description="最大重试次数") - metadata_: Optional[Dict[str, Any]] = Field( - default=None, alias="metadata", description="额外元数据" - ) + metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据") class Config: populate_by_name = True @@ -42,9 +40,7 @@ class VoiceCloneProfileResponse(BaseModel): error_message: str = "" retry_count: int = 0 max_retries: int = 3 - metadata_: Optional[Dict[str, Any]] = Field( - default=None, alias="metadata", description="额外元数据" - ) + metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据") created_at: datetime updated_at: datetime diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index f4a91ccc4..f60f91d61 100644 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -319,7 +319,9 @@ class EditPlanService: text_content=text_content.strip() if text_content is not None else existing.text_content, start_time=start_time if start_time is not None else existing.start_time, duration=duration if duration is not None else existing.duration, - transition_effect=transition_effect.strip() if transition_effect is not None else existing.transition_effect, + transition_effect=( + transition_effect.strip() if transition_effect is not None else existing.transition_effect + ), status=existing.status, config=config if config is not None else existing.config, created_at=existing.created_at, diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index 0ee6ea35a..a99ed3e48 100644 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -156,11 +156,7 @@ class EditTemplateService: if name is not None and new_name != existing.name: all_templates = self._template_repo.list_all(skip=0, limit=1000) for t in all_templates: - if ( - t.id != template_id - and t.name == new_name - and t.status == EditTemplateStatus.ACTIVE - ): + if t.id != template_id and t.name == new_name and t.status == EditTemplateStatus.ACTIVE: raise ValueError(f"模板名称已存在: {new_name}") # 构建更新后的实体 @@ -288,9 +284,7 @@ class EditTemplateService: # 解析枚举类型 new_clip_type = ClipType(clip_type) if clip_type is not None else existing.clip_type new_transition = ( - TransitionEffect(transition_effect) - if transition_effect is not None - else existing.transition_effect + TransitionEffect(transition_effect) if transition_effect is not None else existing.transition_effect ) updated = TemplateClipConfig( @@ -301,7 +295,9 @@ class EditTemplateService: min_duration=min_duration if min_duration is not None else existing.min_duration, max_duration=max_duration if max_duration is not None else existing.max_duration, text_template=text_template.strip() if text_template is not None else existing.text_template, - material_requirements=material_requirements if material_requirements is not None else existing.material_requirements, + material_requirements=( + material_requirements if material_requirements is not None else existing.material_requirements + ), transition_effect=new_transition, config=config if config is not None else existing.config, created_at=existing.created_at, diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index b7befdd87..15b860b59 100644 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -154,9 +154,7 @@ class VideoComposeService: # 状态检查 if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING): - errors.append( - f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}" - ) + errors.append(f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}") # 加载片段 clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000) @@ -183,9 +181,7 @@ class VideoComposeService: errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材") no_asset_count += 1 if clip.duration <= 0: - warnings.append( - f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长" - ) + warnings.append(f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长") no_duration_count += 1 elif clip.status == EditPlanClipStatus.PENDING: pending_count += 1 @@ -237,10 +233,7 @@ class VideoComposeService: raise ValueError(f"剪辑计划没有片段: {plan_id}") # 只处理 ready 且有 asset_id 的片段 - ready_clips = [ - c for c in clips - if c.status == EditPlanClipStatus.READY and c.asset_id - ] + ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY and c.asset_id] ready_clips.sort(key=lambda c: c.order) if not ready_clips: @@ -286,13 +279,20 @@ class VideoComposeService: command.extend(["-map", "[outa]"]) # 编码参数 - command.extend([ - "-c:v", codec, - "-crf", str(crf), - "-preset", preset, - "-c:a", "aac", - "-b:a", "192k", - ]) + command.extend( + [ + "-c:v", + codec, + "-crf", + str(crf), + "-preset", + preset, + "-c:a", + "aac", + "-b:a", + "192k", + ] + ) # 输出 command.append(output_path) @@ -333,13 +333,20 @@ class VideoComposeService: # 简单命令:input → filter → output filter_str = ",".join(chain.filters) command = [ - "ffmpeg", "-y", - "-i", clip.asset_id, - "-filter_complex", f"{filter_str}[outv]", - "-map", "[outv]", - "-c:v", DEFAULT_CODEC, - "-crf", str(DEFAULT_CRF), - "-preset", DEFAULT_PRESET, + "ffmpeg", + "-y", + "-i", + clip.asset_id, + "-filter_complex", + f"{filter_str}[outv]", + "-map", + "[outv]", + "-c:v", + DEFAULT_CODEC, + "-crf", + str(DEFAULT_CRF), + "-preset", + DEFAULT_PRESET, output_path, ] @@ -376,7 +383,9 @@ class VideoComposeService: "rendered_clips": len(rendered_clips), "failed_clips": len(failed_clips), "total_duration": total_duration, - "can_compose": len(ready_clips) > 0 and plan.status in ( + "can_compose": len(ready_clips) > 0 + and plan.status + in ( EditPlanStatus.EDITING, EditPlanStatus.RENDERING, ), @@ -408,10 +417,7 @@ class VideoComposeService: filters: list[str] = [] # 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop) - filters.append( - f"scale={output_width}:{output_height}" - f":force_original_aspect_ratio=increase" - ) + filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase") # 2. crop: 居中裁剪 filters.append(f"crop={output_width}:{output_height}") @@ -424,7 +430,7 @@ class VideoComposeService: # 4. trim: 视频时长 filters.append(f"trim=0:{duration}") - filters.append(f"setpts=PTS-STARTPTS") # trim 后需要重置 PTS + filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS video_label = f"v{input_index}" @@ -476,10 +482,7 @@ class VideoComposeService: return filter_str, total_duration # ── 检查是否有转场 ───────────────────────────────────────────── - has_transitions = any( - t != TransitionEffect.CUT and t != "cut" - for t in transitions - ) + has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions) if not has_transitions: return _build_concat_filter(clip_chains) @@ -534,18 +537,14 @@ def _build_concat_filter( audio_parts: list[str] = [] for idx, chain in enumerate(clip_chains): if chain.audio_label: - audio_parts.append( - f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]" - ) + audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]") if audio_parts: parts.extend(audio_parts) audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label) audio_count = sum(1 for c in clip_chains if c.audio_label) if audio_count > 0: - parts.append( - f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]" - ) + parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]") return ";".join(parts), total_duration @@ -618,9 +617,7 @@ def _build_xfade_filter( if len(audio_labels) >= 2: # 简单拼接音频(不做 crossfade) audio_inputs = "".join(f"[{label}]" for label in audio_labels) - parts.append( - f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]" - ) + parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]") elif len(audio_labels) == 1: parts.append(f"[{audio_labels[0]}]acopy[outa]") diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index 33b56621b..7f61d4abd 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -5,19 +5,19 @@ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh"], rules: { - 'react-refresh/only-export-components': [ - 'warn', + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], }, -} +}; diff --git a/apps/web/README.md b/apps/web/README.md index bda73eae0..066fc4ceb 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -91,7 +91,7 @@ VITE_API_URL=http://localhost:8000 使用 Zustand 创建 Store: ```typescript -import { create } from 'zustand'; +import { create } from "zustand"; interface MyStore { data: any; diff --git a/apps/web/app/components/CreateIssueForm.tsx b/apps/web/app/components/CreateIssueForm.tsx deleted file mode 100644 index 166ef542f..000000000 --- a/apps/web/app/components/CreateIssueForm.tsx +++ /dev/null @@ -1,145 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; - -interface CreateIssueFormProps { - taskId: string; - projectId: string; - onSuccess: () => void; - onCancel: () => void; -} - -export default function CreateIssueForm({ taskId, projectId, onSuccess, onCancel }: CreateIssueFormProps) { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [formData, setFormData] = useState({ - title: '', - description: '', - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - - try { - const res = await fetch(`${API_BASE}/api/v1/project-management/issues`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - task_id: taskId, - project_id: projectId, - ...formData, - }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.detail || '创建失败'); - } - - onSuccess(); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }; - - return ( -
- {error && ( -
- {error} -
- )} - -
- - setFormData({ ...formData, title: e.target.value })} - style={{ - width: '100%', - padding: '8px 12px', - border: '1px solid var(--border)', - borderRadius: '4px', - fontSize: '14px', - }} - placeholder="简要描述问题" - /> -
- -
- -