Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 167d186c85 | |||
| d3ed0bb4ba | |||
| 2977e27ede | |||
| 99fa146a5c | |||
| d99cda8bc2 | |||
| 4b2f42f0be | |||
| 531aacb57e | |||
| 7c8748ece6 | |||
| f0a5f4fe42 | |||
| eba96fc864 | |||
| 68fe8aabd5 | |||
| d9e00992a6 | |||
| 2293adfbcf | |||
| 4397c670d8 | |||
| 9c155d0561 | |||
| 535e525a0e | |||
| e705cbdd8a | |||
| ca8b945054 | |||
| 9e9725015e | |||
| 164246c0b0 | |||
| d6549ebf40 | |||
| e2bc857e05 | |||
| fb2785f037 | |||
| 03c789c212 | |||
| 7d3328f462 | |||
| c24f24258a | |||
| f2cea1831e | |||
| ede42dd3c4 | |||
| b797e88b26 |
Regular → Executable
+128
-86
@@ -5,19 +5,32 @@ on:
|
||||
- main
|
||||
- develop
|
||||
- feature/**
|
||||
- feat/**
|
||||
- bugfix/**
|
||||
- fix/**
|
||||
- hotfix/**
|
||||
- release/**
|
||||
- refactor/**
|
||||
- perf/**
|
||||
- docs/**
|
||||
- chore/**
|
||||
- ci/**
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "触发原因"
|
||||
required: false
|
||||
default: "手动触发 - CI漏触发补跑"
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
validate:
|
||||
@@ -68,7 +81,7 @@ jobs:
|
||||
|
||||
python3 -m isort --version-number
|
||||
|
||||
python3 -m flake8 --version
|
||||
python3 -m ruff --version
|
||||
|
||||
bandit --version
|
||||
|
||||
@@ -79,23 +92,14 @@ jobs:
|
||||
shell: sh
|
||||
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n 2>&1 | tee /tmp/secrets-scan.json\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
|
||||
\ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n"
|
||||
- name: Calculate changed Python files (incremental scan)
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_REF_NAME:-}\" ]; then\n echo \"PR mode (#${GITHUB_REF_NAME}) - fetching changed files from API\"\n\n PR_NUMBER=$(echo \"$GITHUB_REF\" | sed 's|refs/pull/||; s|/.*||')\n API_URL=\"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100\"\n\n set +e\n RESPONSE=$(curl -s -w \"\\n%{http_code}\" -H \"Authorization: token ${GITHUB_TOKEN}\" \"${API_URL}\")\n HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n BODY=$(echo \"$RESPONSE\" | sed '$d')\n set -e\n\n if [ \"$HTTP_CODE\" = \"200\" ]; then\n CHANGED_PY_FILES=$(echo \"$BODY\" | python3 -c \"\nimport json, sys\ntry:\n files = json.load(sys.stdin)\n py_files = [f['filename'] for f in files\n if f['filename'].endswith('.py') and f['status'] != 'removed']\n print(' '.join(py_files))\nexcept Exception:\n print('')\n\")\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: API returned HTTP $HTTP_CODE, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n"
|
||||
- name: Run code quality checks
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
|
||||
python3 -m flake8 apps packages tests --count --statistics
|
||||
|
||||
'
|
||||
- name: Ruff lint (advisory mode - 摸底阶段)
|
||||
if: always()
|
||||
shell: sh
|
||||
run: "set +e\necho \"=== Installing ruff ===\"\npython3 -m pip install -q ruff\nruff --version\necho \"\"\necho \"=== Running ruff lint (advisory mode) ===\"\necho \"告警模式,不阻断CI。用于摸底问题数量,后续分批修复后正式替换flake8。\"\necho \"\"\nruff check apps packages tests scripts --statistics --output-format concise 2>&1 | tail -30\nEXIT_CODE=$?\necho \"\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"ruff 发现 lint 问题(告警模式,不阻断)\"\n echo \"问题分类统计见上方,后续将分批修复\"\nelse\n echo \"ruff 检查全部通过 ✅\"\nfi\nexit 0\n"
|
||||
run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n"
|
||||
- name: Type check (mypy, advisory mode)
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -142,7 +146,7 @@ jobs:
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\nif command -v git >/dev/null 2>&1; then\n git init -q\n git config user.email \"ci@localhost\"\n git config user.name \"CI\"\n git add .\n git commit -q -m \"current\"\n REPO_URL=\"https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git\"\n git remote add origin \"$REPO_URL\"\n git fetch origin main --depth=1 -q 2>/dev/null || echo \"WARN: cannot fetch main, will check all migrations\"\nelse\n echo \"WARN: git not available, will check all migrations\"\nfi\n"
|
||||
run: "set -eu\nif command -v git >/dev/null 2>&1; then\n if [ ! -d .git ]; then\n git init -q\n git config user.email \"ci@localhost\"\n git config user.name \"CI\"\n git add .\n git commit -q -m \"current\"\n REPO_URL=\"https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git\"\n git remote add origin \"$REPO_URL\"\n fi\n git fetch origin main --depth=1 -q 2>/dev/null || echo \"WARN: cannot fetch main, will check all migrations\"\nelse\n echo \"WARN: git not available, will check all migrations\"\nfi\n"
|
||||
- name: Check migration safety
|
||||
shell: sh
|
||||
run: "set -eu\nif git rev-parse origin/main >/dev/null 2>&1; then\n python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/main\nelse\n python3 scripts/check_migration_safety.py --allow-medium-risk\nfi\n"
|
||||
@@ -371,19 +375,19 @@ jobs:
|
||||
'
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'rm -rf node_modules/* 2>/dev/null; npm ci --include=dev'\n"
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/eslint\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n"
|
||||
- name: Run ESLint
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 50'\n"
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install eslint src --ext .ts,.tsx --max-warnings 50'\n"
|
||||
- name: Run TypeScript type check
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx tsc --noEmit'\n"
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install tsc --noEmit'\n"
|
||||
- name: Run Prettier check
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx prettier --check \"src/**/*.{ts,tsx,md}\"'\n"
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\"'\n"
|
||||
- name: Run Vitest tests
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'ls node_modules/.bin/vitest 2>/dev/null && node_modules/.bin/vitest run src/test || npm exec vitest run src/test'\n"
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run src/test'\n"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -444,8 +448,20 @@ jobs:
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push API image (buildx cache)
|
||||
shell: sh
|
||||
run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"xiaoxia-saas-api\"\nCACHE_REF=\"${REGISTRY}/api-cache:develop\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=max\"\n echo \"Building API image with read-write cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" -f infra/docker/api.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nelse\n echo \"Building API image with read-only cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" -f infra/docker/api.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nfi\necho\
|
||||
\ \"API image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\n"
|
||||
run: "set -eu
|
||||
REGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"
|
||||
IMAGE_TAG=\"${REGISTRY}/xiaoxia-saas-api:${GITHUB_SHA}\"
|
||||
CACHE_REF=\"${REGISTRY}/api-cache:${GITHUB_REF_NAME}\"
|
||||
|
||||
bash scripts/ci/docker_build_push.sh \
|
||||
infra/docker/api.Dockerfile \
|
||||
\"${IMAGE_TAG}\" \
|
||||
\"${CACHE_REF}\" \
|
||||
APP_VERSION=\"${GITHUB_SHA}\"
|
||||
|
||||
echo
|
||||
echo \"API image pushed: ${IMAGE_TAG}\"
|
||||
"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -506,8 +522,20 @@ jobs:
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push Worker image (buildx cache)
|
||||
shell: sh
|
||||
run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"xiaoxia-saas-worker\"\nCACHE_REF=\"${REGISTRY}/worker-cache:develop\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=min\"\n echo \"Building Worker image with read-write cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nelse\n echo \"Building Worker image with read-only cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push \
|
||||
\ .\nfi\necho \"Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\n"
|
||||
run: "set -eu
|
||||
REGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"
|
||||
IMAGE_TAG=\"${REGISTRY}/xiaoxia-saas-worker:${GITHUB_SHA}\"
|
||||
CACHE_REF=\"${REGISTRY}/worker-cache:${GITHUB_REF_NAME}\"
|
||||
|
||||
bash scripts/ci/docker_build_push.sh \
|
||||
infra/docker/worker.Dockerfile \
|
||||
\"${IMAGE_TAG}\" \
|
||||
\"${CACHE_REF}\" \
|
||||
APP_VERSION=\"${GITHUB_SHA}\"
|
||||
|
||||
echo
|
||||
echo \"WORKER image pushed: ${IMAGE_TAG}\"
|
||||
"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -565,14 +593,26 @@ jobs:
|
||||
run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\" >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\nfi\n"
|
||||
- name: Build frontend assets (npm build)
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npm run build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete: $(ls apps/web/dist/ | head -5)\"\n"
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npx tsc --incremental --tsBuildInfoFile node_modules/.tsbuildinfo && npx vite build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete: $(ls apps/web/dist/ | head -5)\"\n"
|
||||
- name: Setup buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push Web image (buildx cache)
|
||||
shell: sh
|
||||
run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"xiaoxia-saas-web\"\nCACHE_REF=\"${REGISTRY}/web-cache:develop\"\nNGINX_CONF=\"infra/docker/nginx-staging.conf\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=max\"\n echo \"Building Web image with read-write cache...\"\n docker buildx build --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" -f infra/docker/web-artifact.Dockerfile --build-arg \"NGINX_CONF=${NGINX_CONF}\" -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nelse\n echo \"Building Web image with read-only cache...\"\n docker buildx build --cache-from \"${CACHE_FROM}\" -f infra/docker/web-artifact.Dockerfile --build-arg \"NGINX_CONF=${NGINX_CONF}\" -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\
|
||||
\ --push .\nfi\necho \"Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\n"
|
||||
run: "set -eu
|
||||
REGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"
|
||||
IMAGE_TAG=\"${REGISTRY}/xiaoxia-saas-web:${GITHUB_SHA}\"
|
||||
CACHE_REF=\"${REGISTRY}/web-cache:${GITHUB_REF_NAME}\"
|
||||
|
||||
bash scripts/ci/docker_build_push.sh \
|
||||
infra/docker/web.Dockerfile \
|
||||
\"${IMAGE_TAG}\" \
|
||||
\"${CACHE_REF}\" \
|
||||
APP_VERSION=\"${GITHUB_SHA}\"
|
||||
|
||||
echo
|
||||
echo \"WEB image pushed: ${IMAGE_TAG}\"
|
||||
"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -620,7 +660,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: 'set +e
|
||||
|
||||
NOTIFY_MODE=start JOB_NAME="Deploy Staging (Watchtower auto-deploy)" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=start JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Docker login to Registry
|
||||
@@ -634,14 +674,30 @@ jobs:
|
||||
echo "Docker login successful"
|
||||
|
||||
'
|
||||
- name: Tag and push :staging images (Watchtower auto-update)
|
||||
- name: Install SSH client
|
||||
if: success()
|
||||
shell: sh
|
||||
run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\n\nfor svc in api worker web; do\n echo \"Pulling ${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA} ...\"\n docker pull \"${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}\"\n docker tag \"${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}\" \"${REGISTRY}/xiaoxia-saas-${svc}:staging\"\n docker push \"${REGISTRY}/xiaoxia-saas-${svc}:staging\"\n echo \"$svc :staging tagged and pushed\"\ndone\necho \"All :staging images pushed. Watchtower will auto-deploy within 60s.\"\n"
|
||||
run: "set -eu\napt-get update -qq \u0026\u0026 apt-get install -y -qq openssh-client \u003e/dev/null 2\u003e\u00261\necho \"openssh-client installed\"\n"
|
||||
|
||||
- name: Deploy staging over SSH (Registry pull)
|
||||
if: success()
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: "set -eux\nstaging_host=\"${STAGING_SSH_HOST:-47.98.113.167}\"\nstaging_user=\"${STAGING_SSH_USER:-root}\"\nstaging_port=\"${STAGING_SSH_PORT:-22222}\"\necho \"Host: $staging_host\"\necho \"Port: $staging_port\"\n\nmkdir -p ~/.ssh\n\n# 查找可用的SSH密钥\nkey_path=\"\"\nif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then\n key_path=\"/root/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (builder key)\"\nelif [ -f \"$HOME/.ssh/xiaoxia_runtime_builder\" ]; then\n key_path=\"$HOME/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (home key)\"\nelif [ -n \"${STAGING_SSH_KEY:-}\" ]; then\n key_path=\"$HOME/.ssh/id_ed25519\"\n printf \'%s\\n\' \"$STAGING_SSH_KEY\" \u003e \"$key_path\"\n chmod 600 \"$key_path\"\n echo \"Using key from STAGING_SSH_KEY secret\"\nelse\n echo \"ERROR: No SSH key available\"\n ls -la ~/.ssh/ 2\u003e/dev/null || true\n ls -la /root/.ssh/ 2\u003e/dev/null || true\n exit 1\nfi\n\nssh-keyscan -p \"$staging_port\" -H \"$staging_host\" \u003e\u003e ~/.ssh/known_hosts 2\u003e/dev/null\necho \"SSH keyscan done\"\n\n# 测试SSH连接\nssh -p \"$staging_port\" -i \"$key_path\" -o StrictHostKeyChecking=no \"${staging_user}@${staging_host}\" \"echo SSH_CONNECTION_OK \u0026\u0026 hostname\"\necho \"SSH connection verified\"\n\n# 读取部署脚本并通过SSH执行\ncat scripts/ci_staging_deploy.sh | ssh -p \"$staging_port\" -i \"$key_path\" -o StrictHostKeyChecking=no \"${staging_user}@${staging_host}\" \"IMAGE_TAG=\'${GITHUB_SHA}\' REGISTRY_TOKEN=\'${REGISTRY_TOKEN}\' sh\"\n"
|
||||
- name: Staging health check + auto rollback
|
||||
if: success()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: 'set -eu
|
||||
|
||||
@@ -669,7 +725,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: 'set +e
|
||||
|
||||
NOTIFY_MODE=success JOB_NAME="Deploy Staging (Watchtower auto-deploy)" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=success JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Notify on failure
|
||||
@@ -680,7 +736,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: 'set +e
|
||||
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Staging (Watchtower auto-deploy)" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
staging-e2e:
|
||||
@@ -805,24 +861,20 @@ jobs:
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push API image (buildx cache)
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
run: "set -eu
|
||||
REGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"
|
||||
IMAGE_TAG=\"${REGISTRY}/xiaoxia-saas-api:${GITHUB_SHA}\"
|
||||
CACHE_REF=\"${REGISTRY}/api-cache:${GITHUB_REF_NAME}\"
|
||||
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas"
|
||||
bash scripts/ci/docker_build_push.sh \
|
||||
infra/docker/api.Dockerfile \
|
||||
\"${IMAGE_TAG}\" \
|
||||
\"${CACHE_REF}\" \
|
||||
APP_VERSION=\"${GITHUB_SHA}\"
|
||||
|
||||
IMAGE_NAME="xiaoxia-saas-api"
|
||||
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
|
||||
CACHE_REF="${REGISTRY}/api-cache:main"
|
||||
|
||||
|
||||
echo "Building Production API image: ${VERSION}"
|
||||
|
||||
docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push .
|
||||
|
||||
echo "Production API image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
||||
|
||||
'
|
||||
echo
|
||||
echo \"API image pushed: ${IMAGE_TAG}\"
|
||||
"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -880,24 +932,20 @@ jobs:
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push Worker image (buildx cache)
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
run: "set -eu
|
||||
REGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"
|
||||
IMAGE_TAG=\"${REGISTRY}/xiaoxia-saas-worker:${GITHUB_SHA}\"
|
||||
CACHE_REF=\"${REGISTRY}/worker-cache:${GITHUB_REF_NAME}\"
|
||||
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas"
|
||||
bash scripts/ci/docker_build_push.sh \
|
||||
infra/docker/worker.Dockerfile \
|
||||
\"${IMAGE_TAG}\" \
|
||||
\"${CACHE_REF}\" \
|
||||
APP_VERSION=\"${GITHUB_SHA}\"
|
||||
|
||||
IMAGE_NAME="xiaoxia-saas-worker"
|
||||
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
|
||||
CACHE_REF="${REGISTRY}/worker-cache:main"
|
||||
|
||||
|
||||
echo "Building Production Worker image: ${VERSION}"
|
||||
|
||||
docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=min" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push .
|
||||
|
||||
echo "Production Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
||||
|
||||
'
|
||||
echo
|
||||
echo \"WORKER image pushed: ${IMAGE_TAG}\"
|
||||
"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -952,32 +1000,26 @@ jobs:
|
||||
'
|
||||
- name: Build frontend assets (npm build)
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npm run build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete\"\n"
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npx tsc --incremental --tsBuildInfoFile node_modules/.tsbuildinfo && npx vite build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete\"\n"
|
||||
- name: Setup buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push Web image (buildx cache)
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
run: "set -eu
|
||||
REGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"
|
||||
IMAGE_TAG=\"${REGISTRY}/xiaoxia-saas-web:${GITHUB_SHA}\"
|
||||
CACHE_REF=\"${REGISTRY}/web-cache:${GITHUB_REF_NAME}\"
|
||||
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas"
|
||||
bash scripts/ci/docker_build_push.sh \
|
||||
infra/docker/web.Dockerfile \
|
||||
\"${IMAGE_TAG}\" \
|
||||
\"${CACHE_REF}\" \
|
||||
APP_VERSION=\"${GITHUB_SHA}\"
|
||||
|
||||
IMAGE_NAME="xiaoxia-saas-web"
|
||||
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
|
||||
CACHE_REF="${REGISTRY}/web-cache:main"
|
||||
|
||||
NGINX_CONF="infra/docker/nginx-production.conf"
|
||||
|
||||
|
||||
echo "Building Production Web image: ${VERSION}"
|
||||
|
||||
docker buildx build --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/web-artifact.Dockerfile --build-arg "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push .
|
||||
|
||||
echo "Production Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
||||
|
||||
'
|
||||
echo
|
||||
echo \"WEB image pushed: ${IMAGE_TAG}\"
|
||||
"
|
||||
- name: Cleanup old Docker images
|
||||
if: always()
|
||||
shell: sh
|
||||
|
||||
@@ -239,7 +239,9 @@ def get_duplication_detail(
|
||||
return _to_detail_response(record)
|
||||
|
||||
|
||||
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
@router.delete(
|
||||
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
||||
)
|
||||
def delete_duplication_record(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -287,7 +289,7 @@ def retry_duplication(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
) from e
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
Regular → Executable
+12
-8
@@ -108,6 +108,10 @@ class EditPlanGenerationStatusResponse(BaseModel):
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: Optional[str] = None
|
||||
generation_task_status: Optional[str] = None
|
||||
progress: float = 0.0
|
||||
video_url: str = ""
|
||||
error_message: str = ""
|
||||
clips: List[ClipStatusItem]
|
||||
|
||||
|
||||
@@ -275,11 +279,11 @@ def list_plans(
|
||||
if status_filter:
|
||||
try:
|
||||
status_enum = EditPlanStatus(status_filter)
|
||||
except ValueError:
|
||||
except ValueError as _e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的筛选条件,请选择正确的状态",
|
||||
)
|
||||
) from _e
|
||||
|
||||
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
|
||||
if project_id:
|
||||
@@ -322,7 +326,7 @@ def get_plan(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
) from exc
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
@@ -358,7 +362,7 @@ def create_plan(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
) from exc
|
||||
logger.info(
|
||||
"创建剪辑计划: id=%s name=%s by user=%s",
|
||||
created.id,
|
||||
@@ -401,11 +405,11 @@ def update_plan(
|
||||
if body.status is not None:
|
||||
try:
|
||||
target_status = EditPlanStatus(body.status)
|
||||
except ValueError:
|
||||
except ValueError as _e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的状态值,请选择正确的状态",
|
||||
)
|
||||
) from _e
|
||||
svc.transition_status(plan_id, target_status)
|
||||
except ValueError as exc:
|
||||
err_msg = str(exc)
|
||||
@@ -413,11 +417,11 @@ def update_plan(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=err_msg,
|
||||
)
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=err_msg,
|
||||
)
|
||||
) from exc
|
||||
|
||||
# 返回最新状态
|
||||
result = svc.get_plan_or_raise(plan_id)
|
||||
|
||||
@@ -58,7 +58,7 @@ def ai_recommend_clips(
|
||||
try:
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
@@ -103,7 +103,7 @@ def ai_recommend_clips(
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
)
|
||||
except Exception:
|
||||
except Exception as _e:
|
||||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||
try:
|
||||
db.rollback()
|
||||
@@ -116,7 +116,7 @@ def ai_recommend_clips(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI推荐结果保存失败,请稍后重试",
|
||||
)
|
||||
) from _e
|
||||
|
||||
logger.info(
|
||||
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
|
||||
@@ -167,7 +167,7 @@ def generate_cover(
|
||||
try:
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
Regular → Executable
+25
-9
@@ -183,9 +183,7 @@ def _auto_fallback_auto_material_mode(
|
||||
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
||||
"""队列限流预检查"""
|
||||
try:
|
||||
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
|
||||
gen_task_repo, "count_pending_total"
|
||||
)
|
||||
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total")
|
||||
if has_count:
|
||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||
global_pending = gen_task_repo.count_pending_total()
|
||||
@@ -241,7 +239,7 @@ def generate_plan(
|
||||
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))
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if not can_gen:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||
|
||||
@@ -255,12 +253,15 @@ def generate_plan(
|
||||
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
# 从 plan.config 中读取 asset_ids 并传递给 GenerationTask
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
project_id=plan.project_id or "",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
asset_ids=list(config_asset_ids) if config_asset_ids else [],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -286,7 +287,7 @@ def generate_plan(
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
except Exception as _e:
|
||||
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||||
try:
|
||||
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||
@@ -295,7 +296,7 @@ def generate_plan(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成失败,请稍后重试",
|
||||
)
|
||||
) from _e
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -313,7 +314,7 @@ def get_generation_status(
|
||||
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))
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
plan = gen_status["plan"]
|
||||
if plan.project_id:
|
||||
@@ -333,10 +334,25 @@ def get_generation_status(
|
||||
for c in clips
|
||||
]
|
||||
|
||||
# 从 plan.config 中取渲染结果 URL
|
||||
video_url = (plan.config or {}).get("rendered_url", "")
|
||||
# 从 gen_status 中取进度、错误信息、任务状态
|
||||
progress = gen_status.get("progress", 0.0)
|
||||
error_message = gen_status.get("error_message", "")
|
||||
gen_task_status = gen_status.get("generation_task_status")
|
||||
# 如果计划已完成但进度还是0,补100
|
||||
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status_val == "completed" and progress < 100:
|
||||
progress = 100.0
|
||||
|
||||
return EditPlanGenerationStatusResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
|
||||
plan_status=plan_status_val,
|
||||
generation_task_id=gen_status["generation_task_id"],
|
||||
generation_task_status=gen_task_status,
|
||||
progress=progress,
|
||||
video_url=video_url,
|
||||
error_message=error_message,
|
||||
clips=clip_items,
|
||||
)
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ def generate_from_template(
|
||||
try:
|
||||
template = template_svc.get_template_or_raise(body.template_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ async def list_feature_flags(
|
||||
return sorted(result, key=lambda x: x.name)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to list feature flags: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=FeatureFlagResponse)
|
||||
@@ -120,7 +120,7 @@ async def get_feature_flag(
|
||||
return FeatureFlagResponse.from_config(config)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to get feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
|
||||
@@ -136,7 +136,7 @@ async def check_feature_flag(
|
||||
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to check feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=FeatureFlagResponse)
|
||||
@@ -170,7 +170,7 @@ async def update_feature_flag(
|
||||
return FeatureFlagResponse.from_config(config)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to update feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
@@ -178,7 +178,7 @@ async def delete_feature_flag(
|
||||
name: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
) :
|
||||
):
|
||||
"""删除 Feature Flag。
|
||||
|
||||
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
||||
@@ -191,4 +191,4 @@ async def delete_feature_flag(
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Failed to delete feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc
|
||||
|
||||
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
@@ -282,28 +283,28 @@ def create_generation_task(
|
||||
created_tasks.append(task)
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except UserPendingLimitExceeded:
|
||||
except UserPendingLimitExceeded as _e:
|
||||
# 兜底:如果预检查后又并发提交了,在这里也拦住
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
)
|
||||
) from _e
|
||||
break
|
||||
except GlobalQueueFull:
|
||||
except GlobalQueueFull as _e:
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
) from _e
|
||||
break
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
|
||||
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e
|
||||
|
||||
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
@@ -81,11 +81,11 @@ def delete_project(
|
||||
use_case = DeleteProjectUseCase(project_repository)
|
||||
try:
|
||||
deleted = use_case.execute(project_id, authenticated_user.user.id)
|
||||
except PermissionError:
|
||||
except PermissionError as _e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the project owner can delete this project",
|
||||
)
|
||||
) from _e
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return
|
||||
|
||||
@@ -254,7 +254,7 @@ async def payment_callback(
|
||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ def get_template(
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
template = use_case.execute(template_id, user_id)
|
||||
usage = template_repository.get_usage_count(template_id)
|
||||
except Exception:
|
||||
except Exception as _e:
|
||||
logger.exception("get_template 查询失败: template_id=%s", template_id)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return _to_response(template, usage_count=usage)
|
||||
@@ -186,7 +186,7 @@ def create_template(
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@@ -226,10 +226,10 @@ def update_template(
|
||||
use_case = UpdateTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@@ -264,10 +264,10 @@ def copy_template(
|
||||
use_case = CopyTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@@ -299,9 +299,9 @@ def toggle_favorite(
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(template_id, user_id)
|
||||
except Exception:
|
||||
except Exception as _e:
|
||||
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
|
||||
@@ -326,10 +326,10 @@ def validate_template(
|
||||
use_case = ValidateTemplateUseCase(template_repository)
|
||||
try:
|
||||
result = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
return ValidateTemplateResponse(
|
||||
template=_to_response(result.template),
|
||||
@@ -375,7 +375,9 @@ def create_category(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
@router.delete(
|
||||
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
||||
)
|
||||
def delete_category(
|
||||
category_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -148,7 +148,7 @@ def create_title(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
) from exc
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@@ -172,8 +172,8 @@ def update_title(
|
||||
use_case = UpdateTitleLibraryUseCase(title_repository)
|
||||
try:
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
|
||||
@@ -236,8 +236,8 @@ def get_tts_job(
|
||||
use_case = GetTTSJobUseCase(repository)
|
||||
try:
|
||||
job = use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
except TTSJobNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
||||
return _to_response(job, sign_url)
|
||||
|
||||
|
||||
@@ -253,8 +253,8 @@ def get_tts_job_status(
|
||||
use_case = GetTTSJobStatusUseCase(repository)
|
||||
try:
|
||||
job = use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
except TTSJobNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
||||
output_url = job.output_audio_url
|
||||
if output_url:
|
||||
output_url = sign_url(output_url)
|
||||
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
|
||||
get_use_case = GetTTSJobUseCase(tts_repository)
|
||||
try:
|
||||
job = get_use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
except TTSJobNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
||||
|
||||
# 校验已完成
|
||||
if not job.is_completed:
|
||||
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
) from exc
|
||||
|
||||
return SaveToLibraryResponse(
|
||||
id=item.id,
|
||||
|
||||
@@ -141,8 +141,8 @@ def get_voice_clone(
|
||||
use_case = GetVoiceCloneUseCase(repository)
|
||||
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")
|
||||
except VoiceCloneNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
@@ -157,8 +157,8 @@ def get_voice_clone_status(
|
||||
use_case = GetVoiceCloneStatusUseCase(repository)
|
||||
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")
|
||||
except VoiceCloneNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
||||
return VoiceCloneStatusResponse(
|
||||
id=profile.id,
|
||||
status=profile.status,
|
||||
@@ -201,13 +201,13 @@ def retry_voice_clone(
|
||||
user_id = authenticated_user.user.id
|
||||
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")
|
||||
except VoiceCloneNotRetryableError:
|
||||
except VoiceCloneNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
||||
except VoiceCloneNotRetryableError as _e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Voice clone is not retryable (only failed clones can be retried)",
|
||||
)
|
||||
) from _e
|
||||
|
||||
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
|
||||
@@ -287,7 +287,7 @@ def create_voice(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
) from exc
|
||||
return _to_response(item, sign_url)
|
||||
|
||||
|
||||
@@ -317,8 +317,8 @@ def update_voice(
|
||||
use_case = UpdateVoiceLibraryUseCase(voice_repository)
|
||||
try:
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e
|
||||
return _to_response(item, sign_url)
|
||||
|
||||
|
||||
|
||||
@@ -431,6 +431,8 @@ class EditPlanService:
|
||||
"clips": List[EditPlanClip],
|
||||
"generation_task_id": Optional[str],
|
||||
"generation_task_status": Optional[str],
|
||||
"progress": float,
|
||||
"error_message": str,
|
||||
}
|
||||
|
||||
Raises:
|
||||
@@ -442,17 +444,23 @@ class EditPlanService:
|
||||
# 从 plan.config 中获取 generation_task_id
|
||||
generation_task_id = plan.config.get("generation_task_id")
|
||||
generation_task_status = None
|
||||
progress = 0.0
|
||||
error_message = ""
|
||||
|
||||
if generation_task_id:
|
||||
task = self._generation_task_repo.get(generation_task_id)
|
||||
if task:
|
||||
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
|
||||
progress = getattr(task, "progress", 0.0) or 0.0
|
||||
error_message = getattr(task, "error_message", "") or ""
|
||||
|
||||
return {
|
||||
"plan": plan,
|
||||
"clips": clips,
|
||||
"generation_task_id": generation_task_id,
|
||||
"generation_task_status": generation_task_status,
|
||||
"progress": progress,
|
||||
"error_message": error_message,
|
||||
}
|
||||
|
||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||
|
||||
Generated
+17
@@ -33,6 +33,7 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"jsdom": "^24.1.0",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
@@ -4828,6 +4829,22 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.5",
|
||||
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz",
|
||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"jsdom": "^24.1.0",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
|
||||
@@ -37,6 +37,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
cache: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
|
||||
@@ -181,9 +181,9 @@ def _validate_video_path(video_path: str, work_dir: Path) -> None:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
except ValueError as _e:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
|
||||
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}") from _e
|
||||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||||
# 但检查扩展名
|
||||
else:
|
||||
|
||||
@@ -356,7 +356,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
except Exception as e:
|
||||
logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}")
|
||||
session.rollback()
|
||||
raise self.retry(exc=e, countdown=60)
|
||||
raise self.retry(exc=e, countdown=60) from e
|
||||
finally:
|
||||
session.close()
|
||||
import shutil
|
||||
|
||||
@@ -191,9 +191,9 @@ def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
except ValueError as _e:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
|
||||
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}") from _e
|
||||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||||
# 但检查扩展名
|
||||
else:
|
||||
|
||||
@@ -129,8 +129,8 @@ def safe_resolve_path(
|
||||
if not allow_outside:
|
||||
try:
|
||||
full_path.relative_to(base_dir)
|
||||
except ValueError:
|
||||
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
|
||||
except ValueError as _e:
|
||||
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围") from _e
|
||||
|
||||
# 扩展名校验
|
||||
if allowed_extensions is not None:
|
||||
|
||||
@@ -682,6 +682,6 @@ def _validate_subtitle_path(subtitle_path: str, work_dir: Path) -> None:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
except ValueError as _e:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
|
||||
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}") from _e
|
||||
|
||||
@@ -1363,23 +1363,37 @@ class UnifiedRenderService:
|
||||
# 单 clip 层,直接使用预处理标签
|
||||
layer_output_labels[layer.role] = layer_labels[0]
|
||||
else:
|
||||
# 多 clip 层,用 TransitionEngine 构建转场链
|
||||
out_label = f"{layer.role}_merged"
|
||||
# 计算该层使用的转场时长(取首个非零值,否则用默认)
|
||||
layer_dur = 0.0
|
||||
for d in layer_transition_durations:
|
||||
if d > 0:
|
||||
layer_dur = d
|
||||
break
|
||||
xfade_filter, _ = self._transition_engine.build_xfade_chain(
|
||||
clip_durations=layer_durations,
|
||||
clip_video_labels=layer_labels,
|
||||
transitions=layer_transitions,
|
||||
transition_duration=layer_dur if layer_dur > 0 else None,
|
||||
output_label=out_label,
|
||||
# 判断是否全部为硬切:是则用 concat filter,否则用 xfade 转场链
|
||||
all_cut = all(
|
||||
t is None or t == "" or str(t).lower() == "cut"
|
||||
for t in layer_transitions[1:] # 第一个 clip 的转场忽略
|
||||
)
|
||||
if xfade_filter:
|
||||
filter_parts.append(xfade_filter)
|
||||
if all_cut:
|
||||
# 全硬切:用 concat filter,性能远优于 xfade
|
||||
concat_inputs = "".join(f"[{label}]" for label in layer_labels)
|
||||
filter_parts.append(f"{concat_inputs}concat=n={len(layer_labels)}:v=1:a=0[{out_label}]")
|
||||
logger.info(
|
||||
"[unified-render] layer=%s clips=%d using concat (all hard-cut)",
|
||||
layer.role,
|
||||
len(layer_labels),
|
||||
)
|
||||
else:
|
||||
# 有转场效果:用 TransitionEngine 构建 xfade 链
|
||||
layer_dur = 0.0
|
||||
for d in layer_transition_durations:
|
||||
if d > 0:
|
||||
layer_dur = d
|
||||
break
|
||||
xfade_filter, _ = self._transition_engine.build_xfade_chain(
|
||||
clip_durations=layer_durations,
|
||||
clip_video_labels=layer_labels,
|
||||
transitions=layer_transitions,
|
||||
transition_duration=layer_dur if layer_dur > 0 else None,
|
||||
output_label=out_label,
|
||||
)
|
||||
if xfade_filter:
|
||||
filter_parts.append(xfade_filter)
|
||||
layer_output_labels[layer.role] = out_label
|
||||
|
||||
# Step 3: 合成各层
|
||||
|
||||
@@ -63,6 +63,18 @@ def compose_video(self, job_id: str, **kwargs):
|
||||
resolver = get_render_engine_resolver()
|
||||
user_id = job.created_by_user_id or None
|
||||
engine = resolver.get_engine(user_id=user_id)
|
||||
# 灰度期间打印详细 flag 配置,便于排查
|
||||
config = resolver.get_config_snapshot()
|
||||
logger.info(
|
||||
"compose_video 引擎选择: job_id=%s engine=%s user_id=%s enabled=%s percentage=%s whitelist=%d default=%s",
|
||||
job_id,
|
||||
engine,
|
||||
user_id,
|
||||
config.get("enabled"),
|
||||
config.get("percentage"),
|
||||
len(config.get("whitelist", [])),
|
||||
config.get("default_engine"),
|
||||
)
|
||||
|
||||
if engine == "unified":
|
||||
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
|
||||
@@ -78,7 +90,7 @@ def compose_video(self, job_id: str, **kwargs):
|
||||
job_service.fail_job(job_id, str(exc)[:500])
|
||||
except Exception:
|
||||
logger.exception("更新 Job 失败状态时出错")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
raise self.retry(exc=exc, countdown=60) from exc
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -77,9 +77,21 @@ def _resolve_render_engine(user_id: str) -> str:
|
||||
from video_processing.render_engine_resolver import get_render_engine_resolver
|
||||
|
||||
resolver = get_render_engine_resolver()
|
||||
return resolver.get_engine(user_id=user_id)
|
||||
engine = resolver.get_engine(user_id=user_id)
|
||||
# 灰度期间打印详细 flag 配置,便于排查
|
||||
config = resolver.get_config_snapshot()
|
||||
logger.info(
|
||||
"edit_plan 引擎选择: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
|
||||
user_id,
|
||||
engine,
|
||||
config.get("enabled"),
|
||||
config.get("percentage"),
|
||||
len(config.get("whitelist", [])),
|
||||
config.get("default_engine"),
|
||||
)
|
||||
return engine
|
||||
except Exception as exc:
|
||||
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc)
|
||||
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
|
||||
return "legacy"
|
||||
|
||||
|
||||
@@ -95,6 +107,14 @@ def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, err
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = error_msg
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
gen_task.append_log(
|
||||
stage="render_failed",
|
||||
message=error_msg[:500],
|
||||
level="ERROR",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
|
||||
@@ -148,9 +168,11 @@ def _finalize_render_success(
|
||||
clip.mark_rendered()
|
||||
clip_repo.update(clip)
|
||||
|
||||
# 更新 EditPlan 状态为 completed
|
||||
# 更新 EditPlan 状态为 completed + 回写实际渲染时长
|
||||
plan.config["rendered_url"] = output_url or ""
|
||||
plan.config["rendered_storage_key"] = storage_key
|
||||
if hasattr(plan, "total_duration") and duration > 0:
|
||||
plan.total_duration = duration
|
||||
plan.mark_completed()
|
||||
plan_repo.update(plan)
|
||||
|
||||
@@ -160,7 +182,15 @@ def _finalize_render_success(
|
||||
if gen_task:
|
||||
gen_task.status = "completed"
|
||||
gen_task.progress = 100.0
|
||||
gen_task.result_count = len(rendered_clip_ids)
|
||||
# 剪辑计划是多片段合成 1 个成片,result_count = 1
|
||||
gen_task.result_count = 1
|
||||
gen_task.append_log(
|
||||
stage="render_complete",
|
||||
message=f"渲染完成,输出时长 {duration:.1f}s",
|
||||
level="INFO",
|
||||
engine=engine,
|
||||
clip_count=len(rendered_clip_ids),
|
||||
)
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
@@ -365,6 +395,13 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
if gen_task:
|
||||
gen_task.status = "running"
|
||||
gen_task.started_at = datetime.now(timezone.utc)
|
||||
gen_task.append_log(
|
||||
stage="render_start",
|
||||
message=f"开始渲染,引擎 {engine},片段数 {len(clips)}",
|
||||
level="INFO",
|
||||
engine=engine,
|
||||
clip_count=len(clips),
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
# 3. 下载素材并构建 asset_path_map
|
||||
@@ -429,9 +466,28 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = "所有片段素材下载失败"
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
gen_task.append_log(
|
||||
stage="download_failed",
|
||||
message="所有片段素材下载失败",
|
||||
level="ERROR",
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
return {"status": "error", "message": "所有片段素材下载失败"}
|
||||
|
||||
# 素材下载完成,记录日志
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
stage="download_done",
|
||||
message=f"素材下载完成,成功 {len(asset_path_map)} 个,失败 {len(failed_clip_ids)} 个",
|
||||
level="INFO",
|
||||
success_count=len(asset_path_map),
|
||||
failed_count=len(failed_clip_ids),
|
||||
)
|
||||
gen_task.progress = 30.0
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
# 4. 根据引擎选择渲染方式
|
||||
if engine == "unified":
|
||||
result = _render_with_unified(
|
||||
@@ -483,6 +539,15 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
gen_task.append_log(
|
||||
stage="render_failed",
|
||||
message=f"渲染异常: {type(exc).__name__}: {str(exc)[:500]}",
|
||||
level="ERROR",
|
||||
exception_type=type(exc).__name__,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
gen_task_repo.update(gen_task)
|
||||
logger.info(
|
||||
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
|
||||
@@ -493,6 +558,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
logger.warning(
|
||||
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
|
||||
)
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
raise self.retry(exc=exc, countdown=60) from exc
|
||||
|
||||
return {"status": "error", "message": "数据库连接失败"}
|
||||
|
||||
@@ -787,10 +787,23 @@ def _resolve_render_engine(user_id: str) -> str:
|
||||
from video_processing.render_engine_resolver import get_render_engine_resolver
|
||||
|
||||
resolver = get_render_engine_resolver()
|
||||
return resolver.get_engine(user_id=user_id)
|
||||
engine = resolver.get_engine(user_id=user_id)
|
||||
# 灰度期间打印详细 flag 配置,便于排查
|
||||
config = resolver.get_config_snapshot()
|
||||
logger.info(
|
||||
"[渲染引擎] flag 解析: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
|
||||
user_id,
|
||||
engine,
|
||||
config.get("enabled"),
|
||||
config.get("percentage"),
|
||||
len(config.get("whitelist", [])),
|
||||
config.get("default_engine"),
|
||||
)
|
||||
return engine
|
||||
except Exception as exc:
|
||||
logger.warning("获取渲染引擎配置失败,fallback 到 unified: %s", exc)
|
||||
return ENGINE_UNIFIED
|
||||
# 异常时 fallback 到 legacy(保守策略,与 edit_plan_generation 一致)
|
||||
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
|
||||
return ENGINE_LEGACY
|
||||
|
||||
|
||||
# ── 旧引擎渲染(FFmpeg filter_complex) ────────────────────────────────────────
|
||||
|
||||
@@ -63,7 +63,7 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
|
||||
if session is not None:
|
||||
session.rollback()
|
||||
# 超时重试,指数退避
|
||||
raise self.retry(exc=e, countdown=30)
|
||||
raise self.retry(exc=e, countdown=30) from e
|
||||
|
||||
except CosyVoiceError as e:
|
||||
logger.error(f"TTS synthesis failed for {job_id}: {e}")
|
||||
@@ -139,7 +139,7 @@ def process_tts_segment_synthesis(self: Task, job_id: str) -> dict:
|
||||
logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}")
|
||||
if session is not None:
|
||||
session.rollback()
|
||||
raise self.retry(exc=e, countdown=60)
|
||||
raise self.retry(exc=e, countdown=60) from e
|
||||
|
||||
except CosyVoiceError as e:
|
||||
logger.error(f"TTS segment synthesis failed for {job_id}: {e}")
|
||||
|
||||
@@ -73,7 +73,7 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
|
||||
if session is not None:
|
||||
session.rollback()
|
||||
# 超时属于临时性故障,延迟 30 秒后重试
|
||||
raise self.retry(exc=e, countdown=30)
|
||||
raise self.retry(exc=e, countdown=30) from e
|
||||
|
||||
except CosyVoiceError as e:
|
||||
logger.error(f"Voice clone failed for {profile_id}: {e}")
|
||||
|
||||
@@ -105,7 +105,7 @@ def extract_voice_task(self: Task, asset_id: str) -> dict:
|
||||
except Exception as e:
|
||||
logger.error(f"Voice extraction failed for {asset_id}: {str(e)}")
|
||||
session.rollback()
|
||||
raise self.retry(exc=e, countdown=60)
|
||||
raise self.retry(exc=e, countdown=60) from e
|
||||
finally:
|
||||
session.close()
|
||||
import shutil
|
||||
@@ -141,7 +141,7 @@ def extract_background_task(self: Task, asset_id: str) -> dict:
|
||||
except Exception as e:
|
||||
logger.error(f"Background extraction failed for {asset_id}: {str(e)}")
|
||||
session.rollback()
|
||||
raise self.retry(exc=e, countdown=60)
|
||||
raise self.retry(exc=e, countdown=60) from e
|
||||
finally:
|
||||
session.close()
|
||||
import shutil
|
||||
|
||||
@@ -1,48 +1,104 @@
|
||||
# ============================================================
|
||||
# Worker Dockerfile - 专门用于 Celery Worker
|
||||
# 优化:依赖分层缓存,基础大包和业务依赖分开
|
||||
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身)
|
||||
# 优化项:
|
||||
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
|
||||
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
|
||||
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
|
||||
# ============================================================
|
||||
|
||||
# 基础镜像:Python 3.12 + ffmpeg
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
# ==================== Builder 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
|
||||
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
# 使用阿里云镜像加速
|
||||
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
|
||||
|
||||
# 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 \
|
||||
-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
|
||||
|
||||
# 构建参数:版本号
|
||||
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 \
|
||||
ffmpeg \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libgl1 \
|
||||
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
|
||||
|
||||
# 设置工作目录
|
||||
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
|
||||
|
||||
# ---- 依赖分层:Worker 专属大包(视频处理,变化极少)----
|
||||
COPY requirements-worker.txt /tmp/requirements-worker.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-worker.txt \
|
||||
&& rm /tmp/requirements-worker.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/worker/ /app/apps/worker/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
@@ -51,13 +107,13 @@ COPY packages/ /app/packages/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
|
||||
# 复制 Worker 启动脚本(支持 WORKER_CONCURRENCY 环境变量)
|
||||
# 复制 Worker 启动脚本
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 设置 Python 路径
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
|
||||
@@ -147,10 +147,10 @@ class JWTService:
|
||||
algorithms=[self.config.ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except ExpiredSignatureError:
|
||||
raise ExpiredSignatureError("Token has expired")
|
||||
except ExpiredSignatureError as _e:
|
||||
raise ExpiredSignatureError("Token has expired") from _e
|
||||
except InvalidTokenError as e:
|
||||
raise InvalidTokenError(f"Invalid token: {str(e)}")
|
||||
raise InvalidTokenError(f"Invalid token: {str(e)}") from e
|
||||
|
||||
def verify_access_token(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -656,8 +656,8 @@ class CosyVoiceService:
|
||||
code = body.get("code", "")
|
||||
message = body.get("message", "")
|
||||
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
|
||||
except ValueError:
|
||||
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}")
|
||||
except ValueError as _e:
|
||||
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}") from _e
|
||||
elif response.status_code >= 500:
|
||||
# 服务端错误,可重试
|
||||
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
|
||||
|
||||
@@ -11,7 +11,7 @@ import shutil
|
||||
import tempfile
|
||||
from subprocess import CalledProcessError, TimeoutExpired
|
||||
|
||||
from shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
from packages.shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -78,16 +78,16 @@ class AudioMerger:
|
||||
run_ffmpeg(cmd, timeout=120)
|
||||
except CalledProcessError as e:
|
||||
logger.error(f"FFmpeg 合并失败: stderr={e.stderr}")
|
||||
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}")
|
||||
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}") from e
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
except TimeoutExpired:
|
||||
raise AudioMergeError("FFmpeg 合并超时(120 秒)")
|
||||
except TimeoutExpired as _e:
|
||||
raise AudioMergeError("FFmpeg 合并超时(120 秒)") from _e
|
||||
except AudioMergeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise AudioMergeError(f"音频合并失败: {e}")
|
||||
raise AudioMergeError(f"音频合并失败: {e}") from e
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@@ -170,8 +170,8 @@ class GenerationTask:
|
||||
if isinstance(new_status, str):
|
||||
try:
|
||||
new_status = GenerationTaskStatus(new_status)
|
||||
except ValueError:
|
||||
raise ValueError(f"无效状态: {new_status}")
|
||||
except ValueError as _e:
|
||||
raise ValueError(f"无效状态: {new_status}") from _e
|
||||
|
||||
allowed = _VALID_TRANSITIONS.get(self.status, set())
|
||||
if new_status not in allowed:
|
||||
|
||||
@@ -147,8 +147,8 @@ class Job:
|
||||
if isinstance(job_type, str):
|
||||
try:
|
||||
job_type = JobType(job_type)
|
||||
except ValueError:
|
||||
raise ValueError(f"不支持的任务类型: {job_type}")
|
||||
except ValueError as _e:
|
||||
raise ValueError(f"不支持的任务类型: {job_type}") from _e
|
||||
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
@@ -182,8 +182,8 @@ class Job:
|
||||
if isinstance(new_status, str):
|
||||
try:
|
||||
new_status = JobStatus(new_status)
|
||||
except ValueError:
|
||||
raise ValueError(f"无效状态: {new_status}")
|
||||
except ValueError as _e:
|
||||
raise ValueError(f"无效状态: {new_status}") from _e
|
||||
|
||||
allowed = _VALID_TRANSITIONS.get(self.status, set())
|
||||
if new_status not in allowed:
|
||||
|
||||
@@ -193,8 +193,8 @@ class TTSJob:
|
||||
if isinstance(new_status, str):
|
||||
try:
|
||||
new_status = TTSJobStatus(new_status)
|
||||
except ValueError:
|
||||
raise ValueError(f"无效状态: {new_status}")
|
||||
except ValueError as _e:
|
||||
raise ValueError(f"无效状态: {new_status}") from _e
|
||||
|
||||
allowed = _VALID_TRANSITIONS.get(self.status, set())
|
||||
if new_status not in allowed:
|
||||
|
||||
@@ -177,8 +177,8 @@ class VoiceCloneProfile:
|
||||
if isinstance(new_status, str):
|
||||
try:
|
||||
new_status = VoiceCloneStatus(new_status)
|
||||
except ValueError:
|
||||
raise ValueError(f"无效状态: {new_status}")
|
||||
except ValueError as _e:
|
||||
raise ValueError(f"无效状态: {new_status}") from _e
|
||||
|
||||
allowed = _VALID_TRANSITIONS.get(self.status, set())
|
||||
if new_status not in allowed:
|
||||
|
||||
@@ -114,7 +114,7 @@ class SharedStorageService:
|
||||
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to upload file to OSS: {e}")
|
||||
raise Exception(f"Failed to upload file to OSS: {e}") from e
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""Get public URL for a file."""
|
||||
@@ -129,7 +129,7 @@ class SharedStorageService:
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
self.bucket.get_object_to_file(storage_key, local_path)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to download file from OSS: {e}")
|
||||
raise Exception(f"Failed to download file from OSS: {e}") from e
|
||||
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
"""Get signed download URL."""
|
||||
|
||||
+14
-12
@@ -66,18 +66,18 @@ exclude = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
# 当前阶段:摸底模式,规则集与原flake8对齐
|
||||
# 正式替换 flake8:规则集与原 flake8 完全对齐
|
||||
# 后续迭代计划:
|
||||
# Phase 1: 修完 bugbear 后正式替换 flake8
|
||||
# Phase 2: 启用 UP(pyupgrade) + SIM(simplify)
|
||||
# Phase 3: 启用 RET(return) + ARG(unused-args)
|
||||
# Phase 2: 加入 B (flake8-bugbear),修完后升级为阻断级
|
||||
# Phase 3: 启用 UP(pyupgrade) + SIM(simplify)
|
||||
# Phase 4: 启用 RET(return) + ARG(unused-args)
|
||||
select = [
|
||||
"E", # pycodestyle errors(同flake8)
|
||||
"F", # pyflakes(同flake8)
|
||||
"W", # pycodestyle warnings(同flake8)
|
||||
"B", # flake8-bugbear(新增,摸底用)
|
||||
"E", # pycodestyle errors(同 flake8)
|
||||
"F", # pyflakes(同 flake8)
|
||||
"W", # pycodestyle warnings(同 flake8)
|
||||
]
|
||||
# 与原 setup.cfg flake8 配置对齐,确保不新增阻断
|
||||
# 与原 setup.cfg + .flake8 的 flake8 配置完全对齐
|
||||
# 注意:W503 在 ruff≥0.14 中已被移除(行为变默认),故不列入
|
||||
ignore = [
|
||||
"E203",
|
||||
"E501", # line-too-long(black管)
|
||||
@@ -90,12 +90,14 @@ ignore = [
|
||||
"F403",
|
||||
"F405",
|
||||
"F841", # unused-variable
|
||||
"B008", # do-not-perform-callback-from-arg(fastapi依赖注入)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401", "F403", "F405"]
|
||||
"tests/*" = ["E402", "F401", "F841"]
|
||||
"packages/ports/*" = ["E301"]
|
||||
"tests/*" = ["E402", "F401", "F821", "F841"]
|
||||
"packages/ports/*" = ["E301"] # E704 在 ruff≥0.14 已移除
|
||||
"apps/api/app/api/routes/auth.py" = ["ALL"]
|
||||
"apps/api/app/api/routes/workspaces.py" = ["ALL"]
|
||||
"apps/api/app/middleware/auth.py" = ["ALL"]
|
||||
"apps/*/migrations/*" = ["ALL"]
|
||||
"alembic/*" = ["ALL"]
|
||||
|
||||
Regular → Executable
+1
-1
@@ -3,7 +3,7 @@
|
||||
# 代码质量
|
||||
black==26.5.1
|
||||
isort==8.0.1
|
||||
flake8==7.3.0
|
||||
ruff==0.14.0
|
||||
bandit==1.9.4
|
||||
|
||||
# 测试
|
||||
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底)
|
||||
# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题
|
||||
# 用法: docker_build_push.sh <Dockerfile> <image_tag> <cache_ref> [build_arg...]
|
||||
set -eu
|
||||
|
||||
DOCKERFILE="$1"
|
||||
IMAGE_TAG="$2"
|
||||
CACHE_REF="$3"
|
||||
shift 3
|
||||
BUILD_ARGS=""
|
||||
for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder --driver docker-container
|
||||
echo "Created ci-builder"
|
||||
else
|
||||
docker buildx use ci-builder
|
||||
echo "Using existing ci-builder"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
# 从cache_ref中提取缓存名称(如 api-cache:develop -> api-cache-develop)
|
||||
CACHE_NAME=$(echo "$CACHE_REF" | tr '/' '_' | tr ':' '-')
|
||||
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
|
||||
|
||||
mkdir -p "$LOCAL_CACHE_DIR"
|
||||
|
||||
# 缓存源:local优先,registry兜底
|
||||
CACHE_FROM_LOCAL="type=local,src=${LOCAL_CACHE_DIR}"
|
||||
CACHE_FROM_REGISTRY="type=registry,ref=${CACHE_REF},ignore-error=true"
|
||||
|
||||
# 本地缓存目标(必选,mode=max最大化命中率)
|
||||
CACHE_TO_LOCAL="type=local,dest=${LOCAL_CACHE_DIR},mode=max"
|
||||
|
||||
echo "=== Step 1: Build & push image (local cache read-write + registry read) ==="
|
||||
echo "Local cache: ${LOCAL_CACHE_DIR}"
|
||||
echo "Registry cache: ${CACHE_REF}"
|
||||
echo ""
|
||||
|
||||
docker buildx build \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "${CACHE_FROM_LOCAL}" \
|
||||
--cache-from "${CACHE_FROM_REGISTRY}" \
|
||||
--cache-to "${CACHE_TO_LOCAL}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--push \
|
||||
.
|
||||
|
||||
echo ""
|
||||
echo "Image pushed: ${IMAGE_TAG}"
|
||||
echo "Local cache updated"
|
||||
|
||||
echo ""
|
||||
echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
|
||||
CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
|
||||
|
||||
MAX_RETRIES=3
|
||||
SUCCESS=0
|
||||
for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
|
||||
if docker buildx build \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "${CACHE_FROM_LOCAL}" \
|
||||
--cache-to "${CACHE_TO_REGISTRY}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--push \
|
||||
.; then
|
||||
echo "Registry cache synced (attempt $attempt)"
|
||||
SUCCESS=1
|
||||
break
|
||||
else
|
||||
echo "Registry cache sync failed (attempt $attempt)"
|
||||
if [ $attempt -lt $MAX_RETRIES ]; then
|
||||
WAIT=$((attempt * 5))
|
||||
echo "Retrying in ${WAIT}s..."
|
||||
sleep $WAIT
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $SUCCESS -eq 0 ]; then
|
||||
echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Build completed: ${IMAGE_TAG}"
|
||||
Executable
+250
@@ -0,0 +1,250 @@
|
||||
#!/bin/sh
|
||||
# ===========================================
|
||||
# Staging 部署脚本(SSH 模式)
|
||||
# ===========================================
|
||||
# 通过 SSH 在 staging 服务器上执行
|
||||
#
|
||||
# 环境变量:
|
||||
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
|
||||
# REGISTRY_TOKEN - Registry 访问令牌
|
||||
# REGISTRY - Registry 地址(默认 git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas)
|
||||
# REGISTRY_USER - Registry 用户名(默认 xiaoxia)
|
||||
# ENV_FILE - 环境变量文件路径
|
||||
# GENERATED_DIR - 生成文件目录
|
||||
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false)
|
||||
|
||||
set -eu
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Staging 部署 - $IMAGE_TAG"
|
||||
echo "=========================================="
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 新版本镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# Re-tag 成本地名
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "All images pulled and tagged."
|
||||
|
||||
# ---- 备份 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"
|
||||
fi
|
||||
rm -rf "$_tmpdir"
|
||||
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
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 创建网络(不存在则创建) ----
|
||||
docker network create xiaoxia-net-staging 2>/dev/null || true
|
||||
|
||||
# ---- 数据库迁移 ----
|
||||
if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
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
|
||||
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..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web ----
|
||||
LEGACY_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 \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-staging
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1: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"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Staging deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
+249
-104
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# CI Staging 健康检查 + 自动回滚脚本(Watchtower 模式)
|
||||
# CI Staging 健康检查 + 自动回滚脚本(SSH 部署模式)
|
||||
# ===========================================
|
||||
#
|
||||
# 在 CI Runner 上执行,通过公网 URL 检查 Staging 部署健康状态。
|
||||
# 不健康则自动回滚(把 :staging tag 指回旧版本镜像,Watchtower 检测到后自动回滚)。
|
||||
# 不健康则通过 SSH 自动回滚到上一个版本的镜像。
|
||||
#
|
||||
# 用法:
|
||||
# ./ci_staging_healthcheck.sh
|
||||
@@ -13,14 +13,15 @@
|
||||
# STAGING_API_URL - Staging API 地址 (默认 https://staging-api.xiaoxiajianji.com)
|
||||
# STAGING_WEB_URL - Staging Web 地址 (默认 https://staging.xiaoxiajianji.com)
|
||||
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 120)
|
||||
# WATCHTOWER_WAIT - 等待 Watchtower 检测更新的秒数 (默认 90)
|
||||
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
|
||||
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
||||
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
||||
#
|
||||
# REGISTRY_TOKEN - Gitea Registry Token(回滚时需要)
|
||||
# REGISTRY_HOST - Registry 地址 (默认 git.xiaoxiajianji.com)
|
||||
# REGISTRY_REPO - 仓库路径 (默认 xiaoxia/xiaoxia-saas)
|
||||
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167)
|
||||
# STAGING_SSH_USER - SSH 用户名 (默认 root)
|
||||
# STAGING_SSH_PORT - SSH 端口 (默认 22222)
|
||||
# STAGING_SSH_KEY - SSH 私钥内容
|
||||
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
|
||||
#
|
||||
# GITHUB_SHA - 当前 commit SHA
|
||||
# GITHUB_REF_NAME - 分支名
|
||||
@@ -36,12 +37,15 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
STAGING_API_URL="${STAGING_API_URL:-https://staging-api.xiaoxiajianji.com}"
|
||||
STAGING_WEB_URL="${STAGING_WEB_URL:-https://staging.xiaoxiajianji.com}"
|
||||
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
|
||||
WATCHTOWER_WAIT="${WATCHTOWER_WAIT:-90}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
|
||||
|
||||
REGISTRY_HOST="${REGISTRY_HOST:-git.xiaoxiajianji.com}"
|
||||
REGISTRY_REPO="${REGISTRY_REPO:-xiaoxia/xiaoxia-saas}"
|
||||
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
|
||||
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
|
||||
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
# 颜色
|
||||
RED='\033[0;31m'
|
||||
@@ -56,59 +60,85 @@ log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
||||
|
||||
# ===========================================
|
||||
# 1. 记录部署前 :staging 镜像 digest(用于回滚)
|
||||
# SSH 工具函数
|
||||
# ===========================================
|
||||
SSH_KEY_PATH=""
|
||||
|
||||
setup_ssh() {
|
||||
# 查找或创建 SSH 密钥
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/staging_deploy_key"
|
||||
mkdir -p "$HOME/.ssh"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$SSH_KEY_PATH"
|
||||
chmod 600 "$SSH_KEY_PATH"
|
||||
else
|
||||
log_error "没有可用的 SSH 密钥"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -p "$STAGING_SSH_PORT" -H "$STAGING_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
log_info "SSH 已配置: ${STAGING_SSH_USER}@${STAGING_SSH_HOST}:${STAGING_SSH_PORT}"
|
||||
}
|
||||
|
||||
run_ssh() {
|
||||
local cmd="$1"
|
||||
ssh -p "$STAGING_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
|
||||
"${STAGING_SSH_USER}@${STAGING_SSH_HOST}" "$cmd"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 1. 记录部署前各服务的镜像版本(用于回滚)
|
||||
# ===========================================
|
||||
ROLLBACK_API_TAG=""
|
||||
ROLLBACK_WORKER_TAG=""
|
||||
ROLLBACK_WEB_TAG=""
|
||||
|
||||
save_rollback_target() {
|
||||
log_step "记录当前 :staging 镜像 digest(回滚目标)..."
|
||||
log_step "记录当前 staging 各服务镜像版本(回滚目标)..."
|
||||
|
||||
# 登录 Registry
|
||||
if [ -n "${REGISTRY_TOKEN:-}" ]; then
|
||||
printf '%s' "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" -u xiaoxia --password-stdin 2>/dev/null || true
|
||||
fi
|
||||
# 通过 SSH 获取当前运行的容器镜像
|
||||
local api_image worker_image web_image
|
||||
api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-staging 2>/dev/null || echo ''")
|
||||
worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-staging 2>/dev/null || echo ''")
|
||||
web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-staging 2>/dev/null || echo ''")
|
||||
|
||||
ROLLBACK_IMAGES=""
|
||||
# 提取 tag(镜像名是 xiaoxia-saas-api:abc123 或 git.xiaoxiajianji.com/.../xiaoxia-saas-api:staging 格式)
|
||||
ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "")
|
||||
ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "")
|
||||
ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "")
|
||||
|
||||
for svc in api worker web; do
|
||||
image="${REGISTRY_HOST}/${REGISTRY_REPO}/xiaoxia-saas-${svc}:staging"
|
||||
# pull 当前 :staging 镜像获取 digest
|
||||
if docker pull "$image" 2>/dev/null; then
|
||||
digest=$(docker inspect -f '{{index .RepoDigests 0}}' "$image" 2>/dev/null || echo "")
|
||||
if [ -n "$digest" ]; then
|
||||
ROLLBACK_IMAGES="${ROLLBACK_IMAGES}${svc}:${digest} "
|
||||
log_info " $svc: $digest"
|
||||
else
|
||||
log_warn " $svc: 无法获取 digest,该服务将不参与回滚"
|
||||
fi
|
||||
log_info " API: ${ROLLBACK_API_TAG:-未知}"
|
||||
log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}"
|
||||
log_info " Web: ${ROLLBACK_WEB_TAG:-未知}"
|
||||
|
||||
# 验证三个服务版本是否一致
|
||||
if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then
|
||||
if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then
|
||||
log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG"
|
||||
export ROLLBACK_TAG="$ROLLBACK_API_TAG"
|
||||
else
|
||||
log_warn " $svc: 拉取 :staging 镜像失败,该服务将不参与回滚"
|
||||
log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本"
|
||||
export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG
|
||||
export ROLLBACK_TAG_MIXED="true"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$ROLLBACK_IMAGES" ]; then
|
||||
log_warn "没有获取到任何可回滚镜像,回滚功能将不可用"
|
||||
else
|
||||
log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限"
|
||||
fi
|
||||
|
||||
export ROLLBACK_IMAGES
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 2. 等待 Watchtower 更新
|
||||
# ===========================================
|
||||
wait_for_watchtower() {
|
||||
log_step "等待 Watchtower 检测新镜像并更新(${WATCHTOWER_WAIT}s)..."
|
||||
sleep "$WATCHTOWER_WAIT"
|
||||
log_info "等待结束,开始健康检查"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 3. 健康检查
|
||||
# 2. 健康检查(公网视角)
|
||||
# ===========================================
|
||||
health_check() {
|
||||
local timeout="$HEALTH_CHECK_TIMEOUT"
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
log_step "健康检查(超时 ${timeout}s)..."
|
||||
log_step "公网健康检查(超时 ${timeout}s)..."
|
||||
log_info " API: ${STAGING_API_URL}/health"
|
||||
log_info " Web: ${STAGING_WEB_URL}/"
|
||||
|
||||
@@ -171,69 +201,183 @@ health_check() {
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 4. 执行回滚(把 :staging tag 改回旧版本)
|
||||
# 3. 执行回滚(SSH 重新部署旧版本)
|
||||
# ===========================================
|
||||
do_rollback() {
|
||||
log_step "执行回滚:将 :staging tag 指回旧版本镜像..."
|
||||
log_step "执行回滚:通过 SSH 重新部署旧版本镜像..."
|
||||
|
||||
if [ -z "${ROLLBACK_IMAGES:-}" ]; then
|
||||
log_error "没有可回滚的镜像记录,无法自动回滚"
|
||||
local rollback_tag="${ROLLBACK_TAG:-}"
|
||||
if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then
|
||||
log_error "没有可回滚的版本记录,无法自动回滚"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local rollback_ok=true
|
||||
# 如果版本不一致,用 API 的版本作为回滚目标
|
||||
if [ -z "$rollback_tag" ]; then
|
||||
rollback_tag="$ROLLBACK_API_TAG"
|
||||
fi
|
||||
|
||||
for entry in $ROLLBACK_IMAGES; do
|
||||
svc="${entry%%:*}"
|
||||
digest="${entry#*:}"
|
||||
# digest 是 host/repo/image@sha256:xxx 格式,entry 里只取了第一部分,需要重新组合
|
||||
# 实际上 ROLLBACK_IMAGES 存的是 svc:repo/image@sha256:xxx
|
||||
# 让我们重新从完整digest中提取
|
||||
image_digest="${entry#*:}:${entry#*:*:}"
|
||||
# 不对,格式是 svc:REGISTRY_HOST/REGISTRY_REPO/xiaoxia-saas-svc@sha256:xxx
|
||||
# 让我重新组织一下
|
||||
:
|
||||
done
|
||||
if [ -z "$rollback_tag" ]; then
|
||||
log_error "无法确定回滚版本"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 上面的解析有问题,重新来
|
||||
# ROLLBACK_IMAGES 格式: "api:digest1 worker:digest2 web:digest3"
|
||||
# 但digest本身带冒号(@sha256:xxx),所以分隔有问题
|
||||
# 改用数组方式
|
||||
log_info "开始回滚各服务..."
|
||||
log_info "回滚目标版本: $rollback_tag"
|
||||
|
||||
local IFS=' '
|
||||
for pair in $ROLLBACK_IMAGES; do
|
||||
svc="${pair%%:*}"
|
||||
# 剩余部分是完整的 digest 字符串(含 @sha256:xxx)
|
||||
digest="${pair#*:}"
|
||||
# 构建回滚脚本(直接部署旧版本镜像,不跑 migration)
|
||||
local rollback_script=$(cat << 'ROLLBACK_EOF'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
image_name="${REGISTRY_HOST}/${REGISTRY_REPO}/xiaoxia-saas-${svc}"
|
||||
IMAGE_TAG="$1"
|
||||
REGISTRY_TOKEN="$2"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
log_info " 回滚 $svc 到: $digest"
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
# 通过 digest 拉取旧镜像
|
||||
if docker pull "$digest" 2>/dev/null; then
|
||||
# 重新打 :staging tag
|
||||
docker tag "$digest" "${image_name}:staging"
|
||||
docker push "${image_name}:staging"
|
||||
log_info " ✅ $svc 已回滚"
|
||||
else
|
||||
log_error " ❌ $svc 回滚失败:无法拉取旧镜像"
|
||||
rollback_ok=false
|
||||
fi
|
||||
done
|
||||
echo "=== Rollback to $IMAGE_TAG ==="
|
||||
|
||||
if [ "$rollback_ok" = true ]; then
|
||||
log_info "所有服务回滚命令执行完成,Watchtower 将在 ~60s 内检测到并更新"
|
||||
# 登录 Registry
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Pull 旧版本镜像
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API"
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER"
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB"
|
||||
|
||||
echo "Rollback images pulled."
|
||||
|
||||
# 停止当前容器(回滚不跑 migration,避免数据问题)
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 启动 API(回滚不跑 migration)
|
||||
echo "Starting API (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# 启动 Worker
|
||||
echo "Starting Worker (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# 启动 Web
|
||||
LEGACY_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
|
||||
|
||||
echo "Starting Web (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# 等待 API 健康
|
||||
echo "Waiting for API (rollback)..."
|
||||
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 (rollback)."
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 等待 Web 健康
|
||||
echo "Waiting for Web (rollback)..."
|
||||
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 (rollback)."
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "=== Rollback complete: $IMAGE_TAG ==="
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
ROLLBACK_EOF
|
||||
)
|
||||
|
||||
# 将脚本 base64 编码后通过 SSH 执行
|
||||
local script_b64
|
||||
script_b64=$(echo "$rollback_script" | base64 -w 0)
|
||||
|
||||
log_info "在 staging 服务器上执行回滚脚本..."
|
||||
if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then
|
||||
log_info "✅ 回滚命令执行完成"
|
||||
return 0
|
||||
else
|
||||
log_error "部分服务回滚失败"
|
||||
log_error "❌ 回滚命令执行失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 5. 发送通知
|
||||
# 4. 发送通知
|
||||
# ===========================================
|
||||
send_notification() {
|
||||
local status="$1" # success / failure / rollback
|
||||
@@ -250,12 +394,12 @@ send_notification() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 调用通知脚本
|
||||
if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then
|
||||
python3 "$SCRIPT_DIR/deploy_notify.py" \
|
||||
--status "$status" \
|
||||
--detail "$detail" \
|
||||
--webhook "$webhook" \
|
||||
--env staging \
|
||||
2>/dev/null || log_warn "通知发送失败(非致命)"
|
||||
else
|
||||
log_warn "找不到 deploy_notify.py,跳过通知"
|
||||
@@ -267,41 +411,42 @@ send_notification() {
|
||||
# ===========================================
|
||||
main() {
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CI Staging 健康检查 + 自动回滚"
|
||||
echo "=========================================="
|
||||
echo "==========================================="
|
||||
echo " CI Staging 健康检查 + 自动回滚(SSH模式)"
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
|
||||
local deploy_status="success"
|
||||
local deploy_detail=""
|
||||
|
||||
# 1. 记录部署前状态(回滚目标)
|
||||
save_rollback_target
|
||||
# 1. 设置 SSH
|
||||
if ! setup_ssh; then
|
||||
log_error "SSH 配置失败,无法执行回滚"
|
||||
fi
|
||||
|
||||
# 2. 等待 Watchtower 更新
|
||||
wait_for_watchtower
|
||||
# 2. 记录部署前状态(回滚目标)
|
||||
save_rollback_target || true
|
||||
|
||||
# 3. 健康检查
|
||||
# 3. 健康检查(公网视角)
|
||||
if ! health_check; then
|
||||
log_error "健康检查失败"
|
||||
deploy_status="failure"
|
||||
deploy_detail="健康检查超时,部署后服务未正常启动"
|
||||
deploy_detail="公网健康检查超时,部署后服务未正常响应"
|
||||
|
||||
# 自动回滚
|
||||
if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then
|
||||
log_warn "开始自动回滚..."
|
||||
if do_rollback; then
|
||||
deploy_status="rollback"
|
||||
deploy_detail="健康检查失败,已自动回滚到上一版本"
|
||||
deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})"
|
||||
|
||||
# 回滚后再检查一下回滚后的状态
|
||||
log_info "等待 Watchtower 应用回滚(90s)..."
|
||||
sleep 90
|
||||
# 回滚后再检查一下公网状态
|
||||
log_info "回滚完成,重新检查公网健康状态..."
|
||||
if health_check; then
|
||||
log_info "✅ 回滚后服务已恢复"
|
||||
deploy_detail="${deploy_detail},回滚后服务已恢复"
|
||||
else
|
||||
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
|
||||
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
|
||||
deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查"
|
||||
fi
|
||||
else
|
||||
@@ -321,7 +466,7 @@ main() {
|
||||
log_info " ✅ Staging 部署成功!"
|
||||
log_info "=================================="
|
||||
|
||||
deploy_detail="部署成功,所有健康检查通过"
|
||||
deploy_detail="部署成功,所有健康检查通过 (${GITHUB_SHA:-未知版本})"
|
||||
send_notification "success" "$deploy_detail"
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
+10
-1
@@ -665,7 +665,16 @@ class TestResponseSchema:
|
||||
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
expected_keys = {"plan_id", "plan_status", "generation_task_id", "clips"}
|
||||
expected_keys = {
|
||||
"plan_id",
|
||||
"plan_status",
|
||||
"generation_task_id",
|
||||
"generation_task_status",
|
||||
"progress",
|
||||
"video_url",
|
||||
"error_message",
|
||||
"clips",
|
||||
}
|
||||
assert set(data.keys()) == expected_keys
|
||||
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@ class TestVerifyUrlRedirectValidation:
|
||||
except urllib.error.HTTPError as e:
|
||||
if 300 <= e.code < 400 and e.headers.get("Location"):
|
||||
if redirect_count >= max_redirects:
|
||||
raise Exception(f"重定向次数超过上限 ({max_redirects})")
|
||||
raise Exception(f"重定向次数超过上限 ({max_redirects})") from e
|
||||
location = e.headers["Location"]
|
||||
current = urljoin(safe_url, location)
|
||||
redirect_count += 1
|
||||
|
||||
@@ -45,6 +45,7 @@ class FakeClip:
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
status: str = "ready"
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -65,6 +66,7 @@ def _make_clip(
|
||||
asset_id: str = "",
|
||||
duration: float = 0.0,
|
||||
transition_effect: str = "cut",
|
||||
transition_duration: float = 0.0,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> FakeClip:
|
||||
return FakeClip(
|
||||
@@ -74,6 +76,7 @@ def _make_clip(
|
||||
asset_id=asset_id or f"asset_{clip_id}.mp4",
|
||||
duration=duration,
|
||||
transition_effect=transition_effect,
|
||||
transition_duration=transition_duration,
|
||||
config=config or {},
|
||||
)
|
||||
|
||||
@@ -332,7 +335,7 @@ class TestBuildFilterComplex:
|
||||
assert "[final_video]" in fc
|
||||
|
||||
def test_single_layer_multi_clips(self):
|
||||
"""多个 main clips → xfade 串联。"""
|
||||
"""多个 main clips(默认硬切)→ concat 串联。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=3.0),
|
||||
_make_clip("c2", "main", order=1, duration=3.0),
|
||||
@@ -343,6 +346,35 @@ class TestBuildFilterComplex:
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
fc, input_args = svc._build_filter_complex(layers)
|
||||
|
||||
assert input_args.count("-i") == 2
|
||||
# 全硬切场景走 concat filter(性能远优于 xfade)
|
||||
assert "concat=n=2:v=1:a=0" in fc
|
||||
assert "[final_video]" in fc
|
||||
|
||||
def test_single_layer_multi_clips_with_transition(self):
|
||||
"""多个 main clips 带转场效果 → xfade 串联。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=3.0),
|
||||
_make_clip(
|
||||
"c2",
|
||||
"main",
|
||||
order=1,
|
||||
duration=3.0,
|
||||
transition_effect="fade",
|
||||
transition_duration=0.5,
|
||||
),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
@@ -380,7 +412,7 @@ class TestBuildFilterComplex:
|
||||
"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=3.0),
|
||||
_make_clip("c2", "main", order=1, duration=5.0),
|
||||
_make_clip("c2", "main", order=1, duration=5.0, transition_effect="fade", transition_duration=0.5),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
|
||||
Reference in New Issue
Block a user