Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fccfcd181 | |||
| 8ea3263a12 | |||
| a75f749b39 | |||
| f5d8a32229 | |||
| 7fc68e797b | |||
| 34e5b18a30 | |||
| f6fc9736a5 | |||
| fde3ab3063 | |||
| 09697f4230 | |||
| 4e042ba597 | |||
| eb4ad569be | |||
| 76a0fdd00c | |||
| 876c3a2aaa | |||
| 576722f60b | |||
| 34ea6a866b | |||
| a7a1a6395b | |||
| 5f17fd5faf | |||
| cbda1ec4d5 | |||
| c5fb5b47ac | |||
| 637c5a8b6f | |||
| 21ae1c627f | |||
| a4a279ae2b | |||
| 0724ddfbf3 | |||
| 4967891d8b | |||
| 7cec7b4a31 | |||
| b2700b85ff | |||
| e4ce3caad1 | |||
| 35f92f8101 | |||
| ed061aae3e | |||
| 6d8656b3f6 | |||
| 52e866b750 | |||
| 2cb273b472 | |||
| 7a1f7c89b5 | |||
| 8c1566cec3 | |||
| 246406eeb0 | |||
| a0c27e3132 | |||
| 61f0601cd4 | |||
| a39680dbca | |||
| 319347cb41 | |||
| 8a59fff246 | |||
| 13e5c03e77 | |||
| 83d373c840 | |||
| 7b2d794126 | |||
| 6ce7db7f8e | |||
| 6b45ddb6fc | |||
| d3438b9786 | |||
| 1669e787c2 | |||
| 7cffe564a9 | |||
| c2ad5003df | |||
| 14be2b654b | |||
| 92c11ced63 | |||
| 3379f07a15 | |||
| cfe75543a1 | |||
| 5e44f3dd1a | |||
| 95683ab5ef | |||
| 6bde806462 | |||
| 0ae105628d | |||
| 24efd5121f | |||
| e0c3bae072 | |||
| 05b84ac669 | |||
| 809339fee3 | |||
| 6fe648432a | |||
| 81ae089985 | |||
| 524dbc002a | |||
| df0e95ee58 | |||
| b59cf8aab5 | |||
| 906eb5de9c | |||
| f22a6fd90e | |||
| a18244b8a4 | |||
| 48102ca2e2 | |||
| baf6a4143c | |||
| 2b0724dd57 | |||
| b1babaaedd | |||
| 44402f01d6 | |||
| c424ffcc66 | |||
| cd27bd087f | |||
| abc3fbea1b | |||
| 07f6705534 | |||
| a2a8d163f9 | |||
| d66046aa4b | |||
| 9554373f24 | |||
| 9f69fde30b | |||
| 4c5057637a | |||
| faf018030f | |||
| 933973311f | |||
| cffe84c764 | |||
| ec5b29197e | |||
| 5e3a3099cf | |||
| d9f9bce1bd | |||
| 8193e7756a | |||
| 9a1eb14fbb | |||
| beabc932e6 | |||
| 269723ced2 | |||
| 75bcff9620 | |||
| 15c635b553 | |||
| 4f7da8e793 | |||
| cda899ad5f | |||
| 3a8da7ea12 | |||
| df38101bd9 | |||
| 6b1051f1e8 | |||
| 981d965d89 | |||
| efcc5eafdf | |||
| ba6cacec8d | |||
| 29bae54328 | |||
| d4fdebd1e5 | |||
| 8be5df5a11 | |||
| 61dcd196fd | |||
| 7260ee282f | |||
| 9a457d06cb | |||
| 6aa27c4fb3 | |||
| 151ec6e3d5 | |||
| d46ef0a336 |
+2
-1
@@ -1 +1,2 @@
|
||||
trigger: 1784009947
|
||||
CI trigger file - safe to delete
|
||||
updated!
|
||||
@@ -0,0 +1,78 @@
|
||||
name: CI Failure Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # 每6小时检查一次
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days:
|
||||
description: '统计最近N天的失败'
|
||||
required: false
|
||||
default: '7'
|
||||
fail_threshold:
|
||||
description: '失败次数阈值'
|
||||
required: false
|
||||
default: '3'
|
||||
fail_rate_threshold:
|
||||
description: '失败率阈值(%)'
|
||||
required: false
|
||||
default: '30'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: CI重复失败检测
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
|
||||
- name: Run failure detection
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
GITEA_URL: https://git.xiaoxiajianji.com
|
||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
FAIL_CHECK_DAYS: ${{ inputs.days || 7 }}
|
||||
FAIL_THRESHOLD: ${{ inputs.fail_threshold || 3 }}
|
||||
FAIL_RATE_THRESHOLD: ${{ inputs.fail_rate_threshold || 30 }}
|
||||
run: |
|
||||
set +e
|
||||
python3 scripts/ci/ci_repeated_failure_detector.py
|
||||
EXIT_CODE=$?
|
||||
echo "检测完成,退出码: $EXIT_CODE"
|
||||
# 0=无异常, 1=有警告, 2=有严重问题
|
||||
# 监控脚本永远不fail,避免告警风暴
|
||||
exit 0
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
@@ -0,0 +1,103 @@
|
||||
name: CI Health Daily Report
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 1 * * *' # UTC 01:00 = 北京时间 09:00
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
ci-health-report:
|
||||
name: CI健康度每日巡检
|
||||
runs-on: saas
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Generate CI Dashboard HTML
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
echo "=== 生成 CI 健康度 HTML 看板 ==="
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output ci_dashboard.html
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -eq 0 ] && [ -f ci_dashboard.html ]; then
|
||||
HTML_SIZE=$(wc -c < ci_dashboard.html)
|
||||
echo ""
|
||||
echo "✅ HTML 看板生成成功 (${HTML_SIZE} bytes)"
|
||||
echo "路径: $(pwd)/ci_dashboard.html"
|
||||
# 输出文件内容前几行,方便在 Actions 日志中确认
|
||||
echo ""
|
||||
echo "--- 看板预览 (前 5 行) ---"
|
||||
head -5 ci_dashboard.html
|
||||
echo "...(完整内容见产物文件)"
|
||||
else
|
||||
echo "❌ HTML 看板生成失败 (exit code: $EXIT_CODE)"
|
||||
fi
|
||||
echo ""
|
||||
# 永远成功,看板生成失败不影响主流程
|
||||
exit 0
|
||||
|
||||
- name: Run CI health check and report
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI健康度每日巡检 ==="
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
python3 scripts/ci/ci_health_report.py --limit 30
|
||||
EXIT_CODE=$?
|
||||
echo ""
|
||||
echo "巡检完成 (exit code: $EXIT_CODE)"
|
||||
# 永远成功,不影响CI状态(通知失败不应该标红)
|
||||
exit 0
|
||||
@@ -76,18 +76,12 @@ jobs:
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Validate Code Quality And Tests
|
||||
validate-code-quality:
|
||||
name: Validate - Code Quality
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 8
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -102,7 +96,6 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# pip install 带重试(网络不稳定时自动重试)
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
@@ -127,16 +120,17 @@ jobs:
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run all quality checks
|
||||
- name: Run code quality and security checks
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/run_validate.sh
|
||||
run: bash scripts/ci/validate_code_quality.sh
|
||||
- name: Auto-fix formatting (black + isort)
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
run: python3 scripts/ci/auto_fix_formatting.py
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
@@ -146,7 +140,7 @@ jobs:
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -159,7 +153,161 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-type-check:
|
||||
name: Validate - Type Check (mypy)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run mypy type check
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_mypy.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Type Check (mypy)" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-migration:
|
||||
name: Validate - Migration (alembic)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run alembic migration validation
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_migration.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Migration (alembic)" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -335,9 +483,6 @@ jobs:
|
||||
- name: Run Prettier check
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_frontend_run.sh "npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\""
|
||||
- name: Run Vitest tests
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run src/test"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -390,9 +535,11 @@ jobs:
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run Vitest with coverage
|
||||
- name: Run Vitest (incremental for PRs, full for main branches)
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run --coverage"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/vitest_incremental.sh
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -419,6 +566,196 @@ jobs:
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
build-pr:
|
||||
name: PR Build ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: api
|
||||
service_display: API
|
||||
dockerfile: infra/docker/api.Dockerfile
|
||||
image_name: xiaoxia-saas-api
|
||||
cache_name: api-cache
|
||||
timeout: 30
|
||||
- service: worker
|
||||
service_display: Worker
|
||||
dockerfile: infra/docker/worker.Dockerfile
|
||||
image_name: xiaoxia-saas-worker
|
||||
cache_name: worker-cache
|
||||
timeout: 40
|
||||
- service: web
|
||||
service_display: Web
|
||||
dockerfile: infra/docker/web.Dockerfile
|
||||
image_name: xiaoxia-saas-web
|
||||
cache_name: web-cache
|
||||
timeout: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Docker login to Registry (for cache read)
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "Docker login attempt $i/3"
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Pre-build worker base images (fallback if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
id: prebuild
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# 尝试拉取基础镜像
|
||||
echo "检查基础镜像..."
|
||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||
echo "基础镜像已存在,使用远程镜像"
|
||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "基础镜像不存在,本地构建(fallback模式)..."
|
||||
|
||||
# 构建builder基础镜像
|
||||
echo "构建 worker-base-builder..."
|
||||
# 用buildx docker-container驱动构建(兼容DooD模式:普通docker build看不到容器内文件)
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap > /dev/null 2>&1
|
||||
|
||||
# 构建builder基础镜像(带重试,buildx容器偶发不稳定)
|
||||
echo "构建 worker-base-builder..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$BASE_BUILDER" .; then
|
||||
echo "worker-base-builder 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-builder 构建失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 构建runtime基础镜像
|
||||
echo "构建 worker-base-runtime..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$BASE_RUNTIME" .; then
|
||||
echo "worker-base-runtime 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-runtime 构建失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像本地构建完成"
|
||||
fi
|
||||
|
||||
- name: Build PR image (verify only, no push)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||
BUILD_ARG_STR=""
|
||||
for arg in $EXTRA_BUILD_ARGS; do
|
||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||
done
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "Fallback PR Build successful"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "PR Build attempt $i/3"
|
||||
if bash scripts/ci/docker_build_only.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "PR Build successful"
|
||||
break
|
||||
fi
|
||||
echo "PR Build failed (attempt $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "Next retry with --no-cache"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
echo "${{ matrix.service_display }} PR build verified: ${IMAGE_TAG}"
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="PR Build ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
@@ -465,9 +802,16 @@ jobs:
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin
|
||||
docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"
|
||||
echo "Docker login successful"
|
||||
# Docker login 带重试(网络波动时自动重试)
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Setup cache strategy
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -526,6 +870,15 @@ jobs:
|
||||
|
||||
echo
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
|
||||
docker buildx rm ci-builder 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -589,9 +942,16 @@ jobs:
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin
|
||||
docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"
|
||||
echo "Docker login successful"
|
||||
# Docker login 带重试(网络波动时自动重试)
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Install SSH client
|
||||
if: success()
|
||||
shell: sh
|
||||
@@ -858,9 +1218,16 @@ jobs:
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin
|
||||
docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"
|
||||
echo "Docker login successful"
|
||||
# Docker login 带重试(网络波动时自动重试)
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Setup cache strategy
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -1187,5 +1554,4 @@ jobs:
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
# 网络波动自动重试2次
|
||||
retry:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
|
||||
- name: Check CI trigger status for all open PRs
|
||||
env:
|
||||
|
||||
@@ -25,9 +25,21 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# 网络波动自动重试2次
|
||||
retry:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# 确保 python3-pip 可用(兼容不同基础镜像)
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq python3-pip python3-venv >/dev/null 2>&1
|
||||
fi
|
||||
# 部分镜像 ensurepip 方式兜底
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
||||
python3 -m ensurepip --upgrade 2>/dev/null || curl -sS https://bootstrap.pypa.io/get-pip.py | python3
|
||||
fi
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install requests
|
||||
|
||||
@@ -64,3 +76,4 @@ jobs:
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: PR Auto Scan
|
||||
# 定时扫描所有open PR,对CI全绿的触发审批/合并
|
||||
# 作为短作业模式的兜底,防止事件驱动遗漏
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/5 * * * *" # 每5分钟扫描一次
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
auto-scan:
|
||||
name: Auto Scan Open PRs
|
||||
runs-on: ci-check
|
||||
timeout-minutes: 5
|
||||
if: github.repository == 'xiaoxia/xiaoxia-saas'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/pr_auto_scan.py?ref=develop" -o /tmp/pr_auto_scan.py
|
||||
python3 /tmp/pr_auto_scan.py --help > /dev/null 2>&1 || {
|
||||
# fallback: checkout
|
||||
echo "使用checkout方式"
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=develop" | bash
|
||||
}
|
||||
|
||||
- name: Scan and auto process PRs
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "=== 扫描所有open PR并自动处理 ==="
|
||||
echo "时间: $(date)"
|
||||
echo
|
||||
|
||||
python3 /tmp/pr_auto_scan.py --token "$REVIEW_TOKEN" --repo "$GITHUB_REPOSITORY" --base develop --approve --merge --dry-run false
|
||||
|
||||
echo ""
|
||||
echo "✅ 扫描完成"
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "" || true
|
||||
@@ -3,6 +3,7 @@ name: PR Automation
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,7 +13,7 @@ jobs:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 3 # 短作业模式:最多3分钟,不占runner
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -54,7 +55,9 @@ jobs:
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
@@ -70,7 +73,7 @@ jobs:
|
||||
sleep 30
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 120); do
|
||||
for attempt in $(seq 1 12); do # 短作业模式:最多等2分钟(12次x10秒),不满足就退出等下次触发
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
@@ -155,7 +158,7 @@ jobs:
|
||||
|
||||
# 还有CI在跑 → 继续等
|
||||
if [ "$ANY_PENDING" = "true" ]; then
|
||||
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
|
||||
echo "⏳ CI仍在运行中(第${attempt}/12次),超时后将退出等待下次触发..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
@@ -171,7 +174,7 @@ jobs:
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
|
||||
exit 0
|
||||
|
||||
- name: Report CI trace
|
||||
@@ -190,7 +193,7 @@ jobs:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 3 # 短作业模式:最多3分钟,不占runner
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -233,8 +236,13 @@ jobs:
|
||||
echo "纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)"
|
||||
)
|
||||
echo "检查required门禁(与分支保护一致)"
|
||||
fi
|
||||
@@ -249,7 +257,7 @@ jobs:
|
||||
MAX_405_RETRIES=10
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 180); do
|
||||
for attempt in $(seq 1 18); do # 短作业模式:最多等3分钟(18次x10秒),不满足就退出等下次触发
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
@@ -271,18 +279,11 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查审批状态
|
||||
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
|
||||
echo " 审批: $APPROVAL_RESULT"
|
||||
HAS_APPROVAL=false
|
||||
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
|
||||
HAS_APPROVAL=true
|
||||
fi
|
||||
|
||||
# 全部满足 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
|
||||
# CI全绿 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿 + 审批通过,执行自动合并"
|
||||
echo "CI全绿,执行自动合并"
|
||||
echo "等待60秒冷却,给Gitea内部状态同步时间..."
|
||||
sleep 60
|
||||
|
||||
@@ -350,7 +351,7 @@ jobs:
|
||||
done
|
||||
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
|
||||
exit 0
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Worker Base Image Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements-worker.txt'
|
||||
- 'infra/docker/worker-base-builder.Dockerfile'
|
||||
- 'infra/docker/worker-base-runtime.Dockerfile'
|
||||
workflow_dispatch: # 支持手动触发
|
||||
|
||||
jobs:
|
||||
build-worker-base:
|
||||
name: Build Worker Base Images
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: builder
|
||||
dockerfile: infra/docker/worker-base-builder.Dockerfile
|
||||
image_name: worker-base-builder
|
||||
cache_name: worker-base-builder-cache
|
||||
- name: runtime
|
||||
dockerfile: infra/docker/worker-base-runtime.Dockerfile
|
||||
image_name: worker-base-runtime
|
||||
cache_name: worker-base-runtime-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: Docker login to Registry
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Setup buildx builder
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
echo "Created $BUILDER_NAME"
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
echo "Using existing $BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
|
||||
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
|
||||
|
||||
echo "=== Building ${{ matrix.name }} base image ==="
|
||||
echo "Image: ${IMAGE_TAG}"
|
||||
echo "Cache: ${CACHE_REF}"
|
||||
|
||||
# 用通用构建脚本
|
||||
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
|
||||
|
||||
# 同时推送到 Gitea Packages 作为备份(可选)
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
|
||||
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
|
||||
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
|
||||
|
||||
echo ""
|
||||
echo "✅ ${{ matrix.name }} base image built and pushed"
|
||||
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""Phase 3 - 清理 EditPlan 表冗余字段
|
||||
|
||||
Revision ID: 048
|
||||
Revises: 047
|
||||
Create Date: 2026-07-21
|
||||
|
||||
Changes:
|
||||
1. 删除 edit_plans.result_count 字段(剪辑计划独立功能遗留,模板草稿不用,
|
||||
生成结果数由 generation_tasks.result_count 承载)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "048_cleanup_result_count"
|
||||
down_revision = "047_template_versioning"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 删除 result_count 字段(剪辑计划独立功能遗留字段)
|
||||
op.drop_column("edit_plans", "result_count")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回滚:恢复 result_count 字段,默认值 0
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column(
|
||||
"result_count",
|
||||
sa.Integer,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
"""#558 - 微信登录:手机号绑定字段 + 验证码表
|
||||
|
||||
Revision ID: 049
|
||||
Revises: 048
|
||||
Create Date: 2026-07-21
|
||||
|
||||
Changes:
|
||||
1. users 表新增 phone_verified / binding_completed_at 字段(phone 字段已在 029 中添加)
|
||||
2. users 表 phone 字段添加唯一索引(幂等)
|
||||
3. 新建 verification_codes 表(统一管理邮箱+手机验证码)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "049_wechat_login_phone"
|
||||
down_revision = "048_cleanup_result_count"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
"""检查列是否已存在。离线模式下返回 False。"""
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.first() is not None
|
||||
|
||||
|
||||
def _index_exists(index_name: str) -> bool:
|
||||
"""检查索引是否已存在。离线模式下返回 False。"""
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.first() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. users 表新增手机号验证状态字段(幂等)
|
||||
if not _column_exists("users", "phone_verified"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"phone_verified",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
if not _column_exists("users", "binding_completed_at"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("binding_completed_at", sa.DateTime, nullable=True),
|
||||
)
|
||||
|
||||
# 2. phone 字段唯一索引(幂等 - 029 加了字段但没加索引)
|
||||
if not _index_exists("ix_users_phone"):
|
||||
op.create_index("ix_users_phone", "users", ["phone"], unique=True)
|
||||
|
||||
# 3. verification_codes 表
|
||||
op.create_table(
|
||||
"verification_codes",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("recipient", sa.String(255), nullable=False, index=True),
|
||||
sa.Column("code", sa.String(10), nullable=False),
|
||||
sa.Column("code_type", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("expires_at", sa.DateTime, nullable=False),
|
||||
sa.Column("used_at", sa.DateTime, nullable=True),
|
||||
sa.Column("attempts", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False),
|
||||
sa.Index(
|
||||
"ix_verification_recipient_type",
|
||||
"recipient",
|
||||
"code_type",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("verification_codes")
|
||||
if _index_exists("ix_users_phone"):
|
||||
op.drop_index("ix_users_phone", table_name="users")
|
||||
if _column_exists("users", "binding_completed_at"):
|
||||
op.drop_column("users", "binding_completed_at")
|
||||
if _column_exists("users", "phone_verified"):
|
||||
op.drop_column("users", "phone_verified")
|
||||
@@ -5,7 +5,6 @@ from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
@@ -125,11 +124,6 @@ api_router.include_router(
|
||||
prefix="/templates/{template_id}/editor",
|
||||
tags=["TemplateEditor"],
|
||||
)
|
||||
api_router.include_router(
|
||||
edit_plans_router,
|
||||
prefix="/edit-plans",
|
||||
tags=["EditPlan"],
|
||||
)
|
||||
api_router.include_router(
|
||||
tts_router,
|
||||
prefix="/tts",
|
||||
|
||||
@@ -139,26 +139,3 @@ def format_utc_datetime(dt: datetime | None) -> str:
|
||||
if dt.tzinfo is None:
|
||||
return dt.isoformat() + "Z"
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
# ── Deprecated API 标记 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
import logging as _logging
|
||||
|
||||
from fastapi import Request as _Request
|
||||
|
||||
_deprecated_logger = _logging.getLogger(__name__)
|
||||
|
||||
|
||||
def deprecated_edit_plans_api(request: _Request) -> None:
|
||||
"""标记 /edit-plans/* 系列 API 为废弃,打 warning 日志。
|
||||
|
||||
Phase 2 模板编辑器收敛后,所有剪辑计划 API 迁移到 /templates/{id}/editor/*。
|
||||
旧路径保留 2 个版本周期兼容,之后会下线。
|
||||
"""
|
||||
_deprecated_logger.warning(
|
||||
"Deprecated API called: %s %s. Use /templates/{template_id}/editor/* instead.",
|
||||
request.method,
|
||||
request.url.path,
|
||||
)
|
||||
|
||||
@@ -81,6 +81,9 @@ class CurrentUserResponse(BaseModel):
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -259,12 +262,16 @@ async def get_current_user_info(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CurrentUserResponse:
|
||||
user = authenticated_user.user
|
||||
binding_complete = user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
return CurrentUserResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email_verified=user.email_verified,
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
)
|
||||
|
||||
|
||||
@@ -380,3 +387,202 @@ async def wechat_sync(
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
return WechatSyncResponse(**response.to_dict())
|
||||
|
||||
|
||||
# ==================== 微信网页登录(OAuth) ====================
|
||||
|
||||
|
||||
class WechatAuthUrlResponse(BaseModel):
|
||||
auth_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class WechatCallbackRequest(BaseModel):
|
||||
code: str
|
||||
state: str = ""
|
||||
|
||||
|
||||
class WechatLoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
user_id: str
|
||||
display_name: str
|
||||
avatar_url: str = ""
|
||||
is_new_user: bool
|
||||
binding_complete: bool
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.get("/wechat/url", response_model=WechatAuthUrlResponse)
|
||||
async def get_wechat_auth_url() -> WechatAuthUrlResponse:
|
||||
"""获取微信扫码登录授权链接"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
auth_url, state = oauth_service.generate_auth_url()
|
||||
return WechatAuthUrlResponse(auth_url=auth_url, state=state)
|
||||
|
||||
|
||||
@router.post("/wechat/callback", response_model=WechatLoginResponse)
|
||||
async def wechat_callback(
|
||||
request: WechatCallbackRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatLoginResponse:
|
||||
"""微信登录回调处理"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
|
||||
|
||||
# 1. 用 code 换微信用户信息
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
|
||||
use_case = WechatSyncUseCase(user_repository=user_repository)
|
||||
sync_request = SyncRequest(
|
||||
openid=wechat_user.openid,
|
||||
unionid=wechat_user.unionid,
|
||||
nickname=wechat_user.nickname,
|
||||
avatar_url=wechat_user.avatar_url,
|
||||
source="web",
|
||||
)
|
||||
response, err = use_case.execute(sync_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
# 3. 判断绑定状态
|
||||
user = user_repository.find_by_id(response.user_id)
|
||||
binding_complete = False
|
||||
if user:
|
||||
binding_complete = (
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
|
||||
return WechatLoginResponse(
|
||||
access_token=response.access_token,
|
||||
refresh_token=response.refresh_token,
|
||||
user_id=response.user_id,
|
||||
display_name=response.nickname,
|
||||
avatar_url=response.avatar_url or wechat_user.avatar_url,
|
||||
is_new_user=response.is_new_user,
|
||||
binding_complete=binding_complete,
|
||||
expires_in=response.expires_in,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 验证码 & 绑定 ====================
|
||||
|
||||
|
||||
class SendVerificationCodeRequest(BaseModel):
|
||||
target: str # phone / email
|
||||
value: str
|
||||
purpose: str # bind / login / reset_password
|
||||
|
||||
|
||||
class SendVerificationCodeResponse(BaseModel):
|
||||
expires_in: int
|
||||
resend_after: int
|
||||
|
||||
|
||||
class BindContactRequest(BaseModel):
|
||||
phone: str = ""
|
||||
phone_code: str = ""
|
||||
email: str = ""
|
||||
email_code: str = ""
|
||||
|
||||
|
||||
class BindContactResponse(BaseModel):
|
||||
success: bool
|
||||
user: dict
|
||||
|
||||
|
||||
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
|
||||
async def send_verification_code(
|
||||
request: SendVerificationCodeRequest,
|
||||
) -> SendVerificationCodeResponse:
|
||||
"""发送验证码(手机或邮箱)"""
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
from packages.adapters.sms.sms_service import get_sms_service
|
||||
from packages.adapters.smtp import get_email_service
|
||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
||||
SQLAlchemyVerificationCodeRepository,
|
||||
)
|
||||
from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest
|
||||
from packages.application.auth.bind_contact_use_case import (
|
||||
SendVerificationCodeUseCase,
|
||||
)
|
||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
||||
|
||||
db = next(get_db_session())
|
||||
repo = SQLAlchemyVerificationCodeRepository(db)
|
||||
vc_service = VerificationCodeService(repo=repo)
|
||||
sms_service = get_sms_service()
|
||||
email_service = get_email_service()
|
||||
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=vc_service,
|
||||
sms_service=sms_service,
|
||||
email_service=email_service,
|
||||
)
|
||||
uc_request = UseCaseRequest(
|
||||
target=request.target,
|
||||
value=request.value,
|
||||
purpose=request.purpose,
|
||||
)
|
||||
response, err = use_case.execute(uc_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
return SendVerificationCodeResponse(
|
||||
expires_in=response.expires_in,
|
||||
resend_after=response.resend_after,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bind-contact", response_model=BindContactResponse)
|
||||
async def bind_contact(
|
||||
request: BindContactRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> BindContactResponse:
|
||||
"""绑定手机号和/或邮箱(需登录态)"""
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
||||
SQLAlchemyVerificationCodeRepository,
|
||||
)
|
||||
from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest
|
||||
from packages.application.auth.bind_contact_use_case import (
|
||||
BindContactUseCase,
|
||||
)
|
||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
||||
|
||||
db = next(get_db_session())
|
||||
vc_repo = SQLAlchemyVerificationCodeRepository(db)
|
||||
vc_service = VerificationCodeService(repo=vc_repo)
|
||||
|
||||
use_case = BindContactUseCase(
|
||||
user_repository=user_repository,
|
||||
verification_code_service=vc_service,
|
||||
)
|
||||
uc_request = UseCaseRequest(
|
||||
user_id=current_user.user.id,
|
||||
phone=request.phone,
|
||||
phone_code=request.phone_code,
|
||||
email=request.email,
|
||||
email_code=request.email_code,
|
||||
)
|
||||
response, err = use_case.execute(uc_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
return BindContactResponse(success=True, user=response.to_dict()["user"])
|
||||
|
||||
|
||||
# ==================== 当前用户信息扩展 ====================
|
||||
|
||||
# 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充)
|
||||
# 通过给 get_current_user_info 返回值补充字段实现
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
"""片段调整 API.
|
||||
|
||||
- PUT /clips/{clip_id}/speed 调速
|
||||
- PUT /clips/{clip_id}/volume 音量调节
|
||||
- PUT /clips/{clip_id}/trim 裁剪(trim in/out)
|
||||
- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim)
|
||||
- POST /{plan_id}/clips/batch-speed 批量调速
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SpeedAdjustRequest(BaseModel):
|
||||
"""调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
|
||||
|
||||
|
||||
class VolumeAdjustRequest(BaseModel):
|
||||
"""音量调节请求"""
|
||||
|
||||
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)")
|
||||
|
||||
|
||||
class TrimAdjustRequest(BaseModel):
|
||||
"""裁剪请求"""
|
||||
|
||||
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
|
||||
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
|
||||
|
||||
|
||||
class ClipAdjustmentsRequest(BaseModel):
|
||||
"""统一调整请求"""
|
||||
|
||||
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
trim_start: Optional[float] = Field(default=None, ge=0.0)
|
||||
trim_end: Optional[float] = Field(default=None, ge=0.0)
|
||||
|
||||
|
||||
class BatchSpeedRequest(BaseModel):
|
||||
"""批量调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
|
||||
|
||||
|
||||
class ClipAdjustResponse(BaseModel):
|
||||
"""片段调整响应"""
|
||||
|
||||
clip_id: str
|
||||
speed: float
|
||||
volume: float
|
||||
trim_start: float
|
||||
trim_end: float
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchSpeedResponse(BaseModel):
|
||||
"""批量调速响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_clip_config(clip) -> dict:
|
||||
config = getattr(clip, "config", {}) or {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
return config
|
||||
|
||||
|
||||
def _get_volume(clip) -> float:
|
||||
config = _get_clip_config(clip)
|
||||
return float(config.get("volume", 1.0))
|
||||
|
||||
|
||||
def _get_trim(clip) -> tuple[float, float]:
|
||||
config = _get_clip_config(clip)
|
||||
trim_start = float(config.get("trim_start", 0.0))
|
||||
trim_end = float(config.get("trim_end", 0.0))
|
||||
return trim_start, trim_end
|
||||
|
||||
|
||||
def _build_response(clip) -> ClipAdjustResponse:
|
||||
trim_start, trim_end = _get_trim(clip)
|
||||
return ClipAdjustResponse(
|
||||
clip_id=clip.id,
|
||||
speed=clip.playback_speed,
|
||||
volume=_get_volume(clip),
|
||||
trim_start=trim_start,
|
||||
trim_end=trim_end,
|
||||
duration=clip.duration,
|
||||
)
|
||||
|
||||
|
||||
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
|
||||
"""验证裁剪时长不超过总时长"""
|
||||
if trim_start + trim_end >= total_duration:
|
||||
raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s)")
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
|
||||
svc = EditPlanService(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = svc.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
return svc, plan, clip
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse, deprecated=True)
|
||||
def adjust_speed(
|
||||
clip_id: str,
|
||||
body: SpeedAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段播放速度"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
updated = svc.update_clip(clip_id, playback_speed=body.speed)
|
||||
|
||||
logger.info(
|
||||
"调整片段速度: clip_id=%s speed=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.speed,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse, deprecated=True)
|
||||
def adjust_volume(
|
||||
clip_id: str,
|
||||
body: VolumeAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段音量"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
# 更新 config.volume
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["volume"] = body.volume
|
||||
updated = svc.update_clip(clip_id, config=config)
|
||||
|
||||
logger.info(
|
||||
"调整片段音量: clip_id=%s volume=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.volume,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse, deprecated=True)
|
||||
def adjust_trim(
|
||||
clip_id: str,
|
||||
body: TrimAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""裁剪片段(trim in/out)"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
# 验证裁剪时长
|
||||
try:
|
||||
_validate_trim(body.trim_start, body.trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 更新 config
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["trim_start"] = body.trim_start
|
||||
config["trim_end"] = body.trim_end
|
||||
updated = svc.update_clip(clip_id, config=config)
|
||||
|
||||
logger.info(
|
||||
"裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.trim_start,
|
||||
body.trim_end,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse, deprecated=True)
|
||||
def adjust_all(
|
||||
clip_id: str,
|
||||
body: ClipAdjustmentsRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""统一调整片段的 speed / volume / trim"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
update_kwargs = {}
|
||||
config_updates = {}
|
||||
|
||||
if body.speed is not None:
|
||||
update_kwargs["playback_speed"] = body.speed
|
||||
|
||||
if body.volume is not None:
|
||||
config_updates["volume"] = body.volume
|
||||
|
||||
if body.trim_start is not None:
|
||||
config_updates["trim_start"] = body.trim_start
|
||||
|
||||
if body.trim_end is not None:
|
||||
config_updates["trim_end"] = body.trim_end
|
||||
|
||||
# 验证 trim
|
||||
current_trim_start, current_trim_end = _get_trim(clip)
|
||||
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
|
||||
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
|
||||
|
||||
if body.trim_start is not None or body.trim_end is not None:
|
||||
try:
|
||||
_validate_trim(new_trim_start, new_trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
if config_updates:
|
||||
config = dict(_get_clip_config(clip))
|
||||
config.update(config_updates)
|
||||
update_kwargs["config"] = config
|
||||
|
||||
if not update_kwargs:
|
||||
return _build_response(clip)
|
||||
|
||||
updated = svc.update_clip(clip_id, **update_kwargs)
|
||||
|
||||
logger.info(
|
||||
"统一调整片段: clip_id=%s speed=%s volume=%s by user=%s",
|
||||
clip_id,
|
||||
body.speed,
|
||||
body.volume,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse, deprecated=True)
|
||||
def batch_adjust_speed(
|
||||
plan_id: str,
|
||||
body: BatchSpeedRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchSpeedResponse:
|
||||
"""批量调整计划内所有片段的播放速度"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
clips = svc.list_clips(plan_id, limit=500, skip=0)
|
||||
count = 0
|
||||
for clip in clips:
|
||||
svc.update_clip(clip.id, playback_speed=body.speed)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"批量调速: plan_id=%s count=%d speed=%.2f by user=%s",
|
||||
plan_id,
|
||||
count,
|
||||
body.speed,
|
||||
current_user.user.id,
|
||||
)
|
||||
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
|
||||
@@ -1,205 +0,0 @@
|
||||
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
|
||||
|
||||
从 edit_plans.py 拆分,包含:
|
||||
- POST /{plan_id}/ai-recommend AI 推荐片段方案
|
||||
- POST /{plan_id}/generate-cover AI 生成封面
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
from app.api.routes.edit_plans import (
|
||||
AIRecommendClipItem,
|
||||
AIRecommendRequest,
|
||||
AIRecommendResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/ai-recommend",
|
||||
response_model=AIRecommendResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def ai_recommend_clips(
|
||||
plan_id: str,
|
||||
body: AIRecommendRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AIRecommendResponse:
|
||||
"""AI 推荐片段方案
|
||||
|
||||
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
|
||||
|
||||
流程:
|
||||
1. 验证计划存在且状态为 draft/editing
|
||||
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
|
||||
3. 清除计划现有片段,按推荐方案重新创建
|
||||
4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration
|
||||
5. 返回推荐方案详情
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
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)) from exc
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status not in ("draft", "editing"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
|
||||
)
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
template_id=plan.template_id,
|
||||
asset_ids=body.asset_ids,
|
||||
editing_mode=body.editing_mode,
|
||||
target_duration=body.target_duration,
|
||||
)
|
||||
|
||||
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
|
||||
try:
|
||||
svc.delete_all_clips(plan_id)
|
||||
|
||||
for clip_data in result["clips"]:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_data["clip_type"],
|
||||
order=clip_data["order"],
|
||||
text_content=clip_data.get("text_content", ""),
|
||||
duration=clip_data["duration"],
|
||||
transition_effect=clip_data.get("transition_effect", "cut"),
|
||||
asset_id=clip_data.get("asset_id", ""),
|
||||
start_time=clip_data.get("start_time", 0.0),
|
||||
config=clip_data.get("config", {}),
|
||||
)
|
||||
|
||||
normalized_config = normalize_plan_config(result.get("config", {}))
|
||||
svc.update_plan(
|
||||
plan_id,
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception as rollback_err:
|
||||
logger.error(
|
||||
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
|
||||
plan_id,
|
||||
rollback_err,
|
||||
)
|
||||
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",
|
||||
plan_id,
|
||||
len(result["clips"]),
|
||||
result["total_duration"],
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return AIRecommendResponse(
|
||||
plan_id=plan_id,
|
||||
clips=[
|
||||
AIRecommendClipItem(
|
||||
clip_type=c["clip_type"],
|
||||
order=c["order"],
|
||||
text_content=c.get("text_content", ""),
|
||||
duration=c["duration"],
|
||||
transition_effect=c.get("transition_effect", "cut"),
|
||||
asset_id=c.get("asset_id", ""),
|
||||
start_time=c.get("start_time", 0.0),
|
||||
config=c.get("config", {}),
|
||||
)
|
||||
for c in result["clips"]
|
||||
],
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
confidence=result["confidence"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/generate-cover",
|
||||
response_model=GenerateCoverResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def generate_cover(
|
||||
plan_id: str,
|
||||
body: GenerateCoverRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面
|
||||
|
||||
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
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)) from exc
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
|
||||
current_config = dict(plan.config)
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"AI 封面生成: plan_id=%s type=%s by user=%s",
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(
|
||||
plan_id=plan_id,
|
||||
cover=cover_data,
|
||||
)
|
||||
@@ -1,423 +0,0 @@
|
||||
"""剪辑计划片段(Clip)CRUD 路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClipStatus
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EditPlanClipResponse(BaseModel):
|
||||
"""剪辑片段响应体"""
|
||||
|
||||
id: str
|
||||
plan_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
asset_id: str = ""
|
||||
text_content: str = ""
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
status: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class EditPlanClipListResponse(BaseModel):
|
||||
"""剪辑片段列表响应体"""
|
||||
|
||||
items: List[EditPlanClipResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class EditPlanClipCreateRequest(BaseModel):
|
||||
"""创建剪辑片段请求体"""
|
||||
|
||||
clip_type: str = Field(
|
||||
..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等"
|
||||
)
|
||||
order: int = Field(..., ge=0, description="排序序号")
|
||||
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
|
||||
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
|
||||
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
|
||||
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
|
||||
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
|
||||
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
|
||||
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
|
||||
|
||||
|
||||
class EditPlanClipUpdateRequest(BaseModel):
|
||||
"""更新剪辑片段请求体"""
|
||||
|
||||
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
|
||||
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
|
||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
|
||||
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
|
||||
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
|
||||
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
|
||||
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
|
||||
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
|
||||
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
|
||||
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
|
||||
返回 plan 对象供后续使用,避免重复查询。
|
||||
"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, user_id, project_repository)
|
||||
return plan
|
||||
|
||||
|
||||
def _clip_to_response(clip) -> EditPlanClipResponse:
|
||||
"""将领域对象转换为响应体"""
|
||||
return EditPlanClipResponse(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id or "",
|
||||
text_content=clip.text_content or "",
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=clip.transition_duration or 0.0,
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
|
||||
config=clip.config or {},
|
||||
created_at=clip.created_at.isoformat() if clip.created_at else None,
|
||||
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
def _get_svc(db: Session):
|
||||
"""获取 EditPlanService 实例"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
return EditPlanService(db)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=EditPlanClipListResponse, deprecated=True)
|
||||
def list_clips(
|
||||
plan_id: str,
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
|
||||
skip: int = Query(0, ge=0, description="分页偏移"),
|
||||
limit: int = Query(100, ge=1, le=500, description="每页数量"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipListResponse:
|
||||
"""获取剪辑计划的片段列表"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
|
||||
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
|
||||
total = svc.count_clips(plan_id, status=status_enum)
|
||||
|
||||
return EditPlanClipListResponse(
|
||||
items=[_clip_to_response(c) for c in clips],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED, deprecated=True)
|
||||
def create_clip(
|
||||
plan_id: str,
|
||||
body: EditPlanClipCreateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""创建剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
try:
|
||||
clip = svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=body.clip_type,
|
||||
order=body.order,
|
||||
asset_id=body.asset_id,
|
||||
text_content=body.text_content,
|
||||
start_time=body.start_time,
|
||||
duration=body.duration,
|
||||
transition_effect=body.transition_effect,
|
||||
transition_duration=body.transition_duration,
|
||||
playback_speed=body.playback_speed,
|
||||
config=body.config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.get("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True)
|
||||
def get_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""获取剪辑片段详情"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.put("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True)
|
||||
def update_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
body: EditPlanClipUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""更新剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
# 验证 clip 属于该 plan
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
try:
|
||||
updated = svc.update_clip(
|
||||
clip_id,
|
||||
clip_type=body.clip_type,
|
||||
order=body.order,
|
||||
asset_id=body.asset_id,
|
||||
text_content=body.text_content,
|
||||
start_time=body.start_time,
|
||||
duration=body.duration,
|
||||
transition_effect=body.transition_effect,
|
||||
transition_duration=body.transition_duration,
|
||||
playback_speed=body.playback_speed,
|
||||
config=body.config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return _clip_to_response(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response, deprecated=True
|
||||
)
|
||||
def delete_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
# 验证 clip 属于该 plan
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
deleted = svc.delete_clip(clip_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return None
|
||||
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SplitClipRequest(BaseModel):
|
||||
"""分割片段请求体"""
|
||||
|
||||
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
|
||||
|
||||
|
||||
class MergeClipsRequest(BaseModel):
|
||||
"""合并片段请求体"""
|
||||
|
||||
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{clip_id}/split",
|
||||
response_model=dict[str, Any],
|
||||
summary="分割片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
deprecated=True,
|
||||
)
|
||||
def split_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
body: SplitClipRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将一个片段从指定时间点分割为两个片段。
|
||||
|
||||
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
|
||||
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = svc.split_clip(clip_id, body.split_time)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
|
||||
return {
|
||||
"left_clip": {
|
||||
"id": left.id,
|
||||
"plan_id": left.plan_id,
|
||||
"clip_type": left.clip_type,
|
||||
"order": left.order,
|
||||
"duration": left.duration,
|
||||
"start_time": left.start_time,
|
||||
},
|
||||
"right_clip": {
|
||||
"id": right.id,
|
||||
"plan_id": right.plan_id,
|
||||
"clip_type": right.clip_type,
|
||||
"order": right.order,
|
||||
"duration": right.duration,
|
||||
"start_time": right.start_time,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/merge",
|
||||
response_model=dict[str, Any],
|
||||
summary="合并多个连续片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
deprecated=True,
|
||||
)
|
||||
def merge_clips(
|
||||
plan_id: str,
|
||||
body: MergeClipsRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将多个连续的同类型片段合并为一个片段。
|
||||
|
||||
合并要求:
|
||||
- 至少 2 个片段
|
||||
- 属于同一剪辑计划
|
||||
- order 连续
|
||||
- 类型相同
|
||||
|
||||
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 校验所有片段都属于该 plan
|
||||
for cid in body.clip_ids:
|
||||
clip = svc.get_clip(cid)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {cid}",
|
||||
)
|
||||
|
||||
try:
|
||||
merged = svc.merge_clips(body.clip_ids)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"合并片段: plan_id=%s clip_count=%d by user=%s",
|
||||
plan_id,
|
||||
len(body.clip_ids),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": merged.id,
|
||||
"plan_id": merged.plan_id,
|
||||
"clip_type": merged.clip_type,
|
||||
"order": merged.order,
|
||||
"duration": merged.duration,
|
||||
"text_content": merged.text_content,
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
"""剪辑计划片段批量操作 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipReorderItem(BaseModel):
|
||||
"""重排序条目"""
|
||||
|
||||
clip_id: str
|
||||
new_order: int = Field(..., ge=0, description="新的排序序号")
|
||||
|
||||
|
||||
class ClipReorderRequest(BaseModel):
|
||||
"""片段重排序请求"""
|
||||
|
||||
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
|
||||
|
||||
class ClipReorderResponse(BaseModel):
|
||||
"""片段重排序响应"""
|
||||
|
||||
success: bool
|
||||
updated_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipBatchDeleteRequest(BaseModel):
|
||||
"""批量删除片段请求"""
|
||||
|
||||
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
|
||||
|
||||
class ClipBatchDeleteResponse(BaseModel):
|
||||
"""批量删除片段响应"""
|
||||
|
||||
success: bool
|
||||
deleted_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
"""从素材批量创建片段响应"""
|
||||
|
||||
success: bool
|
||||
created_count: int
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
|
||||
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, user_id, project_repository)
|
||||
return plan
|
||||
|
||||
|
||||
def _get_svc(db: Session):
|
||||
"""获取 EditPlanService 实例"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
return EditPlanService(db)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/reorder", response_model=ClipReorderResponse, deprecated=True)
|
||||
def reorder_clips(
|
||||
plan_id: str,
|
||||
body: ClipReorderRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipReorderResponse:
|
||||
"""批量重排序片段
|
||||
|
||||
前端拖拽调整顺序后,一次性提交所有变更的 order。
|
||||
自动触发编辑状态回退(从 completed/failed 切回 editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 验证所有 clip 都属于该 plan
|
||||
clip_ids = [item.clip_id for item in body.items]
|
||||
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
|
||||
existing_ids = {c.id for c in existing_clips}
|
||||
|
||||
invalid_ids = [cid for cid in clip_ids if cid not in existing_ids]
|
||||
if invalid_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}",
|
||||
)
|
||||
|
||||
# 执行重排序
|
||||
updated_count = 0
|
||||
for item in body.items:
|
||||
try:
|
||||
svc.update_clip(item.clip_id, order=item.new_order)
|
||||
updated_count += 1
|
||||
except ValueError as e:
|
||||
logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e)
|
||||
|
||||
logger.info(
|
||||
"批量重排序片段: plan_id=%s count=%d by user=%s",
|
||||
plan_id,
|
||||
updated_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipReorderResponse(
|
||||
success=True,
|
||||
updated_count=updated_count,
|
||||
message=f"成功更新 {updated_count} 个片段的顺序",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse, deprecated=True)
|
||||
def batch_delete_clips(
|
||||
plan_id: str,
|
||||
body: ClipBatchDeleteRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipBatchDeleteResponse:
|
||||
"""批量删除片段
|
||||
|
||||
自动触发编辑状态回退(从 completed/failed 切回 editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 验证所有 clip 都属于该 plan
|
||||
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
|
||||
existing_ids = {c.id for c in existing_clips}
|
||||
|
||||
valid_ids = [cid for cid in body.clip_ids if cid in existing_ids]
|
||||
skipped = len(body.clip_ids) - len(valid_ids)
|
||||
|
||||
# 执行删除
|
||||
deleted_count = 0
|
||||
for clip_id in valid_ids:
|
||||
if svc.delete_clip(clip_id):
|
||||
deleted_count += 1
|
||||
|
||||
message = f"成功删除 {deleted_count} 个片段"
|
||||
if skipped > 0:
|
||||
message += f",跳过 {skipped} 个不存在的片段"
|
||||
|
||||
logger.info(
|
||||
"批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s",
|
||||
plan_id,
|
||||
deleted_count,
|
||||
skipped,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipBatchDeleteResponse(
|
||||
success=True,
|
||||
deleted_count=deleted_count,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-assets", response_model=ClipsFromAssetsResponse, deprecated=True)
|
||||
def create_clips_from_assets(
|
||||
plan_id: str,
|
||||
body: ClipsFromAssetsRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipsFromAssetsResponse:
|
||||
"""从素材批量创建片段(追加到时间线末尾)
|
||||
|
||||
一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。
|
||||
自动触发编辑状态回退(completed/failed → editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
try:
|
||||
clips = svc.create_clips_from_assets(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
clip_type=body.clip_type,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
clip_ids = [c.id for c in clips]
|
||||
|
||||
logger.info(
|
||||
"从素材批量创建片段: plan_id=%s count=%d by user=%s",
|
||||
plan_id,
|
||||
len(clips),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipsFromAssetsResponse(
|
||||
success=True,
|
||||
created_count=len(clips),
|
||||
message=f"成功创建 {len(clips)} 个片段",
|
||||
clip_ids=clip_ids,
|
||||
)
|
||||
@@ -1,317 +0,0 @@
|
||||
"""封面管理 API.
|
||||
|
||||
- GET /{plan_id}/cover 获取封面配置
|
||||
- PUT /{plan_id}/cover 更新封面配置
|
||||
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
|
||||
- POST /{plan_id}/cover/smart 智能选帧生成封面
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.services import EditPlanService
|
||||
from app.services.cover_service import CoverService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverConfigResponse(BaseModel):
|
||||
"""封面配置响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||
image_url: str = Field(default="", description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverUpdateRequest(BaseModel):
|
||||
"""更新封面配置请求"""
|
||||
|
||||
type: Optional[str] = Field(default=None, description="封面类型")
|
||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverExtractRequest(BaseModel):
|
||||
"""从片段抽帧生成封面请求"""
|
||||
|
||||
clip_id: str = Field(..., description="片段 ID")
|
||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverSmartRequest(BaseModel):
|
||||
"""智能选帧请求"""
|
||||
|
||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||
|
||||
|
||||
class CoverGenerateResponse(BaseModel):
|
||||
"""封面生成响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型")
|
||||
image_url: str = Field(..., description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True)
|
||||
def get_cover(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> CoverConfigResponse:
|
||||
"""获取封面配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
cover = CoverService.get_cover_config(plan.config or {})
|
||||
return CoverConfigResponse(**cover)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True)
|
||||
def update_cover(
|
||||
plan_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> CoverConfigResponse:
|
||||
"""更新封面配置
|
||||
|
||||
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 合并更新
|
||||
current_cover = CoverService.get_cover_config(plan.config or {})
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
new_cover = {**current_cover, **updates}
|
||||
|
||||
# 验证 type 值
|
||||
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
|
||||
if "type" in updates and updates["type"] not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
|
||||
)
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = new_cover
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
result = CoverService.get_cover_config(updated_plan.config or {})
|
||||
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
|
||||
return CoverConfigResponse(**result)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse, deprecated=True)
|
||||
def extract_cover(
|
||||
plan_id: str,
|
||||
body: CoverExtractRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: Any = Depends(get_storage_service),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> CoverGenerateResponse:
|
||||
"""从指定片段的指定时间点抽帧生成封面"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 获取片段对应的素材
|
||||
clip = svc.get_clip(body.clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {body.clip_id}",
|
||||
)
|
||||
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段不属于该剪辑计划",
|
||||
)
|
||||
|
||||
if not clip.asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段没有关联素材,无法抽帧",
|
||||
)
|
||||
|
||||
# 抽帧生成封面
|
||||
cover_svc = CoverService(storage_service, asset_repository)
|
||||
try:
|
||||
cover_data = cover_svc.extract_cover_from_clip(
|
||||
plan_id=plan_id,
|
||||
asset_id=clip.asset_id,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"封面抽帧失败: {e}",
|
||||
) from e
|
||||
|
||||
# 更新到 plan.config.cover
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
body.frame_time,
|
||||
current_user.user.id,
|
||||
)
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse, deprecated=True)
|
||||
def smart_cover(
|
||||
plan_id: str,
|
||||
body: CoverSmartRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: Any = Depends(get_storage_service),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> CoverGenerateResponse:
|
||||
"""智能选帧生成封面
|
||||
|
||||
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
|
||||
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 确定使用哪个片段
|
||||
clip_id = body.clip_id
|
||||
asset_id = ""
|
||||
|
||||
if clip_id:
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段不属于该剪辑计划",
|
||||
)
|
||||
if not clip.asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段没有关联素材",
|
||||
)
|
||||
asset_id = clip.asset_id
|
||||
else:
|
||||
# 找第一个有素材的视频片段
|
||||
clips = svc.list_clips(plan_id, limit=50, skip=0)
|
||||
for c in clips:
|
||||
if c.asset_id and c.clip_type == "video":
|
||||
asset_id = c.asset_id
|
||||
clip_id = c.id
|
||||
break
|
||||
|
||||
if not asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="没有找到可用的视频片段",
|
||||
)
|
||||
|
||||
# 智能选帧
|
||||
cover_svc = CoverService(storage_service, asset_repository)
|
||||
try:
|
||||
cover_data = cover_svc.generate_smart_cover(
|
||||
plan_id=plan_id,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"智能封面生成失败: {e}",
|
||||
) from e
|
||||
|
||||
# 更新到 plan.config.cover
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
|
||||
plan_id,
|
||||
clip_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
@@ -1,276 +0,0 @@
|
||||
"""导出设置 API.
|
||||
|
||||
- GET /{plan_id}/export 获取导出配置
|
||||
- PUT /{plan_id}/export 更新导出配置
|
||||
- GET /export-presets 导出预设列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── 导出预设 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
EXPORT_PRESETS = [
|
||||
{
|
||||
"id": "export_1080p_30",
|
||||
"name": "1080P 高清",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 8000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "balanced",
|
||||
"description": "竖屏高清,适合短视频平台",
|
||||
"size_hint": "约 10MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_1080p_60",
|
||||
"name": "1080P 高帧率",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 60,
|
||||
"video_bitrate": 12000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "high",
|
||||
"description": "60帧高帧率,流畅运动画面",
|
||||
"size_hint": "约 18MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_720p_30",
|
||||
"name": "720P 流畅",
|
||||
"resolution": "720x1280",
|
||||
"fps": 30,
|
||||
"video_bitrate": 4000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "fast",
|
||||
"description": "快速导出,文件较小",
|
||||
"size_hint": "约 5MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_4k_30",
|
||||
"name": "4K 超清",
|
||||
"resolution": "2160x3840",
|
||||
"fps": 30,
|
||||
"video_bitrate": 20000,
|
||||
"audio_bitrate": 192,
|
||||
"format": "mp4",
|
||||
"quality_preset": "best",
|
||||
"description": "4K超清画质,专业品质",
|
||||
"size_hint": "约 30MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_1080p_30_mov",
|
||||
"name": "1080P ProRes",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 15000,
|
||||
"audio_bitrate": 256,
|
||||
"format": "mov",
|
||||
"quality_preset": "high",
|
||||
"description": "MOV格式,适合后期剪辑",
|
||||
"size_hint": "约 25MB/分钟",
|
||||
},
|
||||
]
|
||||
|
||||
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
|
||||
VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ExportConfigResponse(BaseModel):
|
||||
"""导出配置响应"""
|
||||
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
watermark_enabled: bool
|
||||
watermark_text: str
|
||||
|
||||
|
||||
class ExportUpdateRequest(BaseModel):
|
||||
"""更新导出配置请求"""
|
||||
|
||||
resolution: Optional[str] = None
|
||||
fps: Optional[int] = Field(default=None, ge=15, le=60)
|
||||
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
|
||||
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
|
||||
format: Optional[str] = None
|
||||
quality_preset: Optional[str] = None
|
||||
watermark_enabled: Optional[bool] = None
|
||||
watermark_text: Optional[str] = None
|
||||
|
||||
@validator("resolution")
|
||||
def validate_resolution(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not RESOLUTION_PATTERN.match(v):
|
||||
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
|
||||
w, h = v.split("x")
|
||||
if int(w) < 100 or int(h) < 100:
|
||||
raise ValueError("分辨率数值过小")
|
||||
if int(w) > 4096 or int(h) > 4096:
|
||||
raise ValueError("分辨率数值过大,最大 4096x4096")
|
||||
return v
|
||||
|
||||
@validator("format")
|
||||
def validate_format(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_FORMATS:
|
||||
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
|
||||
return v
|
||||
|
||||
@validator("quality_preset")
|
||||
def validate_quality_preset(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_QUALITY_PRESETS:
|
||||
raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}")
|
||||
return v
|
||||
|
||||
|
||||
class ExportPresetItem(BaseModel):
|
||||
"""导出预设条目"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
description: str
|
||||
size_hint: str
|
||||
|
||||
|
||||
class ExportPresetListResponse(BaseModel):
|
||||
"""导出预设列表响应"""
|
||||
|
||||
items: List[ExportPresetItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_export_config(plan_config: dict) -> dict:
|
||||
e = plan_config.get("export", {})
|
||||
if not isinstance(e, dict):
|
||||
e = {}
|
||||
return {
|
||||
"resolution": e.get("resolution", "1080x1920"),
|
||||
"fps": e.get("fps", 30),
|
||||
"video_bitrate": e.get("video_bitrate", 8000),
|
||||
"audio_bitrate": e.get("audio_bitrate", 128),
|
||||
"format": e.get("format", "mp4"),
|
||||
"quality_preset": e.get("quality_preset", "balanced"),
|
||||
"watermark_enabled": e.get("watermark_enabled", False),
|
||||
"watermark_text": e.get("watermark_text", ""),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/export-presets", response_model=ExportPresetListResponse, deprecated=True)
|
||||
def list_export_presets(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ExportPresetListResponse:
|
||||
"""获取导出预设列表"""
|
||||
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
|
||||
return ExportPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True)
|
||||
def get_export_config(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ExportConfigResponse:
|
||||
"""获取导出配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
config = _get_export_config(plan.config or {})
|
||||
return ExportConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True)
|
||||
def update_export_config(
|
||||
plan_id: str,
|
||||
body: ExportUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ExportConfigResponse:
|
||||
"""更新导出配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 合并更新
|
||||
current = _get_export_config(plan.config or {})
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
new_export = {**current, **updates}
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["export"] = new_export
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
|
||||
|
||||
result = _get_export_config(updated_plan.config or {})
|
||||
logger.info(
|
||||
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
|
||||
plan_id,
|
||||
result["resolution"],
|
||||
result["fps"],
|
||||
current_user.user.id,
|
||||
)
|
||||
return ExportConfigResponse(**result)
|
||||
@@ -1,199 +0,0 @@
|
||||
"""滤镜调色 API.
|
||||
|
||||
- GET /filter-presets 滤镜预设列表
|
||||
- GET /{plan_id}/filter 获取全局滤镜配置
|
||||
- PUT /{plan_id}/filter 更新全局滤镜配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.filter_presets import (
|
||||
FilterPreset,
|
||||
get_filter_preset,
|
||||
list_filter_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FilterPresetResponse(BaseModel):
|
||||
"""滤镜预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FilterConfigResponse(BaseModel):
|
||||
"""滤镜配置响应"""
|
||||
|
||||
enabled: bool
|
||||
preset_id: str
|
||||
intensity: int
|
||||
brightness: float
|
||||
contrast: float
|
||||
saturation: float
|
||||
warmth: float
|
||||
|
||||
|
||||
class FilterUpdateRequest(BaseModel):
|
||||
"""更新滤镜配置请求"""
|
||||
|
||||
enabled: Optional[bool] = None
|
||||
preset_id: Optional[str] = None
|
||||
intensity: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
|
||||
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
|
||||
|
||||
class FilterPresetListResponse(BaseModel):
|
||||
"""滤镜预设列表响应"""
|
||||
|
||||
items: List[FilterPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
|
||||
return FilterPresetResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
category=p.category,
|
||||
description=p.description,
|
||||
tags=list(p.tags),
|
||||
)
|
||||
|
||||
|
||||
def _get_filter_config(plan_config: dict) -> dict:
|
||||
"""从 plan.config 中提取滤镜配置"""
|
||||
f = plan_config.get("filter", {})
|
||||
if not isinstance(f, dict):
|
||||
f = {}
|
||||
return {
|
||||
"enabled": f.get("enabled", False),
|
||||
"preset_id": f.get("preset_id", "filter_none"),
|
||||
"intensity": f.get("intensity", 100),
|
||||
"brightness": f.get("brightness", 0.0),
|
||||
"contrast": f.get("contrast", 1.0),
|
||||
"saturation": f.get("saturation", 1.0),
|
||||
"warmth": f.get("warmth", 0.0),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/filter-presets", response_model=FilterPresetListResponse, deprecated=True)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> FilterPresetListResponse:
|
||||
"""获取滤镜预设列表"""
|
||||
presets = list_filter_presets(category=category, keyword=keyword)
|
||||
items = [_preset_to_response(p) for p in presets]
|
||||
return FilterPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True)
|
||||
def get_filter(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> FilterConfigResponse:
|
||||
"""获取剪辑计划的全局滤镜配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
config = _get_filter_config(plan.config or {})
|
||||
return FilterConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True)
|
||||
def update_filter(
|
||||
plan_id: str,
|
||||
body: FilterUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> FilterConfigResponse:
|
||||
"""更新全局滤镜配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证 preset_id
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
if "preset_id" in updates:
|
||||
preset = get_filter_preset(updates["preset_id"])
|
||||
if preset is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的滤镜预设: {updates['preset_id']}",
|
||||
)
|
||||
|
||||
# 合并更新
|
||||
current = _get_filter_config(plan.config or {})
|
||||
new_filter = {**current, **updates}
|
||||
|
||||
# 如果设为原图 preset,自动关闭
|
||||
if new_filter["preset_id"] == "filter_none":
|
||||
new_filter["enabled"] = False
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["filter"] = new_filter
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
|
||||
|
||||
result = _get_filter_config(updated_plan.config or {})
|
||||
logger.info(
|
||||
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
|
||||
plan_id,
|
||||
result["preset_id"],
|
||||
result["intensity"],
|
||||
current_user.user.id,
|
||||
)
|
||||
return FilterConfigResponse(**result)
|
||||
@@ -1,418 +0,0 @@
|
||||
"""剪辑计划生成相关 API 端点。
|
||||
|
||||
从 edit_plans.py 拆分,包含:
|
||||
- POST /{plan_id}/generate 触发剪辑渲染生成
|
||||
- GET /{plan_id}/generation-status 查询生成进度
|
||||
- GET /{plan_id}/generations 查询关联的生成记录
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
from app.api.routes.edit_plans import (
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
)
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||
"""自动兜底 1: draft → editing"""
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
|
||||
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
logger.info(
|
||||
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||
plan_id,
|
||||
plan_check.template_id,
|
||||
)
|
||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||
if configs:
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
)
|
||||
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
|
||||
else:
|
||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||
for seg in segments:
|
||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
duration=avg_duration,
|
||||
config={
|
||||
"material_type": seg.material_type or "",
|
||||
"template_segment_id": seg.id,
|
||||
},
|
||||
)
|
||||
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
|
||||
|
||||
|
||||
def _auto_fallback_assign_assets(
|
||||
svc: EditPlanService,
|
||||
plan_id: str,
|
||||
plan_check,
|
||||
) -> list:
|
||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||
clips_without_asset = []
|
||||
|
||||
return clips_without_asset
|
||||
|
||||
|
||||
def _auto_fallback_auto_material_mode(
|
||||
svc: EditPlanService,
|
||||
plan_id: str,
|
||||
plan_check,
|
||||
clips_without_asset: list,
|
||||
asset_library_repo: Any,
|
||||
asset_repo: Any,
|
||||
) -> None:
|
||||
"""自动兜底 4: 项目有视频素材库时,自动选取 ready 视频素材分配给无素材片段
|
||||
|
||||
注:原先需要 material_mode=="auto" 才触发,但全代码库没有任何地方设置为 auto,
|
||||
导致这道兜底防线永远不生效。现改为:只要有 project_id 且存在无素材片段,
|
||||
就自动从项目视频素材库选取素材兜底,确保一键生成等场景能正常出片。
|
||||
"""
|
||||
if not clips_without_asset:
|
||||
return
|
||||
if not plan_check.project_id:
|
||||
return
|
||||
|
||||
import random
|
||||
|
||||
logger.info(
|
||||
"自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
if ready_videos:
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
video_lib.name,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
else:
|
||||
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
|
||||
else:
|
||||
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
|
||||
|
||||
|
||||
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")
|
||||
if has_count:
|
||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||
global_pending = gen_task_repo.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse, deprecated=True)
|
||||
def generate_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发剪辑计划渲染生成
|
||||
|
||||
前置条件:计划状态必须为 editing,且至少有一个片段。
|
||||
流程:
|
||||
1. 验证计划状态为 editing
|
||||
2. 将 pending 片段标记为 ready
|
||||
3. 创建 GenerationTask
|
||||
4. 调度 Celery 任务 worker.render_edit_plan
|
||||
5. 将计划状态流转为 rendering
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan_check = svc.get_plan(plan_id)
|
||||
if plan_check is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan_check.project_id:
|
||||
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 自动兜底流程
|
||||
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
|
||||
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
|
||||
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
|
||||
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
|
||||
|
||||
# 检查是否可生成
|
||||
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)) from exc
|
||||
if not can_gen:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||
|
||||
# 核心生成流程
|
||||
try:
|
||||
clip_count = svc.mark_clips_ready(plan_id)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
user_id = current_user.user.id
|
||||
_check_queue_limits(gen_task_repo, user_id)
|
||||
|
||||
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=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 [],
|
||||
)
|
||||
)
|
||||
|
||||
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||
|
||||
updated_plan = svc.get_plan_or_raise(plan_id)
|
||||
|
||||
logger.info(
|
||||
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as _e:
|
||||
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||||
try:
|
||||
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||
except Exception:
|
||||
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成失败,请稍后重试",
|
||||
) from _e
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/generation-status",
|
||||
response_model=EditPlanGenerationStatusResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def get_generation_status(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> EditPlanGenerationStatusResponse:
|
||||
"""查询剪辑计划生成进度"""
|
||||
svc = EditPlanService(db)
|
||||
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)) from exc
|
||||
|
||||
plan = gen_status["plan"]
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
clips = gen_status["clips"]
|
||||
|
||||
clip_items = [
|
||||
ClipStatusItem(
|
||||
clip_id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
asset_id=c.asset_id or "",
|
||||
text_content=c.text_content or "",
|
||||
duration=c.duration,
|
||||
)
|
||||
for c in clips
|
||||
]
|
||||
|
||||
# 从 plan.config 中取渲染结果 URL,转换为签名 URL
|
||||
raw_video_url = (plan.config or {}).get("rendered_url", "")
|
||||
video_url = ""
|
||||
if raw_video_url:
|
||||
try:
|
||||
video_url = storage_service.get_download_url(raw_video_url, expires_seconds=86400)
|
||||
except Exception as e:
|
||||
logger.warning("生成视频签名URL失败,返回原始URL: plan_id=%s error=%s", plan_id, e)
|
||||
video_url = raw_video_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_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,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/generations",
|
||||
response_model=EditPlanGenerationsResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def list_plan_generations(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanGenerationsResponse:
|
||||
"""查询剪辑计划关联的所有生成记录"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
items = [
|
||||
GenerationTaskResponse(
|
||||
id=t.id,
|
||||
project_id=t.project_id,
|
||||
asset_library_id=t.asset_library_id,
|
||||
strategy_id=t.strategy_id,
|
||||
voice_library_id=t.voice_library_id,
|
||||
template_id=t.template_id,
|
||||
asset_ids=t.asset_ids,
|
||||
title_ids=t.title_ids,
|
||||
voice_ids=t.voice_ids,
|
||||
source_edit_plan_id=t.source_edit_plan_id or "",
|
||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||||
progress=t.progress,
|
||||
result_count=t.result_count,
|
||||
error_message=t.error_message,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||||
@@ -1,263 +0,0 @@
|
||||
"""剪辑计划时间线 & 模板生成 API 端点。
|
||||
|
||||
从 edit_plans.py 拆分,包含:
|
||||
- GET /{plan_id}/timeline 时间线场景数据
|
||||
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
from app.api.routes._helpers import auto_select_video_assets, check_project_access
|
||||
from app.api.routes.edit_plans import (
|
||||
GenerateFromTemplateRequest,
|
||||
GenerateFromTemplateResponse,
|
||||
_PlanClipItem,
|
||||
_to_response,
|
||||
)
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.services import EditPlanService, PlanGeneratorService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Timeline Schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TimelineSceneResponse(BaseModel):
|
||||
"""时间线场景"""
|
||||
|
||||
scene: str = Field(..., description="场景描述")
|
||||
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
|
||||
duration: float = Field(..., ge=0, description="时长(秒)")
|
||||
color: str = Field(..., description="展示颜色")
|
||||
clip_id: str = Field(default="", description="关联的片段 ID")
|
||||
clip_type: str = Field(default="", description="片段类型")
|
||||
|
||||
|
||||
class TimelineResponse(BaseModel):
|
||||
"""时间线响应"""
|
||||
|
||||
plan_id: str
|
||||
total_duration: float
|
||||
scenes: List[TimelineSceneResponse]
|
||||
|
||||
|
||||
# clip_type → 颜色映射
|
||||
_CLIP_TYPE_COLORS = {
|
||||
"intro": "#6366f1",
|
||||
"title": "#6366f1",
|
||||
"product": "#818cf8",
|
||||
"showcase": "#10b981",
|
||||
"scene": "#10b981",
|
||||
"subtitle": "#f59e0b",
|
||||
"text": "#f59e0b",
|
||||
"cta": "#ef4444",
|
||||
"outro": "#ef4444",
|
||||
"voiceover": "#8b5cf6",
|
||||
"transition": "#64748b",
|
||||
}
|
||||
|
||||
_DEFAULT_COLOR = "#6366f1"
|
||||
|
||||
|
||||
def _format_time(seconds: float) -> str:
|
||||
"""将秒数格式化为 M:SS"""
|
||||
m = int(seconds) // 60
|
||||
s = int(seconds) % 60
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
|
||||
"""根据 clip_type 和 text_content 生成场景描述"""
|
||||
type_labels = {
|
||||
"intro": "开场",
|
||||
"title": "标题",
|
||||
"product": "产品展示",
|
||||
"showcase": "场景展示",
|
||||
"scene": "场景",
|
||||
"subtitle": "字幕",
|
||||
"text": "文字",
|
||||
"cta": "结尾 CTA",
|
||||
"outro": "结尾",
|
||||
"voiceover": "配音",
|
||||
"transition": "转场",
|
||||
}
|
||||
label = type_labels.get(clip_type, clip_type or "片段")
|
||||
if text_content:
|
||||
short = text_content[:20].strip()
|
||||
if short:
|
||||
return f"{label} - {short}"
|
||||
return label
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/timeline",
|
||||
response_model=TimelineResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def get_plan_timeline(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> TimelineResponse:
|
||||
"""获取剪辑计划的时间线场景数据"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
||||
clips.sort(key=lambda c: c.order)
|
||||
|
||||
scenes: List[TimelineSceneResponse] = []
|
||||
current_time = 0.0
|
||||
|
||||
for clip in clips:
|
||||
start = current_time
|
||||
end = start + clip.duration
|
||||
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
|
||||
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
|
||||
|
||||
scenes.append(
|
||||
TimelineSceneResponse(
|
||||
scene=scene_label,
|
||||
time=f"{_format_time(start)} - {_format_time(end)}",
|
||||
duration=clip.duration,
|
||||
color=color,
|
||||
clip_id=clip.id,
|
||||
clip_type=clip.clip_type,
|
||||
)
|
||||
)
|
||||
current_time = end
|
||||
|
||||
total_duration = sum(s.duration for s in scenes) or plan.total_duration
|
||||
|
||||
return TimelineResponse(
|
||||
plan_id=plan_id,
|
||||
total_duration=total_duration,
|
||||
scenes=scenes,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-from-template",
|
||||
response_model=GenerateFromTemplateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
deprecated=True,
|
||||
)
|
||||
def generate_from_template(
|
||||
body: GenerateFromTemplateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
) -> GenerateFromTemplateResponse:
|
||||
"""基于模板 + 素材自动生成剪辑计划"""
|
||||
from app.services import EditTemplateService
|
||||
|
||||
if body.project_id:
|
||||
check_project_access(body.project_id, current_user.user.id, project_repository)
|
||||
|
||||
template_svc = EditTemplateService(db)
|
||||
|
||||
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)) from exc
|
||||
|
||||
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
||||
|
||||
# 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材
|
||||
resolved_asset_ids = list(body.asset_ids)
|
||||
if not resolved_asset_ids and body.project_id:
|
||||
auto_assets = auto_select_video_assets(
|
||||
project_id=body.project_id,
|
||||
asset_library_repo=asset_library_repository,
|
||||
asset_repo=asset_repository,
|
||||
logger=logger,
|
||||
)
|
||||
if auto_assets:
|
||||
resolved_asset_ids = auto_assets
|
||||
logger.info(
|
||||
"generate-from-template 自动选素材: project_id=%s count=%d",
|
||||
body.project_id,
|
||||
len(auto_assets),
|
||||
)
|
||||
|
||||
generator = PlanGeneratorService(db)
|
||||
result = generator.generate_from_template(
|
||||
template=template,
|
||||
clip_configs=clip_configs,
|
||||
asset_ids=resolved_asset_ids,
|
||||
project_id=body.project_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
name=body.name,
|
||||
)
|
||||
|
||||
plan = result["plan"]
|
||||
clips = result["clips"]
|
||||
|
||||
# 把 asset_ids 写入 plan.config,供生成时兜底分配使用
|
||||
if resolved_asset_ids:
|
||||
from app.services import EditPlanService
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
svc = EditPlanService(db)
|
||||
current_config = plan.config or {}
|
||||
if current_config.get("asset_ids") != resolved_asset_ids:
|
||||
current_config["asset_ids"] = resolved_asset_ids
|
||||
plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config))
|
||||
|
||||
logger.info(
|
||||
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%d",
|
||||
plan.id,
|
||||
body.template_id,
|
||||
len(clips),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateFromTemplateResponse(
|
||||
plan=_to_response(plan),
|
||||
clips=[
|
||||
_PlanClipItem(
|
||||
id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
asset_id=c.asset_id,
|
||||
text_content=c.text_content,
|
||||
start_time=c.start_time,
|
||||
duration=c.duration,
|
||||
transition_effect=c.transition_effect,
|
||||
transition_duration=c.transition_duration,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
config=c.config,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
for c in clips
|
||||
],
|
||||
)
|
||||
@@ -1,274 +0,0 @@
|
||||
"""转场特效 API.
|
||||
|
||||
- GET /transition-presets 转场预设列表
|
||||
- PUT /clips/{clip_id}/transition 设置单个片段转场
|
||||
- POST /{plan_id}/transitions/batch 批量设置转场(所有片段)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.transition_presets import (
|
||||
TransitionPreset,
|
||||
get_transition_preset,
|
||||
list_transition_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionPresetResponse(BaseModel):
|
||||
"""转场预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
default_duration: float
|
||||
min_duration: float
|
||||
max_duration: float
|
||||
|
||||
|
||||
class TransitionUpdateRequest(BaseModel):
|
||||
"""更新转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
|
||||
|
||||
class BatchTransitionRequest(BaseModel):
|
||||
"""批量设置转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
apply_to: str = Field(
|
||||
default="all",
|
||||
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
|
||||
)
|
||||
|
||||
|
||||
class ClipTransitionResponse(BaseModel):
|
||||
"""片段转场信息响应"""
|
||||
|
||||
clip_id: str
|
||||
effect: str
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchTransitionResponse(BaseModel):
|
||||
"""批量转场响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
class TransitionPresetListResponse(BaseModel):
|
||||
"""转场预设列表响应"""
|
||||
|
||||
items: List[TransitionPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _preset_to_response(p: TransitionPreset) -> TransitionPresetResponse:
|
||||
return TransitionPresetResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
category=p.category,
|
||||
description=p.description,
|
||||
tags=list(p.tags),
|
||||
default_duration=p.default_duration,
|
||||
min_duration=p.min_duration,
|
||||
max_duration=p.max_duration,
|
||||
)
|
||||
|
||||
|
||||
def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple[str, float]:
|
||||
"""验证转场效果和时长,返回 (effect, duration)"""
|
||||
preset = get_transition_preset(effect)
|
||||
if preset is None:
|
||||
raise ValueError(f"无效的转场效果: {effect}")
|
||||
|
||||
# 硬切特殊处理,时长强制为0
|
||||
if effect == "transition_none" or preset.transition == "none":
|
||||
return "cut", 0.0
|
||||
|
||||
final_duration = duration if duration is not None else preset.default_duration
|
||||
if final_duration < preset.min_duration:
|
||||
final_duration = preset.min_duration
|
||||
if final_duration > preset.max_duration:
|
||||
final_duration = preset.max_duration
|
||||
|
||||
return preset.transition, round(final_duration, 3)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/transition-presets", response_model=TransitionPresetListResponse, deprecated=True)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> TransitionPresetListResponse:
|
||||
"""获取转场预设列表"""
|
||||
presets = list_transition_presets(category=category, keyword=keyword)
|
||||
items = [_preset_to_response(p) for p in presets]
|
||||
return TransitionPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse, deprecated=True)
|
||||
def update_clip_transition(
|
||||
clip_id: str,
|
||||
body: TransitionUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipTransitionResponse:
|
||||
"""设置单个片段的转场效果"""
|
||||
svc = EditPlanService(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
|
||||
plan = svc.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证转场参数
|
||||
try:
|
||||
effect, duration = _validate_transition(body.effect, body.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 更新片段
|
||||
updated_clip = svc.update_clip(
|
||||
clip_id,
|
||||
transition_effect=effect,
|
||||
transition_duration=duration,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"更新片段转场: clip_id=%s effect=%s duration=%.3f by user=%s",
|
||||
clip_id,
|
||||
effect,
|
||||
duration,
|
||||
current_user.user.id,
|
||||
)
|
||||
return ClipTransitionResponse(
|
||||
clip_id=clip_id,
|
||||
effect=updated_clip.transition_effect,
|
||||
duration=updated_clip.transition_duration,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse, deprecated=True)
|
||||
def batch_update_transitions(
|
||||
plan_id: str,
|
||||
body: BatchTransitionRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchTransitionResponse:
|
||||
"""批量设置计划内所有片段的转场效果
|
||||
|
||||
apply_to 说明:
|
||||
- all: 所有片段
|
||||
- except_first: 除第一个片段外(第一个片段不需要前转场)
|
||||
- except_last: 除最后一个片段外
|
||||
- middle: 只设置中间片段(除首尾)
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证转场参数
|
||||
try:
|
||||
effect, duration = _validate_transition(body.effect, body.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 获取所有片段
|
||||
clips = svc.list_clips(plan_id, limit=500, skip=0)
|
||||
if not clips:
|
||||
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
|
||||
|
||||
# 确定应用范围
|
||||
total = len(clips)
|
||||
if total <= 1:
|
||||
# 只有一个片段时,只有 all 模式才应用
|
||||
if body.apply_to != "all":
|
||||
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
|
||||
|
||||
# 按 order 排序
|
||||
clips_sorted = sorted(clips, key=lambda c: c.order)
|
||||
indices_to_update = []
|
||||
|
||||
if body.apply_to == "all":
|
||||
indices_to_update = list(range(total))
|
||||
elif body.apply_to == "except_first":
|
||||
indices_to_update = list(range(1, total))
|
||||
elif body.apply_to == "except_last":
|
||||
indices_to_update = list(range(total - 1))
|
||||
elif body.apply_to == "middle":
|
||||
if total <= 2:
|
||||
indices_to_update = []
|
||||
else:
|
||||
indices_to_update = list(range(1, total - 1))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的 apply_to: {body.apply_to}",
|
||||
)
|
||||
|
||||
# 批量更新
|
||||
count = 0
|
||||
for idx in indices_to_update:
|
||||
clip = clips_sorted[idx]
|
||||
svc.update_clip(
|
||||
clip.id,
|
||||
transition_effect=effect,
|
||||
transition_duration=duration,
|
||||
)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"批量更新转场: plan_id=%s count=%d effect=%s apply_to=%s by user=%s",
|
||||
plan_id,
|
||||
count,
|
||||
effect,
|
||||
body.apply_to,
|
||||
current_user.user.id,
|
||||
)
|
||||
return BatchTransitionResponse(updated_count=count, plan_id=plan_id)
|
||||
Executable → Regular
+533
-67
@@ -23,60 +23,462 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re as _re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.api.routes.edit_plans import (
|
||||
AIRecommendRequest,
|
||||
AIRecommendResponse,
|
||||
BGMConfigUpdateRequest,
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
from app.api.routes.edit_plans_adjustments import (
|
||||
BatchSpeedRequest,
|
||||
BatchSpeedResponse,
|
||||
ClipAdjustmentsRequest,
|
||||
ClipAdjustResponse,
|
||||
SpeedAdjustRequest,
|
||||
TrimAdjustRequest,
|
||||
VolumeAdjustRequest,
|
||||
)
|
||||
from app.api.routes.edit_plans_clips_batch import (
|
||||
ClipBatchDeleteRequest,
|
||||
ClipBatchDeleteResponse,
|
||||
ClipReorderRequest,
|
||||
ClipReorderResponse,
|
||||
ClipsFromAssetsRequest,
|
||||
ClipsFromAssetsResponse,
|
||||
)
|
||||
from app.api.routes.edit_plans_cover import (
|
||||
CoverConfigResponse,
|
||||
CoverExtractRequest,
|
||||
CoverGenerateResponse,
|
||||
CoverSmartRequest,
|
||||
CoverUpdateRequest,
|
||||
)
|
||||
from app.api.routes.edit_plans_export import (
|
||||
ExportConfigResponse,
|
||||
ExportPresetListResponse,
|
||||
ExportUpdateRequest,
|
||||
)
|
||||
from app.api.routes.edit_plans_filter import (
|
||||
FilterConfigResponse,
|
||||
FilterPresetListResponse,
|
||||
FilterUpdateRequest,
|
||||
)
|
||||
from app.api.routes.edit_plans_transitions import (
|
||||
BatchTransitionRequest,
|
||||
BatchTransitionResponse,
|
||||
ClipTransitionResponse,
|
||||
TransitionPresetListResponse,
|
||||
TransitionUpdateRequest,
|
||||
)
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
# ── Pydantic Schemas (migrated from edit_plans*) ────────────────────────────
|
||||
|
||||
|
||||
# ── From edit_plans.py ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipStatusItem(BaseModel):
|
||||
"""片段生成状态"""
|
||||
|
||||
clip_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
status: str
|
||||
asset_id: str
|
||||
text_content: str
|
||||
duration: float
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
class EditPlanGenerateResponse(BaseModel):
|
||||
"""剪辑计划触发生成响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: str
|
||||
clip_count: int
|
||||
|
||||
|
||||
class EditPlanGenerationsResponse(BaseModel):
|
||||
"""剪辑计划关联的生成记录列表响应体"""
|
||||
|
||||
items: List[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class AIRecommendRequest(BaseModel):
|
||||
"""AI 推荐片段方案请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
|
||||
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||
|
||||
|
||||
class AIRecommendClipItem(BaseModel):
|
||||
"""AI 推荐的单个片段"""
|
||||
|
||||
clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro")
|
||||
order: int = Field(..., ge=0, description="片段顺序")
|
||||
text_content: str = Field(default="", description="文字内容")
|
||||
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
|
||||
transition_effect: str = Field(default="cut", description="转场效果")
|
||||
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
|
||||
asset_id: str = Field(default="", description="关联素材 ID")
|
||||
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
|
||||
|
||||
|
||||
class AIRecommendResponse(BaseModel):
|
||||
"""AI 推荐片段方案响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
||||
config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)")
|
||||
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
class BGMConfigUpdateRequest(BaseModel):
|
||||
"""更新BGM配置请求体"""
|
||||
|
||||
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
|
||||
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
|
||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
|
||||
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
|
||||
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
|
||||
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
|
||||
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
|
||||
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
|
||||
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
|
||||
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
|
||||
|
||||
|
||||
# ── From edit_plans_adjustments.py ──────────────────────────────────────────
|
||||
|
||||
|
||||
class SpeedAdjustRequest(BaseModel):
|
||||
"""调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
|
||||
|
||||
|
||||
class VolumeAdjustRequest(BaseModel):
|
||||
"""音量调节请求"""
|
||||
|
||||
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)")
|
||||
|
||||
|
||||
class TrimAdjustRequest(BaseModel):
|
||||
"""裁剪请求"""
|
||||
|
||||
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
|
||||
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
|
||||
|
||||
|
||||
class ClipAdjustmentsRequest(BaseModel):
|
||||
"""统一调整请求"""
|
||||
|
||||
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
trim_start: Optional[float] = Field(default=None, ge=0.0)
|
||||
trim_end: Optional[float] = Field(default=None, ge=0.0)
|
||||
|
||||
|
||||
class BatchSpeedRequest(BaseModel):
|
||||
"""批量调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
|
||||
|
||||
|
||||
class ClipAdjustResponse(BaseModel):
|
||||
"""片段调整响应"""
|
||||
|
||||
clip_id: str
|
||||
speed: float
|
||||
volume: float
|
||||
trim_start: float
|
||||
trim_end: float
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchSpeedResponse(BaseModel):
|
||||
"""批量调速响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
# ── From edit_plans_clips_batch.py ──────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipReorderItem(BaseModel):
|
||||
"""重排序条目"""
|
||||
|
||||
clip_id: str
|
||||
new_order: int = Field(..., ge=0, description="新的排序序号")
|
||||
|
||||
|
||||
class ClipReorderRequest(BaseModel):
|
||||
"""片段重排序请求"""
|
||||
|
||||
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
|
||||
|
||||
class ClipReorderResponse(BaseModel):
|
||||
"""片段重排序响应"""
|
||||
|
||||
success: bool
|
||||
updated_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipBatchDeleteRequest(BaseModel):
|
||||
"""批量删除片段请求"""
|
||||
|
||||
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
|
||||
|
||||
class ClipBatchDeleteResponse(BaseModel):
|
||||
"""批量删除片段响应"""
|
||||
|
||||
success: bool
|
||||
deleted_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
"""从素材批量创建片段响应"""
|
||||
|
||||
success: bool
|
||||
created_count: int
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
|
||||
|
||||
# ── From edit_plans_cover.py ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverConfigResponse(BaseModel):
|
||||
"""封面配置响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||
image_url: str = Field(default="", description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverUpdateRequest(BaseModel):
|
||||
"""更新封面配置请求"""
|
||||
|
||||
type: Optional[str] = Field(default=None, description="封面类型")
|
||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverExtractRequest(BaseModel):
|
||||
"""从片段抽帧生成封面请求"""
|
||||
|
||||
clip_id: str = Field(..., description="片段 ID")
|
||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverSmartRequest(BaseModel):
|
||||
"""智能选帧请求"""
|
||||
|
||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||
|
||||
|
||||
class CoverGenerateResponse(BaseModel):
|
||||
"""封面生成响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型")
|
||||
image_url: str = Field(..., description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── From edit_plans_export.py ───────────────────────────────────────────────
|
||||
|
||||
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
|
||||
_EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
|
||||
_EXPORT_VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
|
||||
class ExportConfigResponse(BaseModel):
|
||||
"""导出配置响应"""
|
||||
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
watermark_enabled: bool
|
||||
watermark_text: str
|
||||
|
||||
|
||||
class ExportUpdateRequest(BaseModel):
|
||||
"""更新导出配置请求"""
|
||||
|
||||
resolution: Optional[str] = None
|
||||
fps: Optional[int] = Field(default=None, ge=15, le=60)
|
||||
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
|
||||
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
|
||||
format: Optional[str] = None
|
||||
quality_preset: Optional[str] = None
|
||||
watermark_enabled: Optional[bool] = None
|
||||
watermark_text: Optional[str] = None
|
||||
|
||||
@validator("resolution")
|
||||
def validate_resolution(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not _EXPORT_RESOLUTION_PATTERN.match(v):
|
||||
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
|
||||
w, h = v.split("x")
|
||||
if int(w) < 100 or int(h) < 100:
|
||||
raise ValueError("分辨率数值过小")
|
||||
if int(w) > 4096 or int(h) > 4096:
|
||||
raise ValueError("分辨率数值过大,最大 4096x4096")
|
||||
return v
|
||||
|
||||
@validator("format")
|
||||
def validate_format(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in _EXPORT_VALID_FORMATS:
|
||||
raise ValueError(f"无效格式: {v},支持: {_EXPORT_VALID_FORMATS}")
|
||||
return v
|
||||
|
||||
@validator("quality_preset")
|
||||
def validate_quality_preset(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in _EXPORT_VALID_QUALITY_PRESETS:
|
||||
raise ValueError(f"无效质量预设: {v},支持: {_EXPORT_VALID_QUALITY_PRESETS}")
|
||||
return v
|
||||
|
||||
|
||||
class ExportPresetItem(BaseModel):
|
||||
"""导出预设条目"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
description: str
|
||||
size_hint: str
|
||||
|
||||
|
||||
class ExportPresetListResponse(BaseModel):
|
||||
"""导出预设列表响应"""
|
||||
|
||||
items: List[ExportPresetItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ── From edit_plans_filter.py ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class FilterPresetResponse(BaseModel):
|
||||
"""滤镜预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FilterConfigResponse(BaseModel):
|
||||
"""滤镜配置响应"""
|
||||
|
||||
enabled: bool
|
||||
preset_id: str
|
||||
intensity: int
|
||||
brightness: float
|
||||
contrast: float
|
||||
saturation: float
|
||||
warmth: float
|
||||
|
||||
|
||||
class FilterUpdateRequest(BaseModel):
|
||||
"""更新滤镜配置请求"""
|
||||
|
||||
enabled: Optional[bool] = None
|
||||
preset_id: Optional[str] = None
|
||||
intensity: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
|
||||
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
|
||||
|
||||
class FilterPresetListResponse(BaseModel):
|
||||
"""滤镜预设列表响应"""
|
||||
|
||||
items: List[FilterPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── From edit_plans_transitions.py ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionPresetResponse(BaseModel):
|
||||
"""转场预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
default_duration: float
|
||||
min_duration: float
|
||||
max_duration: float
|
||||
|
||||
|
||||
class TransitionUpdateRequest(BaseModel):
|
||||
"""更新转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
|
||||
|
||||
class BatchTransitionRequest(BaseModel):
|
||||
"""批量设置转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
apply_to: str = Field(
|
||||
default="all",
|
||||
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
|
||||
)
|
||||
|
||||
|
||||
class ClipTransitionResponse(BaseModel):
|
||||
"""片段转场信息响应"""
|
||||
|
||||
clip_id: str
|
||||
effect: str
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchTransitionResponse(BaseModel):
|
||||
"""批量转场响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
class TransitionPresetListResponse(BaseModel):
|
||||
"""转场预设列表响应"""
|
||||
|
||||
items: List[TransitionPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
@@ -89,7 +491,6 @@ from app.dependencies import (
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
@@ -238,19 +639,84 @@ def get_draft_plan_id(
|
||||
template_id: str,
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> str:
|
||||
"""
|
||||
路径依赖:根据 template_id 获取或创建草稿,返回 plan_id。
|
||||
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
兼容策略:优先从新模板系统(edit_templates 表)查找,
|
||||
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
|
||||
"""
|
||||
tpl_svc, _ = services
|
||||
draft = tpl_svc.get_or_create_draft(
|
||||
template_id,
|
||||
user_id=str(current_user.user_id),
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 2. 新系统有模板 → 用新服务创建草稿
|
||||
if tpl_svc.get_template(template_id) is not None:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 3. 回退到旧模板系统(templates 表)
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id=user_id)
|
||||
if old_template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 4. 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段)
|
||||
pseudo_template = EditTemplate(
|
||||
id=old_template.id,
|
||||
name=old_template.name,
|
||||
editing_mode=old_template.mode,
|
||||
status=EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
return draft.id
|
||||
|
||||
# 将旧模板 segments 转换为 clip_configs
|
||||
clip_configs: list[TemplateClipConfig] = []
|
||||
for seg in old_template.segments or []:
|
||||
clip_configs.append(
|
||||
TemplateClipConfig(
|
||||
id=f"seg_{seg.id}",
|
||||
template_id=old_template.id,
|
||||
clip_type=ClipType.MAIN,
|
||||
order=seg.segment_order,
|
||||
min_duration=seg.duration_min,
|
||||
max_duration=seg.duration_max,
|
||||
)
|
||||
)
|
||||
|
||||
generator = PlanGeneratorService(db)
|
||||
result = generator.generate_from_template(
|
||||
template=pseudo_template,
|
||||
clip_configs=clip_configs,
|
||||
asset_ids=[],
|
||||
created_by_user_id=user_id,
|
||||
name=f"{old_template.name} - 草稿",
|
||||
)
|
||||
plan = result["plan"]
|
||||
|
||||
# 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑)
|
||||
plan_svc.update_plan_config(plan.id, {"is_template_draft": True})
|
||||
|
||||
logger.info(
|
||||
"旧模板自动创建草稿: template_id=%s draft_plan_id=%s user_id=%s",
|
||||
template_id,
|
||||
plan.id,
|
||||
user_id,
|
||||
)
|
||||
return plan.id
|
||||
|
||||
|
||||
# ── 草稿核心端点 ────────────────────────────────────────────────────────────
|
||||
@@ -683,7 +1149,7 @@ def update_editor_bgm(
|
||||
template_id,
|
||||
plan_id,
|
||||
current_bgm.get("enabled", False),
|
||||
current_user.user_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -931,7 +1397,7 @@ def generate_editor_draft(
|
||||
clip_count = plan_svc.mark_clips_ready(plan_id)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
user_id = current_user.user_id
|
||||
user_id = current_user.user.id
|
||||
_check_queue_limits(gen_task_repo, user_id)
|
||||
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
@@ -941,7 +1407,7 @@ def generate_editor_draft(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=plan.project_id or "",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user_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 [],
|
||||
),
|
||||
@@ -959,7 +1425,7 @@ def generate_editor_draft(
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
@@ -1490,7 +1956,7 @@ def editor_ai_recommend(
|
||||
plan_id,
|
||||
len(result["clips"]),
|
||||
result["total_duration"],
|
||||
current_user.user_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return AIRecommendResponse(
|
||||
@@ -1545,7 +2011,7 @@ def editor_generate_cover(
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
@@ -1961,7 +2427,7 @@ def extract_editor_cover(
|
||||
template_id,
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
current_user.user_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
@@ -2003,7 +2469,7 @@ def smart_editor_cover(
|
||||
template_id,
|
||||
plan_id,
|
||||
body.strategy,
|
||||
current_user.user_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
@@ -2084,7 +2550,7 @@ def create_clips_from_assets_editor(
|
||||
template_id,
|
||||
plan_id,
|
||||
len(clips),
|
||||
current_user.user_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipsFromAssetsResponse(
|
||||
|
||||
Executable → Regular
+2
-2
@@ -113,7 +113,7 @@ def update_video_review_status(
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
logger.info(
|
||||
"Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id
|
||||
"Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user.id
|
||||
)
|
||||
return _to_video_response(item, storage)
|
||||
|
||||
@@ -197,7 +197,7 @@ def batch_download_videos(
|
||||
# 发送 celery 任务
|
||||
task = celery_app.send_task(
|
||||
"worker.batch_download_videos",
|
||||
args=[request.video_ids, current_user.user_id],
|
||||
args=[request.video_ids, current_user.user.id],
|
||||
)
|
||||
|
||||
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
|
||||
|
||||
Regular → Executable
+2
-464
@@ -39,70 +39,6 @@ class EditPlanService:
|
||||
|
||||
# ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
def list_plans(
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[EditPlan]:
|
||||
"""列出剪辑计划
|
||||
|
||||
Args:
|
||||
template_id: 按模板 ID 筛选
|
||||
project_id: 按项目 ID 筛选
|
||||
status: 按状态筛选
|
||||
skip: 分页偏移
|
||||
limit: 每页数量
|
||||
"""
|
||||
if project_id:
|
||||
return self._plan_repo.list_by_project(
|
||||
project_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
if template_id:
|
||||
return self._plan_repo.list_by_template(
|
||||
template_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
|
||||
|
||||
def count_plans(
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
) -> int:
|
||||
"""统计计划数量
|
||||
|
||||
Note:
|
||||
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
|
||||
"""
|
||||
if project_id:
|
||||
all_matching = self._plan_repo.list_by_project(
|
||||
project_id,
|
||||
status=status,
|
||||
skip=0,
|
||||
limit=10000,
|
||||
)
|
||||
return len(all_matching)
|
||||
if template_id:
|
||||
all_matching = self._plan_repo.list_by_template(
|
||||
template_id,
|
||||
status=status,
|
||||
skip=0,
|
||||
limit=10000,
|
||||
)
|
||||
return len(all_matching)
|
||||
return self._plan_repo.count(status=status)
|
||||
|
||||
def get_plan(self, plan_id: str) -> Optional[EditPlan]:
|
||||
"""获取计划详情"""
|
||||
return self._plan_repo.get(plan_id)
|
||||
@@ -124,7 +60,7 @@ class EditPlanService:
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
) -> EditPlan:
|
||||
"""创建剪辑计划
|
||||
"""创建剪辑计划(基础 CRUD,供内部测试与脚本使用)
|
||||
|
||||
Raises:
|
||||
ValueError: 参数校验失败
|
||||
@@ -190,23 +126,6 @@ class EditPlanService:
|
||||
logger.info("更新剪辑计划: id=%s", plan_id)
|
||||
return result
|
||||
|
||||
def delete_plan(self, plan_id: str) -> bool:
|
||||
"""删除剪辑计划及其所有片段
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
existing = self._plan_repo.get(plan_id)
|
||||
if existing is None:
|
||||
return False
|
||||
|
||||
# 先删除所有片段
|
||||
self._clip_repo.delete_by_plan(plan_id)
|
||||
# 再删除计划
|
||||
self._plan_repo.delete(plan_id)
|
||||
logger.info("删除剪辑计划: id=%s", plan_id)
|
||||
return True
|
||||
|
||||
# ── 状态机流转 ──────────────────────────────────────────────────────────
|
||||
|
||||
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
|
||||
@@ -447,63 +366,6 @@ class EditPlanService:
|
||||
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
|
||||
return count
|
||||
|
||||
def create_clips_from_assets(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_ids: list[str],
|
||||
*,
|
||||
clip_type: str = "main",
|
||||
) -> list[EditPlanClip]:
|
||||
"""从素材批量创建片段(追加到时间线末尾)。
|
||||
|
||||
Args:
|
||||
plan_id: 计划 ID
|
||||
asset_ids: 素材 ID 列表(按顺序追加)
|
||||
clip_type: 片段类型
|
||||
|
||||
Returns:
|
||||
list[EditPlanClip]: 创建的片段列表
|
||||
"""
|
||||
if not asset_ids:
|
||||
return []
|
||||
|
||||
# 确保计划存在 + 自动回退状态
|
||||
self.get_plan_or_raise(plan_id)
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
# 查询素材信息(取 duration)
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
session = self._clip_repo.session # type: ignore[attr-defined]
|
||||
assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
asset_map = {a.id: a for a in assets}
|
||||
|
||||
# 从现有片段数量开始追加
|
||||
existing_count = self._clip_repo.count(plan_id=plan_id)
|
||||
|
||||
# 批量创建片段
|
||||
created: list[EditPlanClip] = []
|
||||
for i, asset_id in enumerate(asset_ids):
|
||||
asset = asset_map.get(asset_id)
|
||||
duration = asset.duration if asset and asset.duration else 0.0
|
||||
|
||||
clip = self.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=existing_count + i,
|
||||
asset_id=asset_id,
|
||||
duration=duration,
|
||||
)
|
||||
created.append(clip)
|
||||
|
||||
logger.info(
|
||||
"从素材批量创建片段: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(created),
|
||||
)
|
||||
return created
|
||||
|
||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
@@ -674,249 +536,7 @@ class EditPlanService:
|
||||
|
||||
return merged_clip
|
||||
|
||||
# ── 字幕管理 ──────────────────────────────────────────────────────────
|
||||
|
||||
def list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]:
|
||||
"""获取片段的所有字幕
|
||||
|
||||
Returns:
|
||||
List[dict]: 字幕列表,按 start 时间排序
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = clip.config or {}
|
||||
subtitles = config.get("subtitles", [])
|
||||
# 按开始时间排序
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
return subtitles
|
||||
|
||||
def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取单条字幕"""
|
||||
subtitles = self.list_subtitles(clip_id)
|
||||
for s in subtitles:
|
||||
if s.get("id") == subtitle_id:
|
||||
return s
|
||||
return None
|
||||
|
||||
def add_subtitle(
|
||||
self,
|
||||
clip_id: str,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
*,
|
||||
style: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""添加一条字幕
|
||||
|
||||
Args:
|
||||
clip_id: 片段 ID
|
||||
start: 开始时间(秒,相对于片段)
|
||||
end: 结束时间(秒)
|
||||
text: 字幕文本
|
||||
style: 样式配置(字体、大小、颜色、位置等)
|
||||
|
||||
Returns:
|
||||
dict: 新增的字幕条目
|
||||
|
||||
Raises:
|
||||
ValueError: 时间非法或文本为空
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError(f"字幕时间非法: start={start}, end={end}")
|
||||
if not text.strip():
|
||||
raise ValueError("字幕文本不能为空")
|
||||
if end > clip.duration + 0.001:
|
||||
raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}")
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
subtitle = {
|
||||
"id": uuid4().hex,
|
||||
"start": round(start, 3),
|
||||
"end": round(end, 3),
|
||||
"text": text.strip(),
|
||||
"style": style or {},
|
||||
}
|
||||
subtitles.append(subtitle)
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
|
||||
config["subtitles"] = subtitles
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info(
|
||||
"添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs",
|
||||
clip_id,
|
||||
subtitle["id"],
|
||||
start,
|
||||
end,
|
||||
)
|
||||
|
||||
return subtitle
|
||||
|
||||
def update_subtitle(
|
||||
self,
|
||||
clip_id: str,
|
||||
subtitle_id: str,
|
||||
*,
|
||||
start: Optional[float] = None,
|
||||
end: Optional[float] = None,
|
||||
text: Optional[str] = None,
|
||||
style: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""更新一条字幕
|
||||
|
||||
Returns:
|
||||
dict: 更新后的字幕条目
|
||||
|
||||
Raises:
|
||||
ValueError: 字幕不存在或参数非法
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
found = False
|
||||
for i, s in enumerate(subtitles):
|
||||
if s.get("id") == subtitle_id:
|
||||
# 更新字段
|
||||
updated_s = dict(s)
|
||||
if start is not None:
|
||||
updated_s["start"] = round(start, 3)
|
||||
if end is not None:
|
||||
updated_s["end"] = round(end, 3)
|
||||
if text is not None:
|
||||
if not text.strip():
|
||||
raise ValueError("字幕文本不能为空")
|
||||
updated_s["text"] = text.strip()
|
||||
if style is not None:
|
||||
updated_s["style"] = style
|
||||
|
||||
# 校验时间
|
||||
if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]:
|
||||
raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}")
|
||||
if updated_s["end"] > clip.duration + 0.001:
|
||||
raise ValueError("字幕结束时间不能超过片段时长")
|
||||
|
||||
subtitles[i] = updated_s
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise ValueError(f"字幕不存在: {subtitle_id}")
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
config["subtitles"] = subtitles
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
|
||||
|
||||
return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)]
|
||||
|
||||
def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool:
|
||||
"""删除一条字幕
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
|
||||
if len(new_subtitles) == len(subtitles):
|
||||
return False
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
config["subtitles"] = new_subtitles
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
|
||||
return True
|
||||
|
||||
def batch_update_subtitles(
|
||||
self,
|
||||
clip_id: str,
|
||||
subtitles: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""批量更新字幕(全量替换,用于批量编辑或导入)
|
||||
|
||||
Args:
|
||||
clip_id: 片段 ID
|
||||
subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留
|
||||
|
||||
Returns:
|
||||
List[dict]: 更新后的字幕列表
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
validated = []
|
||||
for s in subtitles:
|
||||
start = float(s.get("start", 0))
|
||||
end = float(s.get("end", 0))
|
||||
text = str(s.get("text", ""))
|
||||
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError(f"字幕时间非法: start={start}, end={end}")
|
||||
if not text.strip():
|
||||
continue # 跳过空字幕
|
||||
if end > clip.duration + 0.001:
|
||||
raise ValueError(f"字幕结束时间不能超过片段时长: end={end}")
|
||||
|
||||
subtitle_id = s.get("id") or uuid4().hex
|
||||
validated.append(
|
||||
{
|
||||
"id": subtitle_id,
|
||||
"start": round(start, 3),
|
||||
"end": round(end, 3),
|
||||
"text": text.strip(),
|
||||
"style": s.get("style", {}),
|
||||
}
|
||||
)
|
||||
|
||||
validated.sort(key=lambda s: s["start"])
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
config["subtitles"] = validated
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info(
|
||||
"批量更新字幕: clip_id=%s count=%d",
|
||||
clip_id,
|
||||
len(validated),
|
||||
)
|
||||
|
||||
return validated
|
||||
|
||||
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
|
||||
"""获取计划及其所有片段
|
||||
|
||||
Returns:
|
||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||
"""
|
||||
plan = self.get_plan_or_raise(plan_id)
|
||||
clips = self._clip_repo.list_by_plan(plan_id)
|
||||
return {
|
||||
"plan": plan,
|
||||
"clips": clips,
|
||||
}
|
||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||
|
||||
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
|
||||
"""获取渲染进度状态
|
||||
@@ -1026,85 +646,3 @@ class EditPlanService:
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
return self._plan_repo.update(updated)
|
||||
|
||||
# ── 复制计划 ────────────────────────────────────────────────────────────
|
||||
|
||||
def copy_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
new_name: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> EditPlan:
|
||||
"""复制一个剪辑计划(含所有片段配置)。
|
||||
|
||||
新计划状态为 editing,不含生成任务和结果记录。
|
||||
|
||||
Args:
|
||||
plan_id: 源计划 ID
|
||||
new_name: 新计划名称,不传则为「原名 - 副本」
|
||||
project_id: 新计划的项目 ID,不传则复用源计划
|
||||
|
||||
Returns:
|
||||
EditPlan: 新创建的计划
|
||||
|
||||
Raises:
|
||||
ValueError: 源计划不存在
|
||||
"""
|
||||
source = self.get_plan_or_raise(plan_id)
|
||||
source_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
|
||||
# 新计划名称
|
||||
name = new_name or f"{source.name} - 副本"
|
||||
new_project_id = project_id if project_id is not None else source.project_id
|
||||
|
||||
# 复制 plan 配置(去除渲染结果相关字段)
|
||||
new_config = dict(source.config)
|
||||
new_config.pop("rendered_url", None)
|
||||
new_config.pop("rendered_storage_key", None)
|
||||
new_config.pop("generation_task_id", None)
|
||||
|
||||
# 创建新计划
|
||||
new_plan = EditPlan.create(
|
||||
template_id=source.template_id,
|
||||
name=name,
|
||||
config=new_config,
|
||||
total_duration=source.total_duration,
|
||||
project_id=new_project_id,
|
||||
created_by_user_id=source.created_by_user_id,
|
||||
source_edit_plan_id=plan_id,
|
||||
)
|
||||
# 强制切到 editing 状态
|
||||
if new_plan.status != EditPlanStatus.EDITING:
|
||||
try:
|
||||
new_plan.start_editing()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
created_plan = self._plan_repo.create(new_plan)
|
||||
logger.info(
|
||||
"复制剪辑计划: source=%s target=%s name=%s clips=%d",
|
||||
plan_id,
|
||||
created_plan.id,
|
||||
name,
|
||||
len(source_clips),
|
||||
)
|
||||
|
||||
# 复制所有片段
|
||||
for clip in source_clips:
|
||||
new_clip = self.create_clip(
|
||||
plan_id=created_plan.id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id or "",
|
||||
text_content=clip.text_content or "",
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=clip.transition_duration or 0.0,
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
config=dict(clip.config) if clip.config else None,
|
||||
)
|
||||
logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order)
|
||||
|
||||
return self.get_plan_or_raise(created_plan.id)
|
||||
|
||||
@@ -754,14 +754,8 @@ class EditTemplateService:
|
||||
template.bump_version() # 版本号 +1
|
||||
updated_template = self._template_repo.update(template)
|
||||
|
||||
# 批量删除旧的片段配置(N+1 → 1条DELETE,外层事务统一提交)
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
TemplateClipConfigModel,
|
||||
)
|
||||
|
||||
self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
# 批量删除旧的片段配置(外层事务统一提交)
|
||||
self._clip_config_repo.delete_by_template(template_id, commit=False)
|
||||
|
||||
# 创建新的片段配置
|
||||
created_configs: list[TemplateClipConfig] = []
|
||||
|
||||
@@ -52,7 +52,7 @@ type AssetListResponse = {
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
test("walks through 5-step wizard and starts generation", async ({ page, request }) => {
|
||||
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
@@ -177,7 +177,7 @@ test.describe("Core generation flow", () => {
|
||||
|
||||
// Navigate to generate page
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
|
||||
@@ -196,38 +196,56 @@ test.describe("Core generation flow", () => {
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: title
|
||||
// Step 3: preview (纯展示页,AI 智能匹配预览)
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: voice
|
||||
// Step 5: voice
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
|
||||
await firstVoiceCard.click()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: confirm and generate
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 7: confirm and generate
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
|
||||
// Wait for plan creation API to be called
|
||||
const createPlanPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/edit-plans") &&
|
||||
response.request().method() === "POST" &&
|
||||
!response.url().includes("/generate"),
|
||||
// Wait for generation API to be called
|
||||
// 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
|
||||
// 等 generate 接口返回,确认生成流程启动
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return response.request().method() === "POST" && path.endsWith("/editor/generate")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
// Click generate button
|
||||
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
|
||||
|
||||
// Verify plan was created successfully
|
||||
const planResp = await createPlanPromise
|
||||
expect(planResp.ok()).toBeTruthy()
|
||||
const planData = (await planResp.json()) as { id: string }
|
||||
expect(planData.id).toBeTruthy()
|
||||
// Verify generation was triggered successfully
|
||||
const genResp = await generatePromise
|
||||
if (!genResp.ok()) {
|
||||
const body = await genResp.text()
|
||||
console.error(
|
||||
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
|
||||
)
|
||||
}
|
||||
expect(genResp.ok()).toBeTruthy()
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
|
||||
// Generation may fail in test env (no worker), that's OK
|
||||
// Just verify the flow started - check page shows generation-related UI
|
||||
|
||||
@@ -121,3 +121,68 @@ export const verifyEmail = async (token: string): Promise<{ message: string }> =
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ========== 微信登录 ========== */
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
// 获取微信授权链接
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 微信回调登录
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 绑定联系方式
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ export interface ClipStatusItem {
|
||||
asset_id?: string
|
||||
text_content?: string
|
||||
duration?: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
/** 生成状态轮询响应 */
|
||||
@@ -196,7 +197,10 @@ export interface GenerationStatusResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id?: string
|
||||
error_message?: string
|
||||
clips: ClipStatusItem[]
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||
@@ -341,71 +345,71 @@ export interface EditPlanListResponse {
|
||||
|
||||
/** 获取模板草稿列表(支持分页和筛选) */
|
||||
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(planId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`)
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板草稿 */
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/edit-plans", data)
|
||||
const response = await apiClient.post("/templates/drafts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿 */
|
||||
export async function updateEditPlan(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/edit-plans/${planId}`, data)
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板草稿 */
|
||||
export async function deleteEditPlan(planId: string): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}`)
|
||||
export async function deleteEditPlan(templateId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}/editor`)
|
||||
}
|
||||
|
||||
/** 触发生成 */
|
||||
export async function generateEditPlan(planId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate`)
|
||||
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取生成状态(轮询用) */
|
||||
export async function getGenerationStatus(planId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generation-status`)
|
||||
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 推荐片段方案 */
|
||||
export async function aiRecommendClips(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
data: AIRecommendRequest,
|
||||
): Promise<AIRecommendResponse> {
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/ai-recommend`, data)
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
export async function generateCover(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate-cover`, data)
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取模板草稿关联的生成记录 */
|
||||
export async function getEditPlanGenerations(planId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generations`)
|
||||
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
@@ -416,8 +420,8 @@ export async function getGenerationTaskResults(taskId: string): Promise<Generate
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(planId: string): Promise<void> {
|
||||
await apiClient.post(`/edit-plans/${planId}/cancel`)
|
||||
export async function cancelGeneration(templateId: string): Promise<void> {
|
||||
await apiClient.post(`/templates/${templateId}/editor/cancel`)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -489,43 +493,51 @@ export interface EditPlanClipListParams {
|
||||
|
||||
/** 获取片段列表 */
|
||||
export async function getEditPlanClips(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
params?: EditPlanClipListParams,
|
||||
): Promise<EditPlanClipListResponse> {
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(`/edit-plans/${planId}/clips`, {
|
||||
params,
|
||||
})
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(
|
||||
`/templates/${templateId}/editor/clips`,
|
||||
{
|
||||
params,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个片段详情 */
|
||||
export async function getEditPlanClip(planId: string, clipId: string): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`)
|
||||
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(
|
||||
`/templates/${templateId}/editor/clips/${clipId}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建片段 */
|
||||
export async function createEditPlanClip(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
data: CreateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.post<EditPlanClip>(`/edit-plans/${planId}/clips`, data)
|
||||
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新片段 */
|
||||
export async function updateEditPlanClip(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
clipId: string,
|
||||
data: UpdateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.put<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`, data)
|
||||
const response = await apiClient.put<EditPlanClip>(
|
||||
`/templates/${templateId}/editor/clips/${clipId}`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除片段 */
|
||||
export async function deleteEditPlanClip(planId: string, clipId: string): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`)
|
||||
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -562,11 +574,11 @@ export interface ClipsFromAssetsResponse {
|
||||
|
||||
/** 片段重排序(拖拽排序后一次性提交) */
|
||||
export async function reorderEditPlanClips(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
items: ClipReorderItem[],
|
||||
): Promise<ClipReorderResponse> {
|
||||
const response = await apiClient.post<ClipReorderResponse>(
|
||||
`/edit-plans/${planId}/clips/reorder`,
|
||||
`/templates/${templateId}/editor/clips/reorder`,
|
||||
{ items },
|
||||
)
|
||||
return response.data
|
||||
@@ -574,11 +586,11 @@ export async function reorderEditPlanClips(
|
||||
|
||||
/** 批量删除片段 */
|
||||
export async function batchDeleteEditPlanClips(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
clipIds: string[],
|
||||
): Promise<ClipBatchDeleteResponse> {
|
||||
const response = await apiClient.post<ClipBatchDeleteResponse>(
|
||||
`/edit-plans/${planId}/clips/batch-delete`,
|
||||
`/templates/${templateId}/editor/clips/batch-delete`,
|
||||
{ clip_ids: clipIds },
|
||||
)
|
||||
return response.data
|
||||
@@ -586,12 +598,12 @@ export async function batchDeleteEditPlanClips(
|
||||
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
export async function createClipsFromAssets(
|
||||
planId: string,
|
||||
templateId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/edit-plans/${planId}/clips/from-assets`,
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
)
|
||||
return response.data
|
||||
@@ -608,8 +620,14 @@ export interface CopyEditPlanRequest {
|
||||
}
|
||||
|
||||
/** 复制模板草稿(含所有片段配置) */
|
||||
export async function copyEditPlan(planId: string, data?: CopyEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(`/edit-plans/${planId}/copy`, data || {})
|
||||
export async function copyEditPlan(
|
||||
templateId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/templates/${templateId}/editor/copy`,
|
||||
data || {},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"
|
||||
import type { EditPlanConfig } from "./editPlans"
|
||||
import type { EditPlanConfig } from "./templateEditor"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
||||
import "./AssetSelector.css"
|
||||
import { Input, Select, Button } from "@/components/ui"
|
||||
import type { MediaAsset } from "@/api/editPlans"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/editPlans"
|
||||
import type { MediaAsset } from "@/api/templateEditor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/templateEditor"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Modal, Tabs, Form, Input, Button, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
|
||||
interface BindContactModalProps {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
||||
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
setCountdown(0)
|
||||
}
|
||||
}, [open, form])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const value = form.getFieldValue(activeTab === "email" ? "email" : "phone")
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
setCountdown(60)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.code }
|
||||
: { phone: values.phone, phone_code: values.code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="绑定联系方式"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
>
|
||||
<p style={{ color: "#666", marginBottom: 16 }}>为了保障账号安全,请绑定您的邮箱或手机号</p>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as "email" | "phone")}
|
||||
items={[
|
||||
{
|
||||
key: "email",
|
||||
label: "邮箱绑定",
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ required: true, message: "请输入邮箱" },
|
||||
{ type: "email", message: "请输入有效的邮箱地址" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱地址" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Input placeholder="请输入验证码" size="large" style={{ flex: 1 }} />
|
||||
<Button
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
label: "手机绑定",
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: "请输入手机号" },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: "请输入有效的手机号" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入手机号" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Input placeholder="请输入验证码" size="large" style={{ flex: 1 }} />
|
||||
<Button
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" block size="large" loading={loading} onClick={handleSubmit}>
|
||||
确认绑定
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default BindContactModal
|
||||
@@ -20,24 +20,56 @@ export const useLogin = () => {
|
||||
const data = await mutation.mutateAsync(credentials)
|
||||
const refreshToken = data.refresh_token ?? null
|
||||
|
||||
// 先保存 token
|
||||
localStorage.setItem("access_token", data.access_token)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem("refresh_token", refreshToken)
|
||||
} else {
|
||||
localStorage.removeItem("refresh_token")
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
navigate("/")
|
||||
|
||||
// 跳转到登录前页面或首页
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
return data
|
||||
}
|
||||
|
||||
return { ...mutation, mutateAsync: login }
|
||||
}
|
||||
|
||||
// 微信登录 Hook(用于回调后处理登录状态)
|
||||
export const useWechatCallback = () => {
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ code, state }: { code: string; state: string }) =>
|
||||
authApi.wechatCallback(code, state),
|
||||
})
|
||||
|
||||
const handleCallback = async (code: string, state: string) => {
|
||||
// 校验 state
|
||||
const savedState = localStorage.getItem("wechat_state")
|
||||
if (!savedState || savedState !== state) {
|
||||
throw new Error("安全校验失败")
|
||||
}
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await mutation.mutateAsync({ code, state })
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
// 绑定成功后跳转
|
||||
const finishLogin = () => {
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
|
||||
return { ...mutation, handleCallback, finishLogin, setUser }
|
||||
}
|
||||
|
||||
// 注册 Hook
|
||||
export const useRegister = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
Executable → Regular
+29
-3
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 登录页面 - V21 完全对标
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import { Form, Input, Checkbox, message } from "antd"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { useLogin } from "@/hooks/useAuth"
|
||||
import { getWechatAuthUrl } from "@/api/auth"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "./Login.css"
|
||||
|
||||
@@ -18,6 +19,7 @@ const Login: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const loginMutation = useLogin()
|
||||
const [form] = Form.useForm()
|
||||
const [wechatLoading, setWechatLoading] = useState(false)
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
@@ -33,6 +35,29 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleWechatLogin = async () => {
|
||||
try {
|
||||
setWechatLoading(true)
|
||||
const result = await getWechatAuthUrl()
|
||||
// 保存 state 到 localStorage 用于回调时验证
|
||||
localStorage.setItem("wechat_state", result.state)
|
||||
// 记录登录前的来源页,登录成功后跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
}
|
||||
// 跳转到微信授权页
|
||||
window.location.href = result.auth_url
|
||||
} catch (error) {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("微信登录暂不可用,请稍后重试")
|
||||
} finally {
|
||||
setWechatLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-auth-page">
|
||||
<div className="xx-auth-card">
|
||||
@@ -100,10 +125,11 @@ const Login: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn-wechat"
|
||||
onClick={() => message.info("微信登录功能开发中")}
|
||||
onClick={handleWechatLogin}
|
||||
disabled={wechatLoading}
|
||||
>
|
||||
<span className="xx-wechat-icon">💬</span>
|
||||
微信登录
|
||||
{wechatLoading ? "加载中..." : "微信登录"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 微信登录回调页
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { Spin, message } from "antd"
|
||||
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import BindContactModal from "@/components/auth/BindContactModal"
|
||||
|
||||
const WechatCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showBindModal, setShowBindModal] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
if (!code || !state) {
|
||||
setError("无效的回调参数")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// 校验 state,防止 CSRF
|
||||
const savedState = localStorage.getItem("wechat_state")
|
||||
if (!savedState || savedState !== state) {
|
||||
setError("安全校验失败,请重新登录")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
|
||||
// 获取用户信息
|
||||
const userData = await getCurrentUser()
|
||||
const user: User = normalizeUser(userData)
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
if (result.binding_complete) {
|
||||
// 已绑定,跳转到登录前页面或首页
|
||||
message.success("登录成功")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} else {
|
||||
// 未绑定,显示绑定弹窗
|
||||
setLoading(false)
|
||||
setShowBindModal(true)
|
||||
}
|
||||
} catch (err) {
|
||||
setError("登录失败,请重试")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [searchParams, navigate, setAuth])
|
||||
|
||||
const handleBindSuccess = (user: User) => {
|
||||
const setUser = useAuthStore.getState().setUser
|
||||
setUser(user)
|
||||
setShowBindModal(false)
|
||||
message.success("绑定成功")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
|
||||
const handleBindCancel = () => {
|
||||
setShowBindModal(false)
|
||||
navigate("/login")
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, color: "#666" }}>正在登录...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "var(--primary-color, #3b82f6)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BindContactModal
|
||||
open={showBindModal}
|
||||
onSuccess={handleBindSuccess}
|
||||
onCancel={handleBindCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatCallback
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
getTemplateCategories,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner"
|
||||
import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/editPlans"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/editPlans"
|
||||
import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/templateEditor"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/templateEditor"
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo"
|
||||
import type {
|
||||
ClipData,
|
||||
|
||||
Executable → Regular
+1
-1
@@ -6,7 +6,7 @@ import React, { useRef, useState, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { TemplateMode } from "@/api/editingPlanner"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SubtitleSettings {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
|
||||
import type { EditPlanGeneration } from "@/api/editPlans"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/editPlans"
|
||||
import type { EditPlanGeneration } from "@/api/templateEditor"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/templateEditor"
|
||||
|
||||
interface GenerationHistoryModalProps {
|
||||
open: boolean
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useCallback } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
|
||||
Executable → Regular
+1
-1
@@ -5,7 +5,7 @@
|
||||
import React, { useState } from "react"
|
||||
import type { EditingTemplate } from "@/api/editingPlanner"
|
||||
import { MODE_LABELS } from "@/api/editingPlanner"
|
||||
import type { MediaAsset } from "@/api/editPlans"
|
||||
import type { MediaAsset } from "@/api/templateEditor"
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector"
|
||||
|
||||
interface MediaPanelProps {
|
||||
|
||||
Executable → Regular
+1
-1
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/editPlans"
|
||||
import type { TitleConfig } from "@/api/templateEditor"
|
||||
import type { CoverConfig } from "../types"
|
||||
|
||||
interface SubtitleSettings {
|
||||
|
||||
Executable → Regular
+1
-1
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Slider } from "antd"
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
import type { TransitionConfig, TransitionType } from "../types"
|
||||
import { DEFAULT_TRANSITION } from "../types"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/editPlans"
|
||||
} from "@/api/templateEditor"
|
||||
import {
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/editPlans"
|
||||
} from "@/api/templateEditor"
|
||||
import { useUndoRedo } from "./useUndoRedo"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
@@ -25,16 +25,16 @@ import {
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import {
|
||||
createEditPlan,
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
} from "@/api/editPlans"
|
||||
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans"
|
||||
getGenerationStatus,
|
||||
getEditPlan,
|
||||
} from "@/api/templateEditor"
|
||||
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/templateEditor"
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
import { getEditingTemplates } from "@/api/editingPlanner"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import apiClient from "@/api/client"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { formatDuration } from "@/api/voiceClone"
|
||||
@@ -44,7 +44,6 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { getTags, createTag } from "@/api/tags"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { getEditPlan } from "@/api/editPlans"
|
||||
import "./generate.css"
|
||||
|
||||
const { Text } = Typography
|
||||
@@ -588,7 +587,7 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("素材库素材较少,无法换一批")
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
@@ -994,8 +993,11 @@ const GeneratePage: React.FC = () => {
|
||||
if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim()
|
||||
}
|
||||
|
||||
const plan = await createEditPlan({
|
||||
template_id: selectedTemplate,
|
||||
// 获取或创建草稿(新架构:GET /templates/{templateId}/editor 自动创建)
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
@@ -1018,18 +1020,14 @@ const GeneratePage: React.FC = () => {
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
source_edit_plan_id: editPlanId || undefined,
|
||||
status: "editing",
|
||||
})
|
||||
|
||||
// 后端要求计划处于 editing 状态才能触发渲染,自动转换状态
|
||||
await updateEditPlan(plan.id, { status: "editing" })
|
||||
|
||||
await generateEditPlan(plan.id)
|
||||
await generateEditPlan(selectedTemplate)
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await apiClient.get(`/edit-plans/${plan.id}/generation-status`)
|
||||
const data = status.data
|
||||
const data = await getGenerationStatus(selectedTemplate)
|
||||
|
||||
if (data.plan_status === "completed") {
|
||||
setProgress(100)
|
||||
@@ -1053,12 +1051,13 @@ const GeneratePage: React.FC = () => {
|
||||
setGenerating(false)
|
||||
// 提取后端返回的错误详情,便于排查
|
||||
// 注意:后端返回的 error_message/error/message 可能是对象而非字符串
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const dataAny = data as Record<string, any>
|
||||
const rawMsg =
|
||||
data.error_message ||
|
||||
data.error ||
|
||||
data.message ||
|
||||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
|
||||
?.error_message ||
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(data.clips || []).find((c) => c.status === "failed")?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构)
|
||||
const safeExtract = (val: unknown): string => {
|
||||
@@ -1075,7 +1074,7 @@ const GeneratePage: React.FC = () => {
|
||||
return String(val ?? "")
|
||||
}
|
||||
const errorMsg = safeExtract(rawMsg)
|
||||
console.error("[生成失败] planId:", plan.id, "响应:", data)
|
||||
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
|
||||
setGenerateError(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
@@ -1091,7 +1090,7 @@ const GeneratePage: React.FC = () => {
|
||||
>
|
||||
} catch (pollErr) {
|
||||
// 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次
|
||||
console.error("[轮询出错] planId:", plan.id, pollErr)
|
||||
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
@@ -1198,7 +1197,6 @@ const GeneratePage: React.FC = () => {
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
|
||||
@@ -1953,7 +1953,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleTtsSave}
|
||||
>
|
||||
保存到素材库
|
||||
保存到配音库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Executable → Regular
+5
@@ -9,6 +9,7 @@ import Login from "@/pages/auth/Login"
|
||||
import Register from "@/pages/auth/Register"
|
||||
import ForgotPassword from "@/pages/auth/ForgotPassword"
|
||||
import ResetPassword from "@/pages/auth/ResetPassword"
|
||||
import WechatCallback from "@/pages/auth/WechatCallback"
|
||||
import HomePage from "@/pages/home/HomePage"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
|
||||
@@ -60,6 +61,10 @@ export const router = createBrowserRouter([
|
||||
path: "/reset-password",
|
||||
element: <ResetPassword />,
|
||||
},
|
||||
{
|
||||
path: "/auth/wechat/callback",
|
||||
element: <WechatCallback />,
|
||||
},
|
||||
{
|
||||
path: "/app",
|
||||
element: (
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
copyEditPlan,
|
||||
getMediaAssets,
|
||||
getMediaAsset,
|
||||
} from "@/api/editPlans"
|
||||
} from "@/api/templateEditor"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react"
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector"
|
||||
import type { MediaAsset } from "@/api/editPlans"
|
||||
import type { MediaAsset } from "@/api/templateEditor"
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
||||
|
||||
Regular → Executable
+19
-9
@@ -7,8 +7,18 @@ import { renderHook, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockSetAuth = vi.fn()
|
||||
const mockClearAuth = vi.fn()
|
||||
const mockSetAuth = vi.fn((_user: any, accessToken: string, refreshToken?: string | null) => {
|
||||
localStorage.setItem("access_token", accessToken)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem("refresh_token", refreshToken)
|
||||
} else {
|
||||
localStorage.removeItem("refresh_token")
|
||||
}
|
||||
})
|
||||
const mockClearAuth = vi.fn(() => {
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("refresh_token")
|
||||
})
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockQueryClear = vi.fn()
|
||||
|
||||
@@ -84,7 +94,7 @@ describe("useAuth hooks", () => {
|
||||
expect(typeof result.current.mutateAsync).toBe("function")
|
||||
})
|
||||
|
||||
it("登录成功时保存 token 并调用 setAuth", async () => {
|
||||
it("登录成功时调用 setAuth 并跳转到登录前页面或首页", async () => {
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
access_token: "access-123",
|
||||
refresh_token: "refresh-456",
|
||||
@@ -96,15 +106,15 @@ describe("useAuth hooks", () => {
|
||||
await result.current.mutateAsync({ username: "test", password: "123" })
|
||||
})
|
||||
|
||||
expect(localStorage.getItem("access_token")).toBe("access-123")
|
||||
expect(localStorage.getItem("refresh_token")).toBe("refresh-456")
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/")
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
|
||||
})
|
||||
|
||||
it("没有 refresh_token 时从 localStorage 移除", async () => {
|
||||
it("登录成功后跳转到 login_redirect 指定的页面", async () => {
|
||||
localStorage.setItem("login_redirect", "/app/templates")
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
access_token: "access-123",
|
||||
refresh_token: "refresh-456",
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLogin(), { wrapper })
|
||||
@@ -113,8 +123,8 @@ describe("useAuth hooks", () => {
|
||||
await result.current.mutateAsync({ username: "test", password: "123" })
|
||||
})
|
||||
|
||||
expect(localStorage.getItem("access_token")).toBe("access-123")
|
||||
expect(localStorage.getItem("refresh_token")).toBeNull()
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/app/templates", { replace: true })
|
||||
expect(localStorage.getItem("login_redirect")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ vi.mock("@/api/editingPlanner", () => ({
|
||||
MODE_LABELS: { pip: "画中画", intro_outro: "片头片尾", watermark: "水印" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
vi.mock("@/api/templateEditor", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
generateCover: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -213,11 +213,24 @@ vi.mock("@/api/titles", () => ({
|
||||
getTitles: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
vi.mock("@/api/templateEditor", () => ({
|
||||
generateEditPlan: vi.fn().mockResolvedValue({
|
||||
plan_id: "test-plan",
|
||||
generation_task_id: "test-task",
|
||||
plan_status: "processing",
|
||||
clip_count: 5,
|
||||
}),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({ plan_id: "test-plan", template_id: "test-template" }),
|
||||
getEditPlan: vi.fn().mockResolvedValue({
|
||||
plan_id: "test-plan",
|
||||
template_id: "test-template",
|
||||
name: "",
|
||||
config: {},
|
||||
status: "draft",
|
||||
}),
|
||||
getGenerationStatus: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ plan_status: "completed", generation_task_id: "test-task", clips: [] }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("antd", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
vi.mock("@/api/templateEditor", () => ({
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
updateEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -142,8 +142,6 @@ def _finalize_render_success(
|
||||
plan.config["rendered_storage_key"] = storage_key
|
||||
if hasattr(plan, "total_duration") and duration > 0:
|
||||
plan.total_duration = duration
|
||||
if hasattr(plan, "result_count"):
|
||||
plan.result_count = 1
|
||||
plan.mark_completed()
|
||||
plan_repo.update(plan)
|
||||
|
||||
|
||||
+55
-18
@@ -1,33 +1,69 @@
|
||||
# ============================================================
|
||||
# API Dockerfile - 专门用于 FastAPI 应用
|
||||
# 优化:依赖分层缓存 + 多阶段构建基础层
|
||||
# API Dockerfile - FastAPI 应用
|
||||
# 优化:多阶段构建 + pip cache mount + 依赖分层缓存
|
||||
# ============================================================
|
||||
|
||||
# 基础镜像:Python 3.12
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
# ==================== Builder 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装编译依赖(仅 builder 需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-base.txt \
|
||||
&& rm /tmp/requirements-base.txt
|
||||
|
||||
# ---- 依赖分层:业务依赖(变化频繁)----
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# ---- Python 依赖瘦身 ----
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# ==================== Runtime 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
|
||||
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
|
||||
RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt && rm /tmp/requirements-base.txt
|
||||
|
||||
# ---- 依赖分层:业务依赖(变化频繁)----
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
|
||||
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt && rm /tmp/requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY apps/api/ /app/apps/api/
|
||||
COPY packages/ /app/packages/
|
||||
@@ -43,7 +79,8 @@ ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
|
||||
|
||||
# API 入口点
|
||||
WORKDIR /app/apps/api
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# ============================================================
|
||||
# Worker Builder 基础镜像
|
||||
# 预编译:编译工具 + 基础依赖 + Worker大包
|
||||
# 当 requirements-base.txt 或 requirements-worker.txt 变更时重新构建
|
||||
# 业务构建从此镜像开始,只需要安装业务依赖,节省15+分钟
|
||||
# ============================================================
|
||||
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装编译工具
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
binutils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建 venv
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# 基础依赖(变化极少)
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-base.txt \
|
||||
&& rm /tmp/requirements-base.txt
|
||||
|
||||
# Worker 大包(变化少)
|
||||
COPY requirements-worker.txt /tmp/requirements-worker.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-worker.txt \
|
||||
&& rm /tmp/requirements-worker.txt
|
||||
|
||||
# 预先做一次 strip(基础层瘦身,业务层增量)
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
@@ -0,0 +1,17 @@
|
||||
# ============================================================
|
||||
# Worker Runtime 基础镜像
|
||||
# 预安装:ffmpeg + 运行时依赖
|
||||
# 变化极少,业务构建从此镜像开始
|
||||
# ============================================================
|
||||
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 运行时依赖:ffmpeg + opencv需要的libglib
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -1,98 +1,43 @@
|
||||
# ============================================================
|
||||
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身)
|
||||
# 优化项:
|
||||
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
|
||||
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
|
||||
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
|
||||
# Worker Dockerfile - 分层缓存优化版
|
||||
# 优化:基础依赖 + Worker大包预构建为基础镜像,业务构建仅叠加业务依赖
|
||||
# 基础镜像:worker-base-builder / worker-base-runtime
|
||||
# 预计节省:依赖不变时构建时间从23min降至5min以内
|
||||
# ============================================================
|
||||
|
||||
# ==================== Builder 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
|
||||
# 从预构建的builder基础镜像开始,已经包含:
|
||||
# - 编译工具 (gcc/g++/python3-dev/binutils)
|
||||
# - requirements-base.txt 全部依赖
|
||||
# - requirements-worker.txt 全部依赖 (numpy/scipy/opencv)
|
||||
# - 预strip的.so文件
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-builder:latest AS builder
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装编译工具(仅 builder 需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
binutils \
|
||||
wget \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- 下载静态编译 ffmpeg ----
|
||||
# 使用 johnvansickle.com 的静态编译版本(业界标准)
|
||||
RUN cd /tmp \
|
||||
&& wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
|
||||
&& tar xf ffmpeg-release-amd64-static.tar.xz \
|
||||
&& cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ffmpeg \
|
||||
&& cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ffprobe \
|
||||
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
|
||||
&& rm -rf ffmpeg-*
|
||||
|
||||
# ---- 安装 Python 依赖 ----
|
||||
WORKDIR /tmp
|
||||
|
||||
# 创建 venv
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 基础依赖
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-base.txt \
|
||||
&& rm /tmp/requirements-base.txt
|
||||
WORKDIR /tmp
|
||||
|
||||
# Worker 专属大包
|
||||
COPY requirements-worker.txt /tmp/requirements-worker.txt
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-worker.txt \
|
||||
&& rm /tmp/requirements-worker.txt
|
||||
|
||||
# 业务依赖
|
||||
# ---- 安装业务依赖(变化频繁,单独一层)----
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# ---- Python 依赖瘦身 ----
|
||||
# 1. strip .so 文件的调试符号(节省约 80-100MB)
|
||||
# ---- 增量瘦身(只处理新增的业务依赖)----
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
|
||||
# 2. 清理测试文件(节省约 20MB)
|
||||
RUN find /opt/venv -type d -name "tests" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -type d -name "test" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "test_*.py" -delete 2>/dev/null || true
|
||||
|
||||
# 3. 清理 .pyc 缓存和 __pycache__(节省约 10MB,运行时按需生成)
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# 4. 清理 dist-info 中的文档
|
||||
RUN find /opt/venv -name "*.dist-info" -type d -exec sh -c 'rm -f "$1"/DESCRIPTION.rst "$1"/INSTALLER "$1"/LICENSE* "$1"/WHEEL "$1"/entry_points.txt' _ {} \; 2>/dev/null || true
|
||||
|
||||
# ==================== Runtime 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
|
||||
# 从预构建的runtime基础镜像开始,已经包含:
|
||||
# - ffmpeg
|
||||
# - libglib2.0-0
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-runtime:latest AS runtime
|
||||
|
||||
# 构建参数:版本号
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制 ffmpeg 静态二进制
|
||||
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||
COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
||||
|
||||
# 从 builder 复制 Python 虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
|
||||
Regular → Executable
+34
@@ -17,6 +17,9 @@ class InMemoryUserRepository(UserRepository):
|
||||
self._username_index: Dict[str, str] = {} # username -> user_id
|
||||
self._verification_token_index: Dict[str, str] = {} # token -> user_id
|
||||
self._reset_token_index: Dict[str, str] = {} # token -> user_id
|
||||
self._wechat_openid_index: Dict[str, str] = {} # openid -> user_id
|
||||
self._wechat_unionid_index: Dict[str, str] = {} # unionid -> user_id
|
||||
self._phone_index: Dict[str, str] = {} # phone -> user_id
|
||||
|
||||
def save(self, user: User) -> None:
|
||||
"""保存用户"""
|
||||
@@ -28,6 +31,12 @@ class InMemoryUserRepository(UserRepository):
|
||||
self._verification_token_index[user.email_verification_token] = user.id
|
||||
if user.password_reset_token:
|
||||
self._reset_token_index[user.password_reset_token] = user.id
|
||||
if user.wechat_openid:
|
||||
self._wechat_openid_index[user.wechat_openid] = user.id
|
||||
if user.wechat_unionid:
|
||||
self._wechat_unionid_index[user.wechat_unionid] = user.id
|
||||
if user.phone:
|
||||
self._phone_index[user.phone] = user.id
|
||||
|
||||
def find_by_id(self, user_id: str) -> Optional[User]:
|
||||
"""根据 ID 查找用户"""
|
||||
@@ -61,6 +70,31 @@ class InMemoryUserRepository(UserRepository):
|
||||
return self._users.get(user_id)
|
||||
return None
|
||||
|
||||
def find_by_wechat_openid(self, openid: str) -> Optional[User]:
|
||||
"""根据微信 openid 查找用户"""
|
||||
user_id = self._wechat_openid_index.get(openid)
|
||||
if user_id:
|
||||
return self._users.get(user_id)
|
||||
return None
|
||||
|
||||
def find_by_wechat_unionid(self, unionid: str) -> Optional[User]:
|
||||
"""根据微信 unionid 查找用户"""
|
||||
if not unionid:
|
||||
return None
|
||||
user_id = self._wechat_unionid_index.get(unionid)
|
||||
if user_id:
|
||||
return self._users.get(user_id)
|
||||
return None
|
||||
|
||||
def find_by_phone(self, phone: str) -> Optional[User]:
|
||||
"""根据手机号查找用户"""
|
||||
if not phone:
|
||||
return None
|
||||
user_id = self._phone_index.get(phone)
|
||||
if user_id:
|
||||
return self._users.get(user_id)
|
||||
return None
|
||||
|
||||
def delete(self, user_id: str) -> bool:
|
||||
"""删除用户"""
|
||||
user = self._users.get(user_id)
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
短信服务实现(Noop + 阿里云)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NoopSmsService:
|
||||
"""空实现短信服务 - 开发/测试环境用,只打日志不真发"""
|
||||
|
||||
def send_verification_code(self, phone: str, code: str) -> bool:
|
||||
logger.info("[NoopSMS] 发送验证码到 %s: %s", phone, code)
|
||||
return True
|
||||
|
||||
def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool:
|
||||
logger.info("[NoopSMS] 发送模板短信到 %s, template=%s, params=%s", phone, template_id, params)
|
||||
return True
|
||||
|
||||
|
||||
class AliyunSmsService:
|
||||
"""阿里云短信服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_key_id: str | None = None,
|
||||
access_key_secret: str | None = None,
|
||||
sign_name: str | None = None,
|
||||
verify_template_id: str | None = None,
|
||||
):
|
||||
self.access_key_id = access_key_id or os.environ.get("ALIYUN_SMS_ACCESS_KEY_ID", "")
|
||||
self.access_key_secret = access_key_secret or os.environ.get("ALIYUN_SMS_ACCESS_KEY_SECRET", "")
|
||||
self.sign_name = sign_name or os.environ.get("ALIYUN_SMS_SIGN_NAME", "小应剪辑")
|
||||
self.verify_template_id = verify_template_id or os.environ.get("ALIYUN_SMS_VERIFY_TEMPLATE_ID", "SMS_123456789")
|
||||
|
||||
def send_verification_code(self, phone: str, code: str) -> bool:
|
||||
return self.send_template_sms(phone, self.verify_template_id, {"code": code})
|
||||
|
||||
def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool:
|
||||
try:
|
||||
import json
|
||||
|
||||
from alibabacloud_dysmsapi20170525 import models as dysmsapi_models
|
||||
from alibabacloud_dysmsapi20170525.client import Client as DysmsapiClient
|
||||
from alibabacloud_tea_openapi import models as open_api_models
|
||||
|
||||
config = open_api_models.Config(
|
||||
access_key_id=self.access_key_id,
|
||||
access_key_secret=self.access_key_secret,
|
||||
)
|
||||
config.endpoint = "dysmsapi.aliyuncs.com"
|
||||
client = DysmsapiClient(config)
|
||||
|
||||
request = dysmsapi_models.SendSmsRequest(
|
||||
phone_numbers=phone,
|
||||
sign_name=self.sign_name,
|
||||
template_code=template_id,
|
||||
template_param=json.dumps(params),
|
||||
)
|
||||
response = client.send_sms(request)
|
||||
body = response.body
|
||||
if body.code == "OK":
|
||||
logger.info("阿里云短信发送成功: phone=%s, template=%s", phone, template_id)
|
||||
return True
|
||||
else:
|
||||
logger.error("阿里云短信发送失败: code=%s, message=%s", body.code, body.message)
|
||||
return False
|
||||
except ImportError:
|
||||
logger.error("阿里云短信 SDK 未安装,请 pip install alibabacloud-dysmsapi20170525")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("阿里云短信发送异常: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def get_sms_service() -> "NoopSmsService | AliyunSmsService":
|
||||
"""获取短信服务实例"""
|
||||
provider = os.environ.get("SMS_PROVIDER", "noop").lower()
|
||||
if provider == "aliyun":
|
||||
return AliyunSmsService()
|
||||
return NoopSmsService()
|
||||
@@ -100,7 +100,6 @@ class SQLAlchemyEditPlanRepository:
|
||||
name=plan.name,
|
||||
status=plan.status,
|
||||
total_duration=plan.total_duration,
|
||||
result_count=plan.result_count,
|
||||
source_edit_plan_id=plan.source_edit_plan_id or None,
|
||||
project_id=plan.project_id or "",
|
||||
created_by_user_id=plan.created_by_user_id or "",
|
||||
@@ -120,7 +119,6 @@ class SQLAlchemyEditPlanRepository:
|
||||
model.name = plan.name
|
||||
model.status = plan.status
|
||||
model.total_duration = plan.total_duration
|
||||
model.result_count = plan.result_count
|
||||
model.source_edit_plan_id = plan.source_edit_plan_id or None
|
||||
model.project_id = plan.project_id or ""
|
||||
model.created_by_user_id = plan.created_by_user_id or ""
|
||||
@@ -154,7 +152,6 @@ class SQLAlchemyEditPlanRepository:
|
||||
name=model.name,
|
||||
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
|
||||
total_duration=model.total_duration or 0.0,
|
||||
result_count=int(model.result_count or 0),
|
||||
source_edit_plan_id=model.source_edit_plan_id or "",
|
||||
project_id=model.project_id or "",
|
||||
created_by_user_id=model.created_by_user_id or "",
|
||||
|
||||
@@ -31,9 +31,13 @@ class UserModel(Base):
|
||||
used_storage_gb = Column(Integer, nullable=False, default=0)
|
||||
# 管理员标识
|
||||
is_admin = Column(Boolean, nullable=False, default=False)
|
||||
# 微信登录(小程序端)
|
||||
# 微信登录
|
||||
wechat_openid = Column(String(128), nullable=True, unique=True, index=True)
|
||||
wechat_unionid = Column(String(128), nullable=True, unique=True, index=True)
|
||||
# 手机号绑定
|
||||
phone = Column(String(32), nullable=True, unique=True, index=True)
|
||||
phone_verified = Column(Boolean, nullable=False, default=False)
|
||||
binding_completed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -172,7 +176,6 @@ class EditPlanModel(Base):
|
||||
name = Column(String(200), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="draft", index=True)
|
||||
total_duration = Column(Float, nullable=False, default=0.0)
|
||||
result_count = Column(Integer, nullable=False, default=0)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
source_edit_plan_id = Column(String(36), nullable=True, index=True)
|
||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||
@@ -556,3 +559,18 @@ class BillingRecordModel(Base):
|
||||
invoice_url = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class VerificationCodeModel(Base):
|
||||
"""验证码(邮箱/手机统一管理)"""
|
||||
|
||||
__tablename__ = "verification_codes"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
recipient = Column(String(255), nullable=False, index=True) # 邮箱或手机号
|
||||
code = Column(String(10), nullable=False)
|
||||
code_type = Column(String(32), nullable=False, index=True) # email_bind / phone_bind / ...
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
attempts = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
Regular → Executable
+9
-3
@@ -92,14 +92,20 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def delete_by_template(self, template_id: str) -> int:
|
||||
"""删除模板下所有片段配置,返回删除数量"""
|
||||
def delete_by_template(self, template_id: str, *, commit: bool = True) -> int:
|
||||
"""删除模板下所有片段配置,返回删除数量
|
||||
|
||||
Args:
|
||||
template_id: 模板ID
|
||||
commit: 是否提交事务,默认True。外层有事务控制时传False。
|
||||
"""
|
||||
count = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.delete()
|
||||
)
|
||||
self.session.commit()
|
||||
if commit:
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def count(self, *, template_id: Optional[str] = None) -> int:
|
||||
|
||||
Regular → Executable
+12
@@ -35,6 +35,9 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
model.is_admin = user.is_admin
|
||||
model.wechat_openid = user.wechat_openid
|
||||
model.wechat_unionid = user.wechat_unionid
|
||||
model.phone = user.phone
|
||||
model.phone_verified = user.phone_verified
|
||||
model.binding_completed_at = user.binding_completed_at
|
||||
model.created_at = user.created_at
|
||||
|
||||
self.session.commit()
|
||||
@@ -61,6 +64,12 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
model = self.session.query(UserModel).filter(UserModel.wechat_unionid == unionid.strip()).first()
|
||||
return self._to_entity(model)
|
||||
|
||||
def find_by_phone(self, phone: str) -> User | None:
|
||||
if not phone or not phone.strip():
|
||||
return None
|
||||
model = self.session.query(UserModel).filter(UserModel.phone == phone.strip()).first()
|
||||
return self._to_entity(model)
|
||||
|
||||
def find_by_verification_token(self, token: str) -> User | None:
|
||||
model = self.session.query(UserModel).filter(UserModel.email_verification_token == token).first()
|
||||
return self._to_entity(model)
|
||||
@@ -101,5 +110,8 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
is_admin=model.is_admin or False,
|
||||
wechat_openid=model.wechat_openid,
|
||||
wechat_unionid=model.wechat_unionid,
|
||||
phone=model.phone,
|
||||
phone_verified=model.phone_verified or False,
|
||||
binding_completed_at=model.binding_completed_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
验证码仓储 SQLAlchemy 实现
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import VerificationCodeModel
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
from packages.ports.verification_code_repository import VerificationCodeRepository
|
||||
|
||||
|
||||
class SQLAlchemyVerificationCodeRepository(VerificationCodeRepository):
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def save(self, code: VerificationCode) -> None:
|
||||
model = self.session.get(VerificationCodeModel, code.id)
|
||||
if model is None:
|
||||
model = VerificationCodeModel(id=code.id)
|
||||
self.session.add(model)
|
||||
|
||||
model.recipient = code.recipient
|
||||
model.code = code.code
|
||||
model.code_type = code.code_type
|
||||
model.expires_at = code.expires_at
|
||||
model.used_at = code.used_at
|
||||
model.attempts = code.attempts
|
||||
model.created_at = code.created_at
|
||||
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
|
||||
def find_latest(self, recipient: str, code_type: str) -> Optional[VerificationCode]:
|
||||
model = (
|
||||
self.session.query(VerificationCodeModel)
|
||||
.filter(
|
||||
VerificationCodeModel.recipient == recipient.strip(),
|
||||
VerificationCodeModel.code_type == code_type,
|
||||
)
|
||||
.order_by(VerificationCodeModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
return self._to_entity(model)
|
||||
|
||||
def find_by_id(self, code_id: str) -> Optional[VerificationCode]:
|
||||
return self._to_entity(self.session.get(VerificationCodeModel, code_id))
|
||||
|
||||
def count_today(self, recipient: str, code_type: str) -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return (
|
||||
self.session.query(VerificationCodeModel)
|
||||
.filter(
|
||||
VerificationCodeModel.recipient == recipient.strip(),
|
||||
VerificationCodeModel.code_type == code_type,
|
||||
VerificationCodeModel.created_at >= start_of_day,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_entity(model: VerificationCodeModel | None) -> VerificationCode | None:
|
||||
if model is None:
|
||||
return None
|
||||
return VerificationCode(
|
||||
id=model.id,
|
||||
recipient=model.recipient,
|
||||
code=model.code,
|
||||
code_type=model.code_type,
|
||||
expires_at=model.expires_at,
|
||||
used_at=model.used_at,
|
||||
attempts=model.attempts,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
微信登录 + 绑定手机号邮箱 Use Case
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from packages.application.auth.verification_code_service import (
|
||||
CODE_TYPE_EMAIL_BIND,
|
||||
CODE_TYPE_PHONE_BIND,
|
||||
normalize_phone,
|
||||
validate_email,
|
||||
validate_phone,
|
||||
)
|
||||
from packages.domain.entities import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BindContactRequest:
|
||||
"""绑定联系方式请求"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
phone: str = "",
|
||||
phone_code: str = "",
|
||||
email: str = "",
|
||||
email_code: str = "",
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.phone = normalize_phone(phone) if phone else ""
|
||||
self.phone_code = phone_code.strip() if phone_code else ""
|
||||
self.email = email.strip().lower() if email else ""
|
||||
self.email_code = email_code.strip() if email_code else ""
|
||||
|
||||
|
||||
class BindContactResponse:
|
||||
"""绑定响应"""
|
||||
|
||||
def __init__(self, user: User):
|
||||
self.user = user
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"user": {
|
||||
"id": self.user.id,
|
||||
"email": self.user.email,
|
||||
"phone": self.user.phone,
|
||||
"phone_verified": self.user.phone_verified,
|
||||
"display_name": self.user.display_name,
|
||||
"binding_complete": self.user.binding_completed_at is not None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BindContactUseCase:
|
||||
"""绑定手机+邮箱用例"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_repository,
|
||||
verification_code_service,
|
||||
email_service=None,
|
||||
):
|
||||
self.user_repo = user_repository
|
||||
self.verification_service = verification_code_service
|
||||
self.email_service = email_service
|
||||
|
||||
def execute(self, request: BindContactRequest) -> tuple[Optional[BindContactResponse], Optional[str]]:
|
||||
try:
|
||||
# 1. 校验参数
|
||||
if not request.phone and not request.email:
|
||||
return None, "至少填写手机号或邮箱"
|
||||
|
||||
# 2. 查找用户
|
||||
user = self.user_repo.find_by_id(request.user_id)
|
||||
if not user:
|
||||
return None, "用户不存在"
|
||||
|
||||
# 3. 手机号绑定
|
||||
if request.phone:
|
||||
ok, err = validate_phone(request.phone)
|
||||
if not ok:
|
||||
return None, err
|
||||
|
||||
if not request.phone_code:
|
||||
return None, "请输入手机验证码"
|
||||
|
||||
# 校验手机号未被其他账号绑定
|
||||
existing = self.user_repo.find_by_phone(request.phone)
|
||||
if existing and existing.id != user.id:
|
||||
return None, "该手机号已被其他账号绑定"
|
||||
|
||||
# 校验验证码
|
||||
ok, err = self.verification_service.verify(
|
||||
recipient=request.phone,
|
||||
code_type=CODE_TYPE_PHONE_BIND,
|
||||
code_value=request.phone_code,
|
||||
)
|
||||
if not ok:
|
||||
return None, f"手机验证码错误:{err}"
|
||||
|
||||
user.phone = request.phone
|
||||
user.phone_verified = True
|
||||
|
||||
# 4. 邮箱绑定
|
||||
if request.email:
|
||||
ok, err = validate_email(request.email)
|
||||
if not ok:
|
||||
return None, err
|
||||
|
||||
if not request.email_code:
|
||||
return None, "请输入邮箱验证码"
|
||||
|
||||
# 校验邮箱未被其他账号绑定
|
||||
existing = self.user_repo.find_by_email(request.email)
|
||||
if existing and existing.id != user.id:
|
||||
return None, "该邮箱已被其他账号绑定"
|
||||
|
||||
# 校验验证码
|
||||
ok, err = self.verification_service.verify(
|
||||
recipient=request.email,
|
||||
code_type=CODE_TYPE_EMAIL_BIND,
|
||||
code_value=request.email_code,
|
||||
)
|
||||
if not ok:
|
||||
return None, f"邮箱验证码错误:{err}"
|
||||
|
||||
user.email = request.email
|
||||
user.email_verified = True
|
||||
|
||||
# 5. 判断是否完成绑定
|
||||
if user.phone_verified and user.email_verified and "@wechat.local" not in user.email:
|
||||
user.binding_completed_at = datetime.now(timezone.utc)
|
||||
|
||||
# 6. 保存
|
||||
self.user_repo.save(user)
|
||||
|
||||
return BindContactResponse(user=user), None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("绑定联系方式失败: %s", e, exc_info=True)
|
||||
return None, f"绑定失败: {str(e)}"
|
||||
|
||||
|
||||
class SendVerificationCodeRequest:
|
||||
"""发送验证码请求"""
|
||||
|
||||
def __init__(self, target: str, value: str, purpose: str):
|
||||
self.target = target # phone / email
|
||||
self.value = value.strip()
|
||||
self.purpose = purpose # bind / login / reset_password
|
||||
|
||||
|
||||
class SendVerificationCodeResponse:
|
||||
"""发送验证码响应"""
|
||||
|
||||
def __init__(self, expires_in: int, resend_after: int):
|
||||
self.expires_in = expires_in
|
||||
self.resend_after = resend_after
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"expires_in": self.expires_in,
|
||||
"resend_after": self.resend_after,
|
||||
}
|
||||
|
||||
|
||||
class SendVerificationCodeUseCase:
|
||||
"""发送验证码用例"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verification_code_service,
|
||||
sms_service=None,
|
||||
email_service=None,
|
||||
):
|
||||
self.verification_service = verification_code_service
|
||||
self.sms_service = sms_service
|
||||
self.email_service = email_service
|
||||
|
||||
def execute(
|
||||
self, request: SendVerificationCodeRequest
|
||||
) -> tuple[Optional[SendVerificationCodeResponse], Optional[str]]:
|
||||
try:
|
||||
# 1. 确定 code_type
|
||||
if request.target == "phone":
|
||||
ok, err = validate_phone(request.value)
|
||||
if not ok:
|
||||
return None, err
|
||||
code_type = f"{request.target}_{request.purpose}"
|
||||
recipient = normalize_phone(request.value)
|
||||
elif request.target == "email":
|
||||
ok, err = validate_email(request.value)
|
||||
if not ok:
|
||||
return None, err
|
||||
code_type = f"{request.target}_{request.purpose}"
|
||||
recipient = request.value.lower()
|
||||
else:
|
||||
return None, f"不支持的目标类型: {request.target}"
|
||||
|
||||
# 2. 生成验证码
|
||||
code_obj, err = self.verification_service.generate(recipient, code_type)
|
||||
if err:
|
||||
return None, err
|
||||
|
||||
# 3. 发送
|
||||
if request.target == "phone" and self.sms_service:
|
||||
self.sms_service.send_verification_code(recipient, code_obj.code)
|
||||
elif request.target == "email" and self.email_service:
|
||||
subject = "验证码 - 小应剪辑"
|
||||
body = f"您的验证码是:{code_obj.code},5分钟内有效。"
|
||||
self.email_service.send_email(recipient, subject, body)
|
||||
|
||||
# 4. 返回
|
||||
ttl = (code_obj.expires_at - code_obj.created_at).total_seconds()
|
||||
return (
|
||||
SendVerificationCodeResponse(
|
||||
expires_in=int(ttl),
|
||||
resend_after=60,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("发送验证码失败: %s", e, exc_info=True)
|
||||
return None, f"发送失败: {str(e)}"
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
验证码服务
|
||||
- 生成验证码
|
||||
- 校验验证码
|
||||
- 频控(60s 冷却 + 每日上限)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
from packages.ports.verification_code_repository import VerificationCodeRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 频控参数
|
||||
RESEND_COOLDOWN_SECONDS = 60 # 重发冷却时间
|
||||
DAILY_LIMIT = 10 # 每日发送上限
|
||||
MAX_ATTEMPTS = 5 # 单验证码最大尝试次数
|
||||
DEFAULT_TTL_SECONDS = 300 # 默认有效期 5 分钟
|
||||
|
||||
# 验证码类型
|
||||
CODE_TYPE_EMAIL_BIND = "email_bind"
|
||||
CODE_TYPE_PHONE_BIND = "phone_bind"
|
||||
CODE_TYPE_EMAIL_LOGIN = "email_login"
|
||||
CODE_TYPE_PHONE_LOGIN = "phone_login"
|
||||
CODE_TYPE_RESET_PASSWORD = "reset_password"
|
||||
|
||||
VALID_CODE_TYPES = {
|
||||
CODE_TYPE_EMAIL_BIND,
|
||||
CODE_TYPE_PHONE_BIND,
|
||||
CODE_TYPE_EMAIL_LOGIN,
|
||||
CODE_TYPE_PHONE_LOGIN,
|
||||
CODE_TYPE_RESET_PASSWORD,
|
||||
}
|
||||
|
||||
|
||||
class VerificationCodeService:
|
||||
"""验证码服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: VerificationCodeRepository,
|
||||
resend_cooldown: int = RESEND_COOLDOWN_SECONDS,
|
||||
daily_limit: int = DAILY_LIMIT,
|
||||
max_attempts: int = MAX_ATTEMPTS,
|
||||
default_ttl: int = DEFAULT_TTL_SECONDS,
|
||||
):
|
||||
self.repo = repo
|
||||
self.resend_cooldown = resend_cooldown
|
||||
self.daily_limit = daily_limit
|
||||
self.max_attempts = max_attempts
|
||||
self.default_ttl = default_ttl
|
||||
|
||||
def generate(
|
||||
self,
|
||||
recipient: str,
|
||||
code_type: str,
|
||||
ttl_seconds: int | None = None,
|
||||
custom_code: str | None = None,
|
||||
) -> tuple[Optional[VerificationCode], Optional[str]]:
|
||||
"""
|
||||
生成验证码
|
||||
|
||||
Returns:
|
||||
(验证码实体, 错误信息)
|
||||
"""
|
||||
recipient = recipient.strip()
|
||||
|
||||
# 参数校验
|
||||
if not recipient:
|
||||
return None, "接收方不能为空"
|
||||
if code_type not in VALID_CODE_TYPES:
|
||||
return None, f"无效的验证码类型: {code_type}"
|
||||
|
||||
# 频控检查
|
||||
can_send, wait_seconds = self._check_rate_limit(recipient, code_type)
|
||||
if not can_send:
|
||||
if wait_seconds > 0:
|
||||
return None, f"发送太频繁,请 {wait_seconds} 秒后再试"
|
||||
return None, "今日发送次数已达上限"
|
||||
|
||||
# 生成并保存
|
||||
code = VerificationCode.create(
|
||||
recipient=recipient,
|
||||
code_type=code_type,
|
||||
ttl_seconds=ttl_seconds or self.default_ttl,
|
||||
custom_code=custom_code,
|
||||
)
|
||||
self.repo.save(code)
|
||||
|
||||
return code, None
|
||||
|
||||
def verify(
|
||||
self,
|
||||
recipient: str,
|
||||
code_type: str,
|
||||
code_value: str,
|
||||
consume: bool = True,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
校验验证码
|
||||
|
||||
Args:
|
||||
recipient: 接收方(邮箱/手机号)
|
||||
code_type: 验证码类型
|
||||
code_value: 用户输入的验证码
|
||||
consume: 校验成功后是否标记为已使用
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误信息)
|
||||
"""
|
||||
recipient = recipient.strip()
|
||||
code_value = code_value.strip()
|
||||
|
||||
if not recipient or not code_value:
|
||||
return False, "参数不完整"
|
||||
|
||||
# 查找最新的验证码
|
||||
latest = self.repo.find_latest(recipient, code_type)
|
||||
if not latest:
|
||||
return False, "验证码不存在或已过期"
|
||||
|
||||
# 增加尝试次数
|
||||
latest.increment_attempts()
|
||||
self.repo.save(latest)
|
||||
|
||||
# 检查是否已使用
|
||||
if latest.is_used:
|
||||
return False, "验证码已使用,请重新获取"
|
||||
|
||||
# 检查是否过期
|
||||
if latest.is_expired:
|
||||
return False, "验证码已过期,请重新获取"
|
||||
|
||||
# 检查尝试次数
|
||||
if latest.attempts > self.max_attempts:
|
||||
return False, "验证次数过多,请重新获取验证码"
|
||||
|
||||
# 校验验证码
|
||||
if latest.code != code_value:
|
||||
return False, "验证码错误"
|
||||
|
||||
# 校验通过,标记为已使用
|
||||
if consume:
|
||||
latest.mark_used()
|
||||
self.repo.save(latest)
|
||||
|
||||
return True, None
|
||||
|
||||
def _check_rate_limit(self, recipient: str, code_type: str) -> tuple[bool, int]:
|
||||
"""
|
||||
频控检查
|
||||
|
||||
Returns:
|
||||
(是否允许发送, 需等待秒数)
|
||||
"""
|
||||
# 检查冷却时间
|
||||
latest = self.repo.find_latest(recipient, code_type)
|
||||
if latest:
|
||||
elapsed = (datetime.now(timezone.utc) - latest.created_at).total_seconds()
|
||||
if elapsed < self.resend_cooldown:
|
||||
wait = int(self.resend_cooldown - elapsed)
|
||||
return False, wait
|
||||
|
||||
# 检查每日上限
|
||||
today_count = self.repo.count_today(recipient, code_type)
|
||||
if today_count >= self.daily_limit:
|
||||
return False, 0
|
||||
|
||||
return True, 0
|
||||
|
||||
|
||||
def validate_phone(phone: str) -> tuple[bool, str]:
|
||||
"""校验手机号格式(中国大陆手机号)"""
|
||||
phone = phone.strip()
|
||||
if not phone:
|
||||
return False, "手机号不能为空"
|
||||
# 支持 +86 前缀或纯 11 位
|
||||
pattern = r"^(\+86)?1[3-9]\d{9}$"
|
||||
if not re.match(pattern, phone):
|
||||
return False, "手机号格式不正确"
|
||||
return True, ""
|
||||
|
||||
|
||||
def normalize_phone(phone: str) -> str:
|
||||
"""标准化手机号(去掉 +86 前缀,统一存储格式)"""
|
||||
phone = phone.strip()
|
||||
if phone.startswith("+86"):
|
||||
phone = phone[3:]
|
||||
return phone
|
||||
|
||||
|
||||
def validate_email(email: str) -> tuple[bool, str]:
|
||||
"""校验邮箱格式"""
|
||||
email = email.strip()
|
||||
if not email:
|
||||
return False, "邮箱不能为空"
|
||||
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
if not re.match(pattern, email):
|
||||
return False, "邮箱格式不正确"
|
||||
return True, ""
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
微信 OAuth 服务
|
||||
- 生成授权链接(网页扫码登录)
|
||||
- 处理回调,用 code 换 access_token + 用户信息
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # state 有效期 10 分钟
|
||||
|
||||
|
||||
class MemoryStateStore:
|
||||
"""内存 state 存储(简单实现,单节点可用)
|
||||
|
||||
多实例部署时建议替换为 Redis 实现。
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = STATE_TTL_SECONDS):
|
||||
self._ttl = ttl_seconds
|
||||
self._states: dict[str, float] = {} # state -> expire_at
|
||||
self._lock = Lock()
|
||||
|
||||
def put(self, state: str) -> None:
|
||||
with self._lock:
|
||||
self._clean_expired()
|
||||
self._states[state] = time.time() + self._ttl
|
||||
|
||||
def verify_and_consume(self, state: str) -> bool:
|
||||
with self._lock:
|
||||
self._clean_expired()
|
||||
if state in self._states:
|
||||
del self._states[state]
|
||||
return True
|
||||
return False
|
||||
|
||||
def _clean_expired(self) -> None:
|
||||
now = time.time()
|
||||
expired = [s for s, exp in self._states.items() if exp < now]
|
||||
for s in expired:
|
||||
del self._states[s]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WechatUserInfo:
|
||||
"""微信用户信息"""
|
||||
|
||||
openid: str
|
||||
unionid: str = ""
|
||||
nickname: str = ""
|
||||
avatar_url: str = ""
|
||||
|
||||
|
||||
class WechatOAuthService:
|
||||
"""微信开放平台 OAuth 服务(网页扫码登录)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: str | None = None,
|
||||
app_secret: str | None = None,
|
||||
redirect_uri: str | None = None,
|
||||
state_store=None,
|
||||
):
|
||||
self.app_id = app_id or os.environ.get("WECHAT_OPEN_APP_ID", "")
|
||||
self.app_secret = app_secret or os.environ.get("WECHAT_OPEN_APP_SECRET", "")
|
||||
self.redirect_uri = redirect_uri or os.environ.get("WECHAT_OPEN_REDIRECT_URI", "")
|
||||
# state 存储(CSRF 防护),默认内存实现
|
||||
self._state_store = state_store or MemoryStateStore()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""检查微信配置是否完整"""
|
||||
return bool(self.app_id and self.app_secret and self.redirect_uri)
|
||||
|
||||
def generate_auth_url(self, scope: str = "snsapi_login") -> tuple[str, str]:
|
||||
"""
|
||||
生成微信授权链接
|
||||
|
||||
Returns:
|
||||
(授权URL, state)
|
||||
"""
|
||||
state = uuid4().hex
|
||||
# 保存 state 用于回调校验(防 CSRF)
|
||||
self._state_store.put(state)
|
||||
|
||||
if not self.is_configured():
|
||||
# 未配置时返回 mock URL,方便前端联调
|
||||
mock_params = urllib.parse.urlencode(
|
||||
{
|
||||
"app_id": "mock",
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": scope,
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
return f"/mock/wechat/auth?{mock_params}", state
|
||||
|
||||
params = {
|
||||
"appid": self.app_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": scope,
|
||||
"state": state,
|
||||
}
|
||||
url = "https://open.weixin.qq.com/connect/qrconnect?" + urllib.parse.urlencode(params) + "#wechat_redirect"
|
||||
return url, state
|
||||
|
||||
def handle_callback(self, code: str, state: str) -> tuple[Optional[WechatUserInfo], Optional[str]]:
|
||||
"""
|
||||
处理微信回调
|
||||
|
||||
Args:
|
||||
code: 微信授权码
|
||||
state: 防 CSRF 状态
|
||||
|
||||
Returns:
|
||||
(微信用户信息, 错误信息)
|
||||
"""
|
||||
if not code:
|
||||
return None, "缺少授权码"
|
||||
|
||||
# 校验 state(防 CSRF)—— 一次性使用
|
||||
if not state or not self._state_store.verify_and_consume(state):
|
||||
logger.warning("微信回调 state 校验失败: state=%s", state)
|
||||
return None, "无效的 state 参数,请求可能已过期或被篡改"
|
||||
|
||||
if not self.is_configured():
|
||||
# 开发模式:返回 mock 用户信息
|
||||
logger.info("微信未配置,使用 mock 用户信息")
|
||||
return (
|
||||
WechatUserInfo(
|
||||
openid=f"mock_{code[:20]}",
|
||||
unionid=f"mock_union_{code[:16]}",
|
||||
nickname="微信测试用户",
|
||||
avatar_url="",
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. 用 code 换 access_token
|
||||
token_url = "https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
token_params = {
|
||||
"appid": self.app_id,
|
||||
"secret": self.app_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
token_resp = requests.get(token_url, params=token_params, timeout=10)
|
||||
token_data = token_resp.json()
|
||||
|
||||
if "errcode" in token_data and token_data["errcode"] != 0:
|
||||
logger.error("微信获取 access_token 失败: %s", token_data)
|
||||
return None, f"微信授权失败: {token_data.get('errmsg', '未知错误')}"
|
||||
|
||||
access_token = token_data["access_token"]
|
||||
openid = token_data["openid"]
|
||||
unionid = token_data.get("unionid", "")
|
||||
|
||||
# 2. 获取用户信息
|
||||
user_url = "https://api.weixin.qq.com/sns/userinfo"
|
||||
user_params = {
|
||||
"access_token": access_token,
|
||||
"openid": openid,
|
||||
"lang": "zh_CN",
|
||||
}
|
||||
user_resp = requests.get(user_url, params=user_params, timeout=10)
|
||||
user_data = user_resp.json()
|
||||
|
||||
if "errcode" in user_data and user_data["errcode"] != 0:
|
||||
logger.error("微信获取用户信息失败: %s", user_data)
|
||||
return None, f"获取用户信息失败: {user_data.get('errmsg', '未知错误')}"
|
||||
|
||||
return (
|
||||
WechatUserInfo(
|
||||
openid=openid,
|
||||
unionid=unionid,
|
||||
nickname=user_data.get("nickname", ""),
|
||||
avatar_url=user_data.get("headimgurl", ""),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error("微信 OAuth 请求异常: %s", e, exc_info=True)
|
||||
return None, "微信服务暂不可用,请稍后再试"
|
||||
except Exception as e:
|
||||
logger.error("微信回调处理异常: %s", e, exc_info=True)
|
||||
return None, "微信登录处理失败"
|
||||
|
||||
|
||||
def get_wechat_oauth_service() -> WechatOAuthService:
|
||||
"""获取微信 OAuth 服务单例"""
|
||||
# TODO: 可替换为 Redis state store
|
||||
return WechatOAuthService()
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
短信服务接口
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class SmsService(ABC):
|
||||
"""短信服务接口"""
|
||||
|
||||
@abstractmethod
|
||||
def send_verification_code(self, phone: str, code: str) -> bool:
|
||||
"""发送验证码短信"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool:
|
||||
"""发送模板短信"""
|
||||
pass
|
||||
@@ -42,7 +42,6 @@ class EditPlan:
|
||||
name: str
|
||||
status: EditPlanStatus = EditPlanStatus.DRAFT
|
||||
total_duration: float = 0.0
|
||||
result_count: int = 0
|
||||
source_edit_plan_id: str = ""
|
||||
project_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
@@ -58,7 +57,6 @@ class EditPlan:
|
||||
*,
|
||||
config: dict[str, Any] | None = None,
|
||||
total_duration: float = 0.0,
|
||||
result_count: int = 0,
|
||||
source_edit_plan_id: str = "",
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
@@ -75,7 +73,6 @@ class EditPlan:
|
||||
name=clean_name,
|
||||
status=EditPlanStatus.DRAFT,
|
||||
total_duration=total_duration,
|
||||
result_count=result_count,
|
||||
source_edit_plan_id=source_edit_plan_id.strip(),
|
||||
project_id=project_id.strip(),
|
||||
created_by_user_id=created_by_user_id.strip(),
|
||||
|
||||
@@ -59,6 +59,11 @@ class User:
|
||||
wechat_openid: str | None = None
|
||||
wechat_unionid: str | None = None
|
||||
|
||||
# 手机号绑定
|
||||
phone: str | None = None
|
||||
phone_verified: bool = False
|
||||
binding_completed_at: datetime | None = None
|
||||
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
验证码领域实体
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VerificationCode:
|
||||
"""验证码(邮箱/手机统一)"""
|
||||
|
||||
id: str
|
||||
recipient: str # 邮箱或手机号
|
||||
code: str
|
||||
code_type: str # email_bind / phone_bind / email_login / phone_login / reset_password
|
||||
expires_at: datetime
|
||||
used_at: datetime | None = None
|
||||
attempts: int = 0
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
recipient: str,
|
||||
code_type: str,
|
||||
ttl_seconds: int = 300,
|
||||
custom_code: str | None = None,
|
||||
) -> "VerificationCode":
|
||||
"""创建验证码"""
|
||||
import random
|
||||
|
||||
code = custom_code or "".join(random.choices("0123456789", k=6))
|
||||
now = datetime.now(timezone.utc)
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
recipient=recipient.strip(),
|
||||
code=code,
|
||||
code_type=code_type,
|
||||
expires_at=now + timedelta(seconds=ttl_seconds),
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""是否已过期"""
|
||||
return datetime.now(timezone.utc) > self.expires_at
|
||||
|
||||
@property
|
||||
def is_used(self) -> bool:
|
||||
"""是否已使用"""
|
||||
return self.used_at is not None
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""是否有效(未过期且未使用)"""
|
||||
return not self.is_expired and not self.is_used
|
||||
|
||||
def mark_used(self) -> None:
|
||||
"""标记为已使用"""
|
||||
self.used_at = datetime.now(timezone.utc)
|
||||
|
||||
def increment_attempts(self) -> None:
|
||||
"""增加尝试次数"""
|
||||
self.attempts += 1
|
||||
Regular → Executable
+5
@@ -51,6 +51,11 @@ class UserRepository(ABC):
|
||||
"""根据微信 unionid 查找用户"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_phone(self, phone: str) -> Optional[User]:
|
||||
"""根据手机号查找用户"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, user_id: str) -> bool:
|
||||
"""删除用户"""
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
验证码仓储接口
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class VerificationCodeRepository(ABC):
|
||||
"""验证码仓储接口"""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, code: VerificationCode) -> None:
|
||||
"""保存验证码"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_latest(self, recipient: str, code_type: str) -> Optional[VerificationCode]:
|
||||
"""查找最新的有效验证码"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_id(self, code_id: str) -> Optional[VerificationCode]:
|
||||
"""根据 ID 查找验证码"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def count_today(self, recipient: str, code_type: str) -> int:
|
||||
"""统计当日发送次数(频控用)"""
|
||||
pass
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# 数据库(基础层)
|
||||
psycopg2-binary==2.9.10
|
||||
psycopg[binary]>=3.2.2
|
||||
psycopg[binary]==3.2.2
|
||||
sqlalchemy==2.0.35
|
||||
alembic==1.13.3
|
||||
|
||||
|
||||
@@ -11,4 +11,5 @@ pytest==8.3.3
|
||||
pytest-asyncio==0.24.0
|
||||
pytest-cov==6.0.0
|
||||
pytest-timeout==2.3.1
|
||||
diff-cover>=8.0
|
||||
pytest-xdist==3.6.1
|
||||
diff-cover==8.0.3
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
# 这些包体积大,API 服务不需要安装
|
||||
|
||||
# 数值计算
|
||||
numpy>=1.24.0
|
||||
numpy==1.26.4
|
||||
|
||||
# 科学计算
|
||||
scipy>=1.10.0
|
||||
scipy==1.13.1
|
||||
|
||||
# 计算机视觉(视频去重、帧处理)
|
||||
opencv-python-headless>=4.8.0
|
||||
opencv-python-headless==4.10.0.84
|
||||
|
||||
# 图像处理
|
||||
Pillow==10.4.0
|
||||
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
# Agent代码提交前自动格式化+质量检查脚本
|
||||
# 用法: scripts/agent-commit.sh <commit_message> [files...]
|
||||
# 效果: 自动跑black+isort+ruff check,通过后才commit+push
|
||||
set -e
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "用法: $0 <commit_message> [file1 file2 ...]"
|
||||
echo "示例: $0 \"feat: add new api\" apps/api/src/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMMIT_MSG="$1"
|
||||
shift
|
||||
|
||||
TARGETS="${@:-.}"
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO_ROOT=$(pwd)
|
||||
echo "仓库根目录: $REPO_ROOT"
|
||||
echo "提交信息: $COMMIT_MSG"
|
||||
echo "目标路径: $TARGETS"
|
||||
echo ""
|
||||
|
||||
# 后端代码格式化(Python文件)
|
||||
PYTHON_FILES=$(find $TARGETS -name "*.py" -type f 2>/dev/null | head -100 || true)
|
||||
if [ -n "$PYTHON_FILES" ]; then
|
||||
echo "=== Step 1/4: 后端代码格式化 (black) ==="
|
||||
if command -v black &> /dev/null; then
|
||||
black $TARGETS 2>&1 | tail -3
|
||||
echo "✅ black 完成"
|
||||
else
|
||||
echo "⚠️ 未安装black,跳过"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Step 2/4: import排序 (isort) ==="
|
||||
if command -v isort &> /dev/null; then
|
||||
isort $TARGETS 2>&1 | tail -3
|
||||
echo "✅ isort 完成"
|
||||
else
|
||||
echo "⚠️ 未安装isort,跳过"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Step 3/4: 代码质量检查 (ruff check) ==="
|
||||
if command -v ruff &> /dev/null; then
|
||||
RUFF_OUTPUT=$(ruff check $TARGETS 2>&1) || true
|
||||
RUFF_ERRORS=$(echo "$RUFF_OUTPUT" | grep -c "^" || echo 0)
|
||||
if [ "$RUFF_ERRORS" -le 2 ] || echo "$RUFF_OUTPUT" | grep -q "All checks passed"; then
|
||||
echo "✅ ruff 检查通过(错误数: $RUFF_ERRORS)"
|
||||
else
|
||||
echo "❌ ruff 发现以下问题:"
|
||||
echo "$RUFF_OUTPUT" | head -30
|
||||
echo ""
|
||||
echo "请修复后重新提交,或手动忽略特定问题"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 未安装ruff,跳过"
|
||||
fi
|
||||
echo ""
|
||||
else
|
||||
echo "ℹ️ 未检测到Python文件,跳过后端格式化"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 前端代码格式化(TS/TSX文件)
|
||||
TS_FILES=$(find $TARGETS -name "*.ts" -o -name "*.tsx" -type f 2>/dev/null | head -100 || true)
|
||||
if [ -n "$TS_FILES" ] && [ -f "apps/web/package.json" ]; then
|
||||
echo "=== Step 4/4: 前端代码格式化 (prettier) ==="
|
||||
if command -v npx &> /dev/null; then
|
||||
cd apps/web && npx prettier --write "src/**/*.{ts,tsx}" 2>&1 | tail -3 || true
|
||||
cd "$REPO_ROOT"
|
||||
echo "✅ prettier 完成"
|
||||
else
|
||||
echo "⚠️ 未安装npx,跳过前端格式化"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Git操作
|
||||
echo "=== 提交代码 ==="
|
||||
git add -A
|
||||
git diff --cached --stat
|
||||
echo ""
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo ""
|
||||
echo "✅ 本地提交完成"
|
||||
|
||||
# 可选:自动推送
|
||||
if [ "$AGENT_AUTO_PUSH" = "true" ]; then
|
||||
echo "正在推送到远程..."
|
||||
git push
|
||||
echo "✅ 推送完成"
|
||||
else
|
||||
echo "ℹ️ 本地已提交,如需推送执行: git push"
|
||||
echo " 设置 AGENT_AUTO_PUSH=true 可自动推送"
|
||||
fi
|
||||
+15
-13
@@ -31,20 +31,22 @@ def main():
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# API返回按时间倒序,第一个就是最新的
|
||||
for s in statuses:
|
||||
if s.get("context") == target_context:
|
||||
status = s.get("status", "pending")
|
||||
# skipped 视为通过(条件跳过的任务不需要等)
|
||||
if status == "skipped":
|
||||
print("success")
|
||||
else:
|
||||
print(status)
|
||||
return
|
||||
# 筛选目标context,按时间倒序取最新的
|
||||
matching = [s for s in statuses if s.get("context") == target_context]
|
||||
if not matching:
|
||||
# 找不到说明CI还没开始写状态,返回pending继续等待
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# 找不到这个context说明CI还没开始写状态,返回pending继续等待
|
||||
# (如果workflow真的被跳过,它会有一条status为skipped的记录)
|
||||
print("pending")
|
||||
# Gitea statuses API按时间正序返回,必须取最新的一条
|
||||
latest = max(matching, key=lambda s: s.get("created_at", ""))
|
||||
status = latest.get("status", "pending")
|
||||
|
||||
# skipped 视为通过(条件跳过的任务不需要等)
|
||||
if status == "skipped":
|
||||
print("success")
|
||||
else:
|
||||
print(status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -62,7 +62,7 @@ def ensure_git_repo(api_url, repo, token, pr_number):
|
||||
# 强制checkout到源分支(覆盖tarball内容)
|
||||
# tarball是merge后的commit,源分支才是我们要修改并推送的目标
|
||||
print("切换到源分支...")
|
||||
run(f"git checkout -B --force {head_branch} FETCH_HEAD")
|
||||
run(f"git checkout -f -B {head_branch} FETCH_HEAD")
|
||||
|
||||
result = run("git status --porcelain")
|
||||
if result.stdout.strip():
|
||||
@@ -169,12 +169,34 @@ def main():
|
||||
|
||||
api_url = os.environ.get("GITHUB_API_URL", "")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
token = os.environ.get("REVIEW_TOKEN", "") or os.environ.get("GITHUB_TOKEN", "")
|
||||
|
||||
# 获取PR作者信息,判断是人还是Agent提交的
|
||||
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
|
||||
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_pr) as resp:
|
||||
pr_info = json.loads(resp.read())
|
||||
pr_author = pr_info.get("user", {}).get("login", "")
|
||||
print(f"PR作者: {pr_author}")
|
||||
|
||||
# 判断是否为Agent提交的PR
|
||||
# Agent账号:actions, auto-approve-bot 等bot用户
|
||||
# 人提交的PR(如xiaoxia):只诊断不自动修
|
||||
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
|
||||
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
|
||||
|
||||
if is_agent_pr:
|
||||
print(f"检测到Agent提交的PR(作者: {pr_author}),将自动修复并推送")
|
||||
fix_mode = "auto_fix_and_push"
|
||||
else:
|
||||
print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改")
|
||||
print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)")
|
||||
fix_mode = "diagnose_only"
|
||||
scan_mode = os.environ.get("SCAN_MODE", "full")
|
||||
changed_files_env = os.environ.get("CHANGED_FILES", "")
|
||||
|
||||
if not token:
|
||||
print("缺少GITHUB_TOKEN,无法推送修复", file=sys.stderr)
|
||||
print("缺少REVIEW_TOKEN或GITHUB_TOKEN,无法推送修复", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
repo_root = os.getcwd()
|
||||
@@ -231,6 +253,26 @@ def main():
|
||||
print("没有需要提交的格式改动")
|
||||
return
|
||||
|
||||
# 诊断模式:只报告问题,不修改不推送
|
||||
if fix_mode == "diagnose_only":
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("以下文件存在格式问题,建议手动修复:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("修复方式:")
|
||||
print(" 后端(Python): 运行 black + isort")
|
||||
print(" 前端: 运行 prettier --write")
|
||||
print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)")
|
||||
print()
|
||||
print("=" * 50)
|
||||
# 以非0状态码退出,让CI继续报失败(因为问题没修)
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("变更文件:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
@@ -238,7 +280,7 @@ def main():
|
||||
|
||||
# 提交修复
|
||||
run("git add -A")
|
||||
run('git commit -m "style: auto-format with black + isort + prettier [ci skip]"')
|
||||
run('git commit -m "style: auto-format with black + isort + prettier"')
|
||||
|
||||
# 推送(head_branch已从ensure_git_repo获取)
|
||||
print(f"\nPR来源分支: {head_branch}")
|
||||
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
检查 Alembic migration 编号连续性。
|
||||
|
||||
扫描 alembic/versions/ 下所有 migration 文件,提取 revision 和 down_revision,
|
||||
验证整条链是否完整——每个 down_revision(除了 baseline 的 None)都必须对应一个存在的 revision。
|
||||
|
||||
支持两种格式:
|
||||
revision: str = "001" # 旧格式(带类型注解)
|
||||
revision = "038_error_retry" # 新格式(带描述后缀)
|
||||
|
||||
匹配策略:提取 revision 名称的数字前缀(如 "001"、"038")作为唯一标识进行匹配,
|
||||
兼容纯数字编号和"数字_描述"两种命名风格。
|
||||
|
||||
用法:
|
||||
python3 scripts/ci/check_migration_chain.py [alembic_versions_dir]
|
||||
|
||||
默认目录: alembic/versions/
|
||||
|
||||
退出码:
|
||||
0 - 链完整
|
||||
1 - 有断链或其他错误
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 匹配 revision / down_revision,支持带类型注解和不带类型注解两种格式
|
||||
# revision: str = "xxx" 或 revision = "xxx"
|
||||
REV_PATTERN = re.compile(
|
||||
r'^\s*revision\s*(?::\s*str\s*)?=\s*["\']([^"\']+)["\']',
|
||||
re.MULTILINE,
|
||||
)
|
||||
DOWN_PATTERN = re.compile(
|
||||
r'^\s*down_revision\s*(?::\s*(?:Union\[str,\s*None\]|str\s*\|\s*None|None|str)\s*)?=\s*(["\']([^"\']+)["\']|None)',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# 提取 revision 名称的数字前缀,如 "001" 或 "038_error_retry" → "038"
|
||||
NUM_PREFIX_PATTERN = re.compile(r"^(\d+)")
|
||||
|
||||
|
||||
def num_prefix(name: str) -> str:
|
||||
"""提取 revision 名称的数字前缀。"""
|
||||
m = NUM_PREFIX_PATTERN.match(name)
|
||||
return m.group(1) if m else name
|
||||
|
||||
|
||||
def extract_migration_info(filepath: Path) -> tuple[str, str | None]:
|
||||
"""从 migration 文件中提取 revision 和 down_revision(返回完整名称)。"""
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
|
||||
rev_match = REV_PATTERN.search(content)
|
||||
down_match = DOWN_PATTERN.search(content)
|
||||
|
||||
if not rev_match:
|
||||
raise ValueError(f"{filepath.name}: 未找到 revision 定义")
|
||||
|
||||
revision = rev_match.group(1)
|
||||
|
||||
if not down_match:
|
||||
raise ValueError(f"{filepath.name}: 未找到 down_revision 定义")
|
||||
|
||||
# down_match group(2) 是引号内的值,如果是 None 则 group(2) 为 None
|
||||
down_revision = down_match.group(2)
|
||||
|
||||
return revision, down_revision
|
||||
|
||||
|
||||
def check_chain(versions_dir: Path) -> list[str]:
|
||||
"""检查 migration 链是否完整,返回错误列表。"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not versions_dir.is_dir():
|
||||
return [f"目录不存在: {versions_dir}"]
|
||||
|
||||
py_files = sorted(versions_dir.glob("*.py"))
|
||||
if not py_files:
|
||||
return [f"目录下没有 migration 文件: {versions_dir}"]
|
||||
|
||||
# 收集所有 revision(用数字前缀做唯一标识)
|
||||
revisions_by_num: dict[str, str] = {} # 数字前缀 -> 完整 revision 名
|
||||
revision_files: dict[str, str] = {} # 数字前缀 -> 文件名
|
||||
down_revisions: list[tuple[str, str | None]] = [] # (文件名, down_revision 数字前缀或None)
|
||||
|
||||
for f in py_files:
|
||||
if f.name.startswith("__"):
|
||||
continue
|
||||
try:
|
||||
rev, down = extract_migration_info(f)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
continue
|
||||
|
||||
rev_num = num_prefix(rev)
|
||||
|
||||
if rev_num in revisions_by_num:
|
||||
errors.append(
|
||||
f"编号重复: 编号 {rev_num} 同时出现在 "
|
||||
f"{f.name} (revision={rev}) 和 {revision_files[rev_num]} (revision={revisions_by_num[rev_num]})"
|
||||
)
|
||||
else:
|
||||
revisions_by_num[rev_num] = rev
|
||||
revision_files[rev_num] = f.name
|
||||
|
||||
down_num = num_prefix(down) if down else None
|
||||
down_revisions.append((f.name, down_num))
|
||||
|
||||
if errors:
|
||||
return errors
|
||||
|
||||
# 检查每个 down_revision 是否存在
|
||||
baselines = 0
|
||||
for filename, down_num in down_revisions:
|
||||
if down_num is None:
|
||||
baselines += 1
|
||||
continue
|
||||
|
||||
if down_num not in revisions_by_num:
|
||||
errors.append(
|
||||
f"断链: {filename} 的 down_revision 指向编号 '{down_num}',但没有任何 migration 的 revision 是这个编号"
|
||||
)
|
||||
|
||||
if baselines == 0:
|
||||
errors.append("没有找到 baseline migration(down_revision = None 的文件)")
|
||||
elif baselines > 1:
|
||||
errors.append(f"发现 {baselines} 个 baseline migration,通常只能有 1 个")
|
||||
|
||||
# 额外检查:数字编号是否连续(只对能提取出数字的)
|
||||
if revisions_by_num and not errors:
|
||||
nums = sorted(int(n) for n in revisions_by_num if n.isdigit())
|
||||
if nums:
|
||||
expected = list(range(nums[0], nums[-1] + 1))
|
||||
missing = [n for n in expected if n not in nums]
|
||||
if missing:
|
||||
missing_str = ", ".join(f"{n:03d}" for n in missing)
|
||||
errors.append(f"编号不连续: 缺少编号 {missing_str}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) > 1:
|
||||
versions_dir = Path(sys.argv[1])
|
||||
else:
|
||||
versions_dir = Path("alembic/versions")
|
||||
|
||||
print(f"检查 migration 编号连续性: {versions_dir}")
|
||||
print()
|
||||
|
||||
errors = check_chain(versions_dir)
|
||||
|
||||
py_files = [f for f in versions_dir.glob("*.py") if not f.name.startswith("__")]
|
||||
|
||||
if errors:
|
||||
print(f"❌ Migration 链有问题(共 {len(py_files)} 个文件,{len(errors)} 个错误):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
print()
|
||||
print("请修复后再提交。常见原因:")
|
||||
print(" 1. 新 migration 的 down_revision 编号写错了")
|
||||
print(" 2. 多个 PR 同时加 migration,编号冲突")
|
||||
print(" 3. 合并代码时漏了某个 migration 文件")
|
||||
return 1
|
||||
|
||||
print(f"✅ Migration 链完整,共 {len(py_files)} 个版本")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+420
-22
@@ -1,12 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI 可观测性看板 - 从 Gitea Actions API 拉取数据并生成 Markdown 日报
|
||||
|
||||
CI 可观测性看板 - 从 Gitea Actions API 拉取数据并生成 Markdown/HTML 日报
|
||||
用法:
|
||||
python3 scripts/ci/ci_dashboard.py --days 7
|
||||
python3 scripts/ci/ci_dashboard.py --days 30 --output ci_report.md
|
||||
python3 scripts/ci/ci_dashboard.py --workflow ci-cd.yml --days 7
|
||||
|
||||
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output dashboard.html
|
||||
环境变量:
|
||||
GITEA_URL Gitea 地址 (默认 https://git.xiaoxiajianji.com)
|
||||
GITEA_REPO 仓库 (默认 xiaoxia/xiaoxia-saas)
|
||||
@@ -177,18 +176,14 @@ def fetch_runs_in_range(ga, start_date, end_date, workflow_filter=None):
|
||||
all_runs = []
|
||||
page = 1
|
||||
print(f"[INFO] 拉取 {start_date} ~ {end_date} 的 CI runs...", file=sys.stderr)
|
||||
|
||||
while True:
|
||||
runs, total = ga.list_runs(status="completed", page=page, limit=PAGE_LIMIT)
|
||||
if not runs:
|
||||
break
|
||||
|
||||
if workflow_filter:
|
||||
runs = [r for r in runs if workflow_filter in r.get("path", "")]
|
||||
|
||||
in_range = []
|
||||
out_range_old = False
|
||||
|
||||
for run in runs:
|
||||
started = to_shanghai(parse_datetime(run.get("started_at")))
|
||||
if not started:
|
||||
@@ -198,27 +193,22 @@ def fetch_runs_in_range(ga, start_date, end_date, workflow_filter=None):
|
||||
in_range.append(run)
|
||||
elif run_date < start_date:
|
||||
out_range_old = True
|
||||
|
||||
all_runs.extend(in_range)
|
||||
print(
|
||||
f"[INFO] 第 {page} 页: {len(runs)} 条, 范围内 {len(in_range)} 条, 累计 {len(all_runs)} 条", file=sys.stderr
|
||||
)
|
||||
|
||||
if out_range_old or len(runs) < PAGE_LIMIT:
|
||||
break
|
||||
|
||||
page += 1
|
||||
if page > 100:
|
||||
print("[WARN] 超过100页,停止拉取", file=sys.stderr)
|
||||
break
|
||||
|
||||
print(f"[INFO] 共获取 {len(all_runs)} 条 run 数据", file=sys.stderr)
|
||||
return all_runs
|
||||
|
||||
|
||||
def enrich_with_jobs(ga, runs, max_failures=50):
|
||||
"""为 runs 补充 job 详情(失败原因分析 + runner 统计)
|
||||
|
||||
失败 run 按时间倒序取最近 N 个(避免 API 调用过多),
|
||||
成功 run 采样用于 runner 分布统计。
|
||||
"""
|
||||
@@ -226,13 +216,11 @@ def enrich_with_jobs(ga, runs, max_failures=50):
|
||||
failure_runs = [r for r in runs if r.get("conclusion") != "success"]
|
||||
failure_runs = failure_runs[:max_failures] # 已经是时间倒序
|
||||
print(f"[INFO] 为最近 {len(failure_runs)} 个失败 run 拉取 job 详情...", file=sys.stderr)
|
||||
|
||||
for i, run in enumerate(failure_runs):
|
||||
jobs = ga.get_run_jobs(run["id"])
|
||||
run["_jobs"] = jobs
|
||||
if (i + 1) % 10 == 0:
|
||||
print(f"[INFO] 已处理 {i+1}/{len(failure_runs)}", file=sys.stderr)
|
||||
|
||||
# 成功 run 采样用于 runner 分布
|
||||
success_runs = [r for r in runs if r.get("conclusion") == "success"]
|
||||
sample_size = min(50, len(success_runs))
|
||||
@@ -243,7 +231,6 @@ def enrich_with_jobs(ga, runs, max_failures=50):
|
||||
if "_jobs" not in run:
|
||||
jobs = ga.get_run_jobs(run["id"])
|
||||
run["_jobs"] = jobs
|
||||
|
||||
return runs
|
||||
|
||||
|
||||
@@ -268,7 +255,6 @@ def analyze_runs(runs):
|
||||
if d and d > 0:
|
||||
durations.append(d)
|
||||
durations.sort()
|
||||
|
||||
avg_dur = statistics.mean(durations) if durations else None
|
||||
median_dur = percentile(durations, 50)
|
||||
p95_dur = percentile(durations, 95)
|
||||
@@ -316,8 +302,31 @@ def analyze_runs(runs):
|
||||
# 失败原因 + runner + job 耗时(需要 _jobs 数据)
|
||||
failure_categories = defaultdict(int)
|
||||
failed_jobs_by_name = defaultdict(int)
|
||||
job_success_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0})
|
||||
runner_stats = defaultdict(lambda: {"jobs": 0, "success": 0, "failure": 0, "durations": []})
|
||||
job_time_stats = defaultdict(list)
|
||||
infra_failures = 0
|
||||
business_failures = 0
|
||||
other_failures_count = 0
|
||||
|
||||
# 基础设施关键词(与 ci_health_check.py 保持一致的分类逻辑)
|
||||
infra_job_keywords = ["checkout", "build", "deploy", "cleanup", "setup", "cache", "install", "docker"]
|
||||
business_job_keywords = [
|
||||
"unit test",
|
||||
"pytest",
|
||||
"vitest",
|
||||
"jest",
|
||||
"lint",
|
||||
"eslint",
|
||||
"prettier",
|
||||
"integration",
|
||||
"e2e",
|
||||
"validate",
|
||||
"code quality",
|
||||
"mypy",
|
||||
"ruff",
|
||||
"flake8",
|
||||
]
|
||||
|
||||
for r in runs:
|
||||
jobs = r.get("_jobs", [])
|
||||
@@ -326,27 +335,45 @@ def analyze_runs(runs):
|
||||
for job in jobs:
|
||||
runner = job.get("runner_name", "unknown")
|
||||
conclusion = job.get("conclusion", "unknown")
|
||||
job_name = job.get("name", "unknown")
|
||||
job_name_lower = job_name.lower()
|
||||
|
||||
runner_stats[runner]["jobs"] += 1
|
||||
job_success_stats[job_name]["total"] += 1
|
||||
if conclusion == "success":
|
||||
runner_stats[runner]["success"] += 1
|
||||
job_success_stats[job_name]["success"] += 1
|
||||
elif conclusion == "failure":
|
||||
runner_stats[runner]["failure"] += 1
|
||||
job_success_stats[job_name]["failure"] += 1
|
||||
|
||||
jd = duration_seconds(job.get("started_at"), job.get("completed_at"))
|
||||
if jd and jd > 0:
|
||||
runner_stats[runner]["durations"].append(jd)
|
||||
job_time_stats[job.get("name", "unknown")].append(jd)
|
||||
job_time_stats[job_name].append(jd)
|
||||
|
||||
if conclusion == "failure":
|
||||
failed_jobs_by_name[job.get("name", "unknown")] += 1
|
||||
failed_jobs_by_name[job_name] += 1
|
||||
failed_step = None
|
||||
for step in job.get("steps", []):
|
||||
if step.get("conclusion") == "failure":
|
||||
failed_step = step.get("name")
|
||||
break
|
||||
category = classify_failure(job.get("name", ""), failed_step)
|
||||
category = classify_failure(job_name, failed_step)
|
||||
failure_categories[category] += 1
|
||||
|
||||
# 基础设施 vs 业务代码分类
|
||||
is_infra = any(k in job_name_lower for k in infra_job_keywords) and not any(
|
||||
k in job_name_lower for k in business_job_keywords
|
||||
)
|
||||
is_business = any(k in job_name_lower for k in business_job_keywords)
|
||||
if is_infra:
|
||||
infra_failures += 1
|
||||
elif is_business:
|
||||
business_failures += 1
|
||||
else:
|
||||
other_failures_count += 1
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"success": success,
|
||||
@@ -365,14 +392,17 @@ def analyze_runs(runs):
|
||||
"failed_jobs_top": dict(sorted(failed_jobs_by_name.items(), key=lambda x: -x[1])[:15]),
|
||||
"runner_stats": dict(runner_stats),
|
||||
"job_time_stats": dict(job_time_stats),
|
||||
"job_success_stats": dict(job_success_stats),
|
||||
"infra_failures": infra_failures,
|
||||
"business_failures": business_failures,
|
||||
"other_failures_combined": other_failures_count,
|
||||
}
|
||||
|
||||
|
||||
# ── 报表生成 ─────────────────────────────────────────
|
||||
# ── Markdown 报表生成 ────────────────────────────────
|
||||
def generate_markdown(stats, start_date, end_date, repo):
|
||||
"""生成 Markdown 格式的日报"""
|
||||
lines = []
|
||||
|
||||
lines.append("# CI 运行状态看板")
|
||||
lines.append("")
|
||||
lines.append(f"> 统计周期: **{start_date} ~ {end_date}**")
|
||||
@@ -526,6 +556,359 @@ def generate_markdown(stats, start_date, end_date, repo):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── HTML 看板生成 ────────────────────────────────────
|
||||
def generate_html(stats, start_date, end_date, repo):
|
||||
"""生成 HTML 格式的可视化看板(内嵌 ECharts)"""
|
||||
# 准备图表数据
|
||||
|
||||
# 1. 每日成功率趋势
|
||||
daily_dates = list(stats["daily_stats"].keys())
|
||||
daily_success_rates = []
|
||||
daily_run_counts = []
|
||||
for day in daily_dates:
|
||||
s = stats["daily_stats"][day]
|
||||
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
|
||||
daily_success_rates.append(round(rate, 1))
|
||||
daily_run_counts.append(s["total"])
|
||||
|
||||
# 2. 各 Workflow 耗时对比
|
||||
wf_sorted = sorted(stats["workflow_stats"].items(), key=lambda x: -x[1]["total"])
|
||||
wf_names = []
|
||||
wf_avg_durations = []
|
||||
for wf, s in wf_sorted:
|
||||
wf_short = wf.split("/")[-1] if "/" in wf else wf
|
||||
wf_names.append(wf_short)
|
||||
avg = statistics.mean(s["durations"]) if s["durations"] else 0
|
||||
wf_avg_durations.append(round(avg / 60, 1)) # 转为分钟
|
||||
|
||||
# 3. 失败原因分布(饼图数据 - 基础设施 vs 业务 vs 其他)
|
||||
total_infra_biz = stats["infra_failures"] + stats["business_failures"] + stats["other_failures_combined"]
|
||||
infra_rate = (stats["infra_failures"] / total_infra_biz * 100) if total_infra_biz > 0 else 0
|
||||
failure_pie_data = [
|
||||
{"value": stats["infra_failures"], "name": "基础设施问题"},
|
||||
{"value": stats["business_failures"], "name": "业务代码问题"},
|
||||
{"value": stats["other_failures_combined"], "name": "其他"},
|
||||
]
|
||||
|
||||
# 4. 各 Job 成功率排行(横向柱状图,取成功率最低的 Top 15)
|
||||
job_stats_list = []
|
||||
for name, s in stats["job_success_stats"].items():
|
||||
if s["total"] >= 3: # 至少有3次才统计
|
||||
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
|
||||
job_stats_list.append(
|
||||
{
|
||||
"name": name,
|
||||
"rate": round(rate, 1),
|
||||
"total": s["total"],
|
||||
"success": s["success"],
|
||||
}
|
||||
)
|
||||
job_stats_list.sort(key=lambda x: x["rate"])
|
||||
job_stats_list = job_stats_list[:15] # 取成功率最低的15个
|
||||
job_names = [j["name"] for j in job_stats_list]
|
||||
job_rates = [j["rate"] for j in job_stats_list]
|
||||
|
||||
# 核心指标
|
||||
total_runs = stats["total"]
|
||||
success_rate = round(stats["success_rate"], 1)
|
||||
avg_dur_min = round(stats["avg_duration"] / 60, 1) if stats["avg_duration"] else 0
|
||||
infra_fail_rate = round(infra_rate, 1)
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 序列化数据为 JSON(供 JS 使用)
|
||||
data_json = json.dumps(
|
||||
{
|
||||
"daily_dates": daily_dates,
|
||||
"daily_success_rates": daily_success_rates,
|
||||
"daily_run_counts": daily_run_counts,
|
||||
"wf_names": wf_names,
|
||||
"wf_avg_durations": wf_avg_durations,
|
||||
"failure_pie_data": failure_pie_data,
|
||||
"job_names": job_names,
|
||||
"job_rates": job_rates,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# HTML 模板(注意:不使用 f-string,避免与 CSS/JS 的大括号冲突)
|
||||
html_parts = []
|
||||
html_parts.append("<!DOCTYPE html>")
|
||||
html_parts.append('<html lang="zh-CN">')
|
||||
html_parts.append("<head>")
|
||||
html_parts.append(' <meta charset="UTF-8">')
|
||||
html_parts.append(' <meta name="viewport" content="width=device-width, initial-scale=1.0">')
|
||||
html_parts.append(f" <title>CI 健康度看板 - {repo}</title>")
|
||||
html_parts.append(' <script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>')
|
||||
html_parts.append(" <style>")
|
||||
html_parts.append(" * { margin: 0; padding: 0; box-sizing: border-box; }")
|
||||
html_parts.append(" body {")
|
||||
html_parts.append(
|
||||
' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",'
|
||||
)
|
||||
html_parts.append(' "Microsoft YaHei", sans-serif;')
|
||||
html_parts.append(" background: #f0f2f5;")
|
||||
html_parts.append(" color: #333;")
|
||||
html_parts.append(" padding: 20px;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .container { max-width: 1400px; margin: 0 auto; }")
|
||||
html_parts.append(" .header {")
|
||||
html_parts.append(" background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);")
|
||||
html_parts.append(" color: white;")
|
||||
html_parts.append(" padding: 24px 32px;")
|
||||
html_parts.append(" border-radius: 12px;")
|
||||
html_parts.append(" margin-bottom: 20px;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .header h1 { font-size: 24px; margin-bottom: 8px; }")
|
||||
html_parts.append(" .header .subtitle { font-size: 14px; opacity: 0.9; }")
|
||||
html_parts.append(" .header .meta { font-size: 12px; opacity: 0.8; margin-top: 8px; }")
|
||||
html_parts.append(" .metrics-row {")
|
||||
html_parts.append(" display: grid;")
|
||||
html_parts.append(" grid-template-columns: repeat(4, 1fr);")
|
||||
html_parts.append(" gap: 16px;")
|
||||
html_parts.append(" margin-bottom: 20px;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .metric-card {")
|
||||
html_parts.append(" background: white;")
|
||||
html_parts.append(" border-radius: 12px;")
|
||||
html_parts.append(" padding: 20px;")
|
||||
html_parts.append(" box-shadow: 0 2px 8px rgba(0,0,0,0.06);")
|
||||
html_parts.append(" transition: transform 0.2s;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(
|
||||
" .metric-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }"
|
||||
)
|
||||
html_parts.append(" .metric-card .label { font-size: 13px; color: #8c8c8c; margin-bottom: 8px; }")
|
||||
html_parts.append(" .metric-card .value { font-size: 28px; font-weight: 600; }")
|
||||
html_parts.append(" .metric-card .unit { font-size: 14px; color: #8c8c8c; margin-left: 4px; }")
|
||||
html_parts.append(" .metric-card.success .value { color: #52c41a; }")
|
||||
html_parts.append(" .metric-card.warning .value { color: #faad14; }")
|
||||
html_parts.append(" .metric-card.danger .value { color: #ff4d4f; }")
|
||||
html_parts.append(" .metric-card.info .value { color: #1890ff; }")
|
||||
html_parts.append(" .charts-grid {")
|
||||
html_parts.append(" display: grid;")
|
||||
html_parts.append(" grid-template-columns: 1fr 1fr;")
|
||||
html_parts.append(" gap: 16px;")
|
||||
html_parts.append(" margin-bottom: 20px;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .chart-card {")
|
||||
html_parts.append(" background: white;")
|
||||
html_parts.append(" border-radius: 12px;")
|
||||
html_parts.append(" padding: 20px;")
|
||||
html_parts.append(" box-shadow: 0 2px 8px rgba(0,0,0,0.06);")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .chart-card.full-width { grid-column: 1 / -1; }")
|
||||
html_parts.append(" .chart-card h3 {")
|
||||
html_parts.append(" font-size: 16px;")
|
||||
html_parts.append(" margin-bottom: 12px;")
|
||||
html_parts.append(" color: #262626;")
|
||||
html_parts.append(" font-weight: 600;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .chart-container { width: 100%; height: 320px; }")
|
||||
html_parts.append(" .chart-container.tall { height: 400px; }")
|
||||
html_parts.append(" .footer {")
|
||||
html_parts.append(" text-align: center;")
|
||||
html_parts.append(" color: #8c8c8c;")
|
||||
html_parts.append(" font-size: 12px;")
|
||||
html_parts.append(" padding: 16px 0;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" @media (max-width: 900px) {")
|
||||
html_parts.append(" .metrics-row { grid-template-columns: repeat(2, 1fr); }")
|
||||
html_parts.append(" .charts-grid { grid-template-columns: 1fr; }")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" @media (max-width: 600px) {")
|
||||
html_parts.append(" .metrics-row { grid-template-columns: 1fr; }")
|
||||
html_parts.append(" body { padding: 12px; }")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" </style>")
|
||||
html_parts.append("</head>")
|
||||
html_parts.append("<body>")
|
||||
html_parts.append(' <div class="container">')
|
||||
html_parts.append(' <div class="header">')
|
||||
html_parts.append(" <h1>📊 CI 健康度看板</h1>")
|
||||
html_parts.append(f' <div class="subtitle">仓库: {repo}</div>')
|
||||
html_parts.append(f' <div class="meta">统计周期: {start_date} ~ {end_date} | 生成时间: {now_str}</div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="metrics-row">')
|
||||
html_parts.append(' <div class="metric-card success">')
|
||||
html_parts.append(' <div class="label">总成功率</div>')
|
||||
html_parts.append(f' <div class="value">{success_rate}<span class="unit">%</span></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="metric-card info">')
|
||||
html_parts.append(' <div class="label">总 Run 数</div>')
|
||||
html_parts.append(f' <div class="value">{total_runs}<span class="unit">次</span></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="metric-card warning">')
|
||||
html_parts.append(' <div class="label">平均耗时</div>')
|
||||
html_parts.append(f' <div class="value">{avg_dur_min}<span class="unit">分钟</span></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="metric-card danger">')
|
||||
html_parts.append(' <div class="label">基础设施故障率</div>')
|
||||
html_parts.append(f' <div class="value">{infra_fail_rate}<span class="unit">%</span></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="charts-grid">')
|
||||
html_parts.append(' <div class="chart-card full-width">')
|
||||
html_parts.append(" <h3>📈 CI 成功率趋势</h3>")
|
||||
html_parts.append(' <div id="chart-success-rate" class="chart-container"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="charts-grid">')
|
||||
html_parts.append(' <div class="chart-card">')
|
||||
html_parts.append(" <h3>⏱️ 各 Workflow 平均耗时</h3>")
|
||||
html_parts.append(' <div id="chart-wf-duration" class="chart-container"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="chart-card">')
|
||||
html_parts.append(" <h3>❌ 失败原因分布</h3>")
|
||||
html_parts.append(' <div id="chart-failure-pie" class="chart-container"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="charts-grid">')
|
||||
html_parts.append(' <div class="chart-card full-width">')
|
||||
html_parts.append(" <h3>📋 各 Job 成功率排行(最低 15 名)</h3>")
|
||||
html_parts.append(' <div id="chart-job-success" class="chart-container tall"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="charts-grid">')
|
||||
html_parts.append(' <div class="chart-card full-width">')
|
||||
html_parts.append(" <h3>📊 每日 Run 数量趋势</h3>")
|
||||
html_parts.append(' <div id="chart-run-count" class="chart-container"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="footer">')
|
||||
html_parts.append(" 由 ci_dashboard.py 自动生成 | ECharts 可视化")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" <script>")
|
||||
html_parts.append(f" const DATA = {data_json};")
|
||||
html_parts.append("")
|
||||
# 图表 1: 成功率趋势
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-success-rate"));')
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
|
||||
html_parts.append(' const p = params[0]; return p.name + "<br/>成功率: <b>" + p.value + "%</b>";')
|
||||
html_parts.append(" }},")
|
||||
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },')
|
||||
html_parts.append(' xAxis: { type: "category", boundaryGap: false, data: DATA.daily_dates,')
|
||||
html_parts.append(" axisLabel: { rotate: 30, fontSize: 11 } },")
|
||||
html_parts.append(' yAxis: { type: "value", min: 0, max: 100, axisLabel: { formatter: "{value}%" } },')
|
||||
html_parts.append(
|
||||
' series: [{ name: "成功率", type: "line", smooth: true, data: DATA.daily_success_rates,'
|
||||
)
|
||||
html_parts.append(' itemStyle: { color: "#52c41a" },')
|
||||
html_parts.append(" areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
|
||||
html_parts.append(' { offset: 0, color: "rgba(82, 196, 26, 0.3)" },')
|
||||
html_parts.append(' { offset: 1, color: "rgba(82, 196, 26, 0.05)" }')
|
||||
html_parts.append(" ])},")
|
||||
html_parts.append(' markLine: { silent: true, data: [{ type: "average", name: "平均值",')
|
||||
html_parts.append(' label: { formatter: "均值 {c}%" } }] }')
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append("")
|
||||
# 图表 2: Workflow 耗时对比
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-wf-duration"));')
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
|
||||
html_parts.append(
|
||||
' const p = params[0]; return p.name + "<br/>平均耗时: <b>" + p.value + " 分钟</b>";'
|
||||
)
|
||||
html_parts.append(" }},")
|
||||
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "15%", containLabel: true },')
|
||||
html_parts.append(' xAxis: { type: "category", data: DATA.wf_names,')
|
||||
html_parts.append(" axisLabel: { rotate: 30, fontSize: 10, interval: 0 } },")
|
||||
html_parts.append(' yAxis: { type: "value", name: "分钟", axisLabel: { formatter: "{value} min" } },')
|
||||
html_parts.append(' series: [{ name: "平均耗时", type: "bar", data: DATA.wf_avg_durations,')
|
||||
html_parts.append(" itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
|
||||
html_parts.append(' { offset: 0, color: "#1890ff" },')
|
||||
html_parts.append(' { offset: 1, color: "#096dd9" }')
|
||||
html_parts.append(" ]), borderRadius: [4, 4, 0, 0] },")
|
||||
html_parts.append(" barMaxWidth: 40")
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append("")
|
||||
# 图表 3: 失败原因饼图
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-failure-pie"));')
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "item", formatter: "{b}: {c} 次 ({d}%)" },')
|
||||
html_parts.append(' legend: { orient: "vertical", right: "5%", top: "center" },')
|
||||
html_parts.append(
|
||||
' series: [{ name: "失败原因", type: "pie", radius: ["40%", "70%"], center: ["35%", "50%"],'
|
||||
)
|
||||
html_parts.append(" avoidLabelOverlap: false,")
|
||||
html_parts.append(' itemStyle: { borderRadius: 6, borderColor: "#fff", borderWidth: 2 },')
|
||||
html_parts.append(' label: { show: false, position: "center" },')
|
||||
html_parts.append(' emphasis: { label: { show: true, fontSize: 16, fontWeight: "bold" } },')
|
||||
html_parts.append(" labelLine: { show: false },")
|
||||
html_parts.append(" data: DATA.failure_pie_data,")
|
||||
html_parts.append(' color: ["#ff4d4f", "#faad14", "#8c8c8c"]')
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append("")
|
||||
# 图表 4: Job 成功率排行(横向柱状图)
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-job-success"));')
|
||||
html_parts.append(" const barData = DATA.job_rates.map(function(rate, i) {")
|
||||
html_parts.append(" return { value: rate, itemStyle: {")
|
||||
html_parts.append(' color: rate >= 90 ? "#52c41a" : (rate >= 70 ? "#faad14" : "#ff4d4f")')
|
||||
html_parts.append(" }};")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
|
||||
html_parts.append(' const p = params[0]; return p.name + "<br/>成功率: <b>" + p.value + "%</b>";')
|
||||
html_parts.append(" }},")
|
||||
html_parts.append(' grid: { left: "3%", right: "8%", bottom: "3%", top: "3%", containLabel: true },')
|
||||
html_parts.append(' xAxis: { type: "value", min: 0, max: 100, axisLabel: { formatter: "{value}%" } },')
|
||||
html_parts.append(' yAxis: { type: "category", data: DATA.job_names, axisLabel: { fontSize: 11 } },')
|
||||
html_parts.append(' series: [{ name: "成功率", type: "bar", data: barData, barWidth: "60%",')
|
||||
html_parts.append(' label: { show: true, position: "right", formatter: "{c}%", fontSize: 11 }')
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append("")
|
||||
# 图表 5: 每日 Run 数量趋势(面积图)
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-run-count"));')
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
|
||||
html_parts.append(
|
||||
' const p = params[0]; return p.name + "<br/>Run 数量: <b>" + p.value + " 次</b>";'
|
||||
)
|
||||
html_parts.append(" }},")
|
||||
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },')
|
||||
html_parts.append(' xAxis: { type: "category", boundaryGap: false, data: DATA.daily_dates,')
|
||||
html_parts.append(" axisLabel: { rotate: 30, fontSize: 11 } },")
|
||||
html_parts.append(' yAxis: { type: "value", name: "次数" },')
|
||||
html_parts.append(
|
||||
' series: [{ name: "Run 数量", type: "line", smooth: true, data: DATA.daily_run_counts,'
|
||||
)
|
||||
html_parts.append(' itemStyle: { color: "#722ed1" },')
|
||||
html_parts.append(" areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
|
||||
html_parts.append(' { offset: 0, color: "rgba(114, 46, 209, 0.3)" },')
|
||||
html_parts.append(' { offset: 1, color: "rgba(114, 46, 209, 0.05)" }')
|
||||
html_parts.append(" ])},")
|
||||
html_parts.append(' markLine: { silent: true, data: [{ type: "average", name: "平均值",')
|
||||
html_parts.append(' label: { formatter: "均值 {c} 次" } }] }')
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append(" </script>")
|
||||
html_parts.append("</body>")
|
||||
html_parts.append("</html>")
|
||||
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
# ── 主函数 ───────────────────────────────────────────
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI 可观测性看板 - 生成 Gitea Actions 运行状态报表")
|
||||
@@ -539,6 +922,11 @@ def main():
|
||||
parser.add_argument("--password", type=str, default=os.environ.get("GITEA_PASSWORD"))
|
||||
parser.add_argument("--no-job-detail", action="store_true", help="不拉取 job 详情")
|
||||
parser.add_argument("--max-failures", type=int, default=50, help="最多分析多少个失败 run 的 job 详情 (默认 50)")
|
||||
|
||||
# HTML 输出相关参数
|
||||
parser.add_argument("--html", action="store_true", help="生成 HTML 可视化看板")
|
||||
parser.add_argument("--html-output", type=str, help="HTML 输出文件路径 (默认 ci_dashboard.html)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
ga = GiteaActions(
|
||||
@@ -561,8 +949,18 @@ def main():
|
||||
runs = enrich_with_jobs(ga, runs, max_failures=args.max_failures)
|
||||
|
||||
stats = analyze_runs(runs)
|
||||
md = generate_markdown(stats, start_date, end_date, args.repo)
|
||||
|
||||
# HTML 模式
|
||||
if args.html:
|
||||
html = generate_html(stats, start_date, end_date, args.repo)
|
||||
html_output = args.html_output or args.output or "ci_dashboard.html"
|
||||
with open(html_output, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"[INFO] HTML 看板已保存到 {html_output}", file=sys.stderr)
|
||||
return
|
||||
|
||||
# 默认 Markdown 模式(向后兼容)
|
||||
md = generate_markdown(stats, start_date, end_date, args.repo)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(md)
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI失败诊断增强脚本:自动分类失败原因 + 提取关键错误 + 给出修复建议。
|
||||
# Trigger CI after auto-format fix
|
||||
|
||||
支持的失败类型:
|
||||
1. Lint/格式问题 (ruff/black/eslint/prettier)
|
||||
2. 单元测试失败
|
||||
3. Docker构建失败
|
||||
4. 依赖安装失败 (pip/npm)
|
||||
5. 超时
|
||||
6. 缓存问题
|
||||
7. 数据库/迁移问题
|
||||
8. 网络问题
|
||||
9. 其他
|
||||
|
||||
用法:
|
||||
python3 scripts/ci/ci_failure_diagnosis.py [--job-name "Job Name"] [--log-file /path/to/log]
|
||||
|
||||
如果不传--log-file,会尝试从Gitea API获取失败job的日志。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class FailureDiagnosis:
|
||||
"""失败诊断结果"""
|
||||
|
||||
category: str # 失败分类
|
||||
category_cn: str # 中文分类名
|
||||
severity: str # 严重程度: high / medium / low
|
||||
summary: str # 一句话摘要
|
||||
error_lines: List[str] = field(default_factory=list) # 关键错误行
|
||||
suggestions: List[str] = field(default_factory=list) # 修复建议
|
||||
auto_fixable: bool = False # 是否可以自动修复
|
||||
related_docs: str = "" # 相关文档链接
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 失败模式定义
|
||||
# ============================================================
|
||||
|
||||
FAILURE_PATTERNS = [
|
||||
# ===== Lint / 格式问题 =====
|
||||
{
|
||||
"pattern": r"(ruff|black|isort)\b.*(error|failed|Error)",
|
||||
"category": "lint_python",
|
||||
"category_cn": "Python代码质量检查",
|
||||
"severity": "low",
|
||||
"summary_contains": ["ruff", "black", "isort"],
|
||||
"suggestions": [
|
||||
"本地运行 `black . && isort . && ruff check --fix .` 自动修复",
|
||||
"使用 `scripts/agent-commit.sh` 提交(自动格式化)",
|
||||
"如确认无误,可加 `# noqa: xxx` 忽略特定规则",
|
||||
],
|
||||
"auto_fixable": True,
|
||||
},
|
||||
{
|
||||
"pattern": r"ESLint|prettier|eslint",
|
||||
"category": "lint_frontend",
|
||||
"category_cn": "前端代码检查",
|
||||
"severity": "low",
|
||||
"summary_contains": ["eslint", "prettier"],
|
||||
"suggestions": [
|
||||
"本地运行 `cd apps/web && npm run lint:fix` 自动修复",
|
||||
"Prettier问题: `cd apps/web && npx prettier --write .`",
|
||||
],
|
||||
"auto_fixable": True,
|
||||
},
|
||||
{
|
||||
"pattern": r"F\d{3}|E\d{3}|W\d{3}.*ruff|ruff.*F\d{3}",
|
||||
"category": "lint_python",
|
||||
"category_cn": "Python代码质量检查",
|
||||
"severity": "low",
|
||||
"suggestions": [
|
||||
"F401: 删除未使用的import",
|
||||
"F841: 删除未使用的变量或加下划线前缀",
|
||||
"E501: 行超长,加 `# noqa: E501`",
|
||||
"F811: 删重复import",
|
||||
"运行 `ruff check --fix .` 自动修复大部分问题",
|
||||
],
|
||||
"auto_fixable": True,
|
||||
},
|
||||
# ===== 单元测试失败 =====
|
||||
{
|
||||
"pattern": r"FAILED|assert.*Error|AssertionError",
|
||||
"category": "unit_test",
|
||||
"category_cn": "单元测试失败",
|
||||
"severity": "high",
|
||||
"suggestions": [
|
||||
"检查相关测试文件,确认是代码问题还是测试用例问题",
|
||||
"本地运行对应测试:`pytest path/to/test.py -v`",
|
||||
"如测试依赖外部服务,检查mock是否正确",
|
||||
],
|
||||
"auto_fixable": False,
|
||||
},
|
||||
{
|
||||
"pattern": r"pytest.*failed|\d+ failed.*\d+ passed",
|
||||
"category": "unit_test",
|
||||
"category_cn": "单元测试失败",
|
||||
"severity": "high",
|
||||
"suggestions": [
|
||||
"查看上方日志中的FAILED测试用例",
|
||||
"检查失败断言的期望值 vs 实际值",
|
||||
"新代码影响了现有测试行为,确认是预期内变更吗?",
|
||||
],
|
||||
"auto_fixable": False,
|
||||
},
|
||||
# ===== Docker 构建失败 =====
|
||||
{
|
||||
"pattern": r"Dockerfile.*not found|docker build.*failed|ERROR: failed to solve",
|
||||
"category": "docker_build",
|
||||
"category_cn": "Docker构建失败",
|
||||
"severity": "high",
|
||||
"suggestions": [
|
||||
"检查Dockerfile语法是否正确",
|
||||
"检查引用的基础镜像是否存在",
|
||||
"本地运行 `docker build -f path/to/Dockerfile .` 复现",
|
||||
],
|
||||
"auto_fixable": False,
|
||||
},
|
||||
{
|
||||
"pattern": r"manifest.*not found|no such image|image.*not found",
|
||||
"category": "docker_build",
|
||||
"category_cn": "镜像不存在",
|
||||
"severity": "medium",
|
||||
"suggestions": [
|
||||
"检查基础镜像名称和tag是否正确",
|
||||
"确认镜像仓库可访问,登录是否有效",
|
||||
"如为新基础镜像,需先手动构建一次基础镜像",
|
||||
],
|
||||
"auto_fixable": False,
|
||||
},
|
||||
{
|
||||
"pattern": r"ETXTBSY|text file busy",
|
||||
"category": "docker_build",
|
||||
"category_cn": "文件锁冲突(ETXTBSY)",
|
||||
"severity": "low",
|
||||
"summary": "esbuild并发构建冲突,重试即可",
|
||||
"suggestions": ["偶发问题,点击Rerun重新运行即可", "如频繁出现,检查是否有多个job并发写入同一文件"],
|
||||
"auto_fixable": True,
|
||||
},
|
||||
# ===== 依赖安装失败 =====
|
||||
{
|
||||
"pattern": r"pip install.*error|Could not find a version|No matching distribution",
|
||||
"category": "dependency",
|
||||
"category_cn": "pip依赖安装失败",
|
||||
"severity": "medium",
|
||||
"suggestions": [
|
||||
"检查requirements.txt中的版本号是否正确",
|
||||
"如为新版本刚发布,可能源还没同步,稍后重试",
|
||||
"检查网络连接,可尝试切换pip镜像源",
|
||||
],
|
||||
"auto_fixable": False,
|
||||
},
|
||||
{
|
||||
"pattern": r"npm.*ERR|npm install.*failed|E404|ECONNREFUSED.*npm",
|
||||
"category": "dependency",
|
||||
"category_cn": "npm依赖安装失败",
|
||||
"severity": "medium",
|
||||
"suggestions": [
|
||||
"检查package.json中的版本号是否存在",
|
||||
"网络问题:检查npm registry是否可访问",
|
||||
"国内网络建议配置npmmirror镜像源",
|
||||
],
|
||||
"auto_fixable": False,
|
||||
},
|
||||
{
|
||||
"pattern": r"Connection refused|timed out|network.*unreachable",
|
||||
"category": "network",
|
||||
"category_cn": "网络问题",
|
||||
"severity": "medium",
|
||||
"summary": "网络连接失败,可能是源站问题或DNS问题",
|
||||
"suggestions": [
|
||||
"点击Rerun重试,网络问题通常是临时的",
|
||||
"如持续失败,检查对应服务是否正常",
|
||||
"检查Runner网络配置",
|
||||
],
|
||||
"auto_fixable": True,
|
||||
},
|
||||
# ===== 超时 =====
|
||||
{
|
||||
"pattern": r"timeout|timed out|exceeded.*time limit|job.*cancelled.*timeout",
|
||||
"category": "timeout",
|
||||
"category_cn": "执行超时",
|
||||
"severity": "medium",
|
||||
"suggestions": [
|
||||
"如首次出现:重试一次,可能是临时性能波动",
|
||||
"频繁出现:检查构建是否变慢了,最近是否加了新依赖",
|
||||
"可适当增加timeout-minutes配置",
|
||||
],
|
||||
"auto_fixable": False,
|
||||
},
|
||||
# ===== 数据库/迁移 =====
|
||||
{
|
||||
"pattern": r"alembic.*error|migration.*failed|relation.*does not exist|column.*does not exist",
|
||||
"category": "migration",
|
||||
"category_cn": "数据库迁移失败",
|
||||
"severity": "high",
|
||||
"suggestions": [
|
||||
"检查迁移脚本是否正确,down_revision是否对",
|
||||
"确认数据库中是否有脏数据或残留表",
|
||||
"迁移脚本合并冲突时,重新生成迁移文件",
|
||||
],
|
||||
"auto_fixable": False,
|
||||
},
|
||||
# ===== 缓存问题 =====
|
||||
{
|
||||
"pattern": r"cache.*corrupt|cache.*invalid|snapshot.*not found|failed to compute cache key",
|
||||
"category": "cache",
|
||||
"category_cn": "缓存损坏",
|
||||
"severity": "low",
|
||||
"suggestions": ["构建系统会自动清理损坏缓存并重试,通常无需干预", "如持续失败,手动清理Runner上的缓存目录"],
|
||||
"auto_fixable": True,
|
||||
},
|
||||
# ===== Checkout 失败 =====
|
||||
{
|
||||
"pattern": r"Could not resolve host|fatal:.*repository|SSL.*problem",
|
||||
"category": "checkout",
|
||||
"category_cn": "代码拉取失败",
|
||||
"severity": "low",
|
||||
"suggestions": ["临时网络问题,点击Rerun重试", "如持续失败,检查Gitea服务状态"],
|
||||
"auto_fixable": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis:
|
||||
"""分析日志,返回诊断结果"""
|
||||
|
||||
lines = log_text.strip().split("\n")
|
||||
|
||||
# 收集所有匹配的模式
|
||||
matched = []
|
||||
error_lines = []
|
||||
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
# 收集ERROR/FAILED/Failed等错误行(最多20行)
|
||||
if re.search(r"(ERROR|FAILED|Error|error:|FAIL:|Traceback)", line_stripped):
|
||||
if len(error_lines) < 20:
|
||||
error_lines.append(line_stripped)
|
||||
|
||||
for pattern_info in FAILURE_PATTERNS:
|
||||
if re.search(pattern_info["pattern"], line_stripped, re.IGNORECASE):
|
||||
matched.append(pattern_info)
|
||||
break # 一行只匹配一个模式
|
||||
|
||||
if not matched:
|
||||
# 未识别的失败类型
|
||||
return FailureDiagnosis(
|
||||
category="unknown",
|
||||
category_cn="未知错误",
|
||||
severity="medium",
|
||||
summary="未识别的失败类型,需要人工查看日志",
|
||||
error_lines=error_lines[:10],
|
||||
suggestions=[
|
||||
"点击'查看失败日志'查看完整日志",
|
||||
"如为偶发问题,可先重试一次",
|
||||
"常见原因:环境问题、配置问题、新增逻辑引入的bug",
|
||||
],
|
||||
auto_fixable=False,
|
||||
)
|
||||
|
||||
# 选最严重、最具体的那个
|
||||
severity_order = {"high": 3, "medium": 2, "low": 1}
|
||||
matched.sort(key=lambda x: severity_order.get(x["severity"], 0), reverse=True)
|
||||
best_match = matched[0]
|
||||
|
||||
# 生成摘要
|
||||
if "summary" in best_match:
|
||||
summary = best_match["summary"]
|
||||
else:
|
||||
summary = f"{best_match['category_cn']}检查失败"
|
||||
if job_name:
|
||||
summary = f"[{job_name}] {summary}"
|
||||
|
||||
# 从error_lines中过滤出与该分类相关的
|
||||
relevant_errors = error_lines[:10]
|
||||
|
||||
return FailureDiagnosis(
|
||||
category=best_match["category"],
|
||||
category_cn=best_match["category_cn"],
|
||||
severity=best_match["severity"],
|
||||
summary=summary,
|
||||
error_lines=relevant_errors,
|
||||
suggestions=best_match["suggestions"],
|
||||
auto_fixable=best_match.get("auto_fixable", False),
|
||||
)
|
||||
|
||||
|
||||
def fetch_failed_job_log(run_id: str, job_id: str, token: str, repo: str) -> Optional[str]:
|
||||
"""从Gitea API获取失败job的日志"""
|
||||
api_base = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}"
|
||||
|
||||
# 尝试获取job的日志
|
||||
url = f"{api_base}/actions/runs/{run_id}/jobs/{job_id}/log"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
print(f"获取日志失败: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def format_diagnosis_markdown(d: FailureDiagnosis, job_name: str = "", run_url: str = "") -> str:
|
||||
"""将诊断结果格式化为飞书卡片markdown"""
|
||||
|
||||
severity_emoji = {"high": "🔴", "medium": "🟡", "low": "🟢"}
|
||||
emoji = severity_emoji.get(d.severity, "⚪")
|
||||
|
||||
lines = []
|
||||
lines.append(f"**分类**: {emoji} {d.category_cn}")
|
||||
lines.append(f"**问题**: {d.summary}")
|
||||
|
||||
if d.error_lines:
|
||||
lines.append("")
|
||||
lines.append("**关键错误行**:")
|
||||
for err in d.error_lines[:5]:
|
||||
# 截断过长的行
|
||||
if len(err) > 150:
|
||||
err = err[:147] + "..."
|
||||
lines.append(f" `{err}`")
|
||||
|
||||
lines.append("")
|
||||
lines.append("**修复建议**:")
|
||||
for i, s in enumerate(d.suggestions[:5], 1):
|
||||
lines.append(f" {i}. {s}")
|
||||
|
||||
if d.auto_fixable:
|
||||
lines.append("")
|
||||
lines.append("💡 **可自动修复**:如格式问题,可尝试点击Rerun让auto-fix自动处理")
|
||||
|
||||
if run_url:
|
||||
lines.append("")
|
||||
lines.append(f"[查看完整日志]({run_url})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
job_name = os.environ.get("FAILED_JOB", "")
|
||||
run_id = os.environ.get("GITHUB_RUN_ID", "")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas")
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
|
||||
# 1. 尝试获取日志
|
||||
log_text = ""
|
||||
|
||||
# 优先从环境变量或文件读取
|
||||
log_file = os.environ.get("CI_LOG_FILE", "")
|
||||
if log_file and os.path.exists(log_file):
|
||||
with open(log_file) as f:
|
||||
log_text = f.read()
|
||||
elif run_id and token:
|
||||
# 尝试从API获取(需要job_id,这里简化处理)
|
||||
pass
|
||||
|
||||
# 如果没有日志,用job_name做粗略分类
|
||||
if not log_text:
|
||||
# 基于job名做初始判断
|
||||
if any(k in job_name.lower() for k in ["validate", "lint", "quality"]):
|
||||
d = FailureDiagnosis(
|
||||
category="lint_general",
|
||||
category_cn="代码质量检查",
|
||||
severity="low",
|
||||
summary=f"{job_name} 检查失败(日志不可用,基于job名初步诊断)",
|
||||
suggestions=["点击查看日志获取具体错误信息", "格式类问题通常可自动修复"],
|
||||
auto_fixable=True,
|
||||
)
|
||||
elif "build" in job_name.lower():
|
||||
d = FailureDiagnosis(
|
||||
category="build_general",
|
||||
category_cn="构建失败",
|
||||
severity="high",
|
||||
summary=f"{job_name} 构建失败(日志不可用)",
|
||||
suggestions=["点击查看日志获取具体构建错误", "常见原因:Dockerfile错误、依赖安装失败、网络问题"],
|
||||
auto_fixable=False,
|
||||
)
|
||||
elif "test" in job_name.lower():
|
||||
d = FailureDiagnosis(
|
||||
category="test_general",
|
||||
category_cn="测试失败",
|
||||
severity="high",
|
||||
summary=f"{job_name} 测试失败(日志不可用)",
|
||||
suggestions=["点击查看日志获取具体失败的测试用例", "检查最近代码改动是否影响了测试"],
|
||||
auto_fixable=False,
|
||||
)
|
||||
elif "deploy" in job_name.lower():
|
||||
d = FailureDiagnosis(
|
||||
category="deploy_general",
|
||||
category_cn="部署失败",
|
||||
severity="high",
|
||||
summary=f"{job_name} 部署失败(日志不可用)",
|
||||
suggestions=["检查目标服务器状态和网络", "检查镜像是否正确推送", "查看服务器上的容器日志"],
|
||||
auto_fixable=False,
|
||||
)
|
||||
else:
|
||||
d = FailureDiagnosis(
|
||||
category="unknown",
|
||||
category_cn="未知错误",
|
||||
severity="medium",
|
||||
summary=f"{job_name} 失败",
|
||||
suggestions=["点击查看日志获取详细信息"],
|
||||
auto_fixable=False,
|
||||
)
|
||||
else:
|
||||
d = analyze_log(log_text, job_name)
|
||||
|
||||
# 输出诊断结果
|
||||
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" if run_id else ""
|
||||
|
||||
print("=" * 60)
|
||||
print(" CI 失败诊断报告")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(format_diagnosis_markdown(d, job_name, run_url))
|
||||
print()
|
||||
print("=" * 60)
|
||||
|
||||
# 将诊断结果写入文件(供通知脚本读取)
|
||||
output_file = os.environ.get("DIAGNOSIS_OUTPUT", "/tmp/ci_diagnosis.json")
|
||||
result = {
|
||||
"category": d.category,
|
||||
"category_cn": d.category_cn,
|
||||
"severity": d.severity,
|
||||
"summary": d.summary,
|
||||
"error_lines": d.error_lines,
|
||||
"suggestions": d.suggestions,
|
||||
"auto_fixable": d.auto_fixable,
|
||||
}
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n诊断结果已保存到: {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI 健康度快速检查脚本
|
||||
- 统计最近 N 条 run 的成功率(按 workflow 分类)
|
||||
- 列出失败的 run 和失败的 job/step
|
||||
- 区分基础设施问题 vs 业务代码问题
|
||||
- 输出简洁的健康度报告
|
||||
|
||||
用法:
|
||||
python3 scripts/ci/ci_health_check.py [--limit 20] [--workflow ci-pipeline.yml] [--json]
|
||||
|
||||
环境变量:
|
||||
GITEA_TOKEN API token(必需)
|
||||
GITEA_API_URL Gitea API 地址,默认 https://git.xiaoxiajianji.com/api/v1
|
||||
GITEA_REPO 仓库,默认 xiaoxia/xiaoxia-saas
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# ---- 基础设施问题关键词(命中即判定为基础设施问题)----
|
||||
INFRA_KEYWORDS = [
|
||||
# 网络/连接
|
||||
"Couldn't connect to server",
|
||||
"Connection refused",
|
||||
"Connection reset",
|
||||
"Connection timed out",
|
||||
"Failed to connect to",
|
||||
"network is unreachable",
|
||||
"TLS handshake timeout",
|
||||
"SSL certificate problem",
|
||||
# 容器/Runner
|
||||
"No such container",
|
||||
"container already exists",
|
||||
"docker: not found",
|
||||
"no space left on device",
|
||||
"out of memory",
|
||||
"OOMKilled",
|
||||
"pull access denied",
|
||||
"manifest unknown",
|
||||
"Error response from daemon",
|
||||
"runner",
|
||||
"runner is not online",
|
||||
"no matching runners",
|
||||
# Checkout/Git
|
||||
"Could not resolve host",
|
||||
"fatal: unable to access",
|
||||
"The remote end hung up unexpectedly",
|
||||
"early EOF",
|
||||
"index-pack failed",
|
||||
"git fetch",
|
||||
"checkout failed",
|
||||
"ETXTBSY",
|
||||
"text file busy",
|
||||
# 镜像/环境
|
||||
"No module named pip",
|
||||
"pip: not found",
|
||||
"command not found: python",
|
||||
"python3: not found",
|
||||
"node: not found",
|
||||
"npm: not found",
|
||||
"exec format error",
|
||||
"standard_init_linux.go",
|
||||
# 系统/资源
|
||||
"Input/output error",
|
||||
"device or resource busy",
|
||||
"No space left on device",
|
||||
"Disk full",
|
||||
# 鉴权/配置
|
||||
"401 Unauthorized",
|
||||
"403 Forbidden",
|
||||
"404 Not Found",
|
||||
"identity_sign: private key",
|
||||
"Permission denied",
|
||||
]
|
||||
|
||||
|
||||
def api_get(path: str) -> dict:
|
||||
base = os.environ.get("GITEA_API_URL", "https://git.xiaoxiajianji.com/api/v1")
|
||||
repo = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
url = f"{base}/repos/{repo}/{path}"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def get_run_jobs(run_id: int) -> list:
|
||||
return api_get(f"actions/runs/{run_id}/jobs").get("jobs", [])
|
||||
|
||||
|
||||
def get_job_log(job_id: int) -> str:
|
||||
try:
|
||||
return api_get(f"actions/jobs/{job_id}/logs")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def classify_failure(job: dict) -> str:
|
||||
"""判断失败原因类型: infra / business / unknown"""
|
||||
name = job.get("name", "")
|
||||
# 仅根据 job 名称做初步分类(更精确需读日志,但代价高)
|
||||
infra_jobs = ["Checkout", "Build", "Deploy", "Cleanup"]
|
||||
business_jobs = [
|
||||
"Unit Tests",
|
||||
"Integration Tests",
|
||||
"Frontend Lint",
|
||||
"Frontend Unit Tests",
|
||||
"Staging E2E",
|
||||
"E2E",
|
||||
"Validate Code Quality",
|
||||
]
|
||||
name_lower = name.lower()
|
||||
if (
|
||||
any(k.lower() in name_lower for k in infra_jobs)
|
||||
and "Test" not in name
|
||||
and "Lint" not in name
|
||||
and "Validate" not in name
|
||||
):
|
||||
return "infra"
|
||||
if any(k.lower() in name_lower for k in business_jobs):
|
||||
return "business"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def analyze_with_log(job_id: int) -> str:
|
||||
"""通过日志关键词精确分类"""
|
||||
log = get_job_log(job_id)
|
||||
log_lower = log.lower()
|
||||
for kw in INFRA_KEYWORDS:
|
||||
if kw.lower() in log_lower:
|
||||
return "infra"
|
||||
return "business"
|
||||
|
||||
|
||||
def fmt_time(t: str) -> str:
|
||||
if not t or t.startswith("1970"):
|
||||
return "-"
|
||||
try:
|
||||
dt = datetime.fromisoformat(t.replace("Z", "+00:00"))
|
||||
bj = dt.astimezone(timezone(timedelta(hours=8)))
|
||||
return bj.strftime("%m-%d %H:%M")
|
||||
except Exception:
|
||||
return t[:16]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI 健康度快速检查")
|
||||
parser.add_argument("--limit", type=int, default=20, help="最近多少条 run")
|
||||
parser.add_argument("--workflow", type=str, default="", help="只看某个 workflow")
|
||||
parser.add_argument("--json", action="store_true", help="JSON 输出")
|
||||
parser.add_argument("--deep", action="store_true", help="深度检查(读日志,较慢)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.environ.get("GITEA_TOKEN"):
|
||||
print("错误: 请设置 GITEA_TOKEN 环境变量", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. 获取最近 run
|
||||
runs = api_get(f"actions/runs?limit={args.limit}").get("workflow_runs", [])
|
||||
if args.workflow:
|
||||
runs = [r for r in runs if args.workflow in r.get("path", "")]
|
||||
|
||||
if not runs:
|
||||
print("没有找到匹配的 run")
|
||||
return
|
||||
|
||||
# 按 workflow 分组统计
|
||||
wf_stats = {}
|
||||
failed_runs = []
|
||||
|
||||
for r in runs:
|
||||
path = r.get("path", "unknown")
|
||||
# 提取 workflow 文件名,兼容各种 path 格式
|
||||
if ".yml" in path or ".yaml" in path:
|
||||
# ci-pipeline.yml@refs/heads/develop -> ci-pipeline.yml
|
||||
wf = path.split("@")[0].split("/")[-1]
|
||||
else:
|
||||
wf = path.split("/")[-1] if "/" in path else path
|
||||
if wf not in wf_stats:
|
||||
wf_stats[wf] = {"total": 0, "success": 0, "failure": 0, "cancelled": 0, "others": 0}
|
||||
wf_stats[wf]["total"] += 1
|
||||
status = r.get("status", "")
|
||||
conc = r.get("conclusion", "")
|
||||
if status != "completed":
|
||||
wf_stats[wf]["others"] += 1
|
||||
continue
|
||||
if conc == "success":
|
||||
wf_stats[wf]["success"] += 1
|
||||
elif conc == "failure":
|
||||
wf_stats[wf]["failure"] += 1
|
||||
failed_runs.append(r)
|
||||
elif conc == "cancelled":
|
||||
wf_stats[wf]["cancelled"] += 1
|
||||
else:
|
||||
wf_stats[wf]["others"] += 1
|
||||
|
||||
# 2. 失败 run 详情
|
||||
failed_details = []
|
||||
for r in failed_runs[:10]: # 最多看10个失败的
|
||||
jobs = get_run_jobs(r["id"])
|
||||
failed_jobs = [j for j in jobs if j.get("conclusion") == "failure"]
|
||||
job_infos = []
|
||||
for j in failed_jobs:
|
||||
cat = classify_failure(j)
|
||||
if args.deep and cat == "unknown":
|
||||
cat = analyze_with_log(j["id"])
|
||||
# 找失败的 step
|
||||
failed_steps = []
|
||||
for step in j.get("steps", []):
|
||||
if step.get("conclusion") == "failure":
|
||||
failed_steps.append(step.get("name", "?"))
|
||||
job_infos.append(
|
||||
{
|
||||
"name": j.get("name", ""),
|
||||
"category": cat,
|
||||
"failed_steps": failed_steps,
|
||||
"runner": j.get("runner_name", ""),
|
||||
}
|
||||
)
|
||||
failed_details.append(
|
||||
{
|
||||
"id": r["id"],
|
||||
"title": r.get("display_title", ""),
|
||||
"branch": r.get("head_branch", ""),
|
||||
"time": fmt_time(r.get("updated_at", "")),
|
||||
"jobs": job_infos,
|
||||
}
|
||||
)
|
||||
|
||||
# 3. 输出
|
||||
if args.json:
|
||||
result = {"workflows": wf_stats, "failed_runs": failed_details}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
# 文本报告
|
||||
print("=" * 60)
|
||||
print(" CI 健康度报告")
|
||||
print("=" * 60)
|
||||
print(f"统计范围: 最近 {len(runs)} 条 run")
|
||||
print(f"时间: {datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print()
|
||||
|
||||
print("📊 各 Workflow 成功率:")
|
||||
print("-" * 60)
|
||||
for wf, s in sorted(wf_stats.items()):
|
||||
total = s["total"]
|
||||
succ = s["success"]
|
||||
rate = (succ / total * 100) if total > 0 else 0
|
||||
bar = "█" * int(rate / 5) + "░" * (20 - int(rate / 5))
|
||||
icon = "🟢" if rate >= 90 else ("🟡" if rate >= 70 else "🔴")
|
||||
print(f" {icon} {wf:35s} {rate:5.1f}% {bar} ({succ}/{total})")
|
||||
if s["failure"]:
|
||||
print(f" 失败: {s['failure']} 取消: {s['cancelled']} 进行中: {s['others']}")
|
||||
|
||||
if failed_details:
|
||||
print()
|
||||
print("❌ 失败详情:")
|
||||
print("-" * 60)
|
||||
for d in failed_details:
|
||||
print(f" #{d['id']} [{d['time']}] {d['title'][:45]}")
|
||||
print(f" 分支: {d['branch']}")
|
||||
for j in d["jobs"]:
|
||||
cat_icon = "🏗️" if j["category"] == "infra" else ("🐛" if j["category"] == "business" else "❓")
|
||||
steps = ", ".join(j["failed_steps"][:3]) if j["failed_steps"] else "未知"
|
||||
print(f" {cat_icon} {j['name'][:30]:30s} 失败步骤: {steps}")
|
||||
if j["runner"]:
|
||||
print(f" runner: {j['runner']}")
|
||||
else:
|
||||
print()
|
||||
print("✅ 最近没有失败的 run")
|
||||
|
||||
# 总结
|
||||
total_all = sum(s["total"] for s in wf_stats.values())
|
||||
succ_all = sum(s["success"] for s in wf_stats.values())
|
||||
fail_all = sum(s["failure"] for s in wf_stats.values())
|
||||
infra_fail = sum(1 for d in failed_details for j in d["jobs"] if j["category"] == "infra")
|
||||
biz_fail = sum(1 for d in failed_details for j in d["jobs"] if j["category"] == "business")
|
||||
rate_all = (succ_all / total_all * 100) if total_all > 0 else 0
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f" 总结: 总成功率 {rate_all:.1f}% ({succ_all}/{total_all})")
|
||||
if fail_all > 0:
|
||||
print(f" 失败job分类: 基础设施 {infra_fail} 个 | 业务代码 {biz_fail} 个")
|
||||
if infra_fail > biz_fail:
|
||||
print(" ⚠️ 主要是基础设施问题,建议优先排查 CI 环境")
|
||||
else:
|
||||
print(" 💡 主要是业务代码问题,建议关注业务侧修复")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI健康度每日巡检报告脚本
|
||||
- 调用ci_health_check.py获取数据
|
||||
- 有失败时生成飞书卡片通知并发送
|
||||
- 无失败时静默退出(不打扰)
|
||||
- 用于每日定时巡检
|
||||
|
||||
用法:
|
||||
python3 scripts/ci/ci_health_report.py [--limit 30] [--dry-run]
|
||||
|
||||
环境变量:
|
||||
GITEA_TOKEN API token(必需)
|
||||
CI_NOTIFY_WEBHOOK 飞书webhook地址(必需,用于发报告)
|
||||
GITEA_API_URL Gitea API 地址
|
||||
GITEA_REPO 仓库
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def run_health_check(limit: int) -> dict:
|
||||
"""调用ci_health_check.py获取JSON结果"""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
cmd = [
|
||||
sys.executable,
|
||||
os.path.join(script_dir, "ci_health_check.py"),
|
||||
"--json",
|
||||
"--limit",
|
||||
str(limit),
|
||||
]
|
||||
env = os.environ.copy()
|
||||
# 确保GITEA_TOKEN传递
|
||||
if not env.get("GITEA_TOKEN") and env.get("GITHUB_TOKEN"):
|
||||
env["GITEA_TOKEN"] = env["GITHUB_TOKEN"]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||
if result.returncode != 0:
|
||||
print(f"health check failed: {result.stderr}")
|
||||
return {"workflows": {}, "failed_runs": []}
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
print(f"failed to parse health check output: {result.stdout[:200]}")
|
||||
return {"workflows": {}, "failed_runs": []}
|
||||
|
||||
|
||||
def build_feishu_card(data: dict) -> dict:
|
||||
"""构建飞书卡片消息"""
|
||||
wf_stats = data.get("workflows", {})
|
||||
failed_runs = data.get("failed_runs", [])
|
||||
|
||||
# 统计数据
|
||||
total_all = sum(s["total"] for s in wf_stats.values())
|
||||
succ_all = sum(s["success"] for s in wf_stats.values())
|
||||
fail_all = sum(s["failure"] for s in wf_stats.values())
|
||||
rate_all = (succ_all / total_all * 100) if total_all > 0 else 0
|
||||
|
||||
# 失败分类
|
||||
infra_fail = 0
|
||||
biz_fail = 0
|
||||
unknown_fail = 0
|
||||
for run in failed_runs:
|
||||
for job in run.get("jobs", []):
|
||||
cat = job.get("category", "unknown")
|
||||
if cat == "infra":
|
||||
infra_fail += 1
|
||||
elif cat == "business":
|
||||
biz_fail += 1
|
||||
else:
|
||||
unknown_fail += 1
|
||||
|
||||
now = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 各workflow成功率行
|
||||
wf_lines = []
|
||||
for wf, s in sorted(wf_stats.items()):
|
||||
total = s["total"]
|
||||
succ = s["success"]
|
||||
fail = s["failure"]
|
||||
rate = (succ / total * 100) if total > 0 else 0
|
||||
icon = "🟢" if rate >= 90 else ("🟡" if rate >= 70 else "🔴")
|
||||
wf_name = (
|
||||
wf.replace("ci-pipeline.yml", "CI Pipeline")
|
||||
.replace("code-review.yml", "Code Review")
|
||||
.replace("daily-check.yml", "Daily Check")
|
||||
.replace("preview-deploy.yml", "Preview Deploy")
|
||||
)
|
||||
wf_lines.append(f"{icon} **{wf_name}**: {rate:.0f}% ({succ}/{total},失败{fail})")
|
||||
|
||||
# 失败详情(最多显示5条)
|
||||
fail_detail_lines = []
|
||||
for i, run in enumerate(failed_runs[:5]):
|
||||
run_id = run["id"]
|
||||
title = run.get("title", "")[:35]
|
||||
branch = run.get("branch", "")
|
||||
jobs_str = ", ".join(j["name"][:15] for j in run.get("jobs", [])[:3])
|
||||
fail_detail_lines.append(f"• **#{run_id}** {title}\n 分支: {branch} | 失败: {jobs_str}")
|
||||
|
||||
if len(failed_runs) > 5:
|
||||
fail_detail_lines.append(f"... 还有 {len(failed_runs) - 5} 条失败记录")
|
||||
|
||||
# 整体状态
|
||||
if fail_all == 0:
|
||||
status_text = "✅ 全部通过"
|
||||
status_color = "green"
|
||||
elif infra_fail > biz_fail:
|
||||
status_text = "⚠️ 基础设施问题为主"
|
||||
status_color = "yellow"
|
||||
else:
|
||||
status_text = "🔴 存在业务失败"
|
||||
status_color = "red"
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"CI告警 - 每日健康度巡检 ({now})"},
|
||||
"template": status_color,
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"**统计范围**: 最近 {total_all} 条 run\n**整体状态**: {status_text}\n**总成功率**: {rate_all:.1f}% ({succ_all}/{total_all})",
|
||||
},
|
||||
},
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": "**📊 各Workflow成功率**\n" + "\n".join(wf_lines) if wf_lines else "暂无数据",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# 失败分类统计
|
||||
if fail_all > 0:
|
||||
card["elements"].append({"tag": "hr"})
|
||||
card["elements"].append(
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"**失败原因分类**\n🏗️ 基础设施: {infra_fail} 个\n🐛 业务代码: {biz_fail} 个\n❓ 待确认: {unknown_fail} 个",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 失败详情
|
||||
if fail_detail_lines:
|
||||
card["elements"].append({"tag": "hr"})
|
||||
card["elements"].append(
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": "**❌ 失败详情**\n" + "\n\n".join(fail_detail_lines),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 查看更多
|
||||
card["elements"].append({"tag": "hr"})
|
||||
base_url = os.environ.get("GITEA_BASE_URL", "https://git.xiaoxiajianji.com")
|
||||
repo = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
|
||||
card["elements"].append(
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看CI面板"},
|
||||
"type": "primary",
|
||||
"url": f"{base_url}/{repo}/actions",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {"msg_type": "interactive", "card": card}
|
||||
|
||||
|
||||
def send_feishu(webhook: str, payload: dict) -> bool:
|
||||
"""发送飞书webhook"""
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
webhook,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
return result.get("code", -1) == 0 or result.get("StatusCode", -1) == 0
|
||||
except Exception as e:
|
||||
print(f"send feishu failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI健康度每日巡检报告")
|
||||
parser.add_argument("--limit", type=int, default=30, help="统计最近N条run")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印不发送")
|
||||
parser.add_argument("--always-notify", action="store_true", help="即使全部通过也发送通知")
|
||||
args = parser.parse_args()
|
||||
|
||||
webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "")
|
||||
if not webhook and not args.dry_run:
|
||||
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
|
||||
# 还是执行健康检查输出到日志,方便排查
|
||||
data = run_health_check(args.limit)
|
||||
print(f"health check done: {len(data.get('failed_runs', []))} failed")
|
||||
return 0
|
||||
|
||||
# 执行健康检查
|
||||
data = run_health_check(args.limit)
|
||||
failed_count = len(data.get("failed_runs", []))
|
||||
|
||||
# 无失败且不强制通知 → 静默退出
|
||||
if failed_count == 0 and not args.always_notify:
|
||||
print("✅ 全部通过,静默退出")
|
||||
return 0
|
||||
|
||||
# 构建并发送卡片
|
||||
card = build_feishu_card(data)
|
||||
|
||||
if args.dry_run:
|
||||
print(json.dumps(card, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
success = send_feishu(webhook, card)
|
||||
if success:
|
||||
print(f"📤 已发送健康度报告,失败 {failed_count} 条")
|
||||
else:
|
||||
print("❌ 发送飞书通知失败")
|
||||
|
||||
# 通知失败不阻断流程
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI重复失败检测脚本
|
||||
- 扫描最近N天的CI失败
|
||||
- 按job名称分组统计失败率
|
||||
- 识别高失败率job(系统性故障)
|
||||
- 飞书通知告警
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def get_env(name, default=None, required=False):
|
||||
val = os.environ.get(name, default)
|
||||
if required and not val:
|
||||
print(f"❌ 缺少环境变量: {name}")
|
||||
sys.exit(1)
|
||||
return val
|
||||
|
||||
|
||||
GITEA_URL = get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
GITEA_TOKEN = get_env("GITEA_API_TOKEN", required=False) or get_env("GITHUB_TOKEN", "")
|
||||
REPO = get_env("GITEA_REPO", "xiaoxia/xiaoxia-saas")
|
||||
DAYS = int(get_env("FAIL_CHECK_DAYS", "7"))
|
||||
FAIL_THRESHOLD = int(get_env("FAIL_THRESHOLD", 3)) # 失败次数阈值
|
||||
FAIL_RATE_THRESHOLD = float(get_env("FAIL_RATE_THRESHOLD", "30")) # 失败率阈值%
|
||||
CONSECUTIVE_FAIL_THRESHOLD = int(get_env("CONSECUTIVE_FAIL_THRESHOLD", "3")) # 连续失败阈值
|
||||
WEBHOOK = get_env("CI_NOTIFY_WEBHOOK", "")
|
||||
|
||||
|
||||
def api_get(path):
|
||||
"""调用Gitea API"""
|
||||
url = f"{GITEA_URL}/api/v1{path}"
|
||||
req = urllib.request.Request(url)
|
||||
if GITEA_TOKEN:
|
||||
req.add_header("Authorization", f"token {GITEA_TOKEN}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" HTTP {e.code}: {path}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" 错误: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_recent_runs(days=7, per_page=50, max_pages=10):
|
||||
"""获取最近N天的runs"""
|
||||
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||
all_runs = []
|
||||
|
||||
for page in range(1, max_pages + 1):
|
||||
path = f"/repos/{REPO}/actions/runs?page={page}&limit={per_page}"
|
||||
data = api_get(path)
|
||||
if not data:
|
||||
break
|
||||
|
||||
runs = data.get("workflow_runs", data.get("runs", []))
|
||||
if not runs:
|
||||
break
|
||||
|
||||
# 检查时间范围(Gitea用started_at,格式2026-07-22T10:58:10+08:00)
|
||||
oldest = None
|
||||
for r in runs:
|
||||
started = r.get("started_at", r.get("created_at", ""))
|
||||
if started and started >= since:
|
||||
all_runs.append(r)
|
||||
else:
|
||||
oldest = started
|
||||
|
||||
if oldest and oldest < since:
|
||||
break
|
||||
|
||||
if len(runs) < per_page:
|
||||
break
|
||||
|
||||
return all_runs
|
||||
|
||||
|
||||
def fetch_run_jobs(run_id):
|
||||
"""获取run的所有jobs"""
|
||||
path = f"/repos/{REPO}/actions/runs/{run_id}/jobs"
|
||||
data = api_get(path)
|
||||
if not data:
|
||||
return []
|
||||
return data.get("jobs", [])
|
||||
|
||||
|
||||
def analyze_failures(runs):
|
||||
"""
|
||||
分析失败情况
|
||||
|
||||
返回:
|
||||
- job_stats: {job_name: {total, success, failure, skipped, failure_rate, failures: [...]}}
|
||||
- consecutive_failures: {job_name: current_streak, max_streak, last_status}
|
||||
"""
|
||||
job_stats = defaultdict(
|
||||
lambda: {
|
||||
"total": 0,
|
||||
"success": 0,
|
||||
"failure": 0,
|
||||
"error": 0,
|
||||
"skipped": 0,
|
||||
"cancelled": 0,
|
||||
"failures": [],
|
||||
}
|
||||
)
|
||||
|
||||
# 按时间正序排列(旧→新)用于连续失败计算
|
||||
sorted_runs = sorted(runs, key=lambda r: r.get("started_at", r.get("created_at", "")))
|
||||
|
||||
# 连续失败跟踪 {job_name: streak}
|
||||
consecutive = defaultdict(lambda: {"current": 0, "max": 0, "last_run": None})
|
||||
|
||||
for run in sorted_runs:
|
||||
run_id = run.get("id")
|
||||
run_status = run.get("status", "")
|
||||
run_conclusion = run.get("conclusion", "")
|
||||
run_started = run.get("started_at", run.get("created_at", ""))
|
||||
event = run.get("event", "")
|
||||
|
||||
# 只统计pull_request和push事件的CI
|
||||
if event not in ("pull_request", "push"):
|
||||
continue
|
||||
|
||||
jobs = fetch_run_jobs(run_id)
|
||||
|
||||
for job in jobs:
|
||||
name = job.get("name", "")
|
||||
status = job.get("status", "")
|
||||
conclusion = job.get("conclusion", "")
|
||||
|
||||
# 跳过非CI核心job(如AI Code Review、Preview等)
|
||||
skip_prefixes = ("AI Code Review", "Preview", "PR Automation", "Auto")
|
||||
if any(name.startswith(p) for p in skip_prefixes):
|
||||
continue
|
||||
|
||||
stats = job_stats[name]
|
||||
stats["total"] += 1
|
||||
|
||||
if conclusion == "success":
|
||||
stats["success"] += 1
|
||||
consecutive[name]["current"] = 0
|
||||
elif conclusion == "failure":
|
||||
stats["failure"] += 1
|
||||
stats["failures"].append(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"time": run_started,
|
||||
"event": event,
|
||||
}
|
||||
)
|
||||
consecutive[name]["current"] += 1
|
||||
if consecutive[name]["current"] > consecutive[name]["max"]:
|
||||
consecutive[name]["max"] = consecutive[name]["current"]
|
||||
consecutive[name]["last_run"] = run_id
|
||||
elif conclusion == "error":
|
||||
stats["error"] += 1
|
||||
# error也算失败的一种
|
||||
consecutive[name]["current"] += 1
|
||||
if consecutive[name]["current"] > consecutive[name]["max"]:
|
||||
consecutive[name]["max"] = consecutive[name]["current"]
|
||||
elif conclusion == "skipped":
|
||||
stats["skipped"] += 1
|
||||
# skipped不算也不打断连续失败
|
||||
elif conclusion == "cancelled":
|
||||
stats["cancelled"] += 1
|
||||
# cancelled不算失败也不打断
|
||||
|
||||
# 计算失败率
|
||||
for name, stats in job_stats.items():
|
||||
total_actual = stats["total"] - stats["skipped"] - stats["cancelled"]
|
||||
if total_actual > 0:
|
||||
stats["failure_rate"] = round((stats["failure"] + stats["error"]) / total_actual * 100, 1)
|
||||
else:
|
||||
stats["failure_rate"] = 0.0
|
||||
|
||||
return dict(job_stats), dict(consecutive)
|
||||
|
||||
|
||||
def find_high_failures(job_stats, consecutive):
|
||||
"""
|
||||
找出高风险job
|
||||
|
||||
告警级别:
|
||||
- critical: 连续失败 >= CONSECUTIVE_FAIL_THRESHOLD,或 失败率>=50%且失败次数>=5
|
||||
- warning: 失败率>=FAIL_RATE_THRESHOLD且失败次数>=FAIL_THRESHOLD
|
||||
- info: 失败次数>=2
|
||||
"""
|
||||
critical = []
|
||||
warning = []
|
||||
info = []
|
||||
|
||||
for name, stats in job_stats.items():
|
||||
fail_count = stats["failure"] + stats["error"]
|
||||
rate = stats["failure_rate"]
|
||||
streak = consecutive.get(name, {}).get("current", 0)
|
||||
max_streak = consecutive.get(name, {}).get("max", 0)
|
||||
|
||||
issue = {
|
||||
"name": name,
|
||||
"fail_count": fail_count,
|
||||
"total": stats["total"],
|
||||
"failure_rate": rate,
|
||||
"current_streak": streak,
|
||||
"max_streak": max_streak,
|
||||
"recent_failures": stats["failures"][-5:], # 最近5次
|
||||
}
|
||||
|
||||
if streak >= CONSECUTIVE_FAIL_THRESHOLD or (rate >= 50 and fail_count >= 5):
|
||||
critical.append(issue)
|
||||
elif rate >= FAIL_RATE_THRESHOLD and fail_count >= FAIL_THRESHOLD:
|
||||
warning.append(issue)
|
||||
elif fail_count >= 2:
|
||||
info.append(issue)
|
||||
|
||||
# 按失败次数倒序
|
||||
critical.sort(key=lambda x: x["fail_count"], reverse=True)
|
||||
warning.sort(key=lambda x: x["fail_count"], reverse=True)
|
||||
info.sort(key=lambda x: x["fail_count"], reverse=True)
|
||||
|
||||
return critical, warning, info
|
||||
|
||||
|
||||
def generate_report(critical, warning, info, days, total_runs):
|
||||
"""生成Markdown报告"""
|
||||
lines = []
|
||||
lines.append("# CI重复失败检测报告")
|
||||
lines.append("")
|
||||
lines.append(f"**统计周期**: 最近{days}天")
|
||||
lines.append(f"**扫描Runs**: {total_runs}个")
|
||||
lines.append(f"**生成时间**: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"## 概览")
|
||||
lines.append("")
|
||||
lines.append(f"| 级别 | 数量 |")
|
||||
lines.append(f"|------|------|")
|
||||
lines.append(f"| 🔴 严重 (连续失败≥{CONSECUTIVE_FAIL_THRESHOLD}次 或 失败率≥50%) | {len(critical)} |")
|
||||
lines.append(f"| 🟡 警告 (失败率≥{FAIL_RATE_THRESHOLD}% 且 失败≥{FAIL_THRESHOLD}次) | {len(warning)} |")
|
||||
lines.append(f"| 🔵 关注 (失败≥2次) | {len(info)} |")
|
||||
lines.append("")
|
||||
|
||||
if critical:
|
||||
lines.append("## 🔴 严重问题")
|
||||
lines.append("")
|
||||
for item in critical:
|
||||
lines.append(f"### {item['name']}")
|
||||
lines.append("")
|
||||
lines.append(f"- 失败次数: **{item['fail_count']}** / {item['total']} 次运行")
|
||||
lines.append(f"- 失败率: **{item['failure_rate']}%**")
|
||||
lines.append(f"- 当前连续失败: **{item['current_streak']}** 次 (历史最高: {item['max_streak']} 次)")
|
||||
lines.append("")
|
||||
if item["recent_failures"]:
|
||||
lines.append("最近失败:")
|
||||
lines.append("")
|
||||
for f in item["recent_failures"]:
|
||||
lines.append(f"- [{f['time'][:16]}] run #{f['run_id']} ({f['event']})")
|
||||
lines.append("")
|
||||
|
||||
if warning:
|
||||
lines.append("## 🟡 警告")
|
||||
lines.append("")
|
||||
for item in warning:
|
||||
lines.append(
|
||||
f"- **{item['name']}**: {item['fail_count']}次失败 / {item['total']}次运行 ({item['failure_rate']}%)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if info:
|
||||
lines.append("## 🔵 关注列表")
|
||||
lines.append("")
|
||||
lines.append("| Job名称 | 失败次数 | 总次数 | 失败率 | 当前连续 |")
|
||||
lines.append("|---------|----------|--------|--------|----------|")
|
||||
for item in info[:20]: # 最多显示20个
|
||||
lines.append(
|
||||
f"| {item['name']} | {item['fail_count']} | {item['total']} | {item['failure_rate']}% | {item['current_streak']} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def send_feishu_notification(critical, warning, info, days):
|
||||
"""发送飞书通知"""
|
||||
if not WEBHOOK:
|
||||
print(" ⚠️ 未配置WEBHOOK,跳过飞书通知")
|
||||
return False
|
||||
|
||||
total_issues = len(critical) + len(warning) + len(info)
|
||||
if total_issues == 0:
|
||||
print(" ✅ 无异常,不发送通知")
|
||||
return True
|
||||
|
||||
level = "🔴 严重告警" if critical else "🟡 警告" if warning else "🔵 关注"
|
||||
|
||||
title = f"CI重复失败检测 - {level}"
|
||||
text = f"统计周期: 最近{days}天\n\n"
|
||||
|
||||
if critical:
|
||||
text += "【严重问题】\n"
|
||||
for item in critical[:5]:
|
||||
text += f"• {item['name']}\n"
|
||||
text += f" 失败 {item['fail_count']}/{item['total']} ({item['failure_rate']}%) 连续{item['current_streak']}次\n"
|
||||
if len(critical) > 5:
|
||||
text += f" ...还有{len(critical)-5}个\n"
|
||||
text += "\n"
|
||||
|
||||
if warning:
|
||||
text += "【警告】\n"
|
||||
for item in warning[:5]:
|
||||
text += f"• {item['name']}: {item['fail_count']}次失败 ({item['failure_rate']}%)\n"
|
||||
if len(warning) > 5:
|
||||
text += f" ...还有{len(warning)-5}个\n"
|
||||
text += "\n"
|
||||
|
||||
if info and not critical and not warning:
|
||||
text += "【关注列表】\n"
|
||||
for item in info[:10]:
|
||||
text += f"• {item['name']}: {item['fail_count']}次失败\n"
|
||||
text += "\n"
|
||||
|
||||
text += f"共发现 {total_issues} 个异常job"
|
||||
|
||||
payload = {"msg_type": "text", "content": {"text": f"{title}\n\n{text}"}}
|
||||
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(WEBHOOK, data=data, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
if result.get("code") == 0 or result.get("StatusCode") == 0:
|
||||
print(" ✅ 飞书通知已发送")
|
||||
return True
|
||||
else:
|
||||
print(f" ⚠️ 飞书返回: {result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ 飞书通知失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print(f"=== CI重复失败检测 ===")
|
||||
print(f"统计周期: 最近{DAYS}天")
|
||||
print(f"仓库: {REPO}")
|
||||
print()
|
||||
|
||||
print("1. 获取最近的Runs...")
|
||||
runs = fetch_recent_runs(days=DAYS)
|
||||
print(f" 找到 {len(runs)} 个runs")
|
||||
|
||||
if not runs:
|
||||
print("⚠️ 没有找到runs,退出")
|
||||
return
|
||||
|
||||
print()
|
||||
print("2. 分析job失败情况(可能需要点时间)...")
|
||||
job_stats, consecutive = analyze_failures(runs)
|
||||
print(f" 共统计 {len(job_stats)} 个job")
|
||||
|
||||
print()
|
||||
print("3. 识别高风险job...")
|
||||
critical, warning, info = find_high_failures(job_stats, consecutive)
|
||||
print(f" 🔴 严重: {len(critical)}")
|
||||
print(f" 🟡 警告: {len(warning)}")
|
||||
print(f" 🔵 关注: {len(info)}")
|
||||
|
||||
print()
|
||||
print("4. 生成报告...")
|
||||
report = generate_report(critical, warning, info, DAYS, len(runs))
|
||||
|
||||
# 保存报告
|
||||
report_path = os.environ.get("REPORT_PATH", f"/tmp/ci_failure_report_{int(time.time())}.md")
|
||||
with open(report_path, "w") as f:
|
||||
f.write(report)
|
||||
print(f" 报告已保存: {report_path}")
|
||||
|
||||
# 打印摘要
|
||||
print()
|
||||
print("=== 摘要 ===")
|
||||
if critical:
|
||||
print("🔴 严重问题:")
|
||||
for item in critical[:5]:
|
||||
print(
|
||||
f" {item['name']}: {item['fail_count']}次失败, {item['failure_rate']}%, 连续{item['current_streak']}次"
|
||||
)
|
||||
if warning:
|
||||
print("🟡 警告:")
|
||||
for item in warning[:5]:
|
||||
print(f" {item['name']}: {item['fail_count']}次失败, {item['failure_rate']}%")
|
||||
|
||||
print()
|
||||
print("5. 发送飞书通知...")
|
||||
send_feishu_notification(critical, warning, info, DAYS)
|
||||
|
||||
print()
|
||||
print("✅ 检测完成")
|
||||
|
||||
# 有严重问题时退出码非零,方便workflow标记
|
||||
if critical:
|
||||
sys.exit(2)
|
||||
elif warning:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# PR构建专用:只构建不推送,只读缓存不写,用于PR阶段验证Dockerfile
|
||||
set -eu
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
if [ "$1" = "--no-cache" ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
shift
|
||||
fi
|
||||
|
||||
DOCKERFILE="$1"
|
||||
IMAGE_TAG="$2"
|
||||
CACHE_REF="$3"
|
||||
shift 3
|
||||
BUILD_ARGS=""
|
||||
for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
CACHE_NAME=$(echo "$CACHE_REF" | tr "/" "_" | tr ":" "-")
|
||||
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
|
||||
mkdir -p "$LOCAL_CACHE_DIR"
|
||||
|
||||
echo "=== PR Build: build only, no push, read-only cache ==="
|
||||
echo "Dockerfile: ${DOCKERFILE}"
|
||||
echo "Image tag: ${IMAGE_TAG}"
|
||||
echo ""
|
||||
|
||||
build_with_retry() {
|
||||
local attempt=1
|
||||
local max_attempts=2
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
local build_output
|
||||
local exit_code
|
||||
set +e
|
||||
build_output=$(docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--load \
|
||||
. 2>&1)
|
||||
exit_code=$?
|
||||
set -e
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "$build_output"
|
||||
return 0
|
||||
fi
|
||||
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
|
||||
echo "$build_output"
|
||||
echo "Local cache corrupted, cleaning and retrying ($attempt/$max_attempts)..."
|
||||
rm -rf "${LOCAL_CACHE_DIR}"
|
||||
mkdir -p "${LOCAL_CACHE_DIR}"
|
||||
docker buildx prune -f -a >/dev/null 2>&1 || true
|
||||
attempt=$((attempt + 1))
|
||||
else
|
||||
echo "$build_output"
|
||||
return $exit_code
|
||||
fi
|
||||
done
|
||||
echo "Local cache failed, building with registry cache only..."
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--load \
|
||||
.
|
||||
}
|
||||
|
||||
build_with_retry
|
||||
echo ""
|
||||
echo "PR build OK (not pushed): ${IMAGE_TAG}"
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底)
|
||||
# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题
|
||||
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache共享)
|
||||
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
|
||||
set -eu
|
||||
|
||||
@@ -35,7 +34,7 @@ LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
|
||||
|
||||
mkdir -p "$LOCAL_CACHE_DIR"
|
||||
|
||||
# 缓存源:local优先(带自动修复),registry兜底
|
||||
# 缓存源:local优先(带自动修复),registry兜底读写
|
||||
# 本地缓存损坏时自动清理后重试,避免snapshot not found导致构建全挂
|
||||
build_with_cache_retry() {
|
||||
local attempt=1
|
||||
@@ -48,8 +47,9 @@ build_with_cache_retry() {
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
|
||||
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--push \
|
||||
@@ -81,15 +81,16 @@ build_with_cache_retry() {
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
|
||||
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--push \
|
||||
.
|
||||
}
|
||||
|
||||
echo "=== Step 1: Build & push image (local cache + registry read, with auto-repair) ==="
|
||||
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
|
||||
echo "Local cache: ${LOCAL_CACHE_DIR}"
|
||||
echo "Registry cache: ${CACHE_REF}"
|
||||
echo ""
|
||||
@@ -99,6 +100,7 @@ build_with_cache_retry
|
||||
echo ""
|
||||
echo "Image pushed: ${IMAGE_TAG}"
|
||||
echo "Local cache updated"
|
||||
echo "Registry cache updated (if supported)"
|
||||
|
||||
echo ""
|
||||
echo "Build completed: ${IMAGE_TAG}"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# CI 健康度看板一键生成脚本
|
||||
# - 从 Gitea Actions API 拉取数据
|
||||
# - 生成 HTML 可视化看板
|
||||
# - 输出文件路径
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/ci/generate_ci_dashboard.sh [--days 7] [--output ci_dashboard.html]
|
||||
#
|
||||
# 环境变量:
|
||||
# GITEA_TOKEN API Token(必需)
|
||||
# GITEA_URL Gitea 地址(可选,默认 https://git.xiaoxiajianji.com)
|
||||
# GITEA_REPO 仓库(可选,默认 xiaoxia/xiaoxia-saas)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
# 默认参数
|
||||
DAYS=7
|
||||
OUTPUT="ci_dashboard.html"
|
||||
|
||||
# 解析参数
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--days)
|
||||
DAYS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--output|-o)
|
||||
OUTPUT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
echo "用法: bash scripts/ci/generate_ci_dashboard.sh [--days 7] [--output ci_dashboard.html]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --days N 统计最近 N 天 (默认 7)"
|
||||
echo " --output PATH HTML 输出路径 (默认 ci_dashboard.html)"
|
||||
echo " --help 显示帮助"
|
||||
echo ""
|
||||
echo "环境变量:"
|
||||
echo " GITEA_TOKEN API Token(必需)"
|
||||
echo " GITEA_URL Gitea 地址"
|
||||
echo " GITEA_REPO 仓库"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 检查 Python
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "[ERROR] 未找到 python3,请先安装 Python 3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Token
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "[ERROR] 请设置 GITEA_TOKEN 环境变量"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo " CI 健康度看板生成器"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "统计天数: ${DAYS} 天"
|
||||
echo "输出文件: ${OUTPUT}"
|
||||
echo ""
|
||||
|
||||
# 生成 HTML 看板
|
||||
echo "[INFO] 正在拉取数据并生成看板..."
|
||||
python3 "${SCRIPT_DIR}/ci_dashboard.py" \
|
||||
--days "${DAYS}" \
|
||||
--html \
|
||||
--html-output "${OUTPUT}"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " ✅ 看板生成完成!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "文件路径: $(realpath "${OUTPUT}")"
|
||||
echo ""
|
||||
|
||||
# 如果在 macOS 上,尝试打开
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
echo "[INFO] 正在打开浏览器..."
|
||||
open "${OUTPUT}"
|
||||
fi
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR自动扫描器:扫描所有open PR,对CI全绿的进行自动审批/合并
|
||||
作为短作业模式的兜底机制,每5分钟运行一次
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def api_request(token, repo, endpoint, method="GET", data=None):
|
||||
"""Gitea API请求"""
|
||||
url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/{endpoint}"
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
# 跳过SSL验证
|
||||
import ssl
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=ctx)
|
||||
return json.loads(resp.read().decode()), resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
|
||||
|
||||
|
||||
def get_open_prs(token, repo, base="develop"):
|
||||
"""获取所有open的PR"""
|
||||
prs = []
|
||||
page = 1
|
||||
while True:
|
||||
data, code = api_request(token, repo, f"pulls?state=open&base={base}&sort=recentupdate&per_page=50&page={page}")
|
||||
if code != 200 or not isinstance(data, list) or len(data) == 0:
|
||||
break
|
||||
prs.extend(data)
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
return prs
|
||||
|
||||
|
||||
def get_commit_status(token, repo, sha):
|
||||
"""获取commit的CI状态汇总"""
|
||||
data, code = api_request(token, repo, f"commits/{sha}/status")
|
||||
if code != 200:
|
||||
return {}, "error"
|
||||
return data, data.get("state", "unknown")
|
||||
|
||||
|
||||
def check_required_contexts(token, repo, sha, contexts):
|
||||
"""检查指定的context是否都通过"""
|
||||
data, _ = get_commit_status(token, repo, sha)
|
||||
statuses = {s["context"]: s["status"] for s in data.get("statuses", [])}
|
||||
|
||||
all_success = True
|
||||
any_pending = False
|
||||
any_failed = False
|
||||
|
||||
for ctx in contexts:
|
||||
state = statuses.get(ctx, "pending")
|
||||
if state != "success":
|
||||
all_success = False
|
||||
if state == "pending":
|
||||
any_pending = True
|
||||
if state in ("failure", "error"):
|
||||
any_failed = True
|
||||
|
||||
return all_success, any_pending, any_failed, statuses
|
||||
|
||||
|
||||
def get_pr_files(token, repo, pr_number):
|
||||
"""获取PR变更文件"""
|
||||
files = []
|
||||
page = 1
|
||||
while True:
|
||||
data, code = api_request(token, repo, f"pulls/{pr_number}/files?per_page=300&page={page}")
|
||||
if code != 200 or not isinstance(data, list) or len(data) == 0:
|
||||
break
|
||||
files.extend(data)
|
||||
if len(data) < 300:
|
||||
break
|
||||
page += 1
|
||||
return [f["filename"] for f in files]
|
||||
|
||||
|
||||
def is_frontend_only(files):
|
||||
"""判断是否纯前端改动"""
|
||||
if not files:
|
||||
return False
|
||||
frontend_count = sum(1 for f in files if f.startswith("apps/web/"))
|
||||
backend_count = len(files) - frontend_count
|
||||
return backend_count == 0 and frontend_count > 0
|
||||
|
||||
|
||||
def has_approval(token, repo, pr_number):
|
||||
"""检查PR是否已有审批"""
|
||||
reviews, code = api_request(token, repo, f"pulls/{pr_number}/reviews")
|
||||
if code != 200:
|
||||
return False
|
||||
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
|
||||
|
||||
|
||||
def approve_pr(token, repo, pr_number):
|
||||
"""审批PR"""
|
||||
# 创建review
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews",
|
||||
method="POST",
|
||||
data={"event": "PENDING", "body": "CI全绿,自动审批通过。"},
|
||||
)
|
||||
|
||||
if code not in (200, 201):
|
||||
return False, f"创建review失败: HTTP {code}"
|
||||
|
||||
review_id = data.get("id")
|
||||
if data.get("state") == "APPROVED":
|
||||
return True, "直接创建APPROVED成功"
|
||||
|
||||
if not review_id:
|
||||
return False, "未获取到review ID"
|
||||
|
||||
# submit为APPROVED
|
||||
data2, code2 = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}/events",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": "CI全绿,自动审批通过。"},
|
||||
)
|
||||
|
||||
if code2 in (200, 201):
|
||||
return True, "审批提交成功"
|
||||
else:
|
||||
# 尝试另一个端点
|
||||
data3, code3 = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": "CI全绿,自动审批通过。"},
|
||||
)
|
||||
if code3 in (200, 201):
|
||||
return True, "审批提交成功(备用端点)"
|
||||
return False, f"审批提交失败: HTTP {code2}/{code3}"
|
||||
|
||||
|
||||
def merge_pr(token, repo, pr_number):
|
||||
"""合并PR(squash merge)"""
|
||||
# 等待几秒让状态同步
|
||||
time.sleep(30)
|
||||
|
||||
# 检查PR状态
|
||||
pr_data, code = api_request(token, repo, f"pulls/{pr_number}")
|
||||
if code != 200:
|
||||
return False, f"获取PR状态失败: HTTP {code}"
|
||||
if pr_data.get("state") != "open":
|
||||
return False, f"PR状态不是open: {pr_data.get('state')}"
|
||||
|
||||
# 执行squash merge
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/merge",
|
||||
method="POST",
|
||||
data={
|
||||
"do": "squash",
|
||||
"merge_title_field": "",
|
||||
"merge_message_field": "",
|
||||
"delete_branch_after_merge": True,
|
||||
"force_merge": False,
|
||||
},
|
||||
)
|
||||
|
||||
if code == 200:
|
||||
return True, "合并成功"
|
||||
elif code == 405:
|
||||
return False, "合并返回405(门禁未满足或冲突)"
|
||||
else:
|
||||
return False, f"合并失败: HTTP {code}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PR自动扫描器")
|
||||
parser.add_argument("--token", required=True, help="Gitea API token")
|
||||
parser.add_argument("--repo", default="xiaoxia/xiaoxia-saas", help="仓库")
|
||||
parser.add_argument("--base", default="develop", help="目标分支")
|
||||
parser.add_argument("--approve", action="store_true", help="执行自动审批")
|
||||
parser.add_argument("--merge", action="store_true", help="执行自动合并")
|
||||
parser.add_argument("--dry-run", default="false", help="试运行模式")
|
||||
parser.add_argument("--max-prs", type=int, default=20, help="最多处理的PR数")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
dry_run = args.dry_run.lower() == "true"
|
||||
|
||||
# required contexts(与分支保护一致)
|
||||
REQUIRED_CONTEXTS_FULL = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)",
|
||||
]
|
||||
REQUIRED_CONTEXTS_APPROVE = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
FRONTEND_ONLY_CONTEXT = [
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
|
||||
# 获取所有open PR
|
||||
print(f"获取 {args.base} 分支的open PR...")
|
||||
prs = get_open_prs(args.token, args.repo, args.base)
|
||||
print(f"找到 {len(prs)} 个open PR")
|
||||
|
||||
approved_count = 0
|
||||
merged_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for pr in prs[: args.max_prs]:
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
base_ref = pr.get("base", {}).get("ref", "")
|
||||
|
||||
# 跳过draft
|
||||
if pr.get("draft"):
|
||||
print(f"\n⏭️ #{pr_num} {pr_title[:50]} - draft,跳过")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 跳过目标分支不对的
|
||||
if base_ref != args.base:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
print(f"\n--- #{pr_num} {pr_title[:60]} ---")
|
||||
|
||||
# 判断是否纯前端
|
||||
files = get_pr_files(args.token, args.repo, pr_num)
|
||||
frontend_only = is_frontend_only(files)
|
||||
|
||||
if frontend_only:
|
||||
approve_contexts = FRONTEND_ONLY_CONTEXT
|
||||
merge_contexts = FRONTEND_ONLY_CONTEXT
|
||||
print(f" 类型: 纯前端改动 ({len(files)}个文件)")
|
||||
else:
|
||||
approve_contexts = REQUIRED_CONTEXTS_APPROVE
|
||||
merge_contexts = REQUIRED_CONTEXTS_FULL
|
||||
print(f" 类型: 全栈/后端改动 ({len(files)}个文件)")
|
||||
|
||||
# 检查审批用的CI状态
|
||||
all_ok, pending, failed, _ = check_required_contexts(args.token, args.repo, head_sha, approve_contexts)
|
||||
|
||||
# === 自动审批 ===
|
||||
if args.approve and all_ok and not failed:
|
||||
if has_approval(args.token, args.repo, pr_num):
|
||||
print(f" ✅ 已有审批,跳过")
|
||||
else:
|
||||
if dry_run:
|
||||
print(f" 🎯 [DRY-RUN] 将自动审批")
|
||||
else:
|
||||
print(f" 🎯 执行自动审批...")
|
||||
ok, msg = approve_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 审批成功: {msg}")
|
||||
approved_count += 1
|
||||
else:
|
||||
print(f" ❌ 审批失败: {msg}")
|
||||
elif failed:
|
||||
print(f" ❌ CI有失败项,跳过审批")
|
||||
elif pending:
|
||||
print(f" ⏳ CI仍在运行,跳过")
|
||||
|
||||
# === 自动合并 ===
|
||||
if args.merge:
|
||||
# 检查合并用的CI状态
|
||||
merge_ok, merge_pending, merge_failed, _ = check_required_contexts(
|
||||
args.token, args.repo, head_sha, merge_contexts
|
||||
)
|
||||
|
||||
# 检查审批
|
||||
approved = has_approval(args.token, args.repo, pr_num)
|
||||
|
||||
if merge_ok and approved and not merge_failed:
|
||||
if dry_run:
|
||||
print(f" 🎯 [DRY-RUN] 将自动合并")
|
||||
else:
|
||||
print(f" 🎯 执行自动合并...")
|
||||
ok, msg = merge_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 合并成功: {msg}")
|
||||
merged_count += 1
|
||||
else:
|
||||
print(f" ⚠️ 合并失败: {msg}")
|
||||
elif merge_pending:
|
||||
print(f" ⏳ 合并条件未满足: CI运行中")
|
||||
elif merge_failed:
|
||||
print(f" ❌ 合并条件未满足: CI有失败")
|
||||
elif not approved:
|
||||
print(f" ⏳ 合并条件未满足: 无审批")
|
||||
|
||||
print(f"\n=== 扫描结果 ===")
|
||||
print(f" 处理PR数: {min(len(prs), args.max_prs)}")
|
||||
print(f" 自动审批: {approved_count} 个")
|
||||
print(f" 自动合并: {merged_count} 个")
|
||||
print(f" 跳过: {skipped_count} 个")
|
||||
print(f" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
# CI Integration Tests Job 主脚本
|
||||
# 包含:依赖安装、ffmpeg安装、Redis启动、PG启动、迁移、测试、清理、覆盖率
|
||||
# 支持 pytest-xdist 并行执行:每个 worker 使用独立数据库,预期加速 2-4 倍
|
||||
set -eu
|
||||
|
||||
echo "=== CI Integration Tests 开始 ==="
|
||||
@@ -28,12 +29,13 @@ for i in 1 2 3; do
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q pytest-rerunfailures && break
|
||||
echo "pip install pytest-rerunfailures 失败,重试 $i/3..."
|
||||
python3 -m pip install -q pytest-rerunfailures pytest-xdist && break
|
||||
echo "pip install pytest-rerunfailures/pytest-xdist 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
pytest --version
|
||||
echo "pytest-xdist: $(python3 -c "import xdist; print(xdist.__version__)" 2>/dev/null || echo 'not installed')"
|
||||
|
||||
# --- 安装 ffmpeg ---
|
||||
echo ""
|
||||
@@ -187,13 +189,14 @@ if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
echo "等待共享PG连接就绪..."
|
||||
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
|
||||
|
||||
# 创建独立数据库
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
# 创建主数据库(xdist 模式下各 worker 会创建自己的数据库,主库作为 fallback)
|
||||
echo "创建主测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
|
||||
cur.close()
|
||||
conn.close()
|
||||
@@ -238,30 +241,50 @@ else
|
||||
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
|
||||
fi
|
||||
|
||||
# --- 执行迁移 ---
|
||||
# --- 执行迁移(主数据库,xdist worker 会各自创建自己的库并迁移) ---
|
||||
echo ""
|
||||
echo "=== 执行 Alembic 迁移 ==="
|
||||
echo "=== 执行 Alembic 迁移(主数据库) ==="
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ 迁移完成"
|
||||
|
||||
# --- 运行集成测试 ---
|
||||
# --- 运行集成测试(pytest-xdist 并行) ---
|
||||
echo ""
|
||||
echo "=== 运行集成测试 ==="
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance"
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=40 > /dev/null
|
||||
echo "=== 运行集成测试(pytest-xdist 并行模式) ==="
|
||||
echo "CPU 核数: $(nproc 2>/dev/null || echo 'unknown')"
|
||||
|
||||
# 根据可用内存动态计算 worker 数,防止 OOM
|
||||
# 每个 worker 约占 200-300MB(含 DB 连接 + FastAPI test client)
|
||||
# 预留 1GB 给系统 + ffmpeg 等子进程
|
||||
AVAIL_MEM_MB=$(($(grep MemAvailable /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 2097152) / 1024))
|
||||
RESERVED_MB=1024
|
||||
PER_WORKER_MB=256
|
||||
MAX_WORKERS=$(( (AVAIL_MEM_MB - RESERVED_MB) / PER_WORKER_MB ))
|
||||
# 下限 2,上限 8,CPU 核数也作为上限
|
||||
CPU_CORES=$(nproc 2>/dev/null || echo 4)
|
||||
XDIST_WORKERS=$MAX_WORKERS
|
||||
[ $XDIST_WORKERS -lt 2 ] && XDIST_WORKERS=2
|
||||
[ $XDIST_WORKERS -gt 8 ] && XDIST_WORKERS=8
|
||||
[ $XDIST_WORKERS -gt $CPU_CORES ] && XDIST_WORKERS=$CPU_CORES
|
||||
echo "可用内存: ${AVAIL_MEM_MB}MB, CPU核数: ${CPU_CORES}, xdist workers: ${XDIST_WORKERS}"
|
||||
|
||||
# 集成测试使用 pytest-xdist 并行加速(coverage 由单元测试负责,并行模式下 coverage 不稳定)
|
||||
# -n N: 并行 worker 数
|
||||
# --dist loadfile: 同一测试文件分配到同一 worker(共享 fixture 更高效)
|
||||
# --maxfail=3: 容忍少量失败(避免偶发 OOM 导致全挂)
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration \
|
||||
-q --timeout=60 --maxfail=3 --reruns 2 --reruns-delay 1 \
|
||||
-m "not performance" \
|
||||
-n $XDIST_WORKERS --dist loadfile \
|
||||
-p no:cacheprovider
|
||||
|
||||
echo "✅ 集成测试通过"
|
||||
|
||||
# --- API 性能基线测试(仅告警) ---
|
||||
# --- API 性能基线测试(仅告警,串行执行) ---
|
||||
echo ""
|
||||
echo "=== API 性能基线测试(仅告警) ==="
|
||||
set +e
|
||||
PERF_OUTPUT=$(mktemp)
|
||||
# 性能测试单独串行运行(不参与并行,避免资源竞争影响测量结果)
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration/test_api_performance.py \
|
||||
-v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT"
|
||||
echo ""
|
||||
@@ -284,14 +307,29 @@ set -e
|
||||
echo ""
|
||||
echo "=== 清理 ==="
|
||||
if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 清理共享PG上的测试数据库
|
||||
echo "清理共享PG测试数据库: $CI_DB_NAME"
|
||||
# 清理共享PG上的测试数据库(主库 + 可能残留的 worker 库)
|
||||
echo "清理共享PG测试数据库..."
|
||||
|
||||
# 清理所有以 CI_DB_NAME 开头的数据库(主库 + worker 库)
|
||||
PGPASSWORD="${SHARED_PG_PASSWORD}" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
|
||||
# 查找所有需要清理的数据库(主库 + worker 库)
|
||||
cur.execute(\"SELECT datname FROM pg_database WHERE datname LIKE '$CI_DB_NAME%'\")
|
||||
dbs = [row[0] for row in cur.fetchall()]
|
||||
|
||||
for db in dbs:
|
||||
try:
|
||||
# 强制断开所有连接
|
||||
cur.execute(f\"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{db}' AND pid <> pg_backend_pid()\")
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"{db}\" WITH (FORCE)')
|
||||
print(f' 已清理: {db}')
|
||||
except Exception as e:
|
||||
print(f' 警告: 清理 {db} 失败: {e}')
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
" 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)"
|
||||
|
||||
+560
-288
@@ -1,29 +1,75 @@
|
||||
#!/bin/bash
|
||||
# CI Validate Job 主脚本:代码质量全量检查
|
||||
# 包含:密钥扫描、格式检查、类型检查、安全扫描、依赖漏洞检查、死代码检测、Alembic迁移验证
|
||||
# CI Validate Job 主脚本:并行化代码质量检查
|
||||
# 将 8 项检查分为 2 组并行执行,预计耗时从 ~1.8min 降至 ~1min
|
||||
#
|
||||
# 并行分组:
|
||||
# Group A(独立并行):
|
||||
# A1: Secret detection (detect-secrets)
|
||||
# A2: Code quality checks (black/isort/ruff/compileall)
|
||||
# A3: Mypy type check
|
||||
# A4: Advisory checks (bandit + pip-audit + vulture + release scripts syntax)
|
||||
# Group B(PG 依赖,独立并行):
|
||||
# B1: Alembic migrations validation(需要 PG)
|
||||
#
|
||||
# 所有子任务同时启动,最后汇总结果。
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 开始全量代码质量检查 ==="
|
||||
|
||||
# --- 密钥检测 ---
|
||||
echo "=== CI Validate: 并行化代码质量检查 ==="
|
||||
echo ""
|
||||
echo "=== [1/8] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
--all-files \
|
||||
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
|
||||
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
|
||||
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
|
||||
--disable-plugin Base64HighEntropyString \
|
||||
--disable-plugin HexHighEntropyString \
|
||||
--disable-plugin BasicAuthDetector \
|
||||
--disable-plugin KeywordDetector \
|
||||
--disable-plugin IPPublicDetector \
|
||||
> /tmp/secrets-scan.json 2>&1
|
||||
# ============================================================
|
||||
# 配置
|
||||
# ============================================================
|
||||
LOG_DIR="/tmp/validate_logs"
|
||||
rm -rf "$LOG_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
FOUND=$(python3 -c "
|
||||
# 子任务结果文件(每个记录 exit code)
|
||||
RESULT_FILE="$LOG_DIR/results.json"
|
||||
echo '{}' > "$RESULT_FILE"
|
||||
|
||||
# ============================================================
|
||||
# 工具函数
|
||||
# ============================================================
|
||||
|
||||
# 记录子任务结果
|
||||
# 用法: record_result <name> <exit_code> <blocking>
|
||||
record_result() {
|
||||
local name="$1"
|
||||
local exit_code="$2"
|
||||
local blocking="$3" # "yes" or "no"
|
||||
# 写入独立文件,避免并发写 JSON 冲突
|
||||
echo "${exit_code}" > "$LOG_DIR/exit_${name}"
|
||||
echo "${blocking}" > "$LOG_DIR/blocking_${name}"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 子任务定义(每个子任务输出写入独立日志文件)
|
||||
# ============================================================
|
||||
|
||||
# --- A1: Secret detection ---
|
||||
task_secret_detection() {
|
||||
local log="$LOG_DIR/task_secret_detection.log"
|
||||
exec > "$log" 2>&1
|
||||
set +e
|
||||
|
||||
echo "=== [A1] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
--all-files \
|
||||
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
|
||||
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
|
||||
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
|
||||
--disable-plugin Base64HighEntropyString \
|
||||
--disable-plugin HexHighEntropyString \
|
||||
--disable-plugin BasicAuthDetector \
|
||||
--disable-plugin KeywordDetector \
|
||||
--disable-plugin IPPublicDetector \
|
||||
> /tmp/secrets-scan.json 2>&1
|
||||
|
||||
FOUND=$(python3 -c "
|
||||
import json
|
||||
try:
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
@@ -35,11 +81,12 @@ except Exception:
|
||||
print('error')
|
||||
")
|
||||
|
||||
echo "Secrets detected: $FOUND"
|
||||
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
|
||||
echo ""
|
||||
echo "=== Secret details ==="
|
||||
python3 -c "
|
||||
echo "Secrets detected: $FOUND"
|
||||
local exit_code=0
|
||||
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
|
||||
echo ""
|
||||
echo "=== Secret details ==="
|
||||
python3 -c "
|
||||
import json
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
@@ -50,28 +97,37 @@ for fpath, items in data.get('results', {}).items():
|
||||
hashed = item.get('hashed_secret', '')[:16]
|
||||
print(f' {fpath}:{line} [{stype}] {hashed}...')
|
||||
"
|
||||
echo ""
|
||||
echo "ERROR: Potential secrets detected in code!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
echo ""
|
||||
echo "ERROR: Potential secrets detected in code!"
|
||||
exit_code=1
|
||||
else
|
||||
echo "✅ Secret scan passed"
|
||||
fi
|
||||
|
||||
# --- 增量/全量模式判断 ---
|
||||
echo ""
|
||||
echo "=== [2/8] Code quality checks ==="
|
||||
SCAN_MODE="full"
|
||||
CHANGED_PY_FILES=""
|
||||
record_result "secret_detection" "$exit_code" "yes"
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
|
||||
# --- A2: Code quality checks ---
|
||||
task_code_quality() {
|
||||
local log="$LOG_DIR/task_code_quality.log"
|
||||
exec > "$log" 2>&1
|
||||
set +e
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
set -e
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
|
||||
echo "=== [A2] Code quality checks (black/isort/ruff/compileall) ==="
|
||||
|
||||
# --- 增量/全量模式判断 ---
|
||||
local SCAN_MODE="full"
|
||||
local CHANGED_PY_FILES=""
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
@@ -80,160 +136,205 @@ try:
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
if [ -n "$CHANGED_PY_FILES" ]; then
|
||||
SCAN_MODE="incremental"
|
||||
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
|
||||
if [ -n "$CHANGED_PY_FILES" ]; then
|
||||
SCAN_MODE="incremental"
|
||||
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
|
||||
else
|
||||
SCAN_MODE="skip_py"
|
||||
echo "No Python files changed in this PR"
|
||||
fi
|
||||
else
|
||||
SCAN_MODE="skip_py"
|
||||
echo "No Python files changed in this PR"
|
||||
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
|
||||
fi
|
||||
else
|
||||
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
|
||||
echo "Full scan mode (not a PR event)"
|
||||
fi
|
||||
else
|
||||
echo "Full scan mode (not a PR event)"
|
||||
fi
|
||||
|
||||
if [ "$SCAN_MODE" = "incremental" ]; then
|
||||
# 防御性过滤:磁盘上不存在的文件(已删除文件)不参与检查,
|
||||
# 避免 black/isort/ruff 报 "Path does not exist" 错误。
|
||||
EXISTING_PY_FILES=""
|
||||
for f in $CHANGED_PY_FILES; do
|
||||
if [ -f "$f" ]; then
|
||||
if [ -z "$EXISTING_PY_FILES" ]; then
|
||||
EXISTING_PY_FILES="$f"
|
||||
local exit_code=0
|
||||
|
||||
if [ "$SCAN_MODE" = "incremental" ]; then
|
||||
# 防御性过滤:磁盘上不存在的文件(已删除文件)不参与检查
|
||||
local EXISTING_PY_FILES=""
|
||||
for f in $CHANGED_PY_FILES; do
|
||||
if [ -f "$f" ]; then
|
||||
if [ -z "$EXISTING_PY_FILES" ]; then
|
||||
EXISTING_PY_FILES="$f"
|
||||
else
|
||||
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
CHANGED_PY_FILES="$EXISTING_PY_FILES"
|
||||
|
||||
python3 -m compileall -q $CHANGED_PY_FILES || exit_code=$?
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
python3 -m black --check --fast $CHANGED_PY_FILES || exit_code=$?
|
||||
fi
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
python3 -m isort --check-only $CHANGED_PY_FILES || exit_code=$?
|
||||
fi
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
local RUFF_FILES
|
||||
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
|
||||
if [ -n "$RUFF_FILES" ]; then
|
||||
python3 -m ruff check $RUFF_FILES --statistics || exit_code=$?
|
||||
else
|
||||
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
|
||||
echo "No ruff-checkable files changed, skipping"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
CHANGED_PY_FILES="$EXISTING_PY_FILES"
|
||||
|
||||
python3 -m compileall -q $CHANGED_PY_FILES
|
||||
python3 -m black --check --fast $CHANGED_PY_FILES
|
||||
python3 -m isort --check-only $CHANGED_PY_FILES
|
||||
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
|
||||
if [ -n "$RUFF_FILES" ]; then
|
||||
python3 -m ruff check $RUFF_FILES --statistics
|
||||
elif [ "$SCAN_MODE" = "skip_py" ]; then
|
||||
echo "No Python files changed - skipping Python lint checks"
|
||||
else
|
||||
echo "No ruff-checkable files changed, skipping"
|
||||
fi
|
||||
elif [ "$SCAN_MODE" = "skip_py" ]; then
|
||||
echo "No Python files changed - skipping Python lint checks"
|
||||
else
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
fi
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Mypy 类型检查 ---
|
||||
echo ""
|
||||
echo "=== [3/8] Type check (mypy) ==="
|
||||
bash scripts/ci/mypy_check.sh
|
||||
echo "✅ Mypy type check passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [4/8] Security scan (bandit, advisory only) ==="
|
||||
set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
set -e
|
||||
if [ "$BANDIT_EXIT" -ne 0 ]; then
|
||||
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
|
||||
else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [5/8] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [6/8] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q vulture
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
|
||||
# --- Release 脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [7/8] Release scripts syntax validation ==="
|
||||
bash -n scripts/backup_postgres.sh
|
||||
bash -n scripts/restore_postgres_plan.sh
|
||||
bash -n scripts/init_production_env.sh
|
||||
echo "✅ Release scripts syntax OK"
|
||||
|
||||
# --- Alembic 迁移验证 ---
|
||||
echo ""
|
||||
echo "=== [8/8] Alembic migrations validation ==="
|
||||
|
||||
# --- DooD模式检测:确定宿主机访问地址 ---
|
||||
# DooD模式下,docker run启动的容器跑在宿主机Docker上
|
||||
# 需要用宿主机IP访问映射端口
|
||||
# 检测策略:host.docker.internal -> docker0桥接IP -> 容器IP直连 -> 默认网关 -> 127.0.0.1
|
||||
detect_docker_host() {
|
||||
local test_port="${1:-5432}"
|
||||
|
||||
# 候选IP列表
|
||||
local candidates=()
|
||||
|
||||
# 1. host.docker.internal(runner配置了--add-host时可用)
|
||||
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
|
||||
candidates+=("host.docker.internal")
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts || exit_code=$?
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
python3 -m black --check --fast alembic apps packages tests scripts || exit_code=$?
|
||||
fi
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
python3 -m isort --check-only alembic apps packages tests scripts || exit_code=$?
|
||||
fi
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
python3 -m ruff check apps packages tests --statistics || exit_code=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. docker0 桥接网关 (172.17.0.1)
|
||||
candidates+=("172.17.0.1")
|
||||
|
||||
# 3. 默认网关(容器网络的网关即宿主机)
|
||||
local gw=""
|
||||
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
|
||||
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
|
||||
candidates+=("$gw")
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "✅ Code quality checks passed"
|
||||
else
|
||||
echo "❌ Code quality checks FAILED"
|
||||
fi
|
||||
|
||||
# 4. 宿主机可能的IP:容器同网段的.1或.254
|
||||
local my_ip=""
|
||||
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$my_ip" ]; then
|
||||
# 尝试同网段的常见宿主机IP
|
||||
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
|
||||
candidates+=("${subnet}.1")
|
||||
candidates+=("${subnet}.254")
|
||||
record_result "code_quality" "$exit_code" "yes"
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
# --- A3: Mypy type check ---
|
||||
task_mypy() {
|
||||
local log="$LOG_DIR/task_mypy.log"
|
||||
exec > "$log" 2>&1
|
||||
set +e
|
||||
|
||||
echo "=== [A3] Type check (mypy) ==="
|
||||
bash scripts/ci/mypy_check.sh
|
||||
local exit_code=$?
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "✅ Mypy type check passed"
|
||||
else
|
||||
echo "❌ Mypy type check FAILED"
|
||||
fi
|
||||
|
||||
# 5. 127.0.0.1 最后尝试
|
||||
candidates+=("127.0.0.1")
|
||||
record_result "mypy" "$exit_code" "yes"
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
# 测试每个候选IP
|
||||
for candidate in "${candidates[@]}"; do
|
||||
if python3 -c "
|
||||
# --- A4: Advisory checks (bandit + pip-audit + vulture + release scripts syntax) ---
|
||||
task_advisory() {
|
||||
local log="$LOG_DIR/task_advisory.log"
|
||||
exec > "$log" 2>&1
|
||||
set +e
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo "=== [A4a] Security scan (bandit, advisory only) ==="
|
||||
bandit -r apps packages -q -ll
|
||||
local BANDIT_EXIT=$?
|
||||
if [ "$BANDIT_EXIT" -ne 0 ]; then
|
||||
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
|
||||
else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [A4b] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || true
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [A4c] Dead code detection (vulture, advisory only) ==="
|
||||
python3 -m pip install -q vulture
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
|
||||
# --- Release 脚本语法校验(不阻断) ---
|
||||
echo ""
|
||||
echo "=== [A4d] Release scripts syntax validation ==="
|
||||
local syntax_exit=0
|
||||
bash -n scripts/backup_postgres.sh || syntax_exit=$?
|
||||
bash -n scripts/restore_postgres_plan.sh || syntax_exit=$?
|
||||
bash -n scripts/init_production_env.sh || syntax_exit=$?
|
||||
if [ $syntax_exit -eq 0 ]; then
|
||||
echo "✅ Release scripts syntax OK"
|
||||
else
|
||||
echo "⚠️ Release scripts have syntax issues (advisory)"
|
||||
fi
|
||||
|
||||
# Advisory checks never block
|
||||
record_result "advisory" 0 "no"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- B1: Alembic migrations validation (needs PG) ---
|
||||
task_alembic() {
|
||||
local log="$LOG_DIR/task_alembic.log"
|
||||
exec > "$log" 2>&1
|
||||
set +e
|
||||
|
||||
echo "=== [B1] Alembic migrations validation ==="
|
||||
|
||||
# --- DooD模式检测:确定宿主机访问地址 ---
|
||||
detect_docker_host() {
|
||||
local test_port="${1:-5432}"
|
||||
local candidates=()
|
||||
|
||||
# 1. host.docker.internal
|
||||
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
|
||||
candidates+=("host.docker.internal")
|
||||
fi
|
||||
|
||||
# 2. docker0 桥接网关 (172.17.0.1)
|
||||
candidates+=("172.17.0.1")
|
||||
|
||||
# 3. 默认网关
|
||||
local gw=""
|
||||
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
|
||||
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
|
||||
candidates+=("$gw")
|
||||
fi
|
||||
|
||||
# 4. 宿主机可能的IP
|
||||
local my_ip=""
|
||||
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$my_ip" ]; then
|
||||
local subnet
|
||||
subnet=$(echo "$my_ip" | cut -d. -f1-3)
|
||||
candidates+=("${subnet}.1")
|
||||
candidates+=("${subnet}.254")
|
||||
fi
|
||||
|
||||
# 5. 127.0.0.1
|
||||
candidates+=("127.0.0.1")
|
||||
|
||||
for candidate in "${candidates[@]}"; do
|
||||
if python3 -c "
|
||||
import socket
|
||||
s = socket.socket()
|
||||
s.settimeout(2)
|
||||
@@ -244,87 +345,87 @@ try:
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null | grep -q ok; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "127.0.0.1"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 指数退避TCP连接检查函数
|
||||
wait_tcp_ready() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local max_attempts="${3:-5}"
|
||||
local delay=1
|
||||
local attempt=1
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
|
||||
sleep "$delay"
|
||||
delay=$((delay * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取宿主机IP
|
||||
local PG_HOST
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
PG_HOST=$(detect_docker_host 5433)
|
||||
if [ "$PG_HOST" = "127.0.0.1" ]; then
|
||||
PG_HOST=$(detect_docker_host 22)
|
||||
fi
|
||||
done
|
||||
|
||||
# 都失败则返回127.0.0.1
|
||||
echo "127.0.0.1"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取宿主机IP(先尝试用共享PG端口5433测试,再回退到其他端口)
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
# 先用共享PG端口5433探测
|
||||
DOCKER_HOST_IP=$(detect_docker_host 5433)
|
||||
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
|
||||
# 如果共享PG端口探测失败,说明不在DooD或共享PG不可用,再试其他端口
|
||||
DOCKER_HOST_IP=$(detect_docker_host 22)
|
||||
echo "检测到DooD模式(/var/run/docker.sock已挂载),宿主机地址: $PG_HOST"
|
||||
else
|
||||
PG_HOST="127.0.0.1"
|
||||
echo "非DooD模式,使用 127.0.0.1"
|
||||
fi
|
||||
echo "检测到DooD模式(/var/run/docker.sock已挂载),宿主机地址: $DOCKER_HOST_IP"
|
||||
else
|
||||
DOCKER_HOST_IP="127.0.0.1"
|
||||
echo "非DooD模式,使用 127.0.0.1"
|
||||
fi
|
||||
PG_HOST="$DOCKER_HOST_IP"
|
||||
echo "PG host: $PG_HOST"
|
||||
echo "PG host: $PG_HOST"
|
||||
|
||||
# 指数退避TCP连接检查函数
|
||||
# 用法: wait_tcp_ready host port max_attempts
|
||||
wait_tcp_ready() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local max_attempts="${3:-5}"
|
||||
local delay=1
|
||||
local attempt=1
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
|
||||
sleep "$delay"
|
||||
delay=$((delay * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
local USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
|
||||
local exit_code=0
|
||||
|
||||
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
|
||||
if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 使用常驻共享PG实例
|
||||
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
|
||||
local SHARED_PG_HOST="$PG_HOST"
|
||||
local SHARED_PG_PORT="5433"
|
||||
local SHARED_PG_USER="postgres"
|
||||
local SHARED_PG_PASSWORD="ci_pg_2026!"
|
||||
local CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
|
||||
|
||||
if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 使用常驻共享PG实例(host.docker.internal:5433)
|
||||
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
|
||||
SHARED_PG_HOST="$PG_HOST"
|
||||
SHARED_PG_PORT="5433"
|
||||
SHARED_PG_USER="postgres"
|
||||
SHARED_PG_PASSWORD="ci_pg_2026!"
|
||||
CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
|
||||
echo "等待共享PG连接就绪..."
|
||||
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
|
||||
|
||||
echo "等待共享PG连接就绪..."
|
||||
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
|
||||
|
||||
# 创建独立数据库
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
|
||||
cur.close()
|
||||
conn.close()
|
||||
"
|
||||
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
|
||||
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
|
||||
" || exit_code=$?
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
|
||||
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
|
||||
|
||||
# 清理数据库
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head || exit_code=$?
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
fi
|
||||
|
||||
# 清理数据库
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
@@ -333,49 +434,220 @@ cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
cur.close()
|
||||
conn.close()
|
||||
" 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)"
|
||||
echo "✅ 共享PG数据库已清理"
|
||||
else
|
||||
# 使用临时PG容器(默认模式)
|
||||
echo "使用临时PG容器模式"
|
||||
PG_CONTAINER=ci-pg-validate-${GITHUB_RUN_ID:-$$}
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
--shm-size=256m \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=xiaoxia_saas \
|
||||
-P \
|
||||
--health-cmd "pg_isready -U postgres" \
|
||||
--health-interval 3s \
|
||||
--health-timeout 3s \
|
||||
--health-retries 20 \
|
||||
postgres:16-alpine
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas"
|
||||
|
||||
# 等待容器健康
|
||||
for i in $(seq 1 30); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "PostgreSQL container is healthy on port $PG_PORT"
|
||||
break
|
||||
echo "✅ 共享PG数据库已清理"
|
||||
fi
|
||||
echo "Waiting for PostgreSQL container health... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
|
||||
|
||||
# TCP连通性检查(指数退避)
|
||||
echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..."
|
||||
wait_tcp_ready "$PG_HOST" "$PG_PORT" 5
|
||||
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
|
||||
else
|
||||
# 使用临时PG容器
|
||||
echo "使用临时PG容器模式"
|
||||
local PG_CONTAINER="ci-pg-validate-${GITHUB_RUN_ID:-$$}"
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
--shm-size=256m \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=xiaoxia_saas \
|
||||
-P \
|
||||
--health-cmd "pg_isready -U postgres" \
|
||||
--health-interval 3s \
|
||||
--health-timeout 3s \
|
||||
--health-retries 20 \
|
||||
postgres:16-alpine || exit_code=$?
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
local PG_PORT
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas"
|
||||
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
fi
|
||||
# 等待容器健康
|
||||
local i
|
||||
for i in $(seq 1 30); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "PostgreSQL container is healthy on port $PG_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for PostgreSQL container health... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if ! docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "❌ PostgreSQL container failed health check"
|
||||
exit_code=1
|
||||
else
|
||||
# TCP连通性检查
|
||||
echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..."
|
||||
if wait_tcp_ready "$PG_HOST" "$PG_PORT" 5; then
|
||||
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head || exit_code=$?
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
fi
|
||||
else
|
||||
echo "❌ TCP connectivity to PostgreSQL failed"
|
||||
exit_code=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 清理
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "✅ Alembic migrations validation passed"
|
||||
else
|
||||
echo "❌ Alembic migrations validation FAILED"
|
||||
fi
|
||||
|
||||
record_result "alembic" "$exit_code" "yes"
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 主流程:并行启动所有子任务
|
||||
# ============================================================
|
||||
|
||||
echo "启动并行检查(5 个子任务同时运行)..."
|
||||
echo ""
|
||||
|
||||
# 记录开始时间
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
# 启动所有子任务(后台运行)
|
||||
task_secret_detection &
|
||||
PID_A1=$!
|
||||
|
||||
task_code_quality &
|
||||
PID_A2=$!
|
||||
|
||||
task_mypy &
|
||||
PID_A3=$!
|
||||
|
||||
task_advisory &
|
||||
PID_A4=$!
|
||||
|
||||
task_alembic &
|
||||
PID_B1=$!
|
||||
|
||||
echo "子任务 PID: A1=$PID_A1 A2=$PID_A2 A3=$PID_A3 A4=$PID_A4 B1=$PID_B1"
|
||||
echo ""
|
||||
|
||||
# 等待所有后台任务完成(不因单个失败而中断)
|
||||
# 使用 set +e 临时取消 errexit
|
||||
set +e
|
||||
wait $PID_A1; EXIT_A1=$?
|
||||
wait $PID_A2; EXIT_A2=$?
|
||||
wait $PID_A3; EXIT_A3=$?
|
||||
wait $PID_A4; EXIT_A4=$?
|
||||
wait $PID_B1; EXIT_B1=$?
|
||||
set -e
|
||||
|
||||
# 计算耗时
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
# ============================================================
|
||||
# 结果汇总
|
||||
# ============================================================
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 所有检查通过 ✅ ==="
|
||||
echo "============================================"
|
||||
echo " CI Validate 结果汇总(耗时 ${ELAPSED}s)"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 定义任务信息:名称 | PID | 退出码 | 描述 | 是否阻断
|
||||
declare -A TASK_DESC
|
||||
TASK_DESC[A1]="Secret detection"
|
||||
TASK_DESC[A2]="Code quality (black/isort/ruff)"
|
||||
TASK_DESC[A3]="Mypy type check"
|
||||
TASK_DESC[A4]="Advisory (bandit/pip-audit/vulture/syntax)"
|
||||
TASK_DESC[B1]="Alembic migrations"
|
||||
|
||||
declare -A TASK_PID
|
||||
TASK_PID[A1]=$PID_A1
|
||||
TASK_PID[A2]=$PID_A2
|
||||
TASK_PID[A3]=$PID_A3
|
||||
TASK_PID[A4]=$PID_A4
|
||||
TASK_PID[B1]=$PID_B1
|
||||
|
||||
declare -A TASK_EXIT
|
||||
TASK_EXIT[A1]=$EXIT_A1
|
||||
TASK_EXIT[A2]=$EXIT_A2
|
||||
TASK_EXIT[A3]=$EXIT_A3
|
||||
TASK_EXIT[A4]=$EXIT_A4
|
||||
TASK_EXIT[B1]=$EXIT_B1
|
||||
|
||||
declare -A TASK_LOG
|
||||
TASK_LOG[A1]="task_secret_detection"
|
||||
TASK_LOG[A2]="task_code_quality"
|
||||
TASK_LOG[A3]="task_mypy"
|
||||
TASK_LOG[A4]="task_advisory"
|
||||
TASK_LOG[B1]="task_alembic"
|
||||
|
||||
declare -A TASK_BLOCKING
|
||||
TASK_BLOCKING[A1]="yes"
|
||||
TASK_BLOCKING[A2]="yes"
|
||||
TASK_BLOCKING[A3]="yes"
|
||||
TASK_BLOCKING[A4]="no"
|
||||
TASK_BLOCKING[B1]="yes"
|
||||
|
||||
OVERALL_EXIT=0
|
||||
FAILED_TASKS=()
|
||||
|
||||
# 按固定顺序打印摘要
|
||||
for task_id in A1 A2 A3 A4 B1; do
|
||||
local_exit=${TASK_EXIT[$task_id]}
|
||||
local_desc=${TASK_DESC[$task_id]}
|
||||
local_blocking=${TASK_BLOCKING[$task_id]}
|
||||
|
||||
if [ "$local_exit" -eq 0 ]; then
|
||||
echo " ✅ $task_id: $local_desc — PASSED"
|
||||
else
|
||||
if [ "$local_blocking" = "yes" ]; then
|
||||
echo " ❌ $task_id: $local_desc — FAILED (blocking)"
|
||||
OVERALL_EXIT=1
|
||||
FAILED_TASKS+=("$task_id")
|
||||
else
|
||||
echo " ⚠️ $task_id: $local_desc — FAILED (advisory, not blocking)"
|
||||
# Advisory tasks don't cause overall failure
|
||||
if [ "$local_blocking" = "no" ]; then
|
||||
echo " → 告警类检查,不阻断流水线"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# 打印失败任务的完整日志
|
||||
if [ ${#FAILED_TASKS[@]} -gt 0 ]; then
|
||||
echo "============================================"
|
||||
echo " 失败任务详细日志"
|
||||
echo "============================================"
|
||||
for task_id in "${FAILED_TASKS[@]}"; do
|
||||
local_log="${TASK_LOG[$task_id]}"
|
||||
local_desc="${TASK_DESC[$task_id]}"
|
||||
echo ""
|
||||
echo "--- $task_id: $local_desc ---"
|
||||
if [ -f "$LOG_DIR/${local_log}.log" ]; then
|
||||
cat "$LOG_DIR/${local_log}.log"
|
||||
else
|
||||
echo "(日志文件不存在)"
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
fi
|
||||
|
||||
# 最终结论
|
||||
echo ""
|
||||
if [ $OVERALL_EXIT -eq 0 ]; then
|
||||
echo "=== CI Validate: 所有检查通过 ✅ (并行耗时 ${ELAPSED}s) ==="
|
||||
else
|
||||
echo "=== CI Validate: 存在阻断性检查失败 ❌ (并行耗时 ${ELAPSED}s) ==="
|
||||
fi
|
||||
|
||||
exit $OVERALL_EXIT
|
||||
|
||||
@@ -1,24 +1,96 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端依赖安装(在 docker node 容器中运行)
|
||||
# 用法:step_frontend_install.sh [模式]
|
||||
# 模式: full (默认) - 完整安装所有依赖
|
||||
# vitest - 同full(保持接口兼容)
|
||||
# CI 公共步骤:前端依赖安装(Docker Volume 持久化缓存方案)
|
||||
# 通过 Docker named volume 缓存 node_modules,按 package-lock.json hash 命名
|
||||
# 缓存命中时跳过 npm ci,直接复用已有 volume
|
||||
set -eu
|
||||
|
||||
MODE="${1:-full}"
|
||||
|
||||
echo "=== 前端依赖安装开始 (模式: $MODE) ==="
|
||||
|
||||
# npm ci 带重试(网络不稳定时自动重试)
|
||||
for i in 1 2 3; do
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc "npm ci --no-audit --no-fund" && break
|
||||
echo "npm ci 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
# npm国内镜像源(加速下载,减少网络失败)
|
||||
NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
|
||||
# 缓存配置 — 与 step_frontend_run.sh 保持一致
|
||||
LOCK_FILE="apps/web/package-lock.json"
|
||||
VOLUME_PREFIX="ci-web-nm-"
|
||||
KEEP_CACHE_COUNT=5
|
||||
|
||||
# 计算 package-lock.json 的 md5 hash 作为缓存 key
|
||||
VOLUME_NAME=""
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
LOCK_HASH=$(md5sum "$LOCK_FILE" | cut -c1-12)
|
||||
VOLUME_NAME="${VOLUME_PREFIX}${LOCK_HASH}"
|
||||
echo "缓存 key: $LOCK_HASH (volume: $VOLUME_NAME)"
|
||||
else
|
||||
echo "警告: 未找到 $LOCK_FILE,将不使用持久化缓存"
|
||||
fi
|
||||
|
||||
# 检查 volume 是否存在(缓存命中)
|
||||
CACHE_HIT=0
|
||||
if [ -n "$VOLUME_NAME" ]; then
|
||||
if docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
CACHE_HIT=1
|
||||
echo "缓存命中!复用 volume: $VOLUME_NAME"
|
||||
else
|
||||
echo "缓存未命中,创建 volume 并安装依赖..."
|
||||
# 创建 volume(失败则降级为无缓存模式)
|
||||
if ! docker volume create "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
echo "警告: 创建 volume 失败,降级为无缓存模式"
|
||||
VOLUME_NAME=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 构建 docker run 的 volume 挂载参数(空时不挂载)
|
||||
VOLUME_ARGS=""
|
||||
if [ -n "$VOLUME_NAME" ]; then
|
||||
VOLUME_ARGS="-v ${VOLUME_NAME}:/workspace/apps/web/node_modules"
|
||||
fi
|
||||
|
||||
# 缓存未命中时执行 npm ci
|
||||
if [ "$CACHE_HIT" -eq 0 ]; then
|
||||
for i in 1 2 3; do
|
||||
echo "npm ci 尝试 $i/3 (镜像: $NPM_REGISTRY)"
|
||||
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break
|
||||
echo "npm ci 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
done
|
||||
else
|
||||
echo "跳过 npm ci,直接使用缓存的 node_modules"
|
||||
fi
|
||||
|
||||
# 清理旧缓存 volume(保留最近 N 个,防止磁盘占用无限增长)
|
||||
if [ -n "$VOLUME_PREFIX" ]; then
|
||||
echo "清理旧缓存 volume(保留最近 ${KEEP_CACHE_COUNT} 个)..."
|
||||
ALL_VOLUMES=$(docker volume ls -q --filter "name=${VOLUME_PREFIX}" 2>/dev/null || true)
|
||||
if [ -n "$ALL_VOLUMES" ]; then
|
||||
TOTAL=$(echo "$ALL_VOLUMES" | wc -l)
|
||||
if [ "$TOTAL" -gt "$KEEP_CACHE_COUNT" ]; then
|
||||
# 按创建时间排序,保留最新的 N 个
|
||||
SORTED_VOLUMES=$(for v in $ALL_VOLUMES; do
|
||||
CREATED=$(docker volume inspect --format '{{.CreatedAt}}' "$v" 2>/dev/null || echo "0")
|
||||
echo "$CREATED $v"
|
||||
done | sort | awk '{print $2}')
|
||||
|
||||
# 删除超出保留数量的旧 volume
|
||||
REMOVE_COUNT=$((TOTAL - KEEP_CACHE_COUNT))
|
||||
TO_DELETE=$(echo "$SORTED_VOLUMES" | head -n "$REMOVE_COUNT")
|
||||
REMOVED=0
|
||||
for v in $TO_DELETE; do
|
||||
# 跳过当前正在使用的 volume
|
||||
if [ "$v" != "$VOLUME_NAME" ]; then
|
||||
if docker volume rm "$v" >/dev/null 2>&1; then
|
||||
REMOVED=$((REMOVED + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "已清理 $REMOVED 个旧缓存 volume,当前共 $((TOTAL - REMOVED)) 个"
|
||||
else
|
||||
echo "当前缓存 volume 数量: $TOTAL,无需清理"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== 前端依赖安装完成 ==="
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端命令执行(在 docker node 容器中运行)
|
||||
# 用法:step_frontend_run.sh "要执行的命令"
|
||||
# 支持 Docker Volume 持久化缓存的 node_modules
|
||||
set -eu
|
||||
|
||||
CMD="${1:-echo 'no command'}"
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc "$CMD"
|
||||
# 缓存配置 — 与 step_frontend_install.sh 保持一致
|
||||
LOCK_FILE="apps/web/package-lock.json"
|
||||
VOLUME_PREFIX="ci-web-nm-"
|
||||
|
||||
# 计算 package-lock.json 的 hash,挂载对应的 volume
|
||||
VOLUME_ARGS=""
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
LOCK_HASH=$(md5sum "$LOCK_FILE" | cut -c1-12)
|
||||
VOLUME_NAME="${VOLUME_PREFIX}${LOCK_HASH}"
|
||||
if docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
VOLUME_ARGS="-v ${VOLUME_NAME}:/workspace/apps/web/node_modules"
|
||||
echo "使用缓存 volume: $VOLUME_NAME"
|
||||
else
|
||||
echo "提示: 未找到缓存 volume $VOLUME_NAME,将使用源码目录 node_modules"
|
||||
fi
|
||||
fi
|
||||
|
||||
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "$CMD"
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
# CI 公共步骤:Job 开始计时
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
echo "Job started at $(date)"
|
||||
# trigger CI run for PR validation
|
||||
# trigger CI - worker dood fallback fix test
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
|
||||
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码质量与安全扫描 ==="
|
||||
|
||||
# --- 密钥检测 ---
|
||||
echo ""
|
||||
echo "=== [1/6] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
--all-files \
|
||||
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
|
||||
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
|
||||
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
|
||||
--disable-plugin Base64HighEntropyString \
|
||||
--disable-plugin HexHighEntropyString \
|
||||
--disable-plugin BasicAuthDetector \
|
||||
--disable-plugin KeywordDetector \
|
||||
--disable-plugin IPPublicDetector \
|
||||
> /tmp/secrets-scan.json 2>&1
|
||||
|
||||
FOUND=$(python3 -c "
|
||||
import json
|
||||
try:
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
results = data.get('results', {})
|
||||
total = sum(len(v) for v in results.values())
|
||||
print(total)
|
||||
except Exception:
|
||||
print('error')
|
||||
")
|
||||
|
||||
echo "Secrets detected: $FOUND"
|
||||
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
|
||||
echo ""
|
||||
echo "=== Secret details ==="
|
||||
python3 -c "
|
||||
import json
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
for fpath, items in data.get('results', {}).items():
|
||||
for item in items:
|
||||
line = item.get('line_number', '?')
|
||||
stype = item.get('type', '?')
|
||||
hashed = item.get('hashed_secret', '')[:16]
|
||||
print(f' {fpath}:{line} [{stype}] {hashed}...')
|
||||
"
|
||||
echo ""
|
||||
echo "ERROR: Potential secrets detected in code!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 增量/全量模式判断 ---
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks ==="
|
||||
SCAN_MODE="full"
|
||||
CHANGED_PY_FILES=""
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
|
||||
set +e
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
set -e
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed']
|
||||
print(' '.join(py_files))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
if [ -n "$CHANGED_PY_FILES" ]; then
|
||||
SCAN_MODE="incremental"
|
||||
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
|
||||
else
|
||||
SCAN_MODE="skip_py"
|
||||
echo "No Python files changed in this PR"
|
||||
fi
|
||||
else
|
||||
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
|
||||
fi
|
||||
else
|
||||
echo "Full scan mode (not a PR event)"
|
||||
fi
|
||||
|
||||
if [ "$SCAN_MODE" = "incremental" ]; then
|
||||
# 防御性过滤
|
||||
EXISTING_PY_FILES=""
|
||||
for f in $CHANGED_PY_FILES; do
|
||||
if [ -f "$f" ]; then
|
||||
if [ -z "$EXISTING_PY_FILES" ]; then
|
||||
EXISTING_PY_FILES="$f"
|
||||
else
|
||||
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
CHANGED_PY_FILES="$EXISTING_PY_FILES"
|
||||
|
||||
python3 -m compileall -q $CHANGED_PY_FILES
|
||||
python3 -m black --check --fast $CHANGED_PY_FILES
|
||||
python3 -m isort --check-only $CHANGED_PY_FILES
|
||||
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
|
||||
if [ -n "$RUFF_FILES" ]; then
|
||||
python3 -m ruff check $RUFF_FILES --statistics
|
||||
else
|
||||
echo "No ruff-checkable files changed, skipping"
|
||||
fi
|
||||
elif [ "$SCAN_MODE" = "skip_py" ]; then
|
||||
echo "No Python files changed - skipping Python lint checks"
|
||||
else
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
fi
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [3/6] Security scan (bandit, advisory only) ==="
|
||||
set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
set -e
|
||||
if [ "$BANDIT_EXIT" -ne 0 ]; then
|
||||
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
|
||||
else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q vulture
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
|
||||
# --- Release 脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [6/6] Release scripts syntax validation ==="
|
||||
bash -n scripts/backup_postgres.sh
|
||||
bash -n scripts/restore_postgres_plan.sh
|
||||
bash -n scripts/init_production_env.sh
|
||||
echo "✅ Release scripts syntax OK"
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: Alembic迁移验证(并行Job 3/3)
|
||||
# 需要PostgreSQL数据库
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: Alembic迁移验证 ==="
|
||||
|
||||
# --- DooD模式检测:确定宿主机访问地址 ---
|
||||
detect_docker_host() {
|
||||
local test_port="${1:-5432}"
|
||||
|
||||
local candidates=()
|
||||
|
||||
# 1. host.docker.internal
|
||||
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
|
||||
candidates+=("host.docker.internal")
|
||||
fi
|
||||
|
||||
# 2. docker0 桥接网关
|
||||
candidates+=("172.17.0.1")
|
||||
|
||||
# 3. 默认网关
|
||||
local gw=""
|
||||
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
|
||||
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
|
||||
candidates+=("$gw")
|
||||
fi
|
||||
|
||||
# 4. 宿主机同网段的.1或.254
|
||||
local my_ip=""
|
||||
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$my_ip" ]; then
|
||||
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
|
||||
candidates+=("${subnet}.1")
|
||||
candidates+=("${subnet}.254")
|
||||
fi
|
||||
|
||||
# 5. 127.0.0.1 最后尝试
|
||||
candidates+=("127.0.0.1")
|
||||
|
||||
for candidate in "${candidates[@]}"; do
|
||||
if python3 -c "
|
||||
import socket
|
||||
s = socket.socket()
|
||||
s.settimeout(2)
|
||||
try:
|
||||
s.connect(('$candidate', $test_port))
|
||||
s.close()
|
||||
print('ok')
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null | grep -q ok; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "127.0.0.1"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取宿主机IP
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_HOST_IP=$(detect_docker_host 5433)
|
||||
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
|
||||
DOCKER_HOST_IP=$(detect_docker_host 22)
|
||||
fi
|
||||
echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP"
|
||||
else
|
||||
DOCKER_HOST_IP="127.0.0.1"
|
||||
echo "非DooD模式,使用 127.0.0.1"
|
||||
fi
|
||||
PG_HOST="$DOCKER_HOST_IP"
|
||||
echo "PG host: $PG_HOST"
|
||||
|
||||
# 指数退避TCP连接检查
|
||||
wait_tcp_ready() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local max_attempts="${3:-5}"
|
||||
local delay=1
|
||||
local attempt=1
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
|
||||
sleep "$delay"
|
||||
delay=$((delay * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
|
||||
|
||||
if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 使用常驻共享PG实例
|
||||
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
|
||||
SHARED_PG_HOST="$PG_HOST"
|
||||
SHARED_PG_PORT="5433"
|
||||
SHARED_PG_USER="postgres"
|
||||
SHARED_PG_PASSWORD="ci_pg_2026!"
|
||||
CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
|
||||
|
||||
echo "等待共享PG连接就绪..."
|
||||
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
|
||||
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
|
||||
cur.close()
|
||||
conn.close()
|
||||
"
|
||||
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
|
||||
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
|
||||
# 清理数据库
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
cur.close()
|
||||
conn.close()
|
||||
" 2>/dev/null || echo "WARN: 数据库清理失败"
|
||||
echo "✅ 共享PG数据库已清理"
|
||||
else
|
||||
# 使用临时PG容器(默认模式)
|
||||
echo "使用临时PG容器模式"
|
||||
PG_CONTAINER=ci-pg-validate-migration-${GITHUB_RUN_ID:-$$}
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
--shm-size=256m \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=xiaoxia_saas \
|
||||
-P \
|
||||
--health-cmd "pg_isready -U postgres" \
|
||||
--health-interval 3s \
|
||||
--health-timeout 3s \
|
||||
--health-retries 20 \
|
||||
postgres:16-alpine
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas"
|
||||
|
||||
# 等待容器健康
|
||||
for i in $(seq 1 30); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "PostgreSQL container is healthy on port $PG_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for PostgreSQL container health... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
|
||||
|
||||
# TCP连通性检查
|
||||
echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..."
|
||||
wait_tcp_ready "$PG_HOST" "$PG_PORT" 5
|
||||
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: Alembic迁移验证 通过 ✅ ==="
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user