Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67bb896c6c | |||
| 65d58323f7 | |||
| fec660bf13 | |||
| 79390b8d87 | |||
| 7e62d886de | |||
| 0a4b753551 |
Executable
+174
@@ -0,0 +1,174 @@
|
||||
name: Auto Approve CI PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 120); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review(Gitea API需要先创建再提交)
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 还有CI在跑(pending状态)→ 继续等
|
||||
if [ "$ANY_PENDING" = "true" ]; then
|
||||
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
# 所有CI都跑完了但有失败 → 退出
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 其他情况继续等
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
name: Auto Merge CI PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
|
||||
- name: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合develop分支
|
||||
if [ "$BASE_REF" != "develop" ]; then
|
||||
echo "Skip: 目标分支不是develop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 判断是否纯前端改动
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
|
||||
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
echo "纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / Integration Tests (pull_request)"
|
||||
)
|
||||
echo "检查全部四门禁"
|
||||
fi
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 405连续计数器:连续多次合并返回405才放弃
|
||||
MERGE_405_COUNT=0
|
||||
MAX_405_RETRIES=10
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 180); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查审批状态
|
||||
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
|
||||
echo
|
||||
echo "CI全绿 + 审批通过,执行自动合并"
|
||||
echo "等待60秒冷却,给Gitea内部状态同步时间..."
|
||||
sleep 60
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行squash merge
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
||||
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
echo
|
||||
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
||||
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
continue
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# 本轮不满足合并条件,重置405计数器
|
||||
MERGE_405_COUNT=0
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
exit 0
|
||||
File diff suppressed because one or more lines are too long
Executable
+640
@@ -0,0 +1,640 @@
|
||||
name: CI/CD Pipeline
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "触发原因"
|
||||
required: false
|
||||
default: "手动触发 - CI漏触发补跑"
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request'
|
||||
outputs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "skip_backend=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then
|
||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_frontend=true" >> $GITHUB_OUTPUT
|
||||
echo "🔧 纯后端改动,跳过前端检查"
|
||||
else
|
||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||
echo "🔧 包含全栈变更,运行完整CI"
|
||||
fi
|
||||
|
||||
validate:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: ci-check
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
|
||||
echo "Job started at $(date)"
|
||||
|
||||
'
|
||||
- name: Verify CI environment
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 --version
|
||||
|
||||
python3 -m pip --version
|
||||
|
||||
echo "CI environment is ready"
|
||||
|
||||
'
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
|
||||
# Force source install of black/isort to ensure consistent formatting
|
||||
# across compiled/source installations on different machines
|
||||
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1
|
||||
|
||||
python3 -m black --version
|
||||
|
||||
python3 -m isort --version-number
|
||||
|
||||
python3 -m ruff --version
|
||||
|
||||
bandit --version
|
||||
|
||||
pytest --version
|
||||
|
||||
'
|
||||
- name: Secret detection (detect-secrets)
|
||||
shell: sh
|
||||
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n > /tmp/secrets-scan.json 2>&1\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
|
||||
\ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n"
|
||||
- name: Calculate changed Python files (incremental scan)
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_REF_NAME:-}\" ]; then\n echo \"PR mode (#${GITHUB_REF_NAME}) - fetching changed files from API\"\n\n PR_NUMBER=$(echo \"$GITHUB_REF\" | sed 's|refs/pull/||; s|/.*||')\n API_URL=\"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100\"\n\n set +e\n RESPONSE=$(curl -s -w \"\\n%{http_code}\" -H \"Authorization: token ${GITHUB_TOKEN}\" \"${API_URL}\")\n HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n BODY=$(echo \"$RESPONSE\" | sed '$d')\n set -e\n\n if [ \"$HTTP_CODE\" = \"200\" ]; then\n CHANGED_PY_FILES=$(echo \"$BODY\" | python3 -c \"\nimport json, sys\ntry:\n files = json.load(sys.stdin)\n py_files = [f['filename'] for f in files\n if f['filename'].endswith('.py') and f['status'] != 'removed']\n print(' '.join(py_files))\nexcept Exception:\n print('')\n\")\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: API returned HTTP $HTTP_CODE, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n"
|
||||
- name: Run code quality checks
|
||||
shell: sh
|
||||
run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n"
|
||||
- name: Auto-fix formatting (black + isort)
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/ci/auto_fix_formatting.py
|
||||
- name: Type check (mypy, hard gate)
|
||||
|
||||
shell: sh
|
||||
run: "bash scripts/ci/mypy_check.sh"
|
||||
- name: Run security scan (bandit)
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
bandit -r apps packages -q -ll
|
||||
|
||||
'
|
||||
- name: Python dependency vulnerability scan (pip-audit)
|
||||
shell: sh
|
||||
run: "set -eu\necho \"=== Installing pip-audit ===\"\npython3 -m pip install -q pip-audit\npip-audit --version\necho \"\"\necho \"=== Scanning Python dependencies ===\"\nEXIT_CODE=0\nfor req_file in requirements.txt requirements-base.txt requirements-dev.txt; do\n if [ -f \"$req_file\" ]; then\n echo \"--- Scanning $req_file ---\"\n pip-audit -r \"$req_file\" --desc on 2>&1 | head -40 || EXIT_CODE=$?\n echo \"\"\n fi\ndone\necho \"pip-audit scan completed (advisory mode - warnings only, not blocking CI)\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"WARNING: Potential vulnerabilities found in dependencies.\"\nfi\nexit 0\n"
|
||||
- name: Dead code detection (vulture)
|
||||
if: always()
|
||||
shell: sh
|
||||
run: "set +e\necho \"=== Installing vulture ===\"\npython3 -m pip install -q vulture\nvulture --version\necho \"\"\necho \"=== Running vulture dead code scan (confidence >= 70%) ===\"\necho \"告警模式,不阻断CI。置信度>=90%建议尽快确认。\"\necho \"\"\n# 按置信度从高到低输出,便于优先查看高价值条目\nvulture apps packages scripts \\\n --exclude \"tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py\" \\\n --min-confidence 70 \\\n 2>&1 | sort -t'(' -k2 -rn | head -80\nEXIT_CODE=$?\necho \"\"\necho \"=== vulture scan summary ===\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"发现潜在死代码(可能包含框架装饰器注册的函数,为误报)\"\n echo \"建议:定期人工审查高置信度(>=90%)条目\"\nelse\n echo \"未发现明显死代码 ✅\"\nfi\nexit 0\n"
|
||||
- name: Validate release scripts syntax
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
bash -n scripts/backup_postgres.sh
|
||||
|
||||
bash -n scripts/restore_postgres_plan.sh
|
||||
|
||||
bash -n scripts/init_production_env.sh
|
||||
|
||||
'
|
||||
- name: Validate Alembic migrations (with isolated PG)
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
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@127.0.0.1:$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 is ready on port $PG_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for PostgreSQL... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
|
||||
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
||||
python3 scripts/check_schema_metadata.py
|
||||
# Initialize git for migration safety diff (CI checkout is tar.gz without .git)
|
||||
git init > /dev/null 2>&1
|
||||
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
|
||||
git fetch origin develop:refs/remotes/origin/develop --depth=100 > /dev/null 2>&1
|
||||
git add -A > /dev/null 2>&1
|
||||
git -c user.email=ci@local -c user.name=CI commit -m "ci-tmp" > /dev/null 2>&1
|
||||
python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
echo "PostgreSQL container cleaned up"
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- 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 Code Quality And Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Unit Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
USE_IN_MEMORY_DB: 'true'
|
||||
OSS_ACCESS_KEY_ID: placeholder
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
|
||||
echo "Job started at $(date)"
|
||||
|
||||
'
|
||||
- name: Install ffmpeg
|
||||
shell: sh
|
||||
run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n"
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
|
||||
pytest --version
|
||||
|
||||
'
|
||||
- name: Select incremental test files
|
||||
if: github.event_name == 'pull_request'
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
|
||||
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
|
||||
|
||||
CHANGED_FILES="$CHANGED_FILES" \
|
||||
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
|
||||
python3 scripts/ci/select_unit_tests.py
|
||||
SELECT_EXIT=$?
|
||||
|
||||
if [ $SELECT_EXIT -eq 0 ]; then
|
||||
echo "UNIT_TEST_MODE=incremental" >> $GITHUB_ENV
|
||||
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
|
||||
echo "SELECTED_TEST_FILES=$TEST_FILES" >> $GITHUB_ENV
|
||||
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
|
||||
else
|
||||
echo "UNIT_TEST_MODE=full" >> $GITHUB_ENV
|
||||
echo "SELECTED_TEST_FILES=tests/unit" >> $GITHUB_ENV
|
||||
echo "全量模式"
|
||||
fi
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
|
||||
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 $SELECTED_TEST_FILES -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
# 增量模式下调低覆盖率门槛(跑的文件少覆盖率自然低,不做强校验)
|
||||
python3 -m coverage report --fail-under=10 > /dev/null || true
|
||||
else
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/unit -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=65 > /dev/null
|
||||
fi
|
||||
- name: Diff coverage check (增量行覆盖率)
|
||||
if: github.event_name == 'pull_request' && env.HAS_APP_CHANGES == 'true'
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
|
||||
# 获取base分支
|
||||
BASE_BRANCH="${{ github.base_ref }}"
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
|
||||
# 初始化git (CI tarball checkout没有.git目录)
|
||||
# 先备份PR代码,再基于base分支建分支,确保HEAD与base有共同祖先
|
||||
PR_CODE_DIR="/tmp/pr-code-$$"
|
||||
mkdir -p "$PR_CODE_DIR"
|
||||
# 排除隐藏文件(如.env)和后续生成的coverage文件,只备份源码
|
||||
find . -maxdepth 1 -mindepth 1 ! -name 'coverage.xml' ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
|
||||
|
||||
rm -rf .git
|
||||
git init > /dev/null 2>&1
|
||||
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
|
||||
git config user.email "ci@local"
|
||||
git config user.name "CI"
|
||||
# 拉取base分支用于对比
|
||||
git fetch origin $BASE_BRANCH --depth=200
|
||||
# 基于base分支创建当前分支,确保有共同祖先
|
||||
git checkout -b ci-pr-branch "origin/$BASE_BRANCH" > /dev/null 2>&1
|
||||
# 清除base分支的源码,用PR代码覆盖
|
||||
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
|
||||
cp -r "$PR_CODE_DIR"/. .
|
||||
rm -rf "$PR_CODE_DIR"
|
||||
# 提交当前代码
|
||||
git add -A > /dev/null 2>&1
|
||||
git commit -m "ci-tmp" > /dev/null 2>&1
|
||||
|
||||
# 根据模式设置门槛
|
||||
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
|
||||
# 增量测试模式覆盖不全,门槛设低一些
|
||||
THRESHOLD=40
|
||||
echo "增量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
|
||||
else
|
||||
THRESHOLD=60
|
||||
echo "全量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
|
||||
fi
|
||||
|
||||
# 运行diff-cover
|
||||
set +e
|
||||
python3 -m diff_cover.diff_cover_tool coverage.xml \
|
||||
--compare-branch="origin/$BASE_BRANCH" \
|
||||
--fail-under=$THRESHOLD \
|
||||
--html-report diff_coverage.html \
|
||||
2>&1
|
||||
DIFF_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ $DIFF_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ 增量覆盖率未达到门槛 (${THRESHOLD}%)"
|
||||
echo " 请为改动的代码添加单元测试后再提交"
|
||||
echo ""
|
||||
echo "=== 覆盖率报告 ==="
|
||||
python3 -m diff_cover.diff_cover_tool coverage.xml \
|
||||
--compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 增量覆盖率达标"
|
||||
- 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="Unit Tests" python3 scripts/ci_notify_failure.py
|
||||
|
||||
'
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- 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="Unit Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 30
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
OSS_ACCESS_KEY_ID: placeholder
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
|
||||
echo "Job started at $(date)"
|
||||
|
||||
'
|
||||
- name: Verify CI environment
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 --version
|
||||
|
||||
python3 -m pip --version
|
||||
|
||||
echo "CI environment is ready"
|
||||
|
||||
'
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
|
||||
pytest --version
|
||||
|
||||
'
|
||||
- name: Install ffmpeg
|
||||
shell: sh
|
||||
run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n"
|
||||
- name: Start Redis
|
||||
shell: sh
|
||||
run: "set -eu\nREDIS_CONTAINER=\"ci-redis-${GITHUB_RUN_ID:-$$}\"\necho \"REDIS_CONTAINER=$REDIS_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$REDIS_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$REDIS_CONTAINER\" \\\n -P \\\n --health-cmd \"redis-cli ping\" \\\n --health-interval 2s \\\n --health-timeout 2s \\\n --health-retries 10 \\\n redis:7-alpine\nREDIS_PORT=$(docker port \"$REDIS_CONTAINER\" 6379/tcp | cut -d: -f2)\necho \"Redis port: $REDIS_PORT\"\necho \"REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 15); do\n if docker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"Redis is ready on port $REDIS_PORT\"\n break\n fi\n echo \"Waiting for Redis... ($i/15)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" | grep -q healthy\n"
|
||||
- name: Start PostgreSQL for integration tests
|
||||
shell: sh
|
||||
run: "set -eu\nPG_CONTAINER=\"ci-pg-${GITHUB_RUN_ID:-$$}\"\necho \"PG_CONTAINER=$PG_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$PG_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$PG_CONTAINER\" \\\n --shm-size=256m \\\n -e POSTGRES_USER=postgres \\\n -e POSTGRES_PASSWORD=postgres \\\n -e POSTGRES_DB=xiaoxia_saas \\\n -P \\\n --health-cmd \"pg_isready -U postgres\" \\\n --health-interval 5s \\\n --health-timeout 5s \\\n --health-retries 12 \\\n postgres:16\n# 获取随机映射的端口\nPG_PORT=$(docker port \"$PG_CONTAINER\" 5432/tcp | cut -d: -f2)\necho \"PostgreSQL port: $PG_PORT\"\necho \"DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 30); do\n if docker inspect --format='{{.State.Health.Status}}' \"$PG_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"PostgreSQL is ready on port $PG_PORT\"\n break\n fi\n echo \"Waiting for PostgreSQL... ($i/30)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}'\
|
||||
\ \"$PG_CONTAINER\" | grep -q healthy\n"
|
||||
- name: Apply migrations for integration tests
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
|
||||
'
|
||||
- name: Run integration tests
|
||||
shell: sh
|
||||
run: "set -eu\npython3 -m pip install -q pytest-rerunfailures\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run --append \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m \"not performance\"\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证\n"
|
||||
- name: Run API performance baseline tests
|
||||
shell: sh
|
||||
continue-on-error: true
|
||||
run: "set +e\necho \"=== API 性能基线测试 ===\"\nPERF_OUTPUT=$(mktemp)\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m pytest tests/integration/test_api_performance.py \\\n -v --timeout=120 -p no:cacheprovider 2>&1 | tee \"$PERF_OUTPUT\"\nPERF_EXIT=$?\n\n# 提取性能统计\necho \"\"\necho \"=== 性能测试摘要 ===\"\ngrep \"PERF_STATS:\" \"$PERF_OUTPUT\" || echo \"PERF_STATS: 未找到统计数据\"\ngrep \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo \"PERF_RESULT: 未找到详细结果\"\n\n# 统计通过率\nTOTAL=$(grep -c \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo 0)\nPASSED=$(grep \"PERF_RESULT: PASS\" \"$PERF_OUTPUT\" | wc -l)\nFAILED=$(grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | wc -l)\n\necho \"\"\necho \"性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标\"\n\nif [ \"$FAILED\" -gt 0 ]; then\n echo \"\"\n echo \"⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:\"\n grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | while read line; do\n echo \" $line\"\n done\n echo \"\"\n echo \"性能测试失败不阻塞主流水线,但建议尽快优化。\"\nelse\n echo \"✅ 所有接口性能达标!\"\nfi\n\nrm -f \"$PERF_OUTPUT\"\
|
||||
\n# 始终返回 0,不阻塞流水线\nexit 0\n"
|
||||
- name: Cleanup PostgreSQL & Redis
|
||||
if: always()
|
||||
shell: sh
|
||||
run: 'docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true
|
||||
|
||||
docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true
|
||||
|
||||
echo "PostgreSQL container cleaned up"
|
||||
|
||||
echo "Redis container cleaned up"
|
||||
|
||||
'
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
COVERAGE_THRESHOLD: '40'
|
||||
run: 'set +e
|
||||
|
||||
echo "=== 覆盖率汇总 ==="
|
||||
|
||||
python3 scripts/ci_coverage_summary.py
|
||||
|
||||
'
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- 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="Integration Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-check
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
|
||||
echo "Job started at $(date)"
|
||||
|
||||
'
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/eslint\" ] && [ -x \"node_modules/.bin/tsc\" ] && [ -x \"node_modules/.bin/prettier\" ] && [ -x \"node_modules/.bin/vitest\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n # Post-install integrity check: verify all critical tools exist\n if [ ! -x \"node_modules/.bin/eslint\" ] || [ ! -x \"node_modules/.bin/tsc\" ] || [ ! -x \"node_modules/.bin/prettier\" ] || [ ! -x \"node_modules/.bin/vitest\" ]; then\n echo \"Post-install check failed: critical binaries missing, cleaning and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n"
|
||||
- name: Run ESLint
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install eslint src --ext .ts,.tsx --max-warnings 0'\n"
|
||||
- name: Run TypeScript type check
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install tsc --noEmit'\n"
|
||||
- name: Run Prettier check
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\"'\n"
|
||||
- name: Run Vitest tests
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run src/test'\n"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- 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="Frontend Lint" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
|
||||
|
||||
frontend-unit-test:
|
||||
name: Frontend Unit Tests
|
||||
runs-on: ci-check
|
||||
timeout-minutes: 15
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
|
||||
echo "Job started at $(date)"
|
||||
|
||||
'
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/vitest\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n # Post-install integrity check\n if [ ! -x \"node_modules/.bin/vitest\" ]; then\n echo \"Post-install check failed: vitest missing, cleaning and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n"
|
||||
- name: Run Vitest with coverage
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run --coverage'\n"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- 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="Frontend Unit Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,15 +34,3 @@ jobs:
|
||||
python3 scripts/ci_trigger_monitor.py
|
||||
# 监控脚本永远不fail,避免告警风暴
|
||||
exit 0
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
@@ -20,8 +20,7 @@ jobs:
|
||||
if: ${{ !gitea.event.pull_request.draft }}
|
||||
|
||||
steps:
|
||||
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
|
||||
- name: Checkout code
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -52,15 +51,3 @@ jobs:
|
||||
python3 scripts/ci_code_review.py
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
|
||||
- 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
|
||||
|
||||
@@ -106,18 +106,6 @@ jobs:
|
||||
echo "======================================"
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
@@ -255,18 +243,6 @@ jobs:
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
@@ -356,18 +332,6 @@ jobs:
|
||||
echo "=========================================="
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
@@ -616,18 +580,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
@@ -704,15 +656,3 @@ jobs:
|
||||
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
|
||||
# 但其他失败的 job 已经让整体流水线标记为失败
|
||||
fi
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
name: PR Automation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 20
|
||||
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: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 120); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 还有CI在跑 → 继续等
|
||||
if [ "$ANY_PENDING" = "true" ]; then
|
||||
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
# 所有CI都跑完了但有失败 → 退出
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
auto-merge:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 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: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合develop分支
|
||||
if [ "$BASE_REF" != "develop" ]; then
|
||||
echo "Skip: 目标分支不是develop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 判断是否纯前端改动
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
|
||||
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
echo "纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
echo "检查required门禁(与分支保护一致)"
|
||||
fi
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 405连续计数器
|
||||
MERGE_405_COUNT=0
|
||||
MAX_405_RETRIES=10
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 180); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查审批状态
|
||||
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
|
||||
echo
|
||||
echo "CI全绿 + 审批通过,执行自动合并"
|
||||
echo "等待60秒冷却,给Gitea内部状态同步时间..."
|
||||
sleep 60
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行squash merge
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
||||
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
echo
|
||||
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
||||
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
continue
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# 本轮不满足合并条件,重置405计数器
|
||||
MERGE_405_COUNT=0
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
exit 0
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
@@ -193,15 +193,3 @@ jobs:
|
||||
> /dev/null
|
||||
echo "Cleanup comment posted"
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
@@ -157,8 +157,8 @@ jobs:
|
||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -eux
|
||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||||
preview_user="${PREVIEW_SSH_USER:-root}"
|
||||
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
@@ -184,16 +184,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# SSH密钥完整性自检
|
||||
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
|
||||
echo "ERROR: SSH密钥损坏(private key contents do not match public)"
|
||||
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
|
||||
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
|
||||
head -2 "$key_path"
|
||||
exit 1
|
||||
fi
|
||||
echo "SSH key integrity check passed"
|
||||
|
||||
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
echo "SSH keyscan done"
|
||||
|
||||
@@ -282,15 +272,3 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
@@ -53,4 +53,3 @@ frontend-v21-ui-prototype-final.html
|
||||
!.vscode/
|
||||
!.vscode/settings.json
|
||||
.vscode/extensions.json
|
||||
.coverage
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Phase 2 - 模板发布版本化:version字段 + 发布历史表
|
||||
|
||||
Revision ID: 047
|
||||
Revises: 046
|
||||
Create Date: 2026-07-20
|
||||
|
||||
Changes:
|
||||
1. edit_templates 加 version 字段(INT,默认1,每次发布+1)
|
||||
2. 新建 edit_template_versions 表存发布历史快照,支持回滚
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "047_template_versioning"
|
||||
down_revision = "046_task_title"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. edit_templates 加 version 字段
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
# 2. 新建 edit_template_versions 发布历史表
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS edit_template_versions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
template_id VARCHAR(32) NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
name VARCHAR(200) NOT NULL DEFAULT '',
|
||||
editing_mode VARCHAR(30) NOT NULL DEFAULT 'one_take',
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
clip_configs JSONB NOT NULL DEFAULT '[]',
|
||||
change_note VARCHAR(500) NOT NULL DEFAULT '',
|
||||
published_by VARCHAR(36) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_edit_template_versions_template_id " "ON edit_template_versions(template_id)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_edit_template_versions_template_version "
|
||||
"ON edit_template_versions(template_id, version)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS edit_template_versions"))
|
||||
op.drop_column("edit_templates", "version")
|
||||
@@ -16,7 +16,6 @@ from app.api.routes.subscription import router as subscription_router
|
||||
from app.api.routes.tags import router as tags_router
|
||||
from app.api.routes.task_center import router as task_center_router
|
||||
from app.api.routes.templates import router as templates_router
|
||||
from app.api.routes.templates_editor import router as templates_editor_router
|
||||
from app.api.routes.titles import router as titles_router
|
||||
from app.api.routes.tts import router as tts_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
@@ -120,11 +119,6 @@ api_router.include_router(
|
||||
prefix="/templates",
|
||||
tags=["Template"],
|
||||
)
|
||||
api_router.include_router(
|
||||
templates_editor_router,
|
||||
prefix="/templates/{template_id}/editor",
|
||||
tags=["TemplateEditor"],
|
||||
)
|
||||
api_router.include_router(
|
||||
edit_plans_router,
|
||||
prefix="/edit-plans",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -38,13 +38,11 @@ from sqlalchemy.orm import Session
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
from ._helpers import auto_select_video_assets, check_project_access, deprecated_edit_plans_api
|
||||
from ._helpers import auto_select_video_assets, check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
|
||||
@@ -296,7 +294,7 @@ router.include_router(transitions_router)
|
||||
# ── CRUD Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=EditPlanListResponse, deprecated=True)
|
||||
@router.get("", response_model=EditPlanListResponse)
|
||||
def list_plans(
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
@@ -355,7 +353,7 @@ def list_plans(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=EditPlanResponse, deprecated=True)
|
||||
@router.get("/{plan_id}", response_model=EditPlanResponse)
|
||||
def get_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -377,7 +375,7 @@ def get_plan(
|
||||
return _to_response(plan)
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED, deprecated=True)
|
||||
@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_plan(
|
||||
body: EditPlanCreateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -500,7 +498,7 @@ def create_plan(
|
||||
return _to_response(plan)
|
||||
|
||||
|
||||
@router.put("/{plan_id}", response_model=EditPlanResponse, deprecated=True)
|
||||
@router.put("/{plan_id}", response_model=EditPlanResponse)
|
||||
def update_plan(
|
||||
plan_id: str,
|
||||
body: EditPlanUpdateRequest,
|
||||
@@ -557,9 +555,7 @@ def update_plan(
|
||||
return _to_response(result)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response, deprecated=True
|
||||
)
|
||||
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -585,7 +581,7 @@ def delete_plan(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED, deprecated=True)
|
||||
@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
|
||||
def copy_plan(
|
||||
plan_id: str,
|
||||
body: CopyPlanRequest,
|
||||
@@ -663,7 +659,6 @@ class SubtitleBatchUpdateRequest(BaseModel):
|
||||
"/clips/{clip_id}/subtitles",
|
||||
response_model=list[dict[str, Any]],
|
||||
summary="获取片段的所有字幕",
|
||||
deprecated=True,
|
||||
)
|
||||
def list_subtitles(
|
||||
clip_id: str,
|
||||
@@ -692,7 +687,6 @@ def list_subtitles(
|
||||
response_model=dict[str, Any],
|
||||
summary="添加一条字幕",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
deprecated=True,
|
||||
)
|
||||
def add_subtitle(
|
||||
clip_id: str,
|
||||
@@ -736,7 +730,6 @@ def add_subtitle(
|
||||
"/clips/{clip_id}/subtitles/{subtitle_id}",
|
||||
response_model=dict[str, Any],
|
||||
summary="更新一条字幕",
|
||||
deprecated=True,
|
||||
)
|
||||
def update_subtitle(
|
||||
clip_id: str,
|
||||
@@ -789,7 +782,6 @@ def update_subtitle(
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
deprecated=True,
|
||||
)
|
||||
def delete_subtitle(
|
||||
clip_id: str,
|
||||
@@ -830,7 +822,6 @@ def delete_subtitle(
|
||||
"/clips/{clip_id}/subtitles",
|
||||
response_model=list[dict[str, Any]],
|
||||
summary="批量更新字幕(全量替换)",
|
||||
deprecated=True,
|
||||
)
|
||||
def batch_update_subtitles(
|
||||
clip_id: str,
|
||||
@@ -896,7 +887,6 @@ class BGMConfigUpdateRequest(BaseModel):
|
||||
"/{plan_id}/bgm",
|
||||
response_model=dict[str, Any],
|
||||
summary="获取剪辑计划的 BGM 配置",
|
||||
deprecated=True,
|
||||
)
|
||||
def get_plan_bgm(
|
||||
plan_id: str,
|
||||
@@ -929,7 +919,6 @@ def get_plan_bgm(
|
||||
"/{plan_id}/bgm",
|
||||
response_model=dict[str, Any],
|
||||
summary="更新剪辑计划的 BGM 配置",
|
||||
deprecated=True,
|
||||
)
|
||||
def update_plan_bgm(
|
||||
plan_id: str,
|
||||
@@ -994,7 +983,6 @@ def update_plan_bgm(
|
||||
"/bgm/presets",
|
||||
response_model=dict[str, Any],
|
||||
summary="获取预设 BGM 列表",
|
||||
deprecated=True,
|
||||
)
|
||||
def list_bgm_presets(
|
||||
style: Optional[str] = Query(default=None, description="按风格筛选"),
|
||||
@@ -1063,7 +1051,6 @@ class SaveAsTemplateRequest(BaseModel):
|
||||
response_model=dict[str, Any],
|
||||
summary="将剪辑计划保存为模板",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
deprecated=True,
|
||||
)
|
||||
def save_plan_as_template(
|
||||
plan_id: str,
|
||||
|
||||
Regular → Executable
+7
-9
@@ -19,13 +19,11 @@ 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
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
@@ -140,7 +138,7 @@ def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
|
||||
return svc, plan, clip
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse, deprecated=True)
|
||||
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
|
||||
def adjust_speed(
|
||||
clip_id: str,
|
||||
body: SpeedAdjustRequest,
|
||||
@@ -162,7 +160,7 @@ def adjust_speed(
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse, deprecated=True)
|
||||
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
|
||||
def adjust_volume(
|
||||
clip_id: str,
|
||||
body: VolumeAdjustRequest,
|
||||
@@ -187,7 +185,7 @@ def adjust_volume(
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse, deprecated=True)
|
||||
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
|
||||
def adjust_trim(
|
||||
clip_id: str,
|
||||
body: TrimAdjustRequest,
|
||||
@@ -220,7 +218,7 @@ def adjust_trim(
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse, deprecated=True)
|
||||
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
|
||||
def adjust_all(
|
||||
clip_id: str,
|
||||
body: ClipAdjustmentsRequest,
|
||||
@@ -277,7 +275,7 @@ def adjust_all(
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse, deprecated=True)
|
||||
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse)
|
||||
def batch_adjust_speed(
|
||||
plan_id: str,
|
||||
body: BatchSpeedRequest,
|
||||
|
||||
@@ -26,19 +26,14 @@ 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 = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/ai-recommend",
|
||||
response_model=AIRecommendResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def ai_recommend_clips(
|
||||
plan_id: str,
|
||||
@@ -155,7 +150,6 @@ def ai_recommend_clips(
|
||||
@router.post(
|
||||
"/{plan_id}/generate-cover",
|
||||
response_model=GenerateCoverResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def generate_cover(
|
||||
plan_id: str,
|
||||
|
||||
@@ -12,13 +12,9 @@ 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)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
@@ -136,7 +132,7 @@ def _get_svc(db: Session):
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=EditPlanClipListResponse, deprecated=True)
|
||||
@router.get("", response_model=EditPlanClipListResponse)
|
||||
def list_clips(
|
||||
plan_id: str,
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
|
||||
@@ -160,7 +156,7 @@ def list_clips(
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED, deprecated=True)
|
||||
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_clip(
|
||||
plan_id: str,
|
||||
body: EditPlanClipCreateRequest,
|
||||
@@ -193,7 +189,7 @@ def create_clip(
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.get("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True)
|
||||
@router.get("/{clip_id}", response_model=EditPlanClipResponse)
|
||||
def get_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
@@ -214,7 +210,7 @@ def get_clip(
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.put("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True)
|
||||
@router.put("/{clip_id}", response_model=EditPlanClipResponse)
|
||||
def update_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
@@ -255,9 +251,7 @@ def update_clip(
|
||||
return _clip_to_response(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response, deprecated=True
|
||||
)
|
||||
@router.delete("/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
@@ -304,7 +298,6 @@ class MergeClipsRequest(BaseModel):
|
||||
response_model=dict[str, Any],
|
||||
summary="分割片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
deprecated=True,
|
||||
)
|
||||
def split_clip(
|
||||
plan_id: str,
|
||||
@@ -366,7 +359,6 @@ def split_clip(
|
||||
response_model=dict[str, Any],
|
||||
summary="合并多个连续片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
deprecated=True,
|
||||
)
|
||||
def merge_clips(
|
||||
plan_id: str,
|
||||
|
||||
@@ -11,13 +11,9 @@ 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)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
@@ -102,7 +98,7 @@ def _get_svc(db: Session):
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/reorder", response_model=ClipReorderResponse, deprecated=True)
|
||||
@router.post("/reorder", response_model=ClipReorderResponse)
|
||||
def reorder_clips(
|
||||
plan_id: str,
|
||||
body: ClipReorderRequest,
|
||||
@@ -154,7 +150,7 @@ def reorder_clips(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse, deprecated=True)
|
||||
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse)
|
||||
def batch_delete_clips(
|
||||
plan_id: str,
|
||||
body: ClipBatchDeleteRequest,
|
||||
@@ -202,7 +198,7 @@ def batch_delete_clips(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-assets", response_model=ClipsFromAssetsResponse, deprecated=True)
|
||||
@router.post("/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets(
|
||||
plan_id: str,
|
||||
body: ClipsFromAssetsRequest,
|
||||
|
||||
Regular → Executable
+6
-8
@@ -26,13 +26,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
@@ -78,7 +76,7 @@ class CoverGenerateResponse(BaseModel):
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True)
|
||||
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse)
|
||||
def get_cover(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -101,7 +99,7 @@ def get_cover(
|
||||
return CoverConfigResponse(**cover)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True)
|
||||
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse)
|
||||
def update_cover(
|
||||
plan_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
@@ -148,7 +146,7 @@ def update_cover(
|
||||
return CoverConfigResponse(**result)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse, deprecated=True)
|
||||
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse)
|
||||
def extract_cover(
|
||||
plan_id: str,
|
||||
body: CoverExtractRequest,
|
||||
@@ -223,7 +221,7 @@ def extract_cover(
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse, deprecated=True)
|
||||
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse)
|
||||
def smart_cover(
|
||||
plan_id: str,
|
||||
body: CoverSmartRequest,
|
||||
|
||||
Regular → Executable
+5
-7
@@ -20,13 +20,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── 导出预设 ──────────────────────────────────────────────────────────────────
|
||||
@@ -202,7 +200,7 @@ def _get_export_config(plan_config: dict) -> dict:
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/export-presets", response_model=ExportPresetListResponse, deprecated=True)
|
||||
@router.get("/export-presets", response_model=ExportPresetListResponse)
|
||||
def list_export_presets(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ExportPresetListResponse:
|
||||
@@ -211,7 +209,7 @@ def list_export_presets(
|
||||
return ExportPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True)
|
||||
@router.get("/{plan_id}/export", response_model=ExportConfigResponse)
|
||||
def get_export_config(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -234,7 +232,7 @@ def get_export_config(
|
||||
return ExportConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True)
|
||||
@router.put("/{plan_id}/export", response_model=ExportConfigResponse)
|
||||
def update_export_config(
|
||||
plan_id: str,
|
||||
body: ExportUpdateRequest,
|
||||
|
||||
Regular → Executable
+5
-7
@@ -24,13 +24,11 @@ from packages.domain.filter_presets import (
|
||||
list_filter_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
@@ -109,7 +107,7 @@ def _get_filter_config(plan_config: dict) -> dict:
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/filter-presets", response_model=FilterPresetListResponse, deprecated=True)
|
||||
@router.get("/filter-presets", response_model=FilterPresetListResponse)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
@@ -121,7 +119,7 @@ def list_presets(
|
||||
return FilterPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True)
|
||||
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse)
|
||||
def get_filter(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -144,7 +142,7 @@ def get_filter(
|
||||
return FilterConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True)
|
||||
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse)
|
||||
def update_filter(
|
||||
plan_id: str,
|
||||
body: FilterUpdateRequest,
|
||||
|
||||
Regular → Executable
+2
-8
@@ -42,13 +42,9 @@ from packages.application.generation_tasks import (
|
||||
)
|
||||
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)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||
@@ -212,7 +208,7 @@ def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
||||
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse, deprecated=True)
|
||||
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
|
||||
def generate_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -311,7 +307,6 @@ def generate_plan(
|
||||
@router.get(
|
||||
"/{plan_id}/generation-status",
|
||||
response_model=EditPlanGenerationStatusResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def get_generation_status(
|
||||
plan_id: str,
|
||||
@@ -378,7 +373,6 @@ def get_generation_status(
|
||||
@router.get(
|
||||
"/{plan_id}/generations",
|
||||
response_model=EditPlanGenerationsResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def list_plan_generations(
|
||||
plan_id: str,
|
||||
|
||||
@@ -29,13 +29,9 @@ 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)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Timeline Schemas ──────────────────────────────────────────────────────────
|
||||
@@ -114,7 +110,6 @@ def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
|
||||
@router.get(
|
||||
"/{plan_id}/timeline",
|
||||
response_model=TimelineResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
def get_plan_timeline(
|
||||
plan_id: str,
|
||||
@@ -165,7 +160,6 @@ def get_plan_timeline(
|
||||
"/generate-from-template",
|
||||
response_model=GenerateFromTemplateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
deprecated=True,
|
||||
)
|
||||
def generate_from_template(
|
||||
body: GenerateFromTemplateRequest,
|
||||
|
||||
Regular → Executable
+5
-7
@@ -23,13 +23,11 @@ from packages.domain.transition_presets import (
|
||||
list_transition_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access, deprecated_edit_plans_api
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(deprecated_edit_plans_api)],
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
@@ -126,7 +124,7 @@ def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/transition-presets", response_model=TransitionPresetListResponse, deprecated=True)
|
||||
@router.get("/transition-presets", response_model=TransitionPresetListResponse)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
@@ -138,7 +136,7 @@ def list_presets(
|
||||
return TransitionPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse, deprecated=True)
|
||||
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse)
|
||||
def update_clip_transition(
|
||||
clip_id: str,
|
||||
body: TransitionUpdateRequest,
|
||||
@@ -186,7 +184,7 @@ def update_clip_transition(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse, deprecated=True)
|
||||
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse)
|
||||
def batch_update_transitions(
|
||||
plan_id: str,
|
||||
body: BatchTransitionRequest,
|
||||
|
||||
@@ -32,7 +32,9 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
||||
|
||||
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
||||
ALLOWED_FLAGS: set[str] = set()
|
||||
ALLOWED_FLAGS = {
|
||||
"render_engine",
|
||||
}
|
||||
|
||||
|
||||
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,11 +41,6 @@ class EditTemplateService:
|
||||
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
from packages.adapters.sqlalchemy_impl.template_version_repository import (
|
||||
SQLAlchemyTemplateVersionRepository,
|
||||
)
|
||||
|
||||
self._version_repo = SQLAlchemyTemplateVersionRepository(db)
|
||||
self._db = db
|
||||
|
||||
# ── 模板 CRUD ──────────────────────────────────────────────────────────
|
||||
@@ -538,422 +533,3 @@ class EditTemplateService:
|
||||
"template": created_template,
|
||||
"clip_configs": created_configs,
|
||||
}
|
||||
|
||||
# ── 模板草稿(编辑器)相关 ──────────────────────────────────────────────────
|
||||
|
||||
def get_template_draft(self, template_id: str) -> Optional[Any]:
|
||||
"""获取模板的草稿剪辑计划
|
||||
|
||||
通过 template_id + config.is_template_draft=True 标记查找。
|
||||
每个模板有且仅有一个草稿计划。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
|
||||
Returns:
|
||||
EditPlan | None: 草稿剪辑计划,不存在则返回 None
|
||||
"""
|
||||
from packages.domain.edit_plan import EditPlan # noqa: F401
|
||||
|
||||
plans = self._plan_repo.list_by_template(template_id, limit=50)
|
||||
for plan in plans:
|
||||
config = plan.config or {}
|
||||
if config.get("is_template_draft") is True:
|
||||
return plan
|
||||
return None
|
||||
|
||||
def create_template_draft(
|
||||
self,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
) -> Any:
|
||||
"""基于模板创建草稿剪辑计划
|
||||
|
||||
草稿与普通剪辑计划的区别:
|
||||
- config.is_template_draft = True
|
||||
- 不绑定具体素材(空素材列表)
|
||||
- 用于模板编辑器的编辑上下文
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 创建者用户 ID
|
||||
project_id: 所属项目 ID(可选)
|
||||
|
||||
Returns:
|
||||
EditPlan: 创建的草稿剪辑计划
|
||||
|
||||
Raises:
|
||||
ValueError: 模板不存在,或草稿已存在
|
||||
"""
|
||||
from .plan_generator_service import PlanGeneratorService
|
||||
|
||||
# 检查模板是否存在
|
||||
template = self.get_template_or_raise(template_id)
|
||||
|
||||
# 检查草稿是否已存在
|
||||
existing = self.get_template_draft(template_id)
|
||||
if existing is not None:
|
||||
raise ValueError(f"模板草稿已存在: {template_id}")
|
||||
|
||||
# 读取模板片段配置
|
||||
clip_configs = self.list_clip_configs(template_id)
|
||||
|
||||
# 基于模板生成计划(空素材)
|
||||
generator = PlanGeneratorService(self._db)
|
||||
result = generator.generate_from_template(
|
||||
template=template,
|
||||
clip_configs=clip_configs,
|
||||
asset_ids=[],
|
||||
project_id=project_id,
|
||||
created_by_user_id=user_id,
|
||||
name=f"{template.name} - 草稿",
|
||||
)
|
||||
plan = result["plan"]
|
||||
|
||||
# 标记为模板草稿
|
||||
plan_config = plan.config or {}
|
||||
plan_config["is_template_draft"] = True
|
||||
plan.config = plan_config
|
||||
plan = self._plan_repo.update(plan)
|
||||
|
||||
logger.info(
|
||||
"创建模板草稿: template_id=%s draft_plan_id=%s user_id=%s",
|
||||
template_id,
|
||||
plan.id,
|
||||
user_id,
|
||||
)
|
||||
return plan
|
||||
|
||||
def get_or_create_draft(
|
||||
self,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
) -> Any:
|
||||
"""获取或创建模板草稿
|
||||
|
||||
首次访问模板编辑器时自动创建草稿。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 操作用户 ID
|
||||
project_id: 所属项目 ID(可选)
|
||||
|
||||
Returns:
|
||||
EditPlan: 草稿剪辑计划
|
||||
"""
|
||||
draft = self.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft
|
||||
return self.create_template_draft(template_id, user_id, project_id=project_id)
|
||||
|
||||
def publish_template_from_draft(
|
||||
self,
|
||||
template_id: str,
|
||||
draft_plan_id: str,
|
||||
*,
|
||||
change_note: str = "",
|
||||
published_by: str = "",
|
||||
) -> Any:
|
||||
"""将草稿剪辑计划的内容发布(同步)到模板
|
||||
|
||||
将草稿的配置和片段结构同步到模板,相当于"保存"编辑结果。
|
||||
使用事务保证一致性,失败则回滚。
|
||||
|
||||
同步规则:
|
||||
- 草稿 plan.config → template.config(过滤掉草稿特有字段)
|
||||
- 草稿 clips → template_clip_configs(先删后插)
|
||||
- 草稿 editing_mode → template.editing_mode
|
||||
- 不更新模板名称、描述等元信息(由专门的接口处理)
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
draft_plan_id: 草稿剪辑计划 ID
|
||||
|
||||
Returns:
|
||||
EditTemplate: 更新后的模板
|
||||
|
||||
Raises:
|
||||
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
||||
"""
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
# 1. 校验模板和草稿
|
||||
template = self.get_template_or_raise(template_id)
|
||||
draft = self._plan_repo.get(draft_plan_id)
|
||||
if draft is None:
|
||||
raise ValueError(f"草稿计划不存在: {draft_plan_id}")
|
||||
if draft.template_id != template_id:
|
||||
raise ValueError(f"草稿不属于该模板: plan_template_id={draft.template_id}")
|
||||
config = draft.config or {}
|
||||
if config.get("is_template_draft") is not True:
|
||||
raise ValueError("指定的计划不是模板草稿")
|
||||
|
||||
# 2. 读取草稿片段
|
||||
draft_clips = self._plan_clip_repo.list_by_plan(draft_plan_id)
|
||||
draft_clips.sort(key=lambda c: c.order)
|
||||
|
||||
# 3. 提取 editing_mode
|
||||
editing_mode = config.get("editing_mode", "one_take")
|
||||
|
||||
# 4. 提取模板配置(去掉草稿/运行时字段)
|
||||
draft_config = draft.config or {}
|
||||
template_config: dict[str, Any] = {}
|
||||
skip_keys = {
|
||||
"is_template_draft",
|
||||
"asset_ids",
|
||||
"source_edit_plan_id",
|
||||
"generation_task_id",
|
||||
}
|
||||
for key, value in draft_config.items():
|
||||
if key not in skip_keys:
|
||||
template_config[key] = value
|
||||
|
||||
# 5. 事务更新
|
||||
try:
|
||||
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
||||
old_version = template.version or 1
|
||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||
old_clip_snapshots = [
|
||||
{
|
||||
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
"order": cfg.order,
|
||||
"min_duration": cfg.min_duration,
|
||||
"max_duration": cfg.max_duration,
|
||||
"text_template": cfg.text_template or "",
|
||||
"transition_effect": (
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
"config": cfg.config or {},
|
||||
}
|
||||
for cfg in old_clip_configs
|
||||
]
|
||||
|
||||
from packages.domain.template_version import EditTemplateVersion
|
||||
|
||||
old_snapshot = EditTemplateVersion.create(
|
||||
template_id=template_id,
|
||||
version=old_version,
|
||||
name=template.name,
|
||||
editing_mode=template.editing_mode,
|
||||
config=dict(template.config) if template.config else {},
|
||||
clip_configs=old_clip_snapshots,
|
||||
change_note=f"v{old_version} 快照(发布前)",
|
||||
published_by=published_by,
|
||||
)
|
||||
self._version_repo.create(old_snapshot)
|
||||
|
||||
# 更新模板元信息
|
||||
template.config = template_config
|
||||
template.editing_mode = editing_mode
|
||||
template.bump_version() # 版本号 +1
|
||||
updated_template = self._template_repo.update(template)
|
||||
|
||||
# 批量删除旧的片段配置(走 repository,保证测试 stub 和真实行为一致)
|
||||
self._clip_config_repo.delete_by_template(template_id)
|
||||
|
||||
# 创建新的片段配置
|
||||
created_configs: list[TemplateClipConfig] = []
|
||||
for clip in draft_clips:
|
||||
clip_config: dict[str, Any] = {}
|
||||
# 播放速度存入 config
|
||||
if clip.playback_speed and clip.playback_speed != 1.0:
|
||||
clip_config["playback_speed"] = clip.playback_speed
|
||||
# 片段自有 config 合并
|
||||
if clip.config:
|
||||
clip_config.update(clip.config)
|
||||
# 去掉素材相关字段
|
||||
clip_config.pop("asset_info", None)
|
||||
clip_config.pop("source_asset_id", None)
|
||||
|
||||
# 转场效果兼容校验
|
||||
try:
|
||||
from packages.domain.template_clip_config import (
|
||||
TransitionEffect,
|
||||
)
|
||||
|
||||
transition = TransitionEffect(clip.transition_effect)
|
||||
except (ValueError, ImportError):
|
||||
transition = TransitionEffect.CUT # type: ignore
|
||||
|
||||
# 片段类型兼容校验
|
||||
try:
|
||||
from packages.domain.template_clip_config import ClipType
|
||||
|
||||
clip_type = ClipType(clip.clip_type)
|
||||
except (ValueError, ImportError):
|
||||
clip_type = ClipType.MAIN # type: ignore
|
||||
|
||||
config_obj = TemplateClipConfig.create(
|
||||
template_id=template_id,
|
||||
clip_type=clip_type,
|
||||
order=clip.order,
|
||||
min_duration=clip.duration,
|
||||
max_duration=clip.duration,
|
||||
text_template=clip.text_content or "",
|
||||
transition_effect=transition,
|
||||
config=clip_config,
|
||||
)
|
||||
created = self._clip_config_repo.create(config_obj)
|
||||
created_configs.append(created)
|
||||
|
||||
self._db.commit()
|
||||
logger.info(
|
||||
"发布模板草稿: template_id=%s draft_plan_id=%s clip_count=%d",
|
||||
template_id,
|
||||
draft_plan_id,
|
||||
len(created_configs),
|
||||
)
|
||||
return updated_template
|
||||
|
||||
except Exception as exc:
|
||||
self._db.rollback()
|
||||
logger.error(
|
||||
"发布模板草稿失败: template_id=%s draft_plan_id=%s error=%s",
|
||||
template_id,
|
||||
draft_plan_id,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
|
||||
# ── 版本历史与回滚 ────────────────────────────────────────────────────
|
||||
|
||||
def list_template_versions(self, template_id: str, limit: int = 50) -> list[Any]:
|
||||
"""列出模板的发布版本历史(按版本号倒序)"""
|
||||
self.get_template_or_raise(template_id) # 校验存在性
|
||||
return self._version_repo.list_by_template(template_id, limit=limit)
|
||||
|
||||
def rollback_to_version(self, template_id: str, version: int) -> Any:
|
||||
"""回滚模板到指定历史版本
|
||||
|
||||
流程:
|
||||
1. 校验目标版本存在
|
||||
2. 保存当前状态为新版本快照(当前版本号)
|
||||
3. 用目标版本的快照覆盖模板 config + clip_configs
|
||||
4. 版本号 +1(回滚本身也是一次发布)
|
||||
|
||||
Returns:
|
||||
EditTemplate: 回滚后的模板
|
||||
|
||||
Raises:
|
||||
ValueError: 模板/版本不存在
|
||||
"""
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
template = self.get_template_or_raise(template_id)
|
||||
|
||||
# 1. 读取目标版本快照
|
||||
target_version = self._version_repo.get_by_version(template_id, version)
|
||||
if target_version is None:
|
||||
raise ValueError(f"模板 {template_id} 不存在版本 {version}")
|
||||
|
||||
current_version = template.version or 1
|
||||
|
||||
try:
|
||||
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||
old_clip_snapshots = [
|
||||
{
|
||||
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
"order": cfg.order,
|
||||
"min_duration": cfg.min_duration,
|
||||
"max_duration": cfg.max_duration,
|
||||
"text_template": cfg.text_template or "",
|
||||
"transition_effect": (
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
"config": cfg.config or {},
|
||||
}
|
||||
for cfg in old_clip_configs
|
||||
]
|
||||
|
||||
from packages.domain.template_version import EditTemplateVersion
|
||||
|
||||
current_snapshot = EditTemplateVersion.create(
|
||||
template_id=template_id,
|
||||
version=current_version,
|
||||
name=template.name,
|
||||
editing_mode=template.editing_mode,
|
||||
config=dict(template.config) if template.config else {},
|
||||
clip_configs=old_clip_snapshots,
|
||||
change_note=f"v{current_version} 快照(回滚到 v{version} 前)",
|
||||
published_by="rollback",
|
||||
)
|
||||
self._version_repo.create(current_snapshot)
|
||||
|
||||
# 3. 覆盖模板配置 + editing_mode + name + preview_url
|
||||
template.config = dict(target_version.config)
|
||||
template.editing_mode = target_version.editing_mode
|
||||
if target_version.name:
|
||||
template.name = target_version.name
|
||||
template.bump_version() # 版本号 +1
|
||||
updated_template = self._template_repo.update(template)
|
||||
|
||||
# 4. 先删后插 clip_configs(批量删除避免N+1)
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
TemplateClipConfigModel,
|
||||
)
|
||||
|
||||
self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
for clip_snap in target_version.clip_configs:
|
||||
# 转场效果兼容校验
|
||||
try:
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
|
||||
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
|
||||
except (ValueError, ImportError):
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
|
||||
transition = TransitionEffect.CUT
|
||||
|
||||
# 片段类型兼容校验
|
||||
try:
|
||||
from packages.domain.template_clip_config import ClipType
|
||||
|
||||
clip_type = ClipType(clip_snap.get("clip_type", "main"))
|
||||
except (ValueError, ImportError):
|
||||
from packages.domain.template_clip_config import ClipType
|
||||
|
||||
clip_type = ClipType.MAIN
|
||||
|
||||
config_obj = TemplateClipConfig.create(
|
||||
template_id=template_id,
|
||||
clip_type=clip_type,
|
||||
order=clip_snap.get("order", 0),
|
||||
min_duration=clip_snap.get("min_duration", 0.0),
|
||||
max_duration=clip_snap.get("max_duration", 0.0),
|
||||
text_template=clip_snap.get("text_template", ""),
|
||||
transition_effect=transition,
|
||||
config=clip_snap.get("config", {}) or {},
|
||||
)
|
||||
self._clip_config_repo.create(config_obj)
|
||||
|
||||
self._db.commit()
|
||||
logger.info(
|
||||
"模板回滚成功: template_id=%s from_v=%d to_v=%d new_v=%d",
|
||||
template_id,
|
||||
current_version,
|
||||
version,
|
||||
updated_template.version,
|
||||
)
|
||||
return updated_template
|
||||
|
||||
except Exception as exc:
|
||||
self._db.rollback()
|
||||
logger.error(
|
||||
"模板回滚失败: template_id=%s target_version=%d error=%s",
|
||||
template_id,
|
||||
version,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
@@ -92,8 +88,6 @@ test.describe("Core generation flow", () => {
|
||||
|
||||
// Upload source video
|
||||
const sourceFileName = "e2e-gen-source.mp4"
|
||||
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
|
||||
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
@@ -102,7 +96,7 @@ test.describe("Core generation flow", () => {
|
||||
file: {
|
||||
name: sourceFileName,
|
||||
mimeType: "video/mp4",
|
||||
buffer: sampleVideoBuffer,
|
||||
buffer: Buffer.from("e2e source data"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
@@ -120,17 +116,15 @@ test.describe("Core media upload flow", () => {
|
||||
timeout: 20_000,
|
||||
})
|
||||
|
||||
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
|
||||
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: "e2e-sample.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: sampleVideoBuffer,
|
||||
name: "e2e-sample.MOV",
|
||||
mimeType: "video/quicktime",
|
||||
buffer: Buffer.from("playwright mov upload smoke"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -158,7 +152,7 @@ test.describe("Core media upload flow", () => {
|
||||
mime_type?: string
|
||||
}>
|
||||
}
|
||||
const asset = data.items.find((item) => item.name === "e2e-sample.mp4")
|
||||
const asset = data.items.find((item) => item.name === "e2e-sample.MOV")
|
||||
return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing"
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
@@ -175,12 +169,12 @@ test.describe("Core media upload flow", () => {
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
await expect(page.getByText("e2e-sample.mp4", { exact: true })).toBeVisible({
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
|
||||
// Verify asset card shows status
|
||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.mp4" })
|
||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.MOV" })
|
||||
await expect(assetCard).toBeVisible()
|
||||
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible()
|
||||
|
||||
|
||||
@@ -184,19 +184,13 @@ export const getAssetsByKind = async (
|
||||
gender?: string
|
||||
style?: string
|
||||
tag_ids?: string[]
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string | number> = { kind }
|
||||
const params: Record<string, string> = { kind }
|
||||
if (filters?.keyword) params.keyword = filters.keyword
|
||||
if (filters?.gender) params.gender = filters.gender
|
||||
if (filters?.style) params.style = filters.style
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||
if (filters?.limit) params.limit = filters.limit
|
||||
if (filters?.page) params.page = filters.page
|
||||
if (filters?.page_size) params.page_size = filters.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface BgmPresetsQuery {
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** BGM 混音配置(嵌入模板) */
|
||||
/** BGM 混音配置(嵌入剪辑计划) */
|
||||
export interface BgmMixConfig {
|
||||
/** 是否启用 BGM */
|
||||
enabled: boolean
|
||||
|
||||
Executable → Regular
+20
-22
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 模板草稿 API — 对接后端 Template Editor Schema
|
||||
* 剪辑计划 API — 对接后端 Edit Plans Schema
|
||||
* 字段名严格匹配后端 API 响应
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
* 后端 API 类型(严格匹配后端 Schema)
|
||||
* ============================================================ */
|
||||
|
||||
/** 模板草稿状态枚举 */
|
||||
/** 剪辑计划状态枚举 */
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
|
||||
|
||||
@@ -71,7 +71,7 @@ export interface SegmentTransitionConfig {
|
||||
duration: number
|
||||
}
|
||||
|
||||
/** 模板草稿中的单个片段(config 内部 segments 项) */
|
||||
/** 剪辑计划中的单个片段(config 内部 segments 项) */
|
||||
export interface EditPlanSegment {
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
@@ -83,7 +83,7 @@ export interface EditPlanSegment {
|
||||
trim_config?: SegmentTrimConfig
|
||||
}
|
||||
|
||||
/** 模板草稿 config 完整类型(对齐后端 config JSON 结构) */
|
||||
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
|
||||
export interface EditPlanConfig {
|
||||
title_config?: TitleConfig
|
||||
subtitle_config?: SubtitleConfig
|
||||
@@ -123,7 +123,7 @@ export interface EditPlanConfig {
|
||||
material_mode?: string
|
||||
}
|
||||
|
||||
/** 模板草稿(后端响应) */
|
||||
/** 剪辑计划(后端响应) */
|
||||
export interface EditPlan {
|
||||
id: string
|
||||
template_id: string
|
||||
@@ -137,17 +137,17 @@ export interface EditPlan {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建模板草稿请求(后端要求 template_id + name 必填) */
|
||||
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
|
||||
export interface CreateEditPlanRequest {
|
||||
template_id: string
|
||||
name: string
|
||||
config?: EditPlanConfig
|
||||
total_duration?: number
|
||||
/** 来源模板草稿 ID(从模板编辑器跳转到智能剪辑时关联) */
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到智能剪辑时关联) */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 更新模板草稿请求 */
|
||||
/** 更新剪辑计划请求 */
|
||||
export interface UpdateEditPlanRequest {
|
||||
name?: string
|
||||
config?: EditPlanConfig
|
||||
@@ -163,7 +163,7 @@ export interface GenerateResponse {
|
||||
clip_count: number
|
||||
}
|
||||
|
||||
/** 模板草稿关联的生成记录(实际是 GenerationTask 对象) */
|
||||
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */
|
||||
export interface EditPlanGeneration {
|
||||
id: string // 即 generation_task_id
|
||||
source_edit_plan_id: string
|
||||
@@ -189,7 +189,6 @@ export interface ClipStatusItem {
|
||||
asset_id?: string
|
||||
text_content?: string
|
||||
duration?: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
/** 生成状态轮询响应 */
|
||||
@@ -197,7 +196,6 @@ export interface GenerationStatusResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id?: string
|
||||
error_message?: string
|
||||
clips: ClipStatusItem[]
|
||||
}
|
||||
|
||||
@@ -325,7 +323,7 @@ export interface MediaAsset {
|
||||
* API 函数 — 严格对接后端
|
||||
* ============================================================ */
|
||||
|
||||
/** 模板草稿列表查询参数 */
|
||||
/** 剪辑计划列表查询参数 */
|
||||
export interface EditPlanListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
@@ -333,7 +331,7 @@ export interface EditPlanListParams {
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** 模板草稿列表分页响应 */
|
||||
/** 剪辑计划列表分页响应 */
|
||||
export interface EditPlanListResponse {
|
||||
items: EditPlan[]
|
||||
total: number
|
||||
@@ -341,7 +339,7 @@ export interface EditPlanListResponse {
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 获取模板草稿列表(支持分页和筛选) */
|
||||
/** 获取剪辑计划列表(支持分页和筛选) */
|
||||
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||
params,
|
||||
@@ -349,19 +347,19 @@ export async function getEditPlans(params?: EditPlanListParams): Promise<EditPla
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
/** 获取单个剪辑计划 */
|
||||
export async function getEditPlan(planId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板草稿 */
|
||||
/** 创建剪辑计划 */
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/edit-plans", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿 */
|
||||
/** 更新剪辑计划 */
|
||||
export async function updateEditPlan(
|
||||
planId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
@@ -370,18 +368,18 @@ export async function updateEditPlan(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板草稿 */
|
||||
/** 删除剪辑计划 */
|
||||
export async function deleteEditPlan(planId: string): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}`)
|
||||
}
|
||||
|
||||
/** 触发生成 */
|
||||
/** 触发剪辑计划生成 */
|
||||
export async function generateEditPlan(planId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取生成状态(轮询用) */
|
||||
/** 获取剪辑计划生成状态(轮询用) */
|
||||
export async function getGenerationStatus(planId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generation-status`)
|
||||
return response.data
|
||||
@@ -405,7 +403,7 @@ export async function generateCover(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取模板草稿关联的生成记录 */
|
||||
/** 获取剪辑计划关联的生成记录 */
|
||||
export async function getEditPlanGenerations(planId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generations`)
|
||||
return response.data.items || []
|
||||
@@ -609,7 +607,7 @@ export interface CopyEditPlanRequest {
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
/** 复制模板草稿(含所有片段配置) */
|
||||
/** 复制剪辑计划(含所有片段配置) */
|
||||
export async function copyEditPlan(planId: string, data?: CopyEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(`/edit-plans/${planId}/copy`, data || {})
|
||||
return response.data
|
||||
|
||||
Regular → Executable
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 模板编辑器 API
|
||||
* 剪辑计划编辑器 API
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* - GET /api/v1/templates — 模板列表(分页/筛选)
|
||||
* - GET /api/v1/templates/{id} — 模板详情
|
||||
* - POST /api/v1/templates/{id}/copy — 复制模板
|
||||
* - POST /api/v1/templates/{id}/generate — 从模板生成
|
||||
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
|
||||
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
@@ -76,14 +76,14 @@ export interface TemplateListResponse {
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 从模板生成请求 */
|
||||
/** 从模板生成剪辑计划请求 */
|
||||
export interface GenerateFromTemplateRequest {
|
||||
asset_ids?: string[]
|
||||
name?: string
|
||||
config?: EditPlanConfig
|
||||
}
|
||||
|
||||
/** 从模板生成响应 */
|
||||
/** 从模板生成剪辑计划响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
plan_id: string
|
||||
template_id: string
|
||||
@@ -134,7 +134,7 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从模板生成 */
|
||||
/** 从模板生成剪辑计划 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
|
||||
@@ -59,7 +59,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/subscription/upgrade": "升级订阅",
|
||||
"/app/subscription/billing": "账单管理",
|
||||
"/app/profile": "个人设置",
|
||||
"/app/editing-planner": "模板制作",
|
||||
"/app/editing-planner": "剪辑规划",
|
||||
"/app/my-templates": "我的模板",
|
||||
"/app/voice-clone": "我的音色",
|
||||
"/app/voice-materials": "配音库",
|
||||
|
||||
Executable → Regular
+12
-1
@@ -81,7 +81,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
|
||||
{
|
||||
key: "edit-plans",
|
||||
label: "剪辑计划",
|
||||
path: "/app/edit-plans",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "智能剪辑",
|
||||
@@ -137,6 +142,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "edit-plans",
|
||||
label: "剪辑计划",
|
||||
path: "/app/edit-plans",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Executable
+515
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* 剪辑计划管理页面
|
||||
* 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Table, Tabs, Select, Tag, Button, message, Popconfirm, Tooltip } from "antd"
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CloseCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
ThunderboltOutlined,
|
||||
CopyOutlined,
|
||||
UnorderedListOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import {
|
||||
getEditPlans,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
cancelGeneration,
|
||||
copyEditPlan,
|
||||
type EditPlan,
|
||||
type EditPlanStatus,
|
||||
type EditPlanListParams,
|
||||
} from "@/api/editPlans"
|
||||
import { getTemplatesList, type TemplateItem } from "@/api/templates"
|
||||
import "./edit-plans.css"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 状态 Tab 配置 */
|
||||
const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "editing", label: "编辑中" },
|
||||
{ key: "rendering", label: "渲染中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "cancelled", label: "已取消" },
|
||||
]
|
||||
|
||||
/** 状态标签配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
EditPlanStatus,
|
||||
{ label: string; color: string; icon: React.ReactNode }
|
||||
> = {
|
||||
draft: {
|
||||
label: "草稿",
|
||||
color: "default",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
editing: {
|
||||
label: "编辑中",
|
||||
color: "processing",
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
rendering: {
|
||||
label: "渲染中",
|
||||
color: "warning",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
color: "success",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
cancelled: {
|
||||
label: "已取消",
|
||||
color: "default",
|
||||
icon: <StopOutlined />,
|
||||
},
|
||||
}
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return "-"
|
||||
const totalSec = Math.round(seconds)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
if (m === 0) return `${s}秒`
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return "-"
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/* ──────────── 主组件 ──────────── */
|
||||
|
||||
export default function EditPlans() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 筛选状态
|
||||
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">("all")
|
||||
const [templateFilter, setTemplateFilter] = useState<string>("all")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
// 查询参数
|
||||
const queryParams: EditPlanListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(statusFilter !== "all" && { status: statusFilter }),
|
||||
...(templateFilter !== "all" && { template_id: templateFilter }),
|
||||
}
|
||||
|
||||
// 获取剪辑计划列表
|
||||
const {
|
||||
data: planData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["edit-plans", queryParams],
|
||||
queryFn: () => getEditPlans(queryParams),
|
||||
refetchInterval: (query) => {
|
||||
// 有进行中的计划时自动刷新
|
||||
const plans = query.state.data?.items ?? []
|
||||
const hasRunning = plans.some((p) => p.status === "rendering" || p.status === "editing")
|
||||
return hasRunning ? 5000 : false
|
||||
},
|
||||
})
|
||||
|
||||
// 获取模板列表(用于筛选下拉)
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ["templates-list-simple"],
|
||||
queryFn: getTemplatesList,
|
||||
})
|
||||
|
||||
const plans = planData?.items ?? []
|
||||
const total = planData?.total ?? 0
|
||||
|
||||
// 模板名称映射
|
||||
const templateNameMap = new Map<string, string>()
|
||||
;(templates ?? []).forEach((t: TemplateItem) => {
|
||||
templateNameMap.set(t.id, t.name)
|
||||
})
|
||||
|
||||
// 删除计划
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("剪辑计划已删除")
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败,请稍后重试")
|
||||
},
|
||||
})
|
||||
|
||||
// 重新生成
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: generateEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("已重新提交生成")
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
|
||||
},
|
||||
onError: () => {
|
||||
message.error("重新生成失败,请稍后重试")
|
||||
},
|
||||
})
|
||||
|
||||
// 取消生成
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelGeneration,
|
||||
onSuccess: () => {
|
||||
message.success("已提交取消请求")
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
|
||||
},
|
||||
onError: () => {
|
||||
message.error("取消失败,请稍后重试")
|
||||
},
|
||||
})
|
||||
|
||||
// 复制计划
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: ({ planId, name }: { planId: string; name?: string }) =>
|
||||
copyEditPlan(planId, name ? { name } : undefined),
|
||||
onSuccess: (newPlan) => {
|
||||
message.success("计划已复制")
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
|
||||
// 自动跳转到新计划的编辑器
|
||||
navigate(`/app/editing-planner?planId=${newPlan.id}`)
|
||||
},
|
||||
onError: () => {
|
||||
message.error("复制失败,请稍后重试")
|
||||
},
|
||||
})
|
||||
|
||||
// 跳转到剪辑编辑器
|
||||
const handleEdit = useCallback(
|
||||
(plan: EditPlan) => {
|
||||
navigate(`/app/editing-planner?planId=${plan.id}`)
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<EditPlan> = [
|
||||
{
|
||||
title: "计划名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (name: string, record: EditPlan) => (
|
||||
<Tooltip title={name}>
|
||||
<span className="plan-name" onClick={() => handleEdit(record)}>
|
||||
{name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "模板",
|
||||
dataIndex: "template_id",
|
||||
key: "template_id",
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (templateId: string) => {
|
||||
const name = templateNameMap.get(templateId)
|
||||
return (
|
||||
<Tag color="blue" className="plan-template-tag">
|
||||
{name || templateId.slice(0, 8)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: EditPlanStatus) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
}
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon} className="plan-status-tag">
|
||||
{config.label}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "时长",
|
||||
dataIndex: "total_duration",
|
||||
key: "total_duration",
|
||||
width: 100,
|
||||
render: (seconds: number) => <span className="plan-duration">{formatDuration(seconds)}</span>,
|
||||
},
|
||||
{
|
||||
title: "视频数",
|
||||
dataIndex: "result_count",
|
||||
key: "result_count",
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (count: number) => (
|
||||
<span className="plan-result-count">{count > 0 ? count : "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 130,
|
||||
render: (time: string) => <span className="plan-time">{formatTime(time)}</span>,
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 130,
|
||||
render: (time: string) => <span className="plan-time">{formatTime(time)}</span>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 240,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: EditPlan) => (
|
||||
<div className="plan-actions">
|
||||
<Tooltip title="片段管理">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => navigate(`/app/edit-plans/${record.id}/clips`)}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
片段
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{record.status === "rendering" && (
|
||||
<Popconfirm
|
||||
title="确认取消生成"
|
||||
description="确定要取消当前生成任务吗?此操作不可恢复。"
|
||||
onConfirm={() => cancelMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="再等等"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
loading={cancelMutation.isPending}
|
||||
className="plan-action-btn plan-cancel-btn"
|
||||
>
|
||||
取消生成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{(record.status === "failed" ||
|
||||
record.status === "completed" ||
|
||||
record.status === "cancelled") && (
|
||||
<Popconfirm
|
||||
title="确认重新生成"
|
||||
description="确定要重新生成这个剪辑计划吗?"
|
||||
onConfirm={() => regenerateMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={regenerateMutation.isPending}
|
||||
className="plan-action-btn plan-regenerate-btn"
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="复制计划"
|
||||
description="确定要复制这个剪辑计划吗?将创建一个编辑中的新副本。"
|
||||
onConfirm={() =>
|
||||
copyMutation.mutate({
|
||||
planId: record.id,
|
||||
name: `${record.name} 副本`,
|
||||
})
|
||||
}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyMutation.isPending}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
|
||||
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
return (
|
||||
<div className="edit-plans-page">
|
||||
<div className="edit-plans-error">
|
||||
<CloseCircleOutlined />
|
||||
<p>加载剪辑计划失败</p>
|
||||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="edit-plans-page">
|
||||
{/* 页面标题 */}
|
||||
<div className="edit-plans-header">
|
||||
<div className="edit-plans-header-text">
|
||||
<h2>剪辑计划</h2>
|
||||
<p>管理所有剪辑计划,支持重新生成和编辑</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate("/app/templates")}>
|
||||
从模板创建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="edit-plans-filters">
|
||||
{/* 状态 Tab */}
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => {
|
||||
setStatusFilter(key as EditPlanStatus | "all")
|
||||
setPage(1)
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="edit-plans-status-tabs"
|
||||
/>
|
||||
|
||||
{/* 模板筛选 */}
|
||||
<Select
|
||||
value={templateFilter}
|
||||
onChange={(value) => {
|
||||
setTemplateFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: "all", label: "全部模板" },
|
||||
...(templates ?? []).map((t: TemplateItem) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
})),
|
||||
]}
|
||||
style={{ minWidth: 180 }}
|
||||
placeholder="选择模板"
|
||||
className="edit-plans-template-filter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 计划表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={plans}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
className="edit-plans-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="edit-plans-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无剪辑计划</p>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => navigate("/app/templates")}
|
||||
>
|
||||
从模板创建
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
/**
|
||||
* 剪辑计划片段管理页面
|
||||
* 对接后端 PR#389 片段 CRUD API
|
||||
* 功能:列表查看、创建、编辑、删除、批量删除、拖拽排序、从素材导入
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import { useParams, useNavigate } from "react-router-dom"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Popconfirm,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Tag,
|
||||
Drawer,
|
||||
Empty,
|
||||
Card,
|
||||
} from "antd"
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
UploadOutlined,
|
||||
OrderedListOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import {
|
||||
getEditPlan,
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
batchDeleteEditPlanClips,
|
||||
reorderEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
getMediaAssets,
|
||||
type EditPlanClip,
|
||||
type EditPlanClipStatus,
|
||||
} from "@/api/editPlans"
|
||||
import "./plan-clips.css"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const CLIP_TYPE_OPTIONS = [
|
||||
{ value: "main", label: "主片段" },
|
||||
{ value: "intro", label: "片头" },
|
||||
{ value: "outro", label: "片尾" },
|
||||
{ value: "overlay", label: "叠加层" },
|
||||
{ value: "background", label: "背景" },
|
||||
{ value: "b_roll", label: "B-roll" },
|
||||
]
|
||||
|
||||
const STATUS_COLORS: Record<EditPlanClipStatus, string> = {
|
||||
pending: "default",
|
||||
processing: "processing",
|
||||
ready: "success",
|
||||
failed: "error",
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<EditPlanClipStatus, string> = {
|
||||
pending: "待处理",
|
||||
processing: "处理中",
|
||||
ready: "就绪",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ value: "cut", label: "硬切" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "dissolve", label: "溶解" },
|
||||
{ value: "zoom", label: "缩放" },
|
||||
{ value: "slide_left", label: "左滑" },
|
||||
{ value: "slide_right", label: "右滑" },
|
||||
{ value: "slide_up", label: "上滑" },
|
||||
{ value: "slide_down", label: "下滑" },
|
||||
{ value: "wipe_left", label: "左擦除" },
|
||||
{ value: "wipe_right", label: "右擦除" },
|
||||
{ value: "wipe_up", label: "上擦除" },
|
||||
{ value: "wipe_down", label: "下擦除" },
|
||||
{ value: "circlecrop", label: "圆形裁切" },
|
||||
{ value: "rectcrop", label: "矩形裁切" },
|
||||
]
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const PlanClipsManager: React.FC = () => {
|
||||
const { planId } = useParams<{ planId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 计划信息 ── */
|
||||
const { data: plan, isLoading: planLoading } = useQuery({
|
||||
queryKey: ["editPlan", planId],
|
||||
queryFn: () => getEditPlan(planId!),
|
||||
enabled: !!planId,
|
||||
})
|
||||
|
||||
/* ── 片段列表 ── */
|
||||
const { data: clipsData, isLoading: clipsLoading } = useQuery({
|
||||
queryKey: ["editPlanClips", planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
})
|
||||
|
||||
const clips = clipsData?.items ?? []
|
||||
|
||||
/* ── 选中的片段(批量操作) ── */
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
|
||||
/* ── 编辑弹窗 ── */
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [editingClip, setEditingClip] = useState<EditPlanClip | null>(null)
|
||||
const [editForm] = Form.useForm()
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
/* ── 素材导入抽屉 ── */
|
||||
const [importDrawerOpen, setImportDrawerOpen] = useState(false)
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
|
||||
const [importLoading, setImportLoading] = useState(false)
|
||||
|
||||
const { data: assets } = useQuery({
|
||||
queryKey: ["mediaAssets"],
|
||||
queryFn: () => getMediaAssets(),
|
||||
enabled: importDrawerOpen,
|
||||
})
|
||||
|
||||
/* ── 重新排序模式 ── */
|
||||
const [reorderMode, setReorderMode] = useState(false)
|
||||
const [reorderItems, setReorderItems] = useState<EditPlanClip[]>([])
|
||||
|
||||
/* ── 列定义 ── */
|
||||
const columns: ColumnsType<EditPlanClip> = [
|
||||
{
|
||||
title: "序号",
|
||||
dataIndex: "order",
|
||||
width: 70,
|
||||
render: (_, __, index) => index + 1,
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "clip_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const opt = CLIP_TYPE_OPTIONS.find((o) => o.value === type)
|
||||
return <Tag>{opt?.label || type}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "素材",
|
||||
dataIndex: "asset_id",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (assetId: string) =>
|
||||
assetId ? (
|
||||
<code className="clip-asset-id">{assetId.slice(0, 12)}...</code>
|
||||
) : (
|
||||
<span style={{ color: "#999" }}>无素材</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "文本内容",
|
||||
dataIndex: "text_content",
|
||||
ellipsis: true,
|
||||
render: (text: string) => text || <span style={{ color: "#999" }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: "时长",
|
||||
dataIndex: "duration",
|
||||
width: 90,
|
||||
render: (d: number) => `${d?.toFixed(1) || 0}s`,
|
||||
},
|
||||
{
|
||||
title: "转场",
|
||||
dataIndex: "transition_effect",
|
||||
width: 100,
|
||||
render: (effect: string) => {
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === effect)
|
||||
return opt?.label || effect || "硬切"
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "播放速度",
|
||||
dataIndex: "playback_speed",
|
||||
width: 90,
|
||||
render: (s: number) => `${s || 1.0}x`,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 90,
|
||||
render: (status: EditPlanClipStatus) => (
|
||||
<Tag color={STATUS_COLORS[status] || "default"}>{STATUS_LABELS[status] || status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 140,
|
||||
fixed: "right",
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditClip(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除片段"
|
||||
description="确定删除这个片段吗?"
|
||||
onConfirm={() => handleDeleteClip(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
/* ── 编辑片段 ── */
|
||||
const handleEditClip = useCallback(
|
||||
(clip: EditPlanClip) => {
|
||||
setEditingClip(clip)
|
||||
editForm.setFieldsValue({
|
||||
clip_type: clip.clip_type,
|
||||
asset_id: clip.asset_id,
|
||||
text_content: clip.text_content,
|
||||
duration: clip.duration,
|
||||
start_time: clip.start_time,
|
||||
transition_effect: clip.transition_effect,
|
||||
transition_duration: clip.transition_duration,
|
||||
playback_speed: clip.playback_speed,
|
||||
})
|
||||
setEditModalOpen(true)
|
||||
},
|
||||
[editForm],
|
||||
)
|
||||
|
||||
const handleNewClip = useCallback(() => {
|
||||
setEditingClip(null)
|
||||
editForm.resetFields()
|
||||
editForm.setFieldsValue({
|
||||
clip_type: "main",
|
||||
duration: 5,
|
||||
transition_effect: "cut",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1.0,
|
||||
})
|
||||
setEditModalOpen(true)
|
||||
}, [editForm])
|
||||
|
||||
const handleSaveClip = async () => {
|
||||
if (!planId) return
|
||||
try {
|
||||
const values = await editForm.validateFields()
|
||||
setEditLoading(true)
|
||||
|
||||
if (editingClip) {
|
||||
// 更新
|
||||
await updateEditPlanClip(planId, editingClip.id, values)
|
||||
message.success("片段已更新")
|
||||
} else {
|
||||
// 新建
|
||||
const maxOrder = clips.length > 0 ? Math.max(...clips.map((c) => c.order)) : -1
|
||||
await createEditPlanClip(planId, {
|
||||
...values,
|
||||
order: maxOrder + 1,
|
||||
})
|
||||
message.success("片段已创建")
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setEditModalOpen(false)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
message.error(editingClip ? "更新失败" : "创建失败")
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const handleDeleteClip = async (clipId: string) => {
|
||||
if (!planId) return
|
||||
try {
|
||||
await deleteEditPlanClip(planId, clipId)
|
||||
message.success("已删除")
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => k !== clipId))
|
||||
} catch {
|
||||
message.error("删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = async () => {
|
||||
if (!planId || selectedRowKeys.length === 0) return
|
||||
try {
|
||||
await batchDeleteEditPlanClips(
|
||||
planId,
|
||||
selectedRowKeys.map((k) => String(k)),
|
||||
)
|
||||
message.success(`已删除 ${selectedRowKeys.length} 个片段`)
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setSelectedRowKeys([])
|
||||
} catch {
|
||||
message.error("批量删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 从素材导入 ── */
|
||||
const handleImportFromAssets = async () => {
|
||||
if (!planId || selectedAssetIds.length === 0) return
|
||||
try {
|
||||
setImportLoading(true)
|
||||
const res = await createClipsFromAssets(planId, selectedAssetIds)
|
||||
message.success(`已导入 ${res.created_count} 个片段`)
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setImportDrawerOpen(false)
|
||||
setSelectedAssetIds([])
|
||||
} catch {
|
||||
message.error("导入失败")
|
||||
} finally {
|
||||
setImportLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 排序模式 ── */
|
||||
const enterReorderMode = () => {
|
||||
setReorderItems([...clips].sort((a, b) => a.order - b.order))
|
||||
setReorderMode(true)
|
||||
}
|
||||
|
||||
const moveClip = (fromIndex: number, toIndex: number) => {
|
||||
if (toIndex < 0 || toIndex >= reorderItems.length) return
|
||||
const newItems = [...reorderItems]
|
||||
const [moved] = newItems.splice(fromIndex, 1)
|
||||
newItems.splice(toIndex, 0, moved)
|
||||
setReorderItems(newItems)
|
||||
}
|
||||
|
||||
const saveReorder = async () => {
|
||||
if (!planId) return
|
||||
const items = reorderItems.map((clip, index) => ({
|
||||
clip_id: clip.id,
|
||||
new_order: index,
|
||||
}))
|
||||
try {
|
||||
await reorderEditPlanClips(planId, items)
|
||||
message.success("排序已保存")
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setReorderMode(false)
|
||||
} catch {
|
||||
message.error("排序保存失败")
|
||||
}
|
||||
}
|
||||
|
||||
const cancelReorder = () => {
|
||||
setReorderMode(false)
|
||||
setReorderItems([])
|
||||
}
|
||||
|
||||
/* ── 渲染 ── */
|
||||
const displayClips = reorderMode ? reorderItems : [...clips].sort((a, b) => a.order - b.order)
|
||||
|
||||
return (
|
||||
<div className="plan-clips-page">
|
||||
{/* 顶部 */}
|
||||
<div className="plan-clips-header">
|
||||
<div className="plan-clips-header-left">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate("/app/edit-plans")}
|
||||
>
|
||||
返回计划列表
|
||||
</Button>
|
||||
<div className="plan-clips-title">
|
||||
<h2>{plan?.name || "加载中..."}</h2>
|
||||
<p>
|
||||
{planLoading
|
||||
? "加载中..."
|
||||
: `共 ${clipsData?.total || 0} 个片段 · ${plan?.status || ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan-clips-header-right">
|
||||
<Space>
|
||||
<Button icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>
|
||||
从素材导入
|
||||
</Button>
|
||||
{reorderMode ? (
|
||||
<>
|
||||
<Button onClick={cancelReorder}>取消排序</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={saveReorder}>
|
||||
保存排序
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
icon={<OrderedListOutlined />}
|
||||
onClick={enterReorderMode}
|
||||
disabled={clips.length === 0}
|
||||
>
|
||||
调整顺序
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleNewClip}>
|
||||
添加片段
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{!reorderMode && selectedRowKeys.length > 0 && (
|
||||
<div className="plan-clips-batch-bar">
|
||||
<span>已选择 {selectedRowKeys.length} 个片段</span>
|
||||
<Popconfirm
|
||||
title="批量删除"
|
||||
description={`确定删除选中的 ${selectedRowKeys.length} 个片段吗?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排序列表 */}
|
||||
{reorderMode && (
|
||||
<Card className="plan-clips-reorder-card" title="拖拽调整顺序">
|
||||
<div className="plan-clips-reorder-list">
|
||||
{reorderItems.map((clip, index) => (
|
||||
<div key={clip.id} className="plan-clips-reorder-item">
|
||||
<span className="reorder-index">{index + 1}</span>
|
||||
<span className="reorder-type">
|
||||
{CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)?.label ||
|
||||
clip.clip_type}
|
||||
</span>
|
||||
<span className="reorder-content">
|
||||
{clip.text_content || clip.asset_id || "无内容"}
|
||||
</span>
|
||||
<span className="reorder-duration">{clip.duration.toFixed(1)}s</span>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => moveClip(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => moveClip(index, index + 1)}
|
||||
disabled={index === reorderItems.length - 1}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 片段列表 */}
|
||||
{!reorderMode && (
|
||||
<div className="plan-clips-table-wrap">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={displayClips}
|
||||
loading={clipsLoading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description="暂无片段,点击上方按钮添加或从素材导入"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingClip ? "编辑片段" : "添加片段"}
|
||||
open={editModalOpen}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
onOk={handleSaveClip}
|
||||
confirmLoading={editLoading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
width={560}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item
|
||||
label="片段类型"
|
||||
name="clip_type"
|
||||
rules={[{ required: true, message: "请选择类型" }]}
|
||||
>
|
||||
<Select options={CLIP_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="素材 ID" name="asset_id">
|
||||
<Input placeholder="关联的素材 ID(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="文本内容" name="text_content">
|
||||
<Input.TextArea rows={3} placeholder="字幕/配音文案等" />
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Form.Item label="起始时间(秒)" name="start_time" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="时长(秒)" name="duration" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Form.Item label="转场效果" name="transition_effect" style={{ flex: 1 }}>
|
||||
<Select options={TRANSITION_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="转场时长" name="transition_duration" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="播放速度" name="playback_speed">
|
||||
<InputNumber min={0.1} max={10} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 素材导入抽屉 */}
|
||||
<Drawer
|
||||
title="从视频库导入"
|
||||
open={importDrawerOpen}
|
||||
onClose={() => setImportDrawerOpen(false)}
|
||||
width={480}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImportFromAssets}
|
||||
loading={importLoading}
|
||||
disabled={selectedAssetIds.length === 0}
|
||||
>
|
||||
导入 {selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{assets && assets.length > 0 ? (
|
||||
<div className="asset-import-list">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`asset-import-item ${
|
||||
selectedAssetIds.includes(asset.id) ? "selected" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedAssetIds((prev) =>
|
||||
prev.includes(asset.id)
|
||||
? prev.filter((id) => id !== asset.id)
|
||||
: [...prev, asset.id],
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div className="asset-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="asset-thumb-placeholder">{asset.type}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-info">
|
||||
<div className="asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="asset-meta">
|
||||
{asset.type}
|
||||
{asset.duration ? ` · ${asset.duration.toFixed(1)}s` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="视频库为空" />
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanClipsManager
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 剪辑计划管理页面样式
|
||||
*/
|
||||
|
||||
/* ── 页面容器 ──────────────────────────────────────────── */
|
||||
.edit-plans-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── 页面头部 ──────────────────────────────────────────── */
|
||||
.edit-plans-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.edit-plans-header-text h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.edit-plans-header-text p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 筛选栏 ────────────────────────────────────────────── */
|
||||
.edit-plans-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-tab {
|
||||
padding: 8px 16px !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--primary-500, #6366f1) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-ink-bar {
|
||||
background: var(--primary-500, #6366f1) !important;
|
||||
}
|
||||
|
||||
.edit-plans-template-filter {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* ── 表格 ──────────────────────────────────────────────── */
|
||||
.edit-plans-table {
|
||||
background: var(--bg-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-tertiary, #f8fafc) !important;
|
||||
border-bottom: 1px solid var(--border-primary, #e2e8f0);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-tbody > tr > td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-hover, #f8fafc) !important;
|
||||
}
|
||||
|
||||
/* ── 计划名称 ──────────────────────────────────────────── */
|
||||
.plan-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.plan-name:hover {
|
||||
color: var(--primary-500, #6366f1);
|
||||
}
|
||||
|
||||
/* ── 状态标签 ──────────────────────────────────────────── */
|
||||
.plan-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-default {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-processing {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-success {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-warning {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ── 时长 ──────────────────────────────────────────────── */
|
||||
.plan-duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 时间 ──────────────────────────────────────────────── */
|
||||
.plan-time {
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 操作按钮 ──────────────────────────────────────────── */
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plan-action-btn {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.plan-action-btn.ant-btn-link {
|
||||
color: var(--primary-500, #6366f1);
|
||||
}
|
||||
|
||||
.plan-action-btn.ant-btn-link:hover {
|
||||
color: var(--primary-600, #4f46e5);
|
||||
}
|
||||
|
||||
.plan-regenerate-btn {
|
||||
color: var(--primary-500, #6366f1) !important;
|
||||
}
|
||||
|
||||
.plan-regenerate-btn:hover {
|
||||
color: var(--primary-600, #4f46e5) !important;
|
||||
}
|
||||
|
||||
/* ── 空状态 ────────────────────────────────────────────── */
|
||||
.edit-plans-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edit-plans-empty .anticon {
|
||||
font-size: 48px;
|
||||
color: var(--text-disabled, #cbd5e1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-empty p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 错误状态 ──────────────────────────────────────────── */
|
||||
.edit-plans-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.edit-plans-error .anticon {
|
||||
font-size: 48px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-error p {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 响应式 ────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.edit-plans-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit-plans-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-plans-template-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
/* 剪辑计划片段管理页面 */
|
||||
|
||||
.plan-clips-page {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.plan-clips-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-clips-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plan-clips-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.plan-clips-title p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.plan-clips-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #e6f4ff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.plan-clips-table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.clip-asset-id {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 排序模式 */
|
||||
.plan-clips-reorder-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-item:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.reorder-index {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reorder-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
padding: 2px 8px;
|
||||
background: #eef2ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reorder-content {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reorder-duration {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 素材导入 */
|
||||
.asset-import-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.asset-import-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.asset-import-item:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.asset-import-item.selected {
|
||||
border-color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.asset-thumb {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
Regular → Executable
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 模板编辑器 — V21 原型 1:1 还原样式
|
||||
* 剪辑计划编辑器 — V21 原型 1:1 还原样式
|
||||
* 颜色:对齐全局V21设计系统,使用 var(--bg-primary)、var(--bg-secondary)、var(--primary) 等
|
||||
* 布局:3行(顶栏52 + 模式栏56 + 三栏主体flex)
|
||||
* 面板宽度:左240 / 右280
|
||||
|
||||
Regular → Executable
+563
-9
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 模板编辑器 — 制作/编辑剪辑模板
|
||||
* 剪辑计划编辑器 — V8 原型 1:1 还原
|
||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { message, Modal } from "antd"
|
||||
import { message, Modal, Progress, Button } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type {
|
||||
EditingTemplate,
|
||||
@@ -20,8 +20,31 @@ import {
|
||||
getTemplateCategories,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner"
|
||||
import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/editPlans"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/editPlans"
|
||||
import type {
|
||||
EditPlanGeneration,
|
||||
EditPlanConfig,
|
||||
GeneratedVideo,
|
||||
MediaAsset,
|
||||
TransitionEffect,
|
||||
TitleConfig,
|
||||
} from "@/api/editPlans"
|
||||
import {
|
||||
getMediaAssets,
|
||||
getEditPlanGenerations,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
batchDeleteEditPlanClips,
|
||||
type EditPlanClip,
|
||||
type CreateEditPlanClipRequest,
|
||||
type ClipStatusItem,
|
||||
} from "@/api/editPlans"
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo"
|
||||
import type {
|
||||
ClipData,
|
||||
@@ -74,6 +97,7 @@ import GreenScreenPanel from "./components/GreenScreenPanel"
|
||||
import StickerPanel from "./components/StickerPanel"
|
||||
|
||||
import SaveModal from "./components/SaveModal"
|
||||
import GenerationHistoryModal from "./components/GenerationHistoryModal"
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
|
||||
import "./EditingPlanner.css"
|
||||
|
||||
@@ -206,8 +230,26 @@ const EditingPlanner: React.FC = () => {
|
||||
setSelectedAssetIds(ids)
|
||||
}
|
||||
|
||||
/* ── 模板草稿(从模板列表编辑进入时) ── */
|
||||
const [loadedPlanId] = useState<string | null>(urlPlanId || null)
|
||||
/* ── 生成历史 ── */
|
||||
const [genHistoryOpen, setGenHistoryOpen] = useState(false)
|
||||
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([])
|
||||
const [genHistoryLoading, setGenHistoryLoading] = useState(false)
|
||||
|
||||
/* ── 剪辑计划(从列表页编辑进入时) ── */
|
||||
const [loadedPlanId, setLoadedPlanId] = useState<string | null>(urlPlanId || null)
|
||||
|
||||
/* ── 生成进度 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [genProgress, setGenProgress] = useState(0)
|
||||
const [genTotalClips, setGenTotalClips] = useState(0)
|
||||
const [genDoneClips, setGenDoneClips] = useState(0)
|
||||
const [generated, setGenerated] = useState(false)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
const [genError, setGenError] = useState<string | null>(null)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [genCancelled, setGenCancelled] = useState(false)
|
||||
const [genClipStatuses, setGenClipStatuses] = useState<ClipStatusItem[]>([])
|
||||
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/* ── 播放 ── */
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
@@ -336,7 +378,7 @@ const EditingPlanner: React.FC = () => {
|
||||
}, [loadedTemplateId, resetClips])
|
||||
|
||||
/**
|
||||
* 加载已有模板草稿数据到编辑器
|
||||
* 加载已有剪辑计划数据到编辑器
|
||||
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
|
||||
*/
|
||||
useEffect(() => {
|
||||
@@ -456,7 +498,7 @@ const EditingPlanner: React.FC = () => {
|
||||
setTimeout(() => resetClips(mapped), 100)
|
||||
}
|
||||
})
|
||||
.catch(() => message.error("加载模板草稿失败"))
|
||||
.catch(() => message.error("加载剪辑计划失败"))
|
||||
}, [loadedPlanId, resetClips])
|
||||
|
||||
/* ──────────── 计算 ──────────── */
|
||||
@@ -730,6 +772,58 @@ const EditingPlanner: React.FC = () => {
|
||||
setStickerSettings(config)
|
||||
}, [])
|
||||
|
||||
/** 构建剪辑计划 config(编辑器状态 → API config) */
|
||||
const buildPlanConfig = (): EditPlanConfig => ({
|
||||
title_config: titleConfig,
|
||||
subtitle_config: {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
},
|
||||
estimated_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
segment_order: i,
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
})
|
||||
|
||||
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
|
||||
const handleOpenSaveModal = () => {
|
||||
setSaveModalOpen(true)
|
||||
@@ -814,6 +908,247 @@ const EditingPlanner: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本地编辑的片段同步到后端 clips 表
|
||||
* 策略:先删除后端所有片段,再批量创建(简单可靠,生成前使用)
|
||||
*/
|
||||
const syncClipsToBackend = async (planId: string): Promise<void> => {
|
||||
if (clips.length === 0) return
|
||||
|
||||
// 1. 获取并删除后端现有片段
|
||||
const existing = await getEditPlanClips(planId, { limit: 500 })
|
||||
if (existing.items.length > 0) {
|
||||
await batchDeleteEditPlanClips(
|
||||
planId,
|
||||
existing.items.map((c) => c.id),
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 批量创建新片段(并发 3 个)
|
||||
const clipDataList: CreateEditPlanClipRequest[] = clips.map((c, i) => ({
|
||||
clip_type: c.type === "voice" ? "voiceover" : "main",
|
||||
order: i,
|
||||
asset_id: c.media_asset_id || "",
|
||||
text_content: c.script_text || "",
|
||||
start_time: 0,
|
||||
duration: c.duration,
|
||||
transition_effect: c.transition?.type || "cut",
|
||||
transition_duration: c.transition?.duration || 0,
|
||||
playback_speed: c.speed?.rate || 1.0,
|
||||
config: {
|
||||
tts_config: c.tts_config || null,
|
||||
trim_config: c.trim_config || null,
|
||||
template_segment_id: c.template_segment_id || null,
|
||||
},
|
||||
}))
|
||||
|
||||
// 并发控制:最多同时 3 个请求
|
||||
const results: EditPlanClip[] = []
|
||||
const concurrency = 3
|
||||
for (let i = 0; i < clipDataList.length; i += concurrency) {
|
||||
const batch = clipDataList.slice(i, i + concurrency)
|
||||
const batchResults = await Promise.all(batch.map((data) => createEditPlanClip(planId, data)))
|
||||
results.push(...batchResults)
|
||||
}
|
||||
|
||||
console.log(`[片段同步] 创建了 ${results.length} 个片段`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 剪辑计划生成
|
||||
* 1. 有 planId → 更新计划配置 + 同步片段 + 触发生成
|
||||
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 同步片段 + 触发生成
|
||||
* 3. 触发生成后轮询状态,完成后获取视频结果
|
||||
*/
|
||||
const handleGoToGenerate = async () => {
|
||||
if (!loadedTemplateId) {
|
||||
message.warning("请先选择一个模板")
|
||||
return
|
||||
}
|
||||
if (clips.length === 0) {
|
||||
message.warning("请先添加片段")
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
setGenerated(false)
|
||||
setGeneratedVideos([])
|
||||
setGenError(null)
|
||||
setGenProgress(0)
|
||||
setGenCancelled(false)
|
||||
|
||||
try {
|
||||
const config = buildPlanConfig()
|
||||
let planId = loadedPlanId
|
||||
|
||||
if (planId) {
|
||||
// 已有计划 → 先重置状态为 draft(failed/editing 等非 draft 状态会被后端拒绝更新和生成)
|
||||
try {
|
||||
await updateEditPlan(planId, { status: "draft" })
|
||||
} catch (resetErr) {
|
||||
console.warn("[状态重置跳过]", resetErr)
|
||||
}
|
||||
// 再更新配置
|
||||
try {
|
||||
await updateEditPlan(planId, {
|
||||
config,
|
||||
total_duration: totalDuration,
|
||||
})
|
||||
} catch (updateErr) {
|
||||
console.warn("[计划更新跳过]", updateErr)
|
||||
}
|
||||
} else {
|
||||
// 无计划 → 创建新计划
|
||||
const plan = await createEditPlan({
|
||||
template_id: loadedTemplateId,
|
||||
name: draftName || "未命名计划",
|
||||
config,
|
||||
total_duration: totalDuration,
|
||||
})
|
||||
planId = plan.id
|
||||
setLoadedPlanId(planId)
|
||||
// 更新 URL 参数(不刷新页面)
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.set("planId", planId)
|
||||
window.history.replaceState(null, "", `?${params.toString()}`)
|
||||
}
|
||||
|
||||
// 同步片段到后端 clips 表(生成前必须同步,后端生成从 clips 表读)
|
||||
try {
|
||||
await syncClipsToBackend(planId)
|
||||
} catch (syncErr) {
|
||||
console.warn("[片段同步失败]", syncErr)
|
||||
message.warning("片段同步失败,将使用模板默认配置生成")
|
||||
// 同步失败不阻塞生成,后端有模板兜底
|
||||
}
|
||||
|
||||
// 触发生成
|
||||
const genRes = await generateEditPlan(planId)
|
||||
setGenTotalClips(genRes.clip_count)
|
||||
message.info("已提交生成,等待处理...")
|
||||
|
||||
// 开始轮询
|
||||
startPolling(planId)
|
||||
} catch (err) {
|
||||
console.error("[生成失败]", err)
|
||||
setGenError("生成提交失败,请重试")
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询生成状态,每 2 秒一次 */
|
||||
const startPolling = (planId: string) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getGenerationStatus(planId)
|
||||
|
||||
// 计算进度
|
||||
const total = status.clips.length || genTotalClips
|
||||
const done = status.clips.filter(
|
||||
(c) => c.status === "completed" || c.status === "failed",
|
||||
).length
|
||||
setGenDoneClips(done)
|
||||
setGenTotalClips(total)
|
||||
setGenClipStatuses(status.clips || [])
|
||||
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5)
|
||||
|
||||
if (status.plan_status === "completed") {
|
||||
setGenProgress(100)
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
|
||||
// 获取视频结果
|
||||
if (status.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(status.generation_task_id)
|
||||
setGeneratedVideos(videos)
|
||||
} catch (e) {
|
||||
console.error("[获取视频结果失败]", e)
|
||||
}
|
||||
}
|
||||
message.success("视频生成完成!")
|
||||
return // 停止轮询
|
||||
}
|
||||
|
||||
if (status.plan_status === "failed") {
|
||||
setGenerating(false)
|
||||
setGenError("生成失败,请重试")
|
||||
return // 停止轮询
|
||||
}
|
||||
|
||||
if (status.plan_status === "cancelled") {
|
||||
setGenerating(false)
|
||||
setGenError("生成已取消")
|
||||
setGenCancelled(true)
|
||||
message.info("生成任务已取消")
|
||||
return // 停止轮询
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
genTimerRef.current = setTimeout(poll, 2000)
|
||||
} catch (err) {
|
||||
console.error("[轮询状态失败]", err)
|
||||
genTimerRef.current = setTimeout(poll, 5000) // 出错后 5 秒重试
|
||||
}
|
||||
}
|
||||
|
||||
// 首次延迟 2 秒后开始
|
||||
genTimerRef.current = setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
/** 清理轮询定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (genTimerRef.current) clearTimeout(genTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 取消生成任务 */
|
||||
const handleCancelGeneration = async () => {
|
||||
const targetId = loadedPlanId
|
||||
if (!targetId) return
|
||||
|
||||
Modal.confirm({
|
||||
title: "确认取消生成",
|
||||
content: "取消后已开始的生成任务,已生成的片段不会保留。确定要取消吗?",
|
||||
okText: "确认取消",
|
||||
cancelText: "继续生成",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
setCancelling(true)
|
||||
await cancelGeneration(targetId)
|
||||
message.success("已提交取消请求")
|
||||
// 轮询会继续运行直到检测到 cancelled 状态
|
||||
} catch (err) {
|
||||
console.error("[取消失败]", err)
|
||||
message.error("取消失败,请稍后重试")
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* 查看生成历史 */
|
||||
const handleViewGenHistory = async () => {
|
||||
const targetId = loadedPlanId || loadedTemplateId
|
||||
if (!targetId) {
|
||||
message.warning("请先加载一个模板或计划")
|
||||
return
|
||||
}
|
||||
setGenHistoryOpen(true)
|
||||
setGenHistoryLoading(true)
|
||||
try {
|
||||
const items = await getEditPlanGenerations(targetId)
|
||||
setGenHistory(items)
|
||||
} catch {
|
||||
message.error("加载生成历史失败")
|
||||
} finally {
|
||||
setGenHistoryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
return (
|
||||
@@ -822,7 +1157,7 @@ const EditingPlanner: React.FC = () => {
|
||||
<div className="ep-top-bar">
|
||||
<div className="ep-top-bar-left">
|
||||
<span className="ep-logo">✂️</span>
|
||||
<span className="ep-app-title">模板制作</span>
|
||||
<span className="ep-app-title">小虾剪辑编排器</span>
|
||||
<span className="ep-divider">|</span>
|
||||
<span className="ep-template-name">{currentTemplate?.name || "未选择模板"}</span>
|
||||
</div>
|
||||
@@ -846,6 +1181,13 @@ const EditingPlanner: React.FC = () => {
|
||||
<button className="ep-btn ep-btn-secondary" onClick={handleOpenSaveModal}>
|
||||
💾 保存模板
|
||||
</button>
|
||||
<button
|
||||
className="ep-btn ep-btn-primary"
|
||||
onClick={handleGoToGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
{loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1010,6 +1352,10 @@ const EditingPlanner: React.FC = () => {
|
||||
<span>🎬 {MODE_LABELS[currentMode]}</span>
|
||||
<span className="ep-status-sep">|</span>
|
||||
<span>📐 模板片段: {currentTemplate?.segments.length || 0}</span>
|
||||
<span className="ep-status-sep">|</span>
|
||||
<button className="ep-status-link" onClick={handleViewGenHistory}>
|
||||
📋 生成历史
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1030,6 +1376,214 @@ const EditingPlanner: React.FC = () => {
|
||||
onCancel={() => setSaveModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ═══ 生成历史弹窗 ═══ */}
|
||||
<GenerationHistoryModal
|
||||
open={genHistoryOpen}
|
||||
loading={genHistoryLoading}
|
||||
history={genHistory}
|
||||
onClose={() => setGenHistoryOpen(false)}
|
||||
onCancel={async () => {
|
||||
Modal.confirm({
|
||||
title: "确认取消生成",
|
||||
content: "确定要取消这个生成任务吗?此操作不可恢复。",
|
||||
okText: "确认取消",
|
||||
cancelText: "再等等",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
if (!loadedPlanId) return
|
||||
try {
|
||||
await cancelGeneration(loadedPlanId)
|
||||
message.success("已提交取消请求")
|
||||
// 刷新历史列表
|
||||
handleViewGenHistory()
|
||||
} catch (err) {
|
||||
console.error("[取消失败]", err)
|
||||
message.error("取消失败,请稍后重试")
|
||||
}
|
||||
},
|
||||
})
|
||||
}}
|
||||
cancelLoading={cancelling}
|
||||
/>
|
||||
|
||||
{/* ═══ 生成进度弹窗 ═══ */}
|
||||
<Modal
|
||||
title={
|
||||
genError
|
||||
? "生成失败"
|
||||
: genCancelled
|
||||
? "已取消生成"
|
||||
: generated
|
||||
? "生成完成"
|
||||
: "正在生成视频"
|
||||
}
|
||||
open={generating || generated || !!genError || genCancelled}
|
||||
footer={
|
||||
generated
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setGenerated(false)
|
||||
setGenerating(false)
|
||||
setGenError(null)
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
generatedVideos.length > 0 && (
|
||||
<Button
|
||||
key="download"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
const v = generatedVideos[0]
|
||||
const url = v.download_url || v.file_url
|
||||
if (url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = v.name || "video.mp4"
|
||||
a.target = "_blank"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}}
|
||||
>
|
||||
下载视频
|
||||
</Button>
|
||||
),
|
||||
]
|
||||
: generating
|
||||
? [
|
||||
<Button key="cancel" danger loading={cancelling} onClick={handleCancelGeneration}>
|
||||
取消生成
|
||||
</Button>,
|
||||
]
|
||||
: genCancelled
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setGenCancelled(false)
|
||||
setGenerating(false)
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: genError
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setGenError(null)
|
||||
setGenerating(false)
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: null
|
||||
}
|
||||
closable={!generating}
|
||||
maskClosable={false}
|
||||
width={520}
|
||||
>
|
||||
{generating && (
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<Progress percent={genProgress} status="active" />
|
||||
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
|
||||
已处理 {genDoneClips}/{genTotalClips} 个片段
|
||||
</p>
|
||||
{genClipStatuses.length > 0 && (
|
||||
<div className="ep-gen-clip-list">
|
||||
{genClipStatuses.map((clip, index) => (
|
||||
<div key={clip.clip_id || index} className="ep-gen-clip-item">
|
||||
<span className="ep-gen-clip-index">{index + 1}</span>
|
||||
<span className="ep-gen-clip-name">
|
||||
{clip.text_content
|
||||
? clip.text_content.slice(0, 20)
|
||||
: clip.clip_type || `片段${index + 1}`}
|
||||
</span>
|
||||
<span className={`ep-gen-clip-status status-${clip.status}`}>
|
||||
{clip.status === "completed"
|
||||
? "✓ 完成"
|
||||
: clip.status === "failed"
|
||||
? "✗ 失败"
|
||||
: clip.status === "processing"
|
||||
? "⟳ 处理中"
|
||||
: "⏳ 等待中"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
请耐心等待,生成过程中请勿关闭页面
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{genCancelled && (
|
||||
<div style={{ padding: "24px 0", textAlign: "center" }}>
|
||||
<p>生成已取消</p>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>你可以继续编辑后重新生成</p>
|
||||
</div>
|
||||
)}
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
<video
|
||||
src={generatedVideos[0].file_url || generatedVideos[0].download_url}
|
||||
controls
|
||||
preload="metadata"
|
||||
style={{ width: "100%", maxHeight: 320, borderRadius: 8 }}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 8,
|
||||
textAlign: "center",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{generatedVideos[0].name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generatedVideos.length && (
|
||||
<div style={{ padding: "24px 0", textAlign: "center" }}>
|
||||
<p>生成完成,但暂未获取到视频结果</p>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
请稍后在剪辑计划列表中查看
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{genError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 0",
|
||||
textAlign: "center",
|
||||
color: "#ff4d4f",
|
||||
}}
|
||||
>
|
||||
<p>{genError}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setGenError(null)
|
||||
setGenerating(false)
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
|
||||
Regular → Executable
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 生成历史弹窗 — 展示当前模板草稿的生成任务记录
|
||||
* 生成历史弹窗 — 展示当前剪辑计划的生成任务记录
|
||||
* 从 EditingPlanner 拆分,避免主文件过大
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
Regular → Executable
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 模板片段管理 Hook
|
||||
* 剪辑计划片段管理 Hook
|
||||
* 对接后端 PR#389 片段 CRUD API,替代原来的 config.segments 模式
|
||||
*
|
||||
* 功能:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -273,120 +273,6 @@
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AI 智能配音推荐
|
||||
============================================================ */
|
||||
.xx-voice-recommend-section {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-voice-recommend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-card:hover {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.xx-voice-recommend-card.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-voice-recommend-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #a5b4fc, #c4b5fd);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-voice-recommend-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.xx-voice-recommend-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
配音卡片(步骤4 choice-list 变体)
|
||||
============================================================ */
|
||||
@@ -662,147 +548,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
生成进度 / 结果卡片
|
||||
============================================================ */
|
||||
.xx-gen-progress-card {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-gen-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-gen-progress-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary-color, #4f46e5);
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-gen-progress-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-gen-progress-phase {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.xx-gen-progress-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.xx-gen-progress-percent {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #4f46e5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-gen-progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(79, 70, 229, 0.15);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-gen-progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4f46e5, #7c3aed);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-gen-progress-tip {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-gen-success-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-gen-success-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-gen-success-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-gen-success-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-gen-success-sub {
|
||||
font-size: 12px;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.xx-gen-error-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-gen-error-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.xx-gen-error-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-gen-error-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-gen-error-msg {
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
确认生成(步骤5)摘要
|
||||
============================================================ */
|
||||
@@ -1392,418 +1137,6 @@
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
.xx-smart-match-section {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-smart-match-input-area {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.xx-smart-match-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-smart-match-input {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary, #1e293b);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.xx-smart-match-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.xx-smart-match-input::placeholder {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-smart-match-input-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.xx-smart-match-tip {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-smart-match-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-results-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-smart-match-results-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color, #4f46e5);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.xx-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.xx-smart-match-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-card {
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-smart-match-card:hover {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.xx-smart-match-card.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-smart-match-score {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-info {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-reason {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-smart-match-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-summary {
|
||||
padding: 12px 16px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AI 智能生成标题
|
||||
============================================================ */
|
||||
.xx-ai-title-section {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-ai-title-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-ai-title-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-ai-title-input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.xx-ai-title-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.xx-ai-title-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.xx-ai-title-input::placeholder {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-ai-title-results {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.xx-ai-title-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-ai-title-results-count {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-ai-title-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.xx-ai-title-card {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-ai-title-card:hover {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.xx-ai-title-card.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-ai-title-card-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #1e293b);
|
||||
line-height: 1.5;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.xx-ai-title-card-tag {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.xx-ai-title-card.catchy .xx-ai-title-card-tag {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.xx-ai-title-card.emotional .xx-ai-title-card-tag {
|
||||
background: #fce7f3;
|
||||
color: #be185d;
|
||||
}
|
||||
|
||||
.xx-ai-title-card.informative .xx-ai-title-card-tag {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.xx-ai-title-card-check {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-ai-title-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.xx-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-divider::before,
|
||||
.xx-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.xx-divider span {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
标题设置(选择标题步骤)
|
||||
============================================================ */
|
||||
@@ -2416,226 +1749,3 @@
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
生成结果(右侧)
|
||||
================================================================ */
|
||||
|
||||
.xx-generate-result {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-result-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-result-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-result-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.xx-result-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.xx-progress-circle {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.xx-progress-circle svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.xx-progress-percent {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-progress-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 视频卡片网格 */
|
||||
.xx-video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.xx-video-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-video-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.xx-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-video-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.xx-video-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-video-play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.xx-video-card:hover .xx-video-play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-video-duration {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-video-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.xx-video-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-video-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-video-action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-video-action-btn:hover {
|
||||
background: var(--primary-100);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-result-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-btn-block {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 预览弹窗 */
|
||||
.xx-preview-modal-content {
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
@@ -111,8 +111,7 @@ const MyTemplates: React.FC = () => {
|
||||
}
|
||||
|
||||
const handleGenerate = (tpl: EditingTemplate) => {
|
||||
// 跳转到智能剪辑页面,统一从智能剪辑出片
|
||||
navigate(`/generate?templateId=${tpl.id}`)
|
||||
navigate(`/editing-planner?template=${tpl.id}&generate=1`)
|
||||
}
|
||||
|
||||
const handleCopy = (tpl: EditingTemplate) => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 对接后端模板管理 API:
|
||||
* - 分页查询(page/page_size/category/keyword/duration_range)
|
||||
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
|
||||
* - 复制模板 / 从模板生成
|
||||
* - 复制模板 / 从模板生成剪辑计划
|
||||
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
||||
*/
|
||||
import React, { useState, useMemo, useCallback } from "react"
|
||||
@@ -597,7 +597,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
<div className="xx-templates-header">
|
||||
<div className="xx-templates-header-text">
|
||||
<h2>模板库</h2>
|
||||
<p>选择模板快速创建,支持自定义修改</p>
|
||||
<p>选择模板快速创建剪辑计划,支持自定义修改</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
||||
+ 创建模板
|
||||
|
||||
Executable → Regular
+2
-92
@@ -36,13 +36,7 @@ import {
|
||||
type VoiceClone,
|
||||
} from "@/api/voiceClone"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import {
|
||||
getAssetsByKind,
|
||||
type AssetItem,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
createAsset,
|
||||
} from "@/api/assets"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import "./voices.css"
|
||||
|
||||
@@ -51,7 +45,7 @@ import "./voices.css"
|
||||
* ============================================================ */
|
||||
type VoiceGender = "male" | "female" | "child" | "elderly"
|
||||
type VoiceLanguage = "zh" | "en" | "ja" | "ko"
|
||||
type TabKey = "preset" | "cloned" | "material"
|
||||
type TabKey = "preset" | "cloned"
|
||||
|
||||
/** 前端展示用的预置音色(从 PresetVoiceItem 映射) */
|
||||
interface PresetVoiceDisplay {
|
||||
@@ -804,12 +798,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
})
|
||||
|
||||
/** 配音素材列表(用户上传音频) */
|
||||
const { data: materialData, isLoading: materialLoading } = useQuery({
|
||||
queryKey: ["voice-materials"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 统一统计(preset_count / clone_count) */
|
||||
const { data: unifiedStats } = useQuery({
|
||||
queryKey: ["voices-unified"],
|
||||
@@ -826,7 +814,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
)
|
||||
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
|
||||
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
|
||||
const materialCount = materialData?.length ?? 0
|
||||
|
||||
const filteredPreset = useMemo(() => {
|
||||
let list = presetVoices
|
||||
@@ -997,17 +984,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
我的克隆
|
||||
<span className="xx-voices-tab-count">{cloneCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTab("material")
|
||||
handlePause()
|
||||
}}
|
||||
>
|
||||
<SoundOutlined />
|
||||
配音素材
|
||||
<span className="xx-voices-tab-count">{materialCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "preset" && (
|
||||
@@ -1157,72 +1133,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "material" && (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 骨架屏加载 */}
|
||||
{materialLoading && (
|
||||
<div className="xx-voice-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="vmat-card vmat-card--skeleton">
|
||||
<div className="vmat-thumb" />
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-skeleton-line vmat-skeleton-title" />
|
||||
<div className="vmat-skeleton-line" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 卡片列表 */}
|
||||
{!materialLoading && (materialData?.length || 0) > 0 && (
|
||||
<div className="xx-voice-grid">
|
||||
{(materialData || []).map((asset: AssetItem) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
return (
|
||||
<div key={asset.id} className="vmat-card">
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="vmat-meta">
|
||||
<span>
|
||||
{asset.file_size
|
||||
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!materialLoading && (materialData?.length || 0) === 0 && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<h3>暂无配音素材</h3>
|
||||
<p>上传您的音频素材,用于视频配音</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setUploadOpen(true)}>
|
||||
<UploadOutlined /> 上传音频
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
|
||||
Executable → Regular
-102
@@ -970,105 +970,3 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
配音素材卡片(与配音库Tab集成)
|
||||
================================================================ */
|
||||
|
||||
.vmat-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vmat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.vmat-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vmat-thumb-icon {
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.vmat-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.vmat-info {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.vmat-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vmat-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.vmat-card--skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vmat-card--skeleton .vmat-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.vmat-skeleton-line {
|
||||
height: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
animation: vmat-shimmer 1.5s infinite linear;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 25%,
|
||||
var(--border-color) 50%,
|
||||
var(--bg-tertiary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.vmat-skeleton-title {
|
||||
width: 70%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes vmat-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,20 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "edit-plans",
|
||||
lazy: () =>
|
||||
import("@/pages/edit-plans/EditPlans").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "edit-plans/:planId/clips",
|
||||
lazy: () =>
|
||||
import("@/pages/edit-plans/PlanClipsManager").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: () =>
|
||||
|
||||
@@ -7,6 +7,5 @@ module.exports = {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
normalizeUser,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
refreshAccessToken,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "@/api/auth"
|
||||
|
||||
const mockPost = vi.fn()
|
||||
const mockGet = vi.fn()
|
||||
const mockAxiosPost = vi.fn()
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
defaults: { baseURL: "/api/v1" },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
},
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { normalizeUser } from "@/api/auth"
|
||||
|
||||
describe("normalizeUser", () => {
|
||||
it("normalizes canonical API current-user fields", () => {
|
||||
@@ -75,192 +44,4 @@ describe("normalizeUser", () => {
|
||||
created_at: "2026-06-22T00:00:00Z",
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers id over user_id when both present", () => {
|
||||
const result = normalizeUser({
|
||||
id: "id-first",
|
||||
user_id: "userid-second",
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
})
|
||||
expect(result.id).toBe("id-first")
|
||||
expect(result.user_id).toBe("id-first")
|
||||
})
|
||||
|
||||
it("prefers is_email_verified over email_verified", () => {
|
||||
const result = normalizeUser({
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
is_email_verified: true,
|
||||
email_verified: false,
|
||||
})
|
||||
expect(result.is_email_verified).toBe(true)
|
||||
expect(result.email_verified).toBe(true)
|
||||
})
|
||||
|
||||
it("defaults email verified to false when both missing", () => {
|
||||
const result = normalizeUser({
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
})
|
||||
expect(result.is_email_verified).toBe(false)
|
||||
expect(result.email_verified).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth API functions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPost.mockResolvedValue({ data: { success: true } })
|
||||
mockGet.mockResolvedValue({ data: {} })
|
||||
mockAxiosPost.mockResolvedValue({ data: { access_token: "tok" } })
|
||||
})
|
||||
|
||||
describe("login", () => {
|
||||
it("calls login API with correct params", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { access_token: "acc", refresh_token: "ref", user_id: "1" },
|
||||
})
|
||||
const result = await login({ email: "test@test.com", password: "pass" })
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/login", {
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
})
|
||||
expect(result.access_token).toBe("acc")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("login failed"))
|
||||
await expect(login({ email: "t", password: "p" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("register", () => {
|
||||
it("calls register API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "ok" } })
|
||||
const result = await register({
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
username: "testuser",
|
||||
})
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/register", {
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
username: "testuser",
|
||||
})
|
||||
expect(result.message).toBe("ok")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("register failed"))
|
||||
await expect(register({ email: "t", password: "p", username: "u" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logout", () => {
|
||||
it("calls logout API", async () => {
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
await logout()
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/logout")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("logout failed"))
|
||||
await expect(logout()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCurrentUser", () => {
|
||||
it("fetches and normalizes user", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: {
|
||||
user_id: "u1",
|
||||
email: "user@test.com",
|
||||
username: "user1",
|
||||
display_name: "User One",
|
||||
email_verified: true,
|
||||
},
|
||||
})
|
||||
const result = await getCurrentUser()
|
||||
expect(mockGet).toHaveBeenCalledWith("/auth/me")
|
||||
expect(result.id).toBe("u1")
|
||||
expect(result.email).toBe("user@test.com")
|
||||
expect(result.is_email_verified).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("fetch failed"))
|
||||
await expect(getCurrentUser()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("refreshAccessToken", () => {
|
||||
it("calls refresh endpoint with raw axios", async () => {
|
||||
mockAxiosPost.mockResolvedValue({
|
||||
data: { access_token: "new-acc", refresh_token: "new-ref" },
|
||||
})
|
||||
const result = await refreshAccessToken("old-refresh")
|
||||
expect(mockAxiosPost).toHaveBeenCalledWith("/api/v1/auth/refresh", {
|
||||
refresh_token: "old-refresh",
|
||||
})
|
||||
expect(result.access_token).toBe("new-acc")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockAxiosPost.mockRejectedValue(new Error("refresh failed"))
|
||||
await expect(refreshAccessToken("tok")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("requestPasswordReset", () => {
|
||||
it("calls forgot-password API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "sent" } })
|
||||
const result = await requestPasswordReset("test@test.com")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/forgot-password", {
|
||||
email: "test@test.com",
|
||||
})
|
||||
expect(result.message).toBe("sent")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("failed"))
|
||||
await expect(requestPasswordReset("e")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetPassword", () => {
|
||||
it("calls reset-password API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "reset ok" } })
|
||||
const result = await resetPassword("token123", "newpass")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/reset-password", {
|
||||
token: "token123",
|
||||
new_password: "newpass",
|
||||
})
|
||||
expect(result.message).toBe("reset ok")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("failed"))
|
||||
await expect(resetPassword("t", "p")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("verifyEmail", () => {
|
||||
it("calls verify-email API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "verified" } })
|
||||
const result = await verifyEmail("verify-token")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/verify-email", {
|
||||
token: "verify-token",
|
||||
})
|
||||
expect(result.message).toBe("verified")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("verify failed"))
|
||||
await expect(verifyEmail("t")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
message: { error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: {
|
||||
getState: vi.fn(() => ({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
refreshAccessToken: vi.fn(),
|
||||
}))
|
||||
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "@/api/auth"
|
||||
import apiClient from "@/api/client"
|
||||
|
||||
// 从真实实例取出拦截器回调
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{
|
||||
fulfilled: (config: unknown) => unknown
|
||||
rejected: (error: unknown) => unknown
|
||||
}>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseHandlers = (apiClient as any).interceptors.response.handlers as Array<{
|
||||
fulfilled: (response: unknown) => unknown
|
||||
rejected: (error: unknown) => Promise<unknown>
|
||||
}>
|
||||
|
||||
const requestInterceptor = requestHandlers[0]?.fulfilled!
|
||||
const requestErrorInterceptor = requestHandlers[0]?.rejected!
|
||||
const responseInterceptor = responseHandlers[0]?.fulfilled!
|
||||
const responseErrorInterceptor = responseHandlers[0]?.rejected!
|
||||
|
||||
function makeAxiosError(status?: number, data?: unknown, code?: string, hasResponse = true) {
|
||||
const err = {
|
||||
config: { headers: {} },
|
||||
message: "error",
|
||||
} as {
|
||||
config: { headers: Record<string, string>; _retry?: boolean; url?: string }
|
||||
response?: { status: number; data: unknown }
|
||||
code?: string
|
||||
message: string
|
||||
}
|
||||
if (hasResponse && status !== undefined) {
|
||||
err.response = { status, data }
|
||||
}
|
||||
if (code) err.code = code
|
||||
return err
|
||||
}
|
||||
|
||||
describe("apiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe("request interceptor", () => {
|
||||
it("adds Authorization header when token exists", () => {
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
const config = { headers: {} }
|
||||
const result = requestInterceptor(config) as { headers: { Authorization?: string } }
|
||||
expect(result.headers.Authorization).toBe("Bearer test-token")
|
||||
})
|
||||
|
||||
it("skips Authorization header when no token", () => {
|
||||
const config = { headers: {} }
|
||||
const result = requestInterceptor(config) as { headers: { Authorization?: string } }
|
||||
expect(result.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rejects on request error", async () => {
|
||||
const error = new Error("request error")
|
||||
await expect(requestErrorInterceptor(error) as Promise<never>).rejects.toThrow(
|
||||
"request error",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - success", () => {
|
||||
it("passes through successful response", () => {
|
||||
const response = { data: { success: true }, status: 200 }
|
||||
expect(responseInterceptor(response)).toBe(response)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - timeout & network", () => {
|
||||
it("shows timeout message for ECONNABORTED", async () => {
|
||||
const err = makeAxiosError(undefined, undefined, "ECONNABORTED")
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试")
|
||||
})
|
||||
|
||||
it("shows timeout message for timeout string", async () => {
|
||||
const err = { ...makeAxiosError(), message: "timeout of 10000ms exceeded" }
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试")
|
||||
})
|
||||
|
||||
it("shows network error when no response", async () => {
|
||||
const err = makeAxiosError(undefined, undefined, undefined, false)
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("网络连接异常,请检查网络设置")
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - server error messages", () => {
|
||||
it("shows detail field", async () => {
|
||||
const err = makeAxiosError(400, { detail: "参数错误" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("参数错误")
|
||||
})
|
||||
|
||||
it("shows message field", async () => {
|
||||
const err = makeAxiosError(400, { message: "操作失败" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("操作失败")
|
||||
})
|
||||
|
||||
it("shows msg field", async () => {
|
||||
const err = makeAxiosError(400, { msg: "出错了" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("出错了")
|
||||
})
|
||||
|
||||
it("handles nested message object", async () => {
|
||||
const err = makeAxiosError(400, { message: { message: "深层错误" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("深层错误")
|
||||
})
|
||||
|
||||
it("handles nested msg object", async () => {
|
||||
const err = makeAxiosError(400, { msg: { msg: "嵌套错误" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("嵌套错误")
|
||||
})
|
||||
|
||||
it("stringifies object with no string fields", async () => {
|
||||
const err = makeAxiosError(400, { detail: { code: 123, foo: "bar" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith('{"code":123,"foo":"bar"}')
|
||||
})
|
||||
|
||||
it("marks __msgShown when message displayed", async () => {
|
||||
const err = makeAxiosError(400, { detail: "test" }) as {
|
||||
config: { headers: Record<string, string> }
|
||||
response: { status: number; data: { detail: string } }
|
||||
message: string
|
||||
__msgShown?: boolean
|
||||
}
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(err.__msgShown).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - HTTP status codes", () => {
|
||||
it("shows file too large for 413", async () => {
|
||||
const err = makeAxiosError(413, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("文件过大,请缩小后重试")
|
||||
})
|
||||
|
||||
it("shows unsupported format for 415", async () => {
|
||||
const err = makeAxiosError(415, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("不支持的文件格式")
|
||||
})
|
||||
|
||||
it("shows service unavailable for 503", async () => {
|
||||
const err = makeAxiosError(503, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务暂不可用,请稍后再试")
|
||||
})
|
||||
|
||||
it("shows server busy for 500", async () => {
|
||||
const err = makeAxiosError(500, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
|
||||
})
|
||||
|
||||
it("shows server busy for 502", async () => {
|
||||
const err = makeAxiosError(502, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
|
||||
})
|
||||
|
||||
it("no message for 4xx without server msg", async () => {
|
||||
const err = makeAxiosError(403, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("no __msgShown for unhandled 4xx", async () => {
|
||||
const err = makeAxiosError(403, {}) as {
|
||||
config: { headers: Record<string, string> }
|
||||
response: { status: number; data: Record<string, never> }
|
||||
message: string
|
||||
__msgShown?: boolean
|
||||
}
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(err.__msgShown).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("safeExtractString edge cases", () => {
|
||||
it("returns empty string for numeric message", async () => {
|
||||
const err = makeAxiosError(400, { message: 123 })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns empty string for null data", async () => {
|
||||
const err = makeAxiosError(400, null)
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles detail with nested detail object", async () => {
|
||||
const err = makeAxiosError(400, { detail: { detail: "nested detail" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("nested detail")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("apiClient - 401 token refresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
localStorage.setItem("access_token", "old-access")
|
||||
localStorage.setItem("refresh_token", "old-refresh")
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("logs out when no refresh token on 401", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
|
||||
it("refreshes token on 401 and calls setAuth", async () => {
|
||||
const mockSetAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: mockSetAuth,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockResolvedValue({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
} as never)
|
||||
|
||||
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
|
||||
// 但我们只关心刷新逻辑是否正确执行
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// 重试会因为没有真实网络而失败,忽略
|
||||
}
|
||||
|
||||
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles refresh failure by logging out", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="page-head">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
||||
Select: () => <select />,
|
||||
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
Empty: () => <div>Empty</div>,
|
||||
Card: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Tooltip: ({ children }: any) => <span>{children}</span>,
|
||||
Upload: ({ children }: any) => <div>{children}</div>,
|
||||
Progress: () => <div />,
|
||||
Drawer: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
Table: () => <div />,
|
||||
Pagination: () => <div />,
|
||||
Tabs: () => <div />,
|
||||
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Popconfirm: ({ children }: any) => <span>{children}</span>,
|
||||
Form: ({ children }: any) => <form>{children}</form>,
|
||||
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
||||
InputNumber: () => <input type="number" />,
|
||||
Select: () => <select />,
|
||||
Empty: () => <div>Empty</div>,
|
||||
Space: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Badge: ({ children }: any) => <span>{children}</span>,
|
||||
Tooltip: ({ children }: any) => <span>{children}</span>,
|
||||
Upload: { Dragger: ({ children }: any) => <div>{children}</div> },
|
||||
Progress: () => <div />,
|
||||
Switch: () => <input type="checkbox" />,
|
||||
Radio: ({ children }: any) => <span>{children}</span>,
|
||||
RadioGroup: ({ children }: any) => <div>{children}</div>,
|
||||
Drawer: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
Popover: ({ children }: any) => <span>{children}</span>,
|
||||
Divider: () => <hr />,
|
||||
Dropdown: ({ children }: any) => <span>{children}</span>,
|
||||
Menu: () => <div />,
|
||||
Checkbox: ({ children }: any) => <span>{children}</span>,
|
||||
List: () => <div />,
|
||||
Avatar: ({ children }: any) => <span>{children}</span>,
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
Result: ({ status, title }: any) => <div data-status={status}>{title}</div>,
|
||||
Spin: () => <div>Loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
PlusOutlined: () => <span>+</span>,
|
||||
SearchOutlined: () => <span>S</span>,
|
||||
InboxOutlined: () => <span>I</span>,
|
||||
VideoCameraOutlined: () => <span>V</span>,
|
||||
PictureOutlined: () => <span>P</span>,
|
||||
PlayCircleOutlined: () => <span>▶</span>,
|
||||
CheckOutlined: () => <span>✓</span>,
|
||||
DeleteOutlined: () => <span>×</span>,
|
||||
ExperimentOutlined: () => <span>E</span>,
|
||||
LoadingOutlined: () => <span>L</span>,
|
||||
ExclamationCircleOutlined: () => <span>!</span>,
|
||||
TagsOutlined: () => <span>T</span>,
|
||||
EditOutlined: () => <span>E</span>,
|
||||
DownloadOutlined: () => <span>D</span>,
|
||||
MoreOutlined: () => <span>M</span>,
|
||||
FolderOutlined: () => <span>F</span>,
|
||||
FolderAddOutlined: () => <span>FA</span>,
|
||||
UploadOutlined: () => <span>U</span>,
|
||||
AudioOutlined: () => <span>A</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
getAssetDiagnosis: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
batchTagAssets: vi.fn().mockResolvedValue({}),
|
||||
batchClassifyAssets: vi.fn().mockResolvedValue({}),
|
||||
batchMarkAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
import AssetLibrary from "@/pages/assets/AssetLibrary"
|
||||
|
||||
describe("AssetLibrary", () => {
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<AssetLibrary />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("shows empty state when no assets", () => {
|
||||
const { getByText } = render(
|
||||
<MemoryRouter>
|
||||
<AssetLibrary />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// 空状态文案应该出现
|
||||
expect(getByText(/暂无素材/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Executable → Regular
+3
-16
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act, cleanup } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
@@ -13,25 +13,12 @@ vi.mock("@/components/layout/PageHead", () => ({
|
||||
import Billing from "@/pages/subscription/Billing"
|
||||
|
||||
describe("Billing Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should render without crashing", async () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<Billing />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// 跑完所有pending的timers和microtasks,确保异步状态更新都执行完
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import EditPlans from "@/pages/edit-plans/EditPlans"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn().mockImplementation((opts: any) => {
|
||||
const key = opts?.queryKey?.[0] || ""
|
||||
if (key === "templates-list-simple") {
|
||||
return { data: [], isLoading: false, isError: false, refetch: vi.fn() }
|
||||
}
|
||||
return {
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
}),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
CheckCircleOutlined: () => <span>CheckCircleOutlined</span>,
|
||||
ClockCircleOutlined: () => <span>ClockCircleOutlined</span>,
|
||||
SyncOutlined: () => <span>SyncOutlined</span>,
|
||||
CloseCircleOutlined: () => <span>CloseCircleOutlined</span>,
|
||||
EditOutlined: () => <span>EditOutlined</span>,
|
||||
DeleteOutlined: () => <span>DeleteOutlined</span>,
|
||||
FileTextOutlined: () => <span>FileTextOutlined</span>,
|
||||
ThunderboltOutlined: () => <span>ThunderboltOutlined</span>,
|
||||
CopyOutlined: () => <span>CopyOutlined</span>,
|
||||
UnorderedListOutlined: () => <span>UnorderedListOutlined</span>,
|
||||
StopOutlined: () => <span>StopOutlined</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/templates", () => ({
|
||||
getTemplatesList: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getEditPlans: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
deleteEditPlan: vi.fn(),
|
||||
generateEditPlan: vi.fn(),
|
||||
cancelGeneration: vi.fn(),
|
||||
copyEditPlan: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("EditPlans", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<EditPlans />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,327 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
VideoCameraOutlined: () => React.createElement("span", null, "V"),
|
||||
PictureOutlined: () => React.createElement("span", null, "P"),
|
||||
SoundOutlined: () => React.createElement("span", null, "S"),
|
||||
PlusOutlined: () => React.createElement("span", null, "+"),
|
||||
DeleteOutlined: () => React.createElement("span", null, "D"),
|
||||
EditOutlined: () => React.createElement("span", null, "E"),
|
||||
CopyOutlined: () => React.createElement("span", null, "C"),
|
||||
DownloadOutlined: () => React.createElement("span", null, "D"),
|
||||
PlayCircleOutlined: () => React.createElement("span", null, ">"),
|
||||
PauseCircleOutlined: () => React.createElement("span", null, "||"),
|
||||
LeftOutlined: () => React.createElement("span", null, "<"),
|
||||
RightOutlined: () => React.createElement("span", null, ">"),
|
||||
UpOutlined: () => React.createElement("span", null, "^"),
|
||||
DownOutlined: () => React.createElement("span", null, "v"),
|
||||
SaveOutlined: () => React.createElement("span", null, "S"),
|
||||
UndoOutlined: () => React.createElement("span", null, "U"),
|
||||
RedoOutlined: () => React.createElement("span", null, "R"),
|
||||
CloseOutlined: () => React.createElement("span", null, "X"),
|
||||
CheckOutlined: () => React.createElement("span", null, "v"),
|
||||
SettingOutlined: () => React.createElement("span", null, "S"),
|
||||
AppstoreOutlined: () => React.createElement("span", null, "#"),
|
||||
UnorderedListOutlined: () => React.createElement("span", null, "="),
|
||||
HistoryOutlined: () => React.createElement("span", null, "H"),
|
||||
UploadOutlined: () => React.createElement("span", null, "U"),
|
||||
SearchOutlined: () => React.createElement("span", null, "S"),
|
||||
FilterOutlined: () => React.createElement("span", null, "F"),
|
||||
FontColorsOutlined: () => React.createElement("span", null, "A"),
|
||||
BgColorsOutlined: () => React.createElement("span", null, "B"),
|
||||
AudioOutlined: () => React.createElement("span", null, "A"),
|
||||
MusicOutlined: () => React.createElement("span", null, "M"),
|
||||
ScissorOutlined: () => React.createElement("span", null, "X"),
|
||||
ThunderboltOutlined: () => React.createElement("span", null, "T"),
|
||||
ExperimentOutlined: () => React.createElement("span", null, "E"),
|
||||
BulbOutlined: () => React.createElement("span", null, "B"),
|
||||
FundOutlined: () => React.createElement("span", null, "F"),
|
||||
LayoutOutlined: () => React.createElement("span", null, "L"),
|
||||
ColumnHeightOutlined: () => React.createElement("span", null, "C"),
|
||||
SwapOutlined: () => React.createElement("span", null, "S"),
|
||||
}))
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => ({
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Tabs: ({ items }: any) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
null,
|
||||
items?.map?.(() => React.createElement("div")),
|
||||
),
|
||||
TabPane: () => React.createElement("div"),
|
||||
Drawer: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Slider: () => React.createElement("div"),
|
||||
ColorPicker: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Row: ({ children }: any) => React.createElement("div", null, children),
|
||||
Col: ({ children }: any) => React.createElement("div", null, children),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
}))
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
createEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
updateEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
getTemplateCategories: vi.fn().mockResolvedValue({ items: [] }),
|
||||
MODE_LABELS: { pip: "画中画", intro_outro: "片头片尾", watermark: "水印" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
generateCover: vi.fn().mockResolvedValue({}),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
cancelGeneration: vi.fn().mockResolvedValue({}),
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "default-lib" }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/projects", () => ({
|
||||
getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "default-project" }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/bgm", () => ({
|
||||
DEFAULT_BGM_MIX_CONFIG: { volume: 1, fade_in: 0, fade_out: 0 },
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/editing-planner/components/MediaPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "MediaPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/PreviewPlayer", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "PreviewPlayer" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TimelinePanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TimelinePanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/ClipPropertiesPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "ClipPropertiesPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/EditorClipList", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "EditorClipList" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/BgmSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "BgmSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SubtitleStylePanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SubtitleStylePanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TransitionSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TransitionSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SpeedPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SpeedPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TtsPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TtsPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/WatermarkPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "WatermarkPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/IntroOutroPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "IntroOutroPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/PipConfigPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "PipConfigPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/FilterPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "FilterPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GreenScreenPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "StickerPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "CoverSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }),
|
||||
}))
|
||||
|
||||
// === useUndoRedo hook mock ===
|
||||
vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({
|
||||
useUndoRedo: vi.fn((initial: any) => ({
|
||||
state: initial,
|
||||
setState: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Types mock ===
|
||||
vi.mock("@/pages/editing-planner/types", () => ({
|
||||
DEFAULT_TRANSITION: { type: "fade", duration: 0.5 },
|
||||
DEFAULT_SPEED: { rate: 1 },
|
||||
DEFAULT_TTS_CONFIG: { enabled: false },
|
||||
DEFAULT_WATERMARK: { enabled: false },
|
||||
DEFAULT_INTRO_OUTRO: { enabled: false },
|
||||
DEFAULT_PIP_CONFIG: { enabled: false },
|
||||
DEFAULT_FILTER_CONFIG: { enabled: false },
|
||||
DEFAULT_CHROMA_KEY_CONFIG: { enabled: false },
|
||||
DEFAULT_STICKER_CONFIG: { enabled: false },
|
||||
DEFAULT_COVER_CONFIG: { enabled: false },
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/editing-planner/types/subtitle", () => ({
|
||||
DEFAULT_SUBTITLE_STYLE: {
|
||||
font_size: 24,
|
||||
font_color: "#ffffff",
|
||||
background_color: "#000000",
|
||||
},
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
import EditingPlanner from "@/pages/editing-planner/EditingPlanner"
|
||||
|
||||
describe("EditingPlanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders with templateId query param", () => {
|
||||
const { container } = render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ["?templateId=tpl-123"] },
|
||||
React.createElement(EditingPlanner),
|
||||
),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders with planId query param", () => {
|
||||
const { container } = render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ["?planId=plan-456"] },
|
||||
React.createElement(EditingPlanner),
|
||||
),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,287 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// Hoisted mock icons factory
|
||||
const hoistedIcons = vi.hoisted(() => {
|
||||
const iconNames = [
|
||||
"AudioOutlined",
|
||||
"ThunderboltOutlined",
|
||||
"CheckCircleFilled",
|
||||
"CheckCircleOutlined",
|
||||
"CloseCircleOutlined",
|
||||
"LoadingOutlined",
|
||||
"PlayCircleOutlined",
|
||||
"PauseCircleOutlined",
|
||||
"DownloadOutlined",
|
||||
"ShareAltOutlined",
|
||||
"SaveOutlined",
|
||||
"PlusOutlined",
|
||||
"MinusOutlined",
|
||||
"CloseOutlined",
|
||||
"SearchOutlined",
|
||||
"EditOutlined",
|
||||
"DeleteOutlined",
|
||||
"UploadOutlined",
|
||||
"FolderOutlined",
|
||||
"FolderAddOutlined",
|
||||
"MoreOutlined",
|
||||
"ExperimentOutlined",
|
||||
"ExclamationCircleOutlined",
|
||||
"InboxOutlined",
|
||||
"VideoCameraOutlined",
|
||||
"PictureOutlined",
|
||||
"SoundOutlined",
|
||||
"UserOutlined",
|
||||
"ManOutlined",
|
||||
"WomanOutlined",
|
||||
"TagsOutlined",
|
||||
"MutedOutlined",
|
||||
"RobotOutlined",
|
||||
"UnorderedListOutlined",
|
||||
"AppstoreOutlined",
|
||||
"UndoOutlined",
|
||||
"RedoOutlined",
|
||||
"SettingOutlined",
|
||||
"HistoryOutlined",
|
||||
"FilterOutlined",
|
||||
"FontColorsOutlined",
|
||||
"BgColorsOutlined",
|
||||
"MusicOutlined",
|
||||
"ScissorOutlined",
|
||||
"BulbOutlined",
|
||||
"FundOutlined",
|
||||
"LayoutOutlined",
|
||||
"ColumnHeightOutlined",
|
||||
"SwapOutlined",
|
||||
"LeftOutlined",
|
||||
"RightOutlined",
|
||||
"UpOutlined",
|
||||
"DownOutlined",
|
||||
"CopyOutlined",
|
||||
]
|
||||
const icons: Record<string, React.FC> = {}
|
||||
iconNames.forEach((name) => {
|
||||
icons[name] = () => React.createElement("span", null, name.charAt(0))
|
||||
})
|
||||
return icons
|
||||
})
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => hoistedIcons)
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => {
|
||||
const Typography = {
|
||||
Text: ({ children }: any) => React.createElement("span", null, children),
|
||||
Title: ({ children }: any) => React.createElement("h1", null, children),
|
||||
Paragraph: ({ children }: any) => React.createElement("p", null, children),
|
||||
}
|
||||
return {
|
||||
Typography,
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tabs: () => React.createElement("div"),
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
Result: ({ status, title }: any) =>
|
||||
React.createElement("div", { "data-status": status }, title),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }),
|
||||
Steps: () => React.createElement("div"),
|
||||
Step: () => React.createElement("div"),
|
||||
}
|
||||
})
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue([]),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }),
|
||||
getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }),
|
||||
saveTtsToLibrary: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voices", () => ({
|
||||
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
MODE_LABELS: { pip: "画中画" },
|
||||
}))
|
||||
|
||||
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({}),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
formatDuration: vi.fn((s: number) => `${s}s`),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
interceptors: { request: { handlers: [] }, response: { handlers: [] } },
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
put: vi.fn().mockResolvedValue({ data: {} }),
|
||||
delete: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}))
|
||||
|
||||
// === Hooks mock ===
|
||||
vi.mock("@/hooks/useCloneProgress", () => ({
|
||||
useCloneProgress: vi.fn(() => ({
|
||||
progress: 0,
|
||||
status: "idle",
|
||||
start: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Components mock ===
|
||||
vi.mock("@/components/voice/CloneModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "CloneModal" }),
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
// CSS mock
|
||||
vi.mock("@/pages/generate/generate.css", () => ({}))
|
||||
|
||||
import GeneratePage from "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("GeneratePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(GeneratePage)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(GeneratePage)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import PlanClipsManager from "@/pages/edit-plans/PlanClipsManager"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ templateId: "test-123" }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { clips: [], name: "Test Plan" },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
ArrowLeftOutlined: () => <span>ArrowLeftOutlined</span>,
|
||||
PlusOutlined: () => <span>PlusOutlined</span>,
|
||||
DeleteOutlined: () => <span>DeleteOutlined</span>,
|
||||
EditOutlined: () => <span>EditOutlined</span>,
|
||||
UploadOutlined: () => <span>UploadOutlined</span>,
|
||||
OrderedListOutlined: () => <span>OrderedListOutlined</span>,
|
||||
SaveOutlined: () => <span>SaveOutlined</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getPlanClips: vi.fn().mockResolvedValue({ clips: [], name: "" }),
|
||||
updatePlanClipsOrder: vi.fn(),
|
||||
deletePlanClip: vi.fn(),
|
||||
createPlanClip: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("PlanClipsManager", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<PlanClipsManager />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// Hoisted mock icons factory - must be before all vi.mock calls
|
||||
const hoistedIcons = vi.hoisted(() => {
|
||||
const iconNames = [
|
||||
"AudioOutlined",
|
||||
"PlayCircleOutlined",
|
||||
"PauseCircleOutlined",
|
||||
"SearchOutlined",
|
||||
"PlusOutlined",
|
||||
"EditOutlined",
|
||||
"DeleteOutlined",
|
||||
"UploadOutlined",
|
||||
"UnorderedListOutlined",
|
||||
"AppstoreOutlined",
|
||||
"CloseOutlined",
|
||||
"SoundOutlined",
|
||||
"UserOutlined",
|
||||
"ManOutlined",
|
||||
"WomanOutlined",
|
||||
"CheckOutlined",
|
||||
"TagsOutlined",
|
||||
"MutedOutlined",
|
||||
"RobotOutlined",
|
||||
"LoadingOutlined",
|
||||
"FolderOutlined",
|
||||
"FolderAddOutlined",
|
||||
"MoreOutlined",
|
||||
"ExperimentOutlined",
|
||||
"ExclamationCircleOutlined",
|
||||
"DownloadOutlined",
|
||||
"InboxOutlined",
|
||||
"VideoCameraOutlined",
|
||||
"PictureOutlined",
|
||||
"SaveOutlined",
|
||||
"UndoOutlined",
|
||||
"RedoOutlined",
|
||||
"SettingOutlined",
|
||||
"HistoryOutlined",
|
||||
"FilterOutlined",
|
||||
"FontColorsOutlined",
|
||||
"BgColorsOutlined",
|
||||
"MusicOutlined",
|
||||
"ScissorOutlined",
|
||||
"ThunderboltOutlined",
|
||||
"BulbOutlined",
|
||||
"FundOutlined",
|
||||
"LayoutOutlined",
|
||||
"ColumnHeightOutlined",
|
||||
"SwapOutlined",
|
||||
"LeftOutlined",
|
||||
"RightOutlined",
|
||||
"UpOutlined",
|
||||
"DownOutlined",
|
||||
"CopyOutlined",
|
||||
]
|
||||
const icons: Record<string, React.FC> = {}
|
||||
iconNames.forEach((name) => {
|
||||
icons[name] = () => React.createElement("span", null, name.charAt(0))
|
||||
})
|
||||
return icons
|
||||
})
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => hoistedIcons)
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => ({
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tabs: () => React.createElement("div"),
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }),
|
||||
}))
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue([]),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
batchTagAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }),
|
||||
getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }),
|
||||
saveTtsToLibrary: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voices", () => ({
|
||||
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
import VoiceMaterialLibrary from "@/pages/voice-materials/VoiceMaterialLibrary"
|
||||
|
||||
describe("VoiceMaterialLibrary", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,493 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
onSuccess: undefined,
|
||||
onError: undefined,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
updateEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
deleteEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
reorderEditPlanClips: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({ deleted_count: 0 }),
|
||||
createClipsFromAssets: vi.fn().mockResolvedValue({ created_count: 0 }),
|
||||
}))
|
||||
|
||||
vi.mock("./useUndoRedo", () => ({
|
||||
useUndoRedo: vi.fn(() => ({
|
||||
state: [],
|
||||
set: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
import { useEditPlanClips } from "@/pages/editing-planner/hooks/useEditPlanClips"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
|
||||
describe("useEditPlanClips", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns default state with planId", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clips).toEqual([])
|
||||
expect(result.current.clipsTotal).toBe(0)
|
||||
expect(result.current.clipsLoading).toBe(false)
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
expect(result.current.selectedClip).toBeNull()
|
||||
})
|
||||
|
||||
it("returns clips from query data", () => {
|
||||
const mockClips = [
|
||||
{ id: "clip-1", type: "video", asset_id: "a1", order: 0 },
|
||||
{ id: "clip-2", type: "video", asset_id: "a2", order: 1 },
|
||||
]
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: mockClips, total: 2 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clips).toHaveLength(2)
|
||||
expect(result.current.clipsTotal).toBe(2)
|
||||
expect(result.current.clips[0].id).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("handles loading state", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clipsLoading).toBe(true)
|
||||
expect(result.current.clips).toEqual([])
|
||||
})
|
||||
|
||||
it("disables query when no planId", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
expect(result.current.clips).toEqual([])
|
||||
})
|
||||
|
||||
it("setSelectedClipId updates selection", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("selectedClip finds matching clip", () => {
|
||||
const mockClips = [{ id: "clip-1", type: "video", order: 0 }]
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: mockClips, total: 1 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClip?.id).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("selectedClip returns null when no match", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: [{ id: "c1", order: 0 }], total: 1 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("nonexistent")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClip).toBeNull()
|
||||
})
|
||||
|
||||
it("addClip calls createMutation with order", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => {
|
||||
// 捕获 onSuccess/onError 回调
|
||||
return {
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.addClip({ type: "video", asset_id: "a1" })
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("addClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.addClip({ type: "video", asset_id: "a1" })
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("removeClip calls deleteMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith("clip-1")
|
||||
})
|
||||
|
||||
it("removeClip clears selection if selected clip is deleted", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
expect(result.current.selectedClipId).toBe("clip-1")
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
})
|
||||
|
||||
it("removeClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("updateClip calls updateMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.updateClip("clip-1", { duration: 10 })
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
clipId: "clip-1",
|
||||
data: { duration: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it("updateClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.updateClip("c1", {})
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("batchRemoveClips calls batchDeleteMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
it("batchRemoveClips does nothing with empty array", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("batchRemoveClips clears selection if selected is in batch", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
})
|
||||
|
||||
it("reorderClips calls reorderMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.reorderClips([{ id: "c1", order: 0 }])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith([{ id: "c1", order: 0 }])
|
||||
})
|
||||
|
||||
it("reorderClips does nothing with empty items", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.reorderClips([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("importFromAssets calls importMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets(["asset-1", "asset-2"])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(["asset-1", "asset-2"])
|
||||
})
|
||||
|
||||
it("importFromAssets does nothing with empty array", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("importFromAssets does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets(["a1"])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns local undo redo state", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
expect(result.current.localClips).toEqual([])
|
||||
expect(typeof result.current.setLocalClips).toBe("function")
|
||||
expect(typeof result.current.undo).toBe("function")
|
||||
expect(typeof result.current.redo).toBe("function")
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
expect(typeof result.current.resetLocalClips).toBe("function")
|
||||
})
|
||||
|
||||
it("exposes mutation status flags", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
expect(result.current.isCreating).toBe(false)
|
||||
expect(result.current.isUpdating).toBe(false)
|
||||
expect(result.current.isDeleting).toBe(false)
|
||||
expect(result.current.isReordering).toBe(false)
|
||||
expect(result.current.isImporting).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
DEFAULT_SUBTITLE_STYLE,
|
||||
type SubtitleStyleConfig,
|
||||
type SubtitleMode,
|
||||
} from "@/pages/editing-planner/types/subtitle"
|
||||
|
||||
describe("subtitle types & defaults", () => {
|
||||
it("DEFAULT_SUBTITLE_STYLE has correct shape", () => {
|
||||
expect(DEFAULT_SUBTITLE_STYLE).toMatchObject<SubtitleStyleConfig>({
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
})
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE enabled is boolean", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.enabled).toBe("boolean")
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE fontSize is number", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.fontSize).toBe("number")
|
||||
expect(DEFAULT_SUBTITLE_STYLE.fontSize).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE position is valid", () => {
|
||||
expect(["top", "center", "bottom"]).toContain(DEFAULT_SUBTITLE_STYLE.position)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE mode is valid SubtitleMode", () => {
|
||||
const mode: SubtitleMode = DEFAULT_SUBTITLE_STYLE.mode
|
||||
expect(["manual", "asr"]).toContain(mode)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE asrLanguage is valid", () => {
|
||||
expect(["zh", "en"]).toContain(DEFAULT_SUBTITLE_STYLE.asrLanguage)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE has all required fields", () => {
|
||||
const keys = Object.keys(DEFAULT_SUBTITLE_STYLE)
|
||||
expect(keys.length).toBeGreaterThanOrEqual(10)
|
||||
expect(keys).toContain("enabled")
|
||||
expect(keys).toContain("mode")
|
||||
expect(keys).toContain("fontSize")
|
||||
expect(keys).toContain("fontColor")
|
||||
expect(keys).toContain("stroke")
|
||||
expect(keys).toContain("shadow")
|
||||
expect(keys).toContain("position")
|
||||
expect(keys).toContain("font")
|
||||
expect(keys).toContain("animation")
|
||||
expect(keys).toContain("asrLanguage")
|
||||
})
|
||||
|
||||
it("fontColor is valid hex color", () => {
|
||||
expect(DEFAULT_SUBTITLE_STYLE.fontColor).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
})
|
||||
|
||||
it("animation is string", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.animation).toBe("string")
|
||||
})
|
||||
|
||||
it("font is non-empty string", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.font).toBe("string")
|
||||
expect(DEFAULT_SUBTITLE_STYLE.font.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -1,220 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter, Routes, Route, Navigate } from "react-router-dom"
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: vi.fn((selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: vi.fn(),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/home/HomePage", () => ({
|
||||
default: () => <div data-testid="home-page">Home</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/layout/MainLayout", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="main-layout">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { router } from "@/router"
|
||||
|
||||
// 模拟 ProtectedRoute 逻辑(和 router/index.tsx 一致)
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
|
||||
if (!isAuthenticated || !hasAccessToken) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const HomeRoute = () => {
|
||||
const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
|
||||
if (isAuthenticated && hasAccessToken) {
|
||||
return <Navigate to="/app/dashboard" replace />
|
||||
}
|
||||
|
||||
return <div data-testid="home-page">Home</div>
|
||||
}
|
||||
|
||||
describe("router - ProtectedRoute", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("redirects to login when not authenticated", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: false }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("login")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("redirects to login when authenticated but no token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.removeItem("access_token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("login")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders children when authenticated and has token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("protected")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("router - HomeRoute", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("shows home page when not authenticated", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: false }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("home-page")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("redirects to dashboard when authenticated with token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("dashboard")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("shows home when authenticated but no localStorage token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("home-page")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("router config", () => {
|
||||
it("exports router", () => {
|
||||
expect(router).toBeDefined()
|
||||
})
|
||||
|
||||
it("router has correct number of top-level routes", () => {
|
||||
const routes = router.routes
|
||||
expect(Array.isArray(routes)).toBe(true)
|
||||
expect(routes.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it("includes login route", () => {
|
||||
const loginRoute = router.routes.find((r: any) => r.path === "/login")
|
||||
expect(loginRoute).toBeDefined()
|
||||
})
|
||||
|
||||
it("includes register route", () => {
|
||||
const registerRoute = router.routes.find((r: any) => r.path === "/register")
|
||||
expect(registerRoute).toBeDefined()
|
||||
})
|
||||
|
||||
it("includes root route", () => {
|
||||
const rootRoute = router.routes.find((r: any) => r.path === "/")
|
||||
expect(rootRoute).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -142,20 +142,19 @@ def _download_via_http(url: str, local_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
|
||||
def upload_to_oss(local_path: Path, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公开 URL。
|
||||
|
||||
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
|
||||
上传加总超时保护(默认 300s),防止网络异常时无限挂死。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径(Path 或 str 均可)
|
||||
local_path: 本地文件路径
|
||||
storage_key: 目标存储键
|
||||
|
||||
Returns:
|
||||
公开访问 URL,上传失败或 OSS 未配置时返回 None。
|
||||
"""
|
||||
local_path = Path(local_path) # 统一转 Path,兼容 str 调用
|
||||
bucket = oss_bucket()
|
||||
if bucket is None:
|
||||
return None
|
||||
|
||||
@@ -186,15 +186,85 @@ class RenderAdapter:
|
||||
|
||||
self._report_progress(progress_cb, 35.0, "准备 BGM 音频")
|
||||
|
||||
# 3~6. 统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)
|
||||
return self._do_render(
|
||||
# 3. 准备 BGM(从 plan.config.bgm 读取配置)
|
||||
bgm_path = self._prepare_bgm(plan, work_dir, plan_id)
|
||||
|
||||
self._report_progress(progress_cb, 40.0, "执行视频渲染")
|
||||
|
||||
# 4. 初始化 ASR 服务(用于自动字幕)
|
||||
asr_service = self._get_asr_service()
|
||||
|
||||
# 5. 从 plan.config.export 读取输出分辨率
|
||||
plan_config = plan.config or {}
|
||||
export_config = plan_config.get("export", {}) or {}
|
||||
output_width, output_height = _parse_resolution(export_config.get("resolution"))
|
||||
logger.info(
|
||||
"渲染输出分辨率: plan_id=%s resolution=%dx%d source=%s",
|
||||
plan_id,
|
||||
output_width,
|
||||
output_height,
|
||||
"config" if export_config.get("resolution") else "default",
|
||||
)
|
||||
|
||||
# 6. 执行统一渲染
|
||||
render_svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=ready_clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=work_dir,
|
||||
plan_id=plan_id,
|
||||
job_id=job_id,
|
||||
progress_cb=progress_cb,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
bgm_path=bgm_path,
|
||||
asr_service=asr_service,
|
||||
)
|
||||
result = render_svc.render()
|
||||
|
||||
self._report_progress(progress_cb, 80.0, "上传渲染结果")
|
||||
|
||||
# 4. 上传结果
|
||||
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
|
||||
output_url = upload_to_oss(result.output_path, storage_key)
|
||||
|
||||
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
|
||||
|
||||
# 5. 生成缩略图(在清理临时目录前)
|
||||
thumbnail_url = ""
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
thumb_err,
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
|
||||
logger.info(
|
||||
"[render-adapter] render success: plan_id=%s job_id=%s engine=unified "
|
||||
"duration=%.2fs file_size=%d resolution=%dx%d clip_count=%d",
|
||||
plan_id,
|
||||
job_id,
|
||||
result.duration,
|
||||
result.file_size,
|
||||
result.width,
|
||||
result.height,
|
||||
len(ready_clips),
|
||||
)
|
||||
|
||||
return RenderAdapterResult(
|
||||
success=True,
|
||||
output_url=output_url or "",
|
||||
output_path=result.output_path,
|
||||
thumbnail_url=thumbnail_url,
|
||||
duration=result.duration,
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
clip_count=len(ready_clips),
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
)
|
||||
@@ -470,215 +540,3 @@ class RenderAdapter:
|
||||
except Exception as e:
|
||||
logger.warning("ASR 服务初始化失败,自动字幕将不可用: %s", e)
|
||||
return None
|
||||
|
||||
def _do_render(
|
||||
self,
|
||||
plan: Any,
|
||||
clips: list[Any],
|
||||
asset_path_map: dict[str, Path],
|
||||
work_dir: Path,
|
||||
*,
|
||||
plan_id: str,
|
||||
job_id: str = "",
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
rendered_clip_ids: list[str] | None = None,
|
||||
failed_clip_ids: list[str] | None = None,
|
||||
) -> RenderAdapterResult:
|
||||
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
|
||||
|
||||
render_plan 和 render_from_memory 共用此方法。
|
||||
|
||||
Args:
|
||||
rendered_clip_ids: 成功下载/准备的 clip id 列表(render_plan 从下载阶段传入)
|
||||
failed_clip_ids: 失败的 clip id 列表
|
||||
|
||||
Returns:
|
||||
RenderAdapterResult
|
||||
"""
|
||||
# 1. 准备 BGM
|
||||
bgm_path = self._prepare_bgm(plan, work_dir, plan_id)
|
||||
|
||||
self._report_progress(progress_cb, 40.0, "执行视频渲染")
|
||||
|
||||
# 2. 初始化 ASR
|
||||
asr_service = self._get_asr_service()
|
||||
|
||||
# 3. 读取输出分辨率
|
||||
plan_config = plan.config or {}
|
||||
export_config = plan_config.get("export", {}) or {}
|
||||
output_width, output_height = _parse_resolution(export_config.get("resolution"))
|
||||
logger.info(
|
||||
"渲染输出分辨率: plan_id=%s resolution=%dx%d source=%s",
|
||||
plan_id,
|
||||
output_width,
|
||||
output_height,
|
||||
"config" if export_config.get("resolution") else "default",
|
||||
)
|
||||
|
||||
# 4. 执行统一渲染
|
||||
render_svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=work_dir,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
bgm_path=bgm_path,
|
||||
asr_service=asr_service,
|
||||
)
|
||||
result = render_svc.render()
|
||||
|
||||
self._report_progress(progress_cb, 80.0, "上传渲染结果")
|
||||
|
||||
# 5. 上传结果
|
||||
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
|
||||
output_url = upload_to_oss(result.output_path, storage_key)
|
||||
|
||||
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
|
||||
|
||||
# 6. 生成缩略图
|
||||
thumbnail_url = ""
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
thumb_err,
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
|
||||
logger.info(
|
||||
"[render-adapter] render success: plan_id=%s job_id=%s engine=unified "
|
||||
"duration=%.2fs file_size=%d resolution=%dx%d clip_count=%d",
|
||||
plan_id,
|
||||
job_id,
|
||||
result.duration,
|
||||
result.file_size,
|
||||
result.width,
|
||||
result.height,
|
||||
len(clips),
|
||||
)
|
||||
|
||||
final_rendered_ids = (
|
||||
rendered_clip_ids if rendered_clip_ids is not None else [c.id for c in clips if hasattr(c, "id")]
|
||||
)
|
||||
final_failed_ids = failed_clip_ids if failed_clip_ids is not None else []
|
||||
|
||||
return RenderAdapterResult(
|
||||
success=True,
|
||||
output_url=output_url or "",
|
||||
output_path=result.output_path,
|
||||
thumbnail_url=thumbnail_url,
|
||||
duration=result.duration,
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
clip_count=len(clips),
|
||||
rendered_clip_ids=final_rendered_ids,
|
||||
failed_clip_ids=final_failed_ids,
|
||||
)
|
||||
|
||||
def render_from_memory(
|
||||
self,
|
||||
plan: Any,
|
||||
clips: list[Any],
|
||||
asset_path_map: dict[str, Path],
|
||||
*,
|
||||
plan_id: str = "",
|
||||
job_id: str = "",
|
||||
work_dir: Path | None = None,
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
) -> RenderAdapterResult:
|
||||
"""使用内存中的 plan/clips/asset_path_map 直接渲染。
|
||||
|
||||
适用于一键生成等不写DB剪辑计划的场景,复用统一的 BGM/ASR/分辨率/渲染/缩略图逻辑。
|
||||
|
||||
Args:
|
||||
plan: 类 EditPlan 的对象(鸭子类型,需有 id/config 等属性)
|
||||
clips: 类 EditPlanClip 的对象列表
|
||||
asset_path_map: asset_id → local_path 映射
|
||||
plan_id: 用于日志的计划标识(不传则用 plan.id)
|
||||
job_id: 关联的 Job ID
|
||||
work_dir: 工作目录,不传则用临时目录
|
||||
progress_cb: 进度回调
|
||||
|
||||
Returns:
|
||||
RenderAdapterResult
|
||||
"""
|
||||
actual_plan_id = plan_id or getattr(plan, "id", "memory_plan")
|
||||
temp_dir = None
|
||||
try:
|
||||
if work_dir is None:
|
||||
temp_dir = tempfile.mkdtemp(prefix="render_mem_")
|
||||
work_dir = Path(temp_dir)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not clips:
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message="没有可渲染的片段",
|
||||
clip_count=0,
|
||||
)
|
||||
|
||||
if not asset_path_map:
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message="素材路径映射为空",
|
||||
clip_count=len(clips),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"开始内存模式渲染: plan_id=%s job_id=%s clip_count=%d engine=unified",
|
||||
actual_plan_id,
|
||||
job_id,
|
||||
len(clips),
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 35.0, "准备 BGM 音频")
|
||||
|
||||
return self._do_render(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=work_dir,
|
||||
plan_id=actual_plan_id,
|
||||
job_id=job_id,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr_text = (exc.stderr or "").strip()
|
||||
logger.error(
|
||||
"[render-adapter] 内存模式渲染失败: plan_id=%s exit_code=%d\nstderr:\n%s",
|
||||
actual_plan_id,
|
||||
exc.returncode,
|
||||
stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
|
||||
)
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[:200]}",
|
||||
error_detail=stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"[render-adapter] 内存模式渲染失败: plan_id=%s error=%s",
|
||||
actual_plan_id,
|
||||
str(exc)[:200],
|
||||
)
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=str(exc)[:500],
|
||||
)
|
||||
finally:
|
||||
if temp_dir:
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
except Exception as cleanup_err:
|
||||
logger.warning("临时目录清理失败: path=%s error=%s", temp_dir, cleanup_err)
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"""渲染引擎 Feature Flag 解析器。
|
||||
|
||||
封装渲染引擎选择逻辑,支持:
|
||||
- 环境变量作为默认值(RENDER_ENGINE=legacy/unified)
|
||||
- Redis Feature Flag 运行时覆盖(白名单 + 百分比 + 全局开关)
|
||||
- 定时刷新,支持热更新不重启 worker
|
||||
|
||||
使用方式:
|
||||
resolver = RenderEngineResolver(redis_url="redis://...", default_engine="legacy")
|
||||
engine = resolver.get_engine(user_id="user123")
|
||||
# engine: "legacy" 或 "unified"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
FeatureFlagStore,
|
||||
InMemoryFeatureFlagStore,
|
||||
RedisFeatureFlagStore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature Flag 名称常量
|
||||
FLAG_RENDER_ENGINE = "render_engine"
|
||||
|
||||
# 引擎常量
|
||||
ENGINE_LEGACY = "legacy"
|
||||
ENGINE_UNIFIED = "unified"
|
||||
VALID_ENGINES = {ENGINE_LEGACY, ENGINE_UNIFIED}
|
||||
|
||||
|
||||
class RenderEngineResolver:
|
||||
"""渲染引擎选择器。
|
||||
|
||||
判定逻辑(从高到低):
|
||||
1. Redis flag 白名单匹配 → unified
|
||||
2. Redis flag 百分比命中 → unified
|
||||
3. Redis flag 全局开启(100%)→ unified
|
||||
4. 环境变量默认值 → legacy / unified
|
||||
|
||||
当 Redis 不可用时,自动降级到环境变量默认值,不影响业务。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default_engine: str = ENGINE_LEGACY,
|
||||
redis_url: Optional[str] = None,
|
||||
refresh_interval: float = 30.0,
|
||||
store: Optional[FeatureFlagStore] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
default_engine: 环境变量默认的引擎名(legacy / unified)
|
||||
redis_url: Redis 连接 URL,传 None 时使用内存实现(测试用)
|
||||
refresh_interval: Redis flag 配置刷新间隔(秒)
|
||||
store: 直接传入 store 实例(测试用,优先级高于 redis_url)
|
||||
"""
|
||||
self._default_engine = default_engine.lower() if default_engine else ENGINE_LEGACY
|
||||
if self._default_engine not in VALID_ENGINES:
|
||||
logger.warning(
|
||||
"Invalid default engine '%s', fallback to '%s'",
|
||||
self._default_engine,
|
||||
ENGINE_LEGACY,
|
||||
)
|
||||
self._default_engine = ENGINE_LEGACY
|
||||
|
||||
if store is not None:
|
||||
self._store = store
|
||||
elif redis_url:
|
||||
self._store = RedisFeatureFlagStore(redis_url=redis_url)
|
||||
else:
|
||||
self._store = InMemoryFeatureFlagStore()
|
||||
logger.info("No Redis configured, using in-memory feature flag store")
|
||||
|
||||
self._refresh_interval = refresh_interval
|
||||
self._lock = threading.Lock()
|
||||
self._cached_config: Optional[FeatureFlagConfig] = None
|
||||
self._last_refresh: float = 0.0
|
||||
|
||||
def _maybe_refresh(self) -> None:
|
||||
"""惰性刷新配置,超过刷新间隔时从存储重新读取。"""
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
if now - self._last_refresh < self._refresh_interval:
|
||||
return
|
||||
|
||||
try:
|
||||
config = self._store.get(FLAG_RENDER_ENGINE)
|
||||
with self._lock:
|
||||
self._cached_config = config
|
||||
self._last_refresh = now
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to refresh render engine flag: %s", exc)
|
||||
# 刷新失败时保留旧缓存,不中断业务
|
||||
if self._cached_config is None:
|
||||
# 首次就读失败,设一个默认值
|
||||
with self._lock:
|
||||
self._cached_config = FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
|
||||
self._last_refresh = now
|
||||
|
||||
def _get_config(self) -> FeatureFlagConfig:
|
||||
"""获取当前 flag 配置(带缓存)。"""
|
||||
if self._cached_config is None:
|
||||
self._maybe_refresh()
|
||||
else:
|
||||
self._maybe_refresh()
|
||||
return self._cached_config or FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
|
||||
|
||||
def get_engine(self, user_id: Optional[str] = None) -> str:
|
||||
"""获取当前应该使用的渲染引擎。
|
||||
|
||||
Args:
|
||||
user_id: 用户ID,用于白名单匹配和百分比哈希。
|
||||
传 None 时只看全局开关。
|
||||
|
||||
Returns:
|
||||
"legacy" 或 "unified"
|
||||
"""
|
||||
config = self._get_config()
|
||||
|
||||
# 全局关闭 → 用默认值
|
||||
if not config.enabled:
|
||||
return self._default_engine
|
||||
|
||||
# 白名单匹配 / 百分比命中 → unified
|
||||
if config.is_active(user_id):
|
||||
return ENGINE_UNIFIED
|
||||
|
||||
# 未命中灰度 → 用默认值
|
||||
return self._default_engine
|
||||
|
||||
def should_use_unified(self, user_id: Optional[str] = None) -> bool:
|
||||
"""便捷方法:是否应该使用统一渲染引擎。"""
|
||||
return self.get_engine(user_id) == ENGINE_UNIFIED
|
||||
|
||||
def force_refresh(self) -> None:
|
||||
"""强制立即刷新配置(用于管理接口修改后立即生效)。"""
|
||||
self._last_refresh = 0.0
|
||||
if isinstance(self._store, RedisFeatureFlagStore):
|
||||
self._store.invalidate_cache(FLAG_RENDER_ENGINE)
|
||||
self._maybe_refresh()
|
||||
|
||||
def get_config_snapshot(self) -> dict:
|
||||
"""获取当前配置快照(用于管理接口展示)。"""
|
||||
config = self._get_config()
|
||||
return {
|
||||
"flag_name": FLAG_RENDER_ENGINE,
|
||||
"default_engine": self._default_engine,
|
||||
"enabled": config.enabled,
|
||||
"percentage": config.percentage,
|
||||
"whitelist": sorted(config.whitelist),
|
||||
"refresh_interval": self._refresh_interval,
|
||||
"last_refresh": self._last_refresh,
|
||||
}
|
||||
|
||||
def set_flag(self, config: FeatureFlagConfig) -> None:
|
||||
"""设置 flag 配置(管理接口用)。"""
|
||||
config.name = FLAG_RENDER_ENGINE
|
||||
self._store.set(config)
|
||||
self.force_refresh()
|
||||
|
||||
|
||||
# 全局单例
|
||||
_resolver: Optional[RenderEngineResolver] = None
|
||||
_resolver_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_render_engine_resolver() -> RenderEngineResolver:
|
||||
"""获取全局单例(基于 worker 配置)。"""
|
||||
global _resolver
|
||||
if _resolver is not None:
|
||||
return _resolver
|
||||
|
||||
with _resolver_lock:
|
||||
if _resolver is not None:
|
||||
return _resolver
|
||||
|
||||
try:
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
redis_url = getattr(settings, "redis_url", None) or getattr(settings, "broker_url", None)
|
||||
default = getattr(settings, "render_engine", ENGINE_LEGACY)
|
||||
_resolver = RenderEngineResolver(
|
||||
default_engine=default,
|
||||
redis_url=redis_url,
|
||||
)
|
||||
logger.info(
|
||||
"RenderEngineResolver initialized: default=%s, redis=%s",
|
||||
default,
|
||||
bool(redis_url),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to init RenderEngineResolver from settings: %s", exc)
|
||||
_resolver = RenderEngineResolver(default_engine=ENGINE_LEGACY)
|
||||
|
||||
return _resolver
|
||||
@@ -38,36 +38,53 @@ def extract_first_frame(
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
|
||||
_is_temp_output = False
|
||||
if output_path is None:
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
output_path = tmp.name
|
||||
_is_temp_output = True
|
||||
|
||||
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||||
except Exception:
|
||||
# probe 失败时 fallback 到第1秒
|
||||
seek_time = min_seek_seconds
|
||||
|
||||
# 格式化为 HH:MM:SS.xx
|
||||
seek_str = _format_seek_time(seek_time)
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
seek_str,
|
||||
"-i",
|
||||
video_path,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
scale_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
|
||||
try:
|
||||
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||||
except Exception:
|
||||
# probe 失败时 fallback 到第1秒
|
||||
seek_time = min_seek_seconds
|
||||
|
||||
# 格式化为 HH:MM:SS.xx
|
||||
seek_str = _format_seek_time(seek_time)
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
|
||||
cmd = [
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
except Exception:
|
||||
# 失败时退回到第0帧兜底
|
||||
cmd2 = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
seek_str,
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:00",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
@@ -76,40 +93,12 @@ def extract_first_frame(
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
except Exception:
|
||||
# 失败时退回到第0帧兜底
|
||||
cmd2 = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:00",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
scale_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
|
||||
return output_path
|
||||
except Exception:
|
||||
# 失败时清理自己创建的临时文件
|
||||
if _is_temp_output and output_path:
|
||||
try:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
return output_path
|
||||
|
||||
|
||||
def _format_seek_time(seconds: float) -> str:
|
||||
|
||||
@@ -188,8 +188,6 @@ class UnifiedRenderService:
|
||||
self.bgm_path = bgm_path
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
self._speed_engine = SpeedEngine()
|
||||
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
|
||||
self._asr_timeline_cached = False
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
"""执行渲染,返回 RenderResult.
|
||||
@@ -591,13 +589,7 @@ class UnifiedRenderService:
|
||||
|
||||
MVP 版本:使用第一个有音频的素材做ASR,然后按比例映射到整个视频时长。
|
||||
后续优化:支持多片段拼接后的完整音频ASR。
|
||||
|
||||
带缓存:同一 plan 只做一次 ASR,TTS 配音和字幕共用结果。
|
||||
"""
|
||||
# 检查缓存
|
||||
if self._asr_timeline_cached:
|
||||
return self._asr_timeline_cache
|
||||
|
||||
from packages.domain.subtitle import SubtitleTimeline
|
||||
|
||||
# 找第一个有本地路径的素材
|
||||
@@ -610,10 +602,7 @@ class UnifiedRenderService:
|
||||
|
||||
if first_asset_path is None:
|
||||
logger.warning("ASR字幕生成失败:找不到可用素材音频")
|
||||
result = SubtitleTimeline(segments=[], total_duration=video_duration)
|
||||
self._asr_timeline_cache = result
|
||||
self._asr_timeline_cached = True
|
||||
return result
|
||||
return SubtitleTimeline(segments=[], total_duration=video_duration)
|
||||
|
||||
# 提取素材音频为 wav(16kHz单声道,ASR友好格式)
|
||||
audio_path = self.work_dir / f"asr_audio_{self.plan.id}.wav"
|
||||
@@ -648,9 +637,6 @@ class UnifiedRenderService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 存入缓存
|
||||
self._asr_timeline_cache = timeline
|
||||
self._asr_timeline_cached = True
|
||||
return timeline
|
||||
|
||||
def _extract_audio(self, video_path: Path, output_path: Path) -> None:
|
||||
@@ -683,75 +669,11 @@ class UnifiedRenderService:
|
||||
) -> bool:
|
||||
"""根据 plan.config 生成 TTS 配音,加到 audio 图层.
|
||||
|
||||
支持三种触发方式:
|
||||
1. config.tts.enabled = true → 标准 TTS 配置
|
||||
2. 顶层 voice_id + custom_text → 桥接模式(自定义文案配音)
|
||||
3. 顶层 voice_id + subtitle.auto_generated=true → ASR 字幕对齐配音(预设配音)
|
||||
|
||||
Returns:
|
||||
是否成功添加了配音音轨
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
tts_cfg = config.get("tts", {}) or {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
use_subtitle_align = False # 是否使用字幕对齐模式
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
if not tts_cfg.get("enabled"):
|
||||
top_voice_id = config.get("voice_id", "") or ""
|
||||
top_text = config.get("custom_text", "") or ""
|
||||
|
||||
# 方式A:voice_id + custom_text → 整段配音
|
||||
if top_voice_id and top_text:
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": top_text,
|
||||
"align_mode": "full",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
logger.info(
|
||||
"[unified-render] 检测到顶层 voice_id+custom_text,桥接到 tts 配置(整段): plan_id=%s voice_id=%s text_len=%d",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
len(top_text),
|
||||
)
|
||||
# 方式B:voice_id + 自动字幕 → 字幕对齐配音(预设配音模式)
|
||||
elif top_voice_id and subtitle_cfg.get("auto_generated", False) and self.asr_service is not None:
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
use_subtitle_align = True
|
||||
logger.info(
|
||||
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
)
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
# 前端一键生成页面传 config.voice_id + config.custom_text,
|
||||
# 统一渲染引擎从 config.tts 读,这里做桥接映射。
|
||||
if not tts_cfg.get("enabled"):
|
||||
top_voice_id = config.get("voice_id", "") or ""
|
||||
top_text = config.get("custom_text", "") or ""
|
||||
if top_voice_id and top_text:
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": top_text,
|
||||
"align_mode": "full",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
logger.info(
|
||||
"[unified-render] 检测到顶层 voice_id+custom_text,桥接到 tts 配置: plan_id=%s voice_id=%s text_len=%d",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
len(top_text),
|
||||
)
|
||||
|
||||
tts_config = TtsConfig.parse(tts_cfg)
|
||||
if not tts_config.enabled:
|
||||
@@ -763,34 +685,8 @@ class UnifiedRenderService:
|
||||
tts_service = get_tts_service()
|
||||
tts_engine = TtsEngine(tts_service, self.work_dir / "tts")
|
||||
|
||||
# 根据对齐模式选择生成方式
|
||||
if use_subtitle_align or tts_config.align_mode == "subtitle":
|
||||
# 字幕对齐模式:先做 ASR,再按字幕生成配音
|
||||
if not self._asr_timeline_cached:
|
||||
self._generate_asr_subtitles(video_duration, subtitle_cfg)
|
||||
timeline = self._asr_timeline_cache
|
||||
if timeline is None or not timeline.segments:
|
||||
logger.warning("TTS 字幕对齐配音:ASR 无识别结果,跳过配音")
|
||||
return False
|
||||
|
||||
# 转换为 TtsEngine 需要的字幕格式
|
||||
subtitles = [
|
||||
{
|
||||
"text": seg.text,
|
||||
"start_time": seg.start,
|
||||
"end_time": seg.end,
|
||||
}
|
||||
for seg in timeline.segments
|
||||
if getattr(seg, "text", "").strip()
|
||||
]
|
||||
if not subtitles:
|
||||
logger.warning("TTS 字幕对齐配音:字幕文本为空,跳过配音")
|
||||
return False
|
||||
|
||||
result = tts_engine.generate_subtitle_voiceover(tts_config, subtitles)
|
||||
else:
|
||||
# 整段配音模式
|
||||
result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration)
|
||||
# 整段配音模式
|
||||
result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration)
|
||||
|
||||
if not result.success or not result.segments:
|
||||
logger.warning("TTS 配音生成失败,跳过: %s", result.error_message)
|
||||
@@ -1154,10 +1050,8 @@ class UnifiedRenderService:
|
||||
|
||||
vf_str = ",".join(filters)
|
||||
|
||||
# 最终输出时长:取 clip 调速后有效时长和 video_duration 的较小值
|
||||
# 注意:必须用调速后的时长,否则减速场景(speed<1)会被 -t 截断
|
||||
adjusted_duration = UnifiedRenderService._clip_adjusted_duration(clip)
|
||||
final_duration = adjusted_duration
|
||||
# 最终输出时长:取 clip 有效时长和 video_duration 的较小值
|
||||
final_duration = effective_duration
|
||||
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
|
||||
final_duration = video_duration
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ class WorkerSettings(BaseSettings):
|
||||
auto_create_schema: bool = False
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
render_engine: str = "legacy"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""视频合成 Celery 任务 — Phase 8 任务 2.10.
|
||||
|
||||
使用 JobService 管理任务生命周期,通过 RenderAdapter 调用 UnifiedRenderService 执行合成。
|
||||
使用 JobService 管理任务生命周期,集成 VideoComposeService 执行合成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,7 +35,9 @@ def _get_job_service():
|
||||
def compose_video(self, job_id: str, **kwargs):
|
||||
"""视频合成任务。
|
||||
|
||||
使用 UnifiedRenderService(图层架构)进行渲染。
|
||||
根据 RENDER_ENGINE 配置选择渲染引擎:
|
||||
- legacy: 旧 VideoComposeService(filter_complex 模式)
|
||||
- unified: 新 UnifiedRenderService(图层架构)
|
||||
|
||||
Args:
|
||||
job_id: JobService 中的任务 ID
|
||||
@@ -54,8 +56,30 @@ def compose_video(self, job_id: str, **kwargs):
|
||||
job_service.fail_job(job_id, "Missing plan_id in job payload")
|
||||
return {"status": "error", "message": "Missing plan_id"}
|
||||
|
||||
# 使用 unified 渲染引擎
|
||||
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
|
||||
# 判断使用哪个渲染引擎
|
||||
# 优先级:Redis Feature Flag(白名单 > 百分比) > 环境变量默认
|
||||
from video_processing.render_engine_resolver import get_render_engine_resolver
|
||||
|
||||
resolver = get_render_engine_resolver()
|
||||
user_id = job.created_by_user_id or None
|
||||
engine = resolver.get_engine(user_id=user_id)
|
||||
# 灰度期间打印详细 flag 配置,便于排查
|
||||
config = resolver.get_config_snapshot()
|
||||
logger.info(
|
||||
"compose_video 引擎选择: job_id=%s engine=%s user_id=%s enabled=%s percentage=%s whitelist=%d default=%s",
|
||||
job_id,
|
||||
engine,
|
||||
user_id,
|
||||
config.get("enabled"),
|
||||
config.get("percentage"),
|
||||
len(config.get("whitelist", [])),
|
||||
config.get("default_engine"),
|
||||
)
|
||||
|
||||
if engine == "unified":
|
||||
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
|
||||
else:
|
||||
return _compose_with_legacy_engine(self, job_service, job, plan_id, db)
|
||||
|
||||
except self.retry_exc as exc:
|
||||
logger.warning("视频合成重试中: job_id=%s, exc=%s", job_id, exc)
|
||||
@@ -71,6 +95,69 @@ def compose_video(self, job_id: str, **kwargs):
|
||||
db.close()
|
||||
|
||||
|
||||
def _compose_with_legacy_engine(task, job_service, job, plan_id: str, db) -> dict:
|
||||
"""旧引擎渲染路径(VideoComposeService)。"""
|
||||
job_id = job.id
|
||||
|
||||
# 标记为 running
|
||||
job_service.update_progress(job_id, progress=10.0, current_stage="初始化合成环境")
|
||||
|
||||
# 延迟导入 VideoComposeService
|
||||
from apps.api.app.services.video_compose_service import VideoComposeService
|
||||
|
||||
compose_svc = VideoComposeService(db)
|
||||
|
||||
# 校验合成条件
|
||||
job_service.update_progress(job_id, progress=20.0, current_stage="校验合成条件")
|
||||
validation = compose_svc.validate_compose(plan_id)
|
||||
if not validation.valid:
|
||||
error_msg = "; ".join(validation.errors)
|
||||
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 构建合成命令
|
||||
job_service.update_progress(job_id, progress=30.0, current_stage="构建 FFmpeg 命令")
|
||||
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
|
||||
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
|
||||
compose_cmd = compose_svc.build_compose_command(plan_id, output_path)
|
||||
|
||||
# 执行 FFmpeg
|
||||
job_service.update_progress(job_id, progress=50.0, current_stage="正在执行视频合成")
|
||||
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
|
||||
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
run_ffmpeg(compose_cmd.command, timeout=3600)
|
||||
except Exception as e:
|
||||
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
|
||||
job_service.fail_job(job_id, error_msg)
|
||||
raise
|
||||
|
||||
# 上传结果
|
||||
job_service.update_progress(job_id, progress=80.0, current_stage="上传合成结果")
|
||||
storage_key = f"rendered/{plan_id}/{job_id}.mp4"
|
||||
|
||||
from worker_app.tasks.edit_plan_generation import _upload_to_oss
|
||||
|
||||
output_url = _upload_to_oss(Path(output_path), storage_key)
|
||||
|
||||
# 更新 Job 状态为完成
|
||||
result_data = {
|
||||
"plan_id": plan_id,
|
||||
"output_path": output_path,
|
||||
"storage_key": storage_key,
|
||||
"output_url": output_url or "",
|
||||
"estimated_duration": compose_cmd.estimated_duration,
|
||||
"clip_count": len(compose_cmd.clip_chains),
|
||||
"engine": "legacy",
|
||||
}
|
||||
job_service.complete_job(job_id, result=result_data)
|
||||
|
||||
logger.info("视频合成完成(legacy): job_id=%s, plan_id=%s", job_id, plan_id)
|
||||
return {"status": "completed", "job_id": job_id, "result": result_data}
|
||||
|
||||
|
||||
def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> dict:
|
||||
"""新引擎渲染路径(UnifiedRenderService + RenderAdapter)。"""
|
||||
job_id = job.id
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
"""剪辑计划渲染任务 — 使用 UnifiedRenderService 统一渲染引擎.
|
||||
"""剪辑计划渲染任务 — 支持 Feature Flag 灰度.
|
||||
|
||||
Celery 任务 worker.render_edit_plan:
|
||||
1. 加载 EditPlan + EditPlanClips
|
||||
2. 通过 RenderAdapter 调用 UnifiedRenderService 渲染
|
||||
2. 根据 Feature Flag 选择渲染引擎(legacy / unified)
|
||||
3. 下载各片段素材 + 渲染
|
||||
4. 上传渲染结果到 OSS
|
||||
5. 创建 GeneratedVideo 记录 + 查重
|
||||
6. 更新 EditPlan / EditPlanClip 状态
|
||||
7. 更新 GenerationTask 进度
|
||||
|
||||
渲染引擎灰度:
|
||||
- 走 Feature Flag (render_engine) 控制
|
||||
- legacy: VideoComposeService + FFmpeg filter_complex
|
||||
- unified: UnifiedRenderService 图层架构
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,6 +35,10 @@ OUTPUT_FPS = 25.0
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.oss_helpers import (
|
||||
download_asset,
|
||||
upload_to_oss,
|
||||
)
|
||||
|
||||
# ── Repository imports (延迟导入避免循环依赖) ─────────────────────────────────
|
||||
|
||||
@@ -56,6 +66,34 @@ def _get_repos():
|
||||
# ── Celery Task ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_render_engine(user_id: str) -> str:
|
||||
"""根据 Feature Flag 决定使用哪个渲染引擎。
|
||||
|
||||
Returns:
|
||||
"legacy" 或 "unified"
|
||||
"""
|
||||
try:
|
||||
from video_processing.render_engine_resolver import get_render_engine_resolver
|
||||
|
||||
resolver = get_render_engine_resolver()
|
||||
engine = resolver.get_engine(user_id=user_id)
|
||||
# 灰度期间打印详细 flag 配置,便于排查
|
||||
config = resolver.get_config_snapshot()
|
||||
logger.info(
|
||||
"edit_plan 引擎选择: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
|
||||
user_id,
|
||||
engine,
|
||||
config.get("enabled"),
|
||||
config.get("percentage"),
|
||||
len(config.get("whitelist", [])),
|
||||
config.get("default_engine"),
|
||||
)
|
||||
return engine
|
||||
except Exception as exc:
|
||||
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
|
||||
return "legacy"
|
||||
|
||||
|
||||
def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg: str):
|
||||
"""统一的计划失败标记工具。"""
|
||||
plan = plan_repo.get(plan_id)
|
||||
@@ -273,13 +311,331 @@ def _render_with_unified(
|
||||
)
|
||||
|
||||
|
||||
def _render_with_legacy(
|
||||
plan,
|
||||
clips,
|
||||
rendered_clip_ids: list[str],
|
||||
failed_clip_ids: list[str],
|
||||
tmpdir_path: Path,
|
||||
plan_id: str,
|
||||
generation_task_id: str,
|
||||
plan_repo,
|
||||
clip_repo,
|
||||
gen_task_repo,
|
||||
db,
|
||||
) -> dict:
|
||||
"""旧引擎路径(VideoComposeService + FFmpeg filter_complex)。"""
|
||||
import os
|
||||
|
||||
from apps.api.app.services.video_compose_service import VideoComposeService
|
||||
|
||||
compose_svc = VideoComposeService(db)
|
||||
|
||||
# 校验合成条件
|
||||
validation = compose_svc.validate_compose(plan_id)
|
||||
if not validation.valid:
|
||||
error_msg = "; ".join(validation.errors)
|
||||
logger.error("合成校验失败(legacy): %s — %s", plan_id, error_msg)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, f"合成校验失败: {error_msg}")
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 构建 FFmpeg 命令
|
||||
output_dir = os.environ.get("VIDEO_OUTPUT_DIR", str(tmpdir_path))
|
||||
output_path = Path(output_dir) / f"{plan_id}.mp4"
|
||||
|
||||
# 从 plan.config.export 读取输出分辨率,兼容 plan 自定义配置
|
||||
plan_config = plan.config or {}
|
||||
export_config = plan_config.get("export", {}) or {}
|
||||
output_width = OUTPUT_WIDTH
|
||||
output_height = OUTPUT_HEIGHT
|
||||
resolution = export_config.get("resolution", "")
|
||||
if resolution and "x" in resolution:
|
||||
try:
|
||||
w_str, h_str = resolution.lower().split("x", 1)
|
||||
output_width = int(w_str)
|
||||
output_height = int(h_str)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
fps = export_config.get("fps", 25)
|
||||
try:
|
||||
fps = int(fps)
|
||||
except (ValueError, TypeError):
|
||||
fps = 25
|
||||
|
||||
compose_cmd = compose_svc.build_compose_command(
|
||||
plan_id,
|
||||
str(output_path),
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
fps=fps,
|
||||
)
|
||||
|
||||
logger.info("执行 FFmpeg (legacy): plan_id=%s cmd=%s", plan_id, " ".join(compose_cmd.command)[:500])
|
||||
|
||||
# 开始渲染,更新进度
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.progress < 40.0:
|
||||
gen_task.progress = 40.0
|
||||
gen_task.append_log(
|
||||
stage="render_start",
|
||||
message="开始FFmpeg渲染(legacy)",
|
||||
level="INFO",
|
||||
progress=40.0,
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
run_ffmpeg(compose_cmd.command, timeout=3600)
|
||||
except Exception as e:
|
||||
# 提取完整 stderr(如果是 CalledProcessError)
|
||||
stderr_text = ""
|
||||
if hasattr(e, "stderr"):
|
||||
stderr_raw = e.stderr
|
||||
if isinstance(stderr_raw, bytes):
|
||||
stderr_text = stderr_raw.decode("utf-8", errors="replace")
|
||||
elif isinstance(stderr_raw, str):
|
||||
stderr_text = stderr_raw
|
||||
|
||||
# 完整命令(截断前2000字符,避免日志过大)
|
||||
full_cmd = " ".join(compose_cmd.command)
|
||||
cmd_preview = full_cmd[:2000] + ("..." if len(full_cmd) > 2000 else "")
|
||||
|
||||
# 拼接完整错误信息:命令 + 异常 + stderr最后1500字符
|
||||
error_parts = [f"FFmpeg渲染失败(exit={getattr(e, 'returncode', 'unknown')})"]
|
||||
error_parts.append("--- cmd ---")
|
||||
error_parts.append(cmd_preview)
|
||||
if stderr_text:
|
||||
# 取最后1500字符,通常错误信息在末尾
|
||||
stderr_preview = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
|
||||
error_parts.append("--- stderr (last 1500 chars) ---")
|
||||
error_parts.append(stderr_preview)
|
||||
error_msg = "\n".join(error_parts)
|
||||
|
||||
logger.error("FFmpeg 执行失败(legacy): plan_id=%s\n%s", plan_id, error_msg)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 获取文件大小 + 实际时长
|
||||
file_size = output_path.stat().st_size if output_path.exists() else 0
|
||||
duration = compose_cmd.estimated_duration or 0.0
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
|
||||
actual_duration = probe_duration(str(output_path))
|
||||
if actual_duration > 0:
|
||||
duration = actual_duration
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 标题/字幕叠加(legacy 引擎补齐) ────────────────────────────────
|
||||
plan_config = plan.config or {}
|
||||
title_cfg = plan_config.get("title", {}) or {}
|
||||
subtitle_cfg = plan_config.get("subtitle", {}) or {}
|
||||
title_text = title_cfg.get("text", "") or ""
|
||||
subtitle_text = subtitle_cfg.get("text", "") or ""
|
||||
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
|
||||
subtitle_enabled = subtitle_cfg.get("enabled", True) and bool(subtitle_text.strip())
|
||||
# ASR 自动字幕 legacy 暂不支持(需要额外 ASR 服务,统一用 unified 引擎)
|
||||
has_subtitle_overlay = title_enabled or subtitle_enabled
|
||||
|
||||
if has_subtitle_overlay and output_path.exists() and duration > 0:
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
from video_processing.render_subtitles import generate_ass_subtitles
|
||||
|
||||
ass_path = tmpdir_path / f"subtitles_{plan_id}.ass"
|
||||
generate_ass_subtitles(
|
||||
ass_path,
|
||||
video_width=output_width,
|
||||
video_height=output_height,
|
||||
video_duration=duration,
|
||||
title_text=title_text,
|
||||
title_config=title_cfg,
|
||||
subtitle_text=subtitle_text,
|
||||
subtitle_config=subtitle_cfg,
|
||||
)
|
||||
# 用 subtitles 滤镜叠加 ASS 字幕,音频直接 copy
|
||||
subtitled_path = tmpdir_path / f"{plan_id}_subtitled.mp4"
|
||||
# 处理 Windows 路径下的 ass 滤镜转义问题
|
||||
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", r"\:")
|
||||
run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(output_path),
|
||||
"-vf",
|
||||
f"subtitles='{ass_filter_path}'",
|
||||
"-c:a",
|
||||
"copy",
|
||||
str(subtitled_path),
|
||||
],
|
||||
timeout=1800,
|
||||
)
|
||||
if subtitled_path.exists() and subtitled_path.stat().st_size > 0:
|
||||
output_path = subtitled_path
|
||||
file_size = subtitled_path.stat().st_size
|
||||
logger.info(
|
||||
"legacy 标题/字幕叠加完成: plan_id=%s title=%s subtitle=%s",
|
||||
plan_id,
|
||||
title_enabled,
|
||||
subtitle_enabled,
|
||||
)
|
||||
except Exception as sub_err:
|
||||
logger.warning("legacy 标题/字幕叠加失败(不影响主流程): plan_id=%s err=%s", plan_id, sub_err)
|
||||
|
||||
# ── TTS 配音混音(legacy 引擎补齐) ────────────────────────────────
|
||||
tts_cfg = plan_config.get("tts", {}) or {}
|
||||
tts_enabled = tts_cfg.get("enabled", False) and bool(tts_cfg.get("text", "").strip())
|
||||
|
||||
if tts_enabled and output_path.exists() and duration > 0:
|
||||
try:
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
tts_config = TtsConfig.parse(tts_cfg)
|
||||
if tts_config.enabled and tts_config.text.strip():
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
tts_service = get_tts_service()
|
||||
voiceover_path = tmpdir_path / f"voiceover_{plan_id}.wav"
|
||||
|
||||
# 生成配音音频
|
||||
audio_path = tts_service.synthesize(
|
||||
text=tts_config.text,
|
||||
voice_id=tts_config.voice_id,
|
||||
speed=tts_config.speed,
|
||||
pitch=tts_config.pitch,
|
||||
output_path=voiceover_path,
|
||||
)
|
||||
|
||||
if audio_path and audio_path.exists() and audio_path.stat().st_size > 0:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
mixed_path = tmpdir_path / f"{plan_id}_with_voiceover.mp4"
|
||||
|
||||
# 混音:配音音量按配置调整
|
||||
voice_volume = max(0.0, min(1.0, tts_config.volume))
|
||||
|
||||
if tts_config.overlap_mode == "mix":
|
||||
# 混音模式:原音 + 配音混合
|
||||
filter_complex = (
|
||||
f"[0:a]volume=1.0[a0];"
|
||||
f"[1:a]volume={voice_volume:.2f}[a1];"
|
||||
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=0[aout]"
|
||||
)
|
||||
else:
|
||||
# replace 模式:配音替换原音
|
||||
filter_complex = f"[1:a]volume={voice_volume:.2f}[aout]"
|
||||
|
||||
run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(output_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"0:v",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
str(mixed_path),
|
||||
],
|
||||
timeout=1800,
|
||||
)
|
||||
|
||||
if mixed_path.exists() and mixed_path.stat().st_size > 0:
|
||||
output_path = mixed_path
|
||||
file_size = mixed_path.stat().st_size
|
||||
logger.info(
|
||||
"legacy TTS 配音混音完成: plan_id=%s voice_id=%s mode=%s",
|
||||
plan_id,
|
||||
tts_config.voice_id,
|
||||
tts_config.overlap_mode,
|
||||
)
|
||||
except Exception as tts_err:
|
||||
logger.warning("legacy TTS 配音混音失败(不影响主流程): plan_id=%s err=%s", plan_id, tts_err)
|
||||
|
||||
# 渲染完成,更新进度
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.progress < 80.0:
|
||||
gen_task.progress = 80.0
|
||||
gen_task.append_log(
|
||||
stage="render_done",
|
||||
message="FFmpeg渲染完成(legacy)",
|
||||
level="INFO",
|
||||
progress=80.0,
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 上传到 OSS
|
||||
storage_key = f"rendered/{plan_id}/output.mp4"
|
||||
output_url = upload_to_oss(output_path, storage_key)
|
||||
|
||||
# 上传完成,更新进度
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.progress < 95.0:
|
||||
gen_task.progress = 95.0
|
||||
gen_task.append_log(
|
||||
stage="upload_done",
|
||||
message="OSS上传完成(legacy)",
|
||||
level="INFO",
|
||||
progress=95.0,
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _finalize_render_success(
|
||||
plan=plan,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
plan_id=plan_id,
|
||||
output_url=output_url or "",
|
||||
storage_key=storage_key,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
width=output_width,
|
||||
height=output_height,
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
generation_task_id=generation_task_id,
|
||||
output_path=output_path,
|
||||
engine="legacy",
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.render_edit_plan", bind=True, max_retries=2)
|
||||
def render_edit_plan(self, plan_id: str) -> dict:
|
||||
"""渲染剪辑计划
|
||||
|
||||
流程:
|
||||
1. 加载 EditPlan + EditPlanClips
|
||||
2. 通过 RenderAdapter 调用 UnifiedRenderService 渲染
|
||||
2. 根据 Feature Flag 选择渲染引擎(legacy / unified)
|
||||
3. 下载素材 + 渲染
|
||||
4. 上传渲染结果到 OSS
|
||||
5. 创建 GeneratedVideo 记录 + 查重
|
||||
@@ -289,6 +645,7 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
|
||||
|
||||
generation_task_id = ""
|
||||
engine = "legacy"
|
||||
|
||||
for repos in _get_repos():
|
||||
plan_repo, clip_repo, gen_task_repo, db = repos
|
||||
@@ -303,7 +660,10 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
# 获取 generation_task_id(提前读取,确保 except 块可用)
|
||||
generation_task_id = plan.config.get("generation_task_id", "")
|
||||
|
||||
# 2. 准备渲染(使用 unified 渲染引擎)
|
||||
# 2. 选择渲染引擎(Feature Flag 灰度控制)
|
||||
user_id = plan.created_by_user_id or ""
|
||||
engine = _resolve_render_engine(user_id)
|
||||
logger.info("剪辑计划渲染引擎: plan_id=%s engine=%s user_id=%s", plan_id, engine, user_id)
|
||||
|
||||
# 3. 加载片段列表(按 order 排序)
|
||||
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||||
@@ -321,9 +681,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
gen_task.started_at = datetime.now(timezone.utc)
|
||||
gen_task.append_log(
|
||||
stage="render_start",
|
||||
message=f"开始渲染,片段数 {len(clips)}",
|
||||
message=f"开始渲染,引擎 {engine},片段数 {len(clips)}",
|
||||
level="INFO",
|
||||
engine="unified",
|
||||
engine=engine,
|
||||
clip_count=len(clips),
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
@@ -346,19 +706,122 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
pass
|
||||
return {"status": "cancelled", "plan_id": plan_id, "message": "任务已取消"}
|
||||
|
||||
# 4. 渲染(unified 引擎:RenderAdapter 统一处理下载 + BGM + ASR + 渲染 + 上传)
|
||||
result = _render_with_unified(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
plan_id=plan_id,
|
||||
generation_task_id=generation_task_id,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
)
|
||||
# 4. 根据引擎选择渲染方式
|
||||
if engine == "unified":
|
||||
# ── unified 路径:RenderAdapter 统一处理(下载 + BGM + ASR + 渲染 + 上传)
|
||||
result = _render_with_unified(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
plan_id=plan_id,
|
||||
generation_task_id=generation_task_id,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
)
|
||||
else:
|
||||
# ── legacy 路径:原有的素材下载 + VideoComposeService
|
||||
with tempfile.TemporaryDirectory(prefix="edit_plan_") as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
asset_path_map: dict[str, Path] = {}
|
||||
rendered_clip_ids: list[str] = []
|
||||
failed_clip_ids: list[str] = []
|
||||
|
||||
result["engine"] = "unified"
|
||||
# 预先批量查询所有素材的 storage_key
|
||||
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
clip_asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
asset_storage_map: dict[str, str] = {}
|
||||
if clip_asset_ids:
|
||||
assets = db.query(AssetModel).filter(AssetModel.id.in_(clip_asset_ids)).all()
|
||||
asset_storage_map = {
|
||||
a.id: (a.storage_key or a.file_url or "") for a in assets if a.storage_key or a.file_url
|
||||
}
|
||||
|
||||
for clip in clips:
|
||||
if not clip.asset_id:
|
||||
# 没有素材的片段跳过,标记为失败
|
||||
clip.mark_failed()
|
||||
clip_repo.update(clip)
|
||||
failed_clip_ids.append(clip.id)
|
||||
continue
|
||||
|
||||
if clip.asset_id in asset_path_map:
|
||||
# 同一素材已下载(多个 clip 共享同一素材)
|
||||
rendered_clip_ids.append(clip.id)
|
||||
continue
|
||||
|
||||
storage_key = asset_storage_map.get(clip.asset_id)
|
||||
if not storage_key:
|
||||
logger.warning(
|
||||
"片段素材无 storage_key,跳过: clip_id=%s asset_id=%s",
|
||||
clip.id,
|
||||
clip.asset_id,
|
||||
)
|
||||
clip.mark_failed()
|
||||
clip_repo.update(clip)
|
||||
failed_clip_ids.append(clip.id)
|
||||
continue
|
||||
|
||||
# 下载素材
|
||||
ext = Path(storage_key).suffix or ".mp4"
|
||||
local_path = tmpdir_path / f"clip_{clip.order:04d}{ext}"
|
||||
if download_asset(storage_key, local_path):
|
||||
asset_path_map[clip.asset_id] = local_path
|
||||
rendered_clip_ids.append(clip.id)
|
||||
else:
|
||||
clip.mark_failed()
|
||||
clip_repo.update(clip)
|
||||
failed_clip_ids.append(clip.id)
|
||||
|
||||
if not asset_path_map:
|
||||
logger.error("所有片段素材下载失败: %s", plan_id)
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = "所有片段素材下载失败"
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
gen_task.append_log(
|
||||
stage="download_failed",
|
||||
message="所有片段素材下载失败",
|
||||
level="ERROR",
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
return {"status": "error", "message": "所有片段素材下载失败"}
|
||||
|
||||
# 素材下载完成,记录日志
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
stage="download_done",
|
||||
message=f"素材下载完成,成功 {len(asset_path_map)} 个,失败 {len(failed_clip_ids)} 个",
|
||||
level="INFO",
|
||||
success_count=len(asset_path_map),
|
||||
failed_count=len(failed_clip_ids),
|
||||
)
|
||||
gen_task.progress = 30.0
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
result = _render_with_legacy(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
tmpdir_path=tmpdir_path,
|
||||
plan_id=plan_id,
|
||||
generation_task_id=generation_task_id,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
)
|
||||
|
||||
result["engine"] = engine
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
Regular → Executable
+210
-95
@@ -138,6 +138,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
|
||||
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from services.asr_service_factory import get_asr_service
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
from video_processing.oss_helpers import (
|
||||
@@ -145,6 +146,8 @@ from video_processing.oss_helpers import (
|
||||
get_signed_download_url,
|
||||
upload_to_oss,
|
||||
)
|
||||
from video_processing.render_engine_resolver import ENGINE_LEGACY, ENGINE_UNIFIED
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
# ── 虚拟 Plan / Clip(内存中构建,不写数据库) ────────────────────────────────
|
||||
|
||||
@@ -941,31 +944,13 @@ def _download_library_assets(
|
||||
def _validate_template_exists(template_id: str) -> None:
|
||||
"""校验 template_id 是否存在且可用。
|
||||
|
||||
优先读新模板系统(EditTemplate),找不到 fallback 到旧模板系统(TemplateModel)。
|
||||
|
||||
Raises:
|
||||
ValueError: template_id 不存在或已禁用时抛出
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditTemplateRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateModel
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
# 优先读新模板系统
|
||||
new_repo = SQLAlchemyEditTemplateRepository(session)
|
||||
new_template = new_repo.get(template_id)
|
||||
if new_template is not None:
|
||||
status_val = new_template.status.value if hasattr(new_template.status, "value") else new_template.status
|
||||
if status_val == "active":
|
||||
logger.info("模板校验通过(新系统): template_id=%s name=%s", template_id, new_template.name)
|
||||
return
|
||||
else:
|
||||
raise ValueError(f"模板已停用: template_id={template_id}")
|
||||
|
||||
# fallback: 旧模板系统
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateModel
|
||||
|
||||
template = (
|
||||
session.query(TemplateModel)
|
||||
.filter(
|
||||
@@ -974,11 +959,9 @@ def _validate_template_exists(template_id: str) -> None:
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if template:
|
||||
logger.info("模板校验通过(旧系统): template_id=%s name=%s", template_id, template.name)
|
||||
return
|
||||
|
||||
raise ValueError(f"模板不存在或已禁用: template_id={template_id}")
|
||||
if template is None:
|
||||
raise ValueError(f"模板不存在或已禁用: template_id={template_id}")
|
||||
logger.info("模板校验通过: template_id=%s name=%s", template_id, template.name)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -986,51 +969,17 @@ def _validate_template_exists(template_id: str) -> None:
|
||||
def _load_template_plan_config(template_id: str) -> dict:
|
||||
"""从模板加载 plan 级配置(BGM、字幕、标题等效果层)。
|
||||
|
||||
优先读新模板系统(EditTemplate.config + TemplateClipConfig),
|
||||
找不到 fallback 到旧模板系统(TemplateModel 独立字段)。
|
||||
|
||||
TemplateModel 里 bgm_config / subtitle_config / title_config 是独立字段,
|
||||
需要组装成 plan.config 的格式({bgm, subtitle, title})后再注入。
|
||||
模板不存在时返回空 dict,不阻塞主流程。
|
||||
"""
|
||||
if not template_id:
|
||||
return {}
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditTemplateRepository,
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateModel
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
# 优先读新模板系统
|
||||
tpl_repo = SQLAlchemyEditTemplateRepository(session)
|
||||
clip_repo = SQLAlchemyTemplateClipConfigRepository(session)
|
||||
template = tpl_repo.get(template_id)
|
||||
|
||||
if template is not None:
|
||||
# 新系统:config 直接就是 plan.config 格式
|
||||
plan_config = dict(template.config or {})
|
||||
|
||||
# 从片段配置中提取 intro/outro 配置
|
||||
clip_configs = clip_repo.list_by_template(template_id, limit=200)
|
||||
if clip_configs:
|
||||
intro_outro = _extract_intro_outro_from_clip_configs(clip_configs)
|
||||
if intro_outro:
|
||||
plan_config["intro_outro"] = intro_outro
|
||||
|
||||
# 把 editing_mode 也带过去
|
||||
if template.editing_mode:
|
||||
plan_config["editing_mode"] = template.editing_mode
|
||||
|
||||
logger.info(
|
||||
"模板配置加载成功(新系统): template_id=%s keys=%s",
|
||||
template_id,
|
||||
list(plan_config.keys()),
|
||||
)
|
||||
return plan_config
|
||||
|
||||
# fallback: 旧模板系统
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateModel
|
||||
|
||||
template = (
|
||||
session.query(TemplateModel)
|
||||
.filter(
|
||||
@@ -1057,7 +1006,7 @@ def _load_template_plan_config(template_id: str) -> dict:
|
||||
plan_config["bgm"] = bgm_cfg
|
||||
|
||||
logger.info(
|
||||
"模板配置加载成功(旧系统): template_id=%s keys=%s",
|
||||
"模板配置加载成功: template_id=%s keys=%s",
|
||||
template_id,
|
||||
list(plan_config.keys()),
|
||||
)
|
||||
@@ -1069,6 +1018,161 @@ def _load_template_plan_config(template_id: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
# ── 渲染引擎选择 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_render_engine(user_id: str) -> str:
|
||||
"""根据 Feature Flag 决定使用哪个渲染引擎。
|
||||
|
||||
Returns:
|
||||
"legacy" 或 "unified"
|
||||
"""
|
||||
try:
|
||||
from video_processing.render_engine_resolver import get_render_engine_resolver
|
||||
|
||||
resolver = get_render_engine_resolver()
|
||||
engine = resolver.get_engine(user_id=user_id)
|
||||
# 灰度期间打印详细 flag 配置,便于排查
|
||||
config = resolver.get_config_snapshot()
|
||||
logger.info(
|
||||
"[渲染引擎] flag 解析: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
|
||||
user_id,
|
||||
engine,
|
||||
config.get("enabled"),
|
||||
config.get("percentage"),
|
||||
len(config.get("whitelist", [])),
|
||||
config.get("default_engine"),
|
||||
)
|
||||
return engine
|
||||
except Exception as exc:
|
||||
# 异常时 fallback 到 legacy(保守策略,与 edit_plan_generation 一致)
|
||||
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
|
||||
return ENGINE_LEGACY
|
||||
|
||||
|
||||
# ── 旧引擎渲染(FFmpeg filter_complex) ────────────────────────────────────────
|
||||
|
||||
|
||||
def _render_with_legacy_engine(
|
||||
task_id: str,
|
||||
virtual_clips: list[_VirtualClip],
|
||||
asset_path_map: dict[str, Path],
|
||||
work_dir: Path,
|
||||
output_path: Path,
|
||||
) -> tuple[float, int]:
|
||||
"""旧引擎渲染路径:手动构建 FFmpeg filter_complex 命令。
|
||||
|
||||
说明:generate_video 任务使用虚拟 clips(无 EditPlan 数据库记录),
|
||||
因此无法直接复用 VideoComposeService。这里手动构建等价的 filter_complex
|
||||
命令,与旧引擎行为一致(scale → crop → setpts → trim → setpts,
|
||||
无 fps 归一化,保持原帧率)。
|
||||
|
||||
支持模式:one_take / pip / voice_over / voice_pip
|
||||
- 所有模式统一走 concat 滤镜(与旧引擎多片段逻辑一致)
|
||||
|
||||
Returns:
|
||||
(duration_seconds, file_size_bytes)
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
main_clips = [
|
||||
c
|
||||
for c in virtual_clips
|
||||
if c.clip_type in ("main", "b_roll", "background")
|
||||
or (c.clip_type == "main" and c.config.get("role") == "b_roll")
|
||||
]
|
||||
if not main_clips:
|
||||
main_clips = virtual_clips[:1]
|
||||
|
||||
input_args: list[str] = []
|
||||
video_filters: list[str] = []
|
||||
audio_filters: list[str] = []
|
||||
|
||||
for i, clip in enumerate(main_clips):
|
||||
local_path = asset_path_map.get(clip.asset_id)
|
||||
if not local_path:
|
||||
continue
|
||||
input_args.extend(["-i", str(local_path)])
|
||||
|
||||
duration = clip.duration or 0.0
|
||||
|
||||
# 视频滤镜:scale → crop → setpts → trim → setpts(与旧引擎一致)
|
||||
vf = (
|
||||
f"[{i}:v]"
|
||||
f"scale={OUTPUT_WIDTH}:{OUTPUT_HEIGHT}:force_original_aspect_ratio=increase,"
|
||||
f"crop={OUTPUT_WIDTH}:{OUTPUT_HEIGHT},"
|
||||
f"setpts=PTS-STARTPTS,"
|
||||
f"trim=0:{duration:.3f},"
|
||||
f"setpts=PTS-STARTPTS"
|
||||
f"[v{i}]"
|
||||
)
|
||||
video_filters.append(vf)
|
||||
|
||||
# 音频滤镜:atrim → asetpts
|
||||
af = f"[{i}:a]atrim=0:{duration:.3f},asetpts=PTS-STARTPTS[a{i}]"
|
||||
audio_filters.append(af)
|
||||
|
||||
n = len(main_clips)
|
||||
|
||||
if n == 1:
|
||||
video_label = "[v0]"
|
||||
audio_label = "[a0]"
|
||||
else:
|
||||
# concat 视频
|
||||
v_inputs = "".join(f"[v{i}]" for i in range(n))
|
||||
video_filters.append(f"{v_inputs}concat=n={n}:v=1:a=0[outv]")
|
||||
# concat 音频
|
||||
a_inputs = "".join(f"[a{i}]" for i in range(n))
|
||||
audio_filters.append(f"{a_inputs}concat=n={n}:v=0:a=1[outa]")
|
||||
video_label = "[outv]"
|
||||
audio_label = "[outa]"
|
||||
|
||||
# 组装 filter_complex
|
||||
fc_parts = video_filters + audio_filters
|
||||
filter_complex = ";".join(fc_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*input_args,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
video_label,
|
||||
"-map",
|
||||
audio_label,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"23",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("[task_id=%s] [渲染] legacy 引擎 FFmpeg 开始: clips=%d", task_id, n)
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(
|
||||
"[task_id=%s] [渲染] legacy 引擎 FFmpeg 失败: %s\nfilter_complex: %s",
|
||||
task_id,
|
||||
e,
|
||||
filter_complex[:500],
|
||||
)
|
||||
raise
|
||||
|
||||
file_size = output_path.stat().st_size if output_path.exists() else 0
|
||||
duration = probe_duration(output_path)
|
||||
return duration, file_size
|
||||
|
||||
|
||||
# ── generate_video 阶段子函数 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1160,15 +1264,13 @@ def _render_video(
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/缩略图逻辑。
|
||||
|
||||
Returns:
|
||||
(output_path, render_duration)
|
||||
"""
|
||||
if not downloaded_videos:
|
||||
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
|
||||
|
||||
# 构建虚拟 plan + clips + asset_path_map
|
||||
# 构建虚拟 plan + clips
|
||||
virtual_plan, virtual_clips, asset_path_map = _build_plan_and_clips_from_task(
|
||||
task_id=task_id,
|
||||
downloaded_paths=downloaded_videos,
|
||||
@@ -1180,6 +1282,7 @@ def _render_video(
|
||||
if template_id:
|
||||
template_config = _load_template_plan_config(template_id)
|
||||
if template_config:
|
||||
# 合并:现有 config 优先级更高(目前为空,模板配置直接生效)
|
||||
base_config = virtual_plan.config or {}
|
||||
virtual_plan.config = {**template_config, **base_config}
|
||||
logger.info(
|
||||
@@ -1188,16 +1291,6 @@ def _render_video(
|
||||
list(template_config.keys()),
|
||||
)
|
||||
|
||||
# 确保输出分辨率配置存在(一键生成默认横屏 1280x720)
|
||||
# RenderAdapter 从 plan.config.export.resolution 读取,
|
||||
# 如果模板没有配置则用默认值,这里显式设置保持和旧逻辑一致
|
||||
plan_cfg = virtual_plan.config or {}
|
||||
export_cfg = plan_cfg.get("export", {}) or {}
|
||||
if not export_cfg.get("resolution"):
|
||||
export_cfg["resolution"] = f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}"
|
||||
plan_cfg["export"] = export_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
|
||||
total_duration = sum(c.duration for c in virtual_clips)
|
||||
logger.info(
|
||||
"[task_id=%s] [剪辑计划] 片段数=%d, 总时长=%.1fs",
|
||||
@@ -1206,42 +1299,64 @@ def _render_video(
|
||||
total_duration,
|
||||
)
|
||||
|
||||
# 选择渲染引擎
|
||||
engine = _resolve_render_engine(user_id) if user_id else ENGINE_UNIFIED
|
||||
logger.info("[task_id=%s] [渲染] 引擎选择: %s (user_id=%s)", task_id, engine, user_id)
|
||||
|
||||
render_start = time.monotonic()
|
||||
logger.info("[task_id=%s] [渲染] RenderAdapter 统一渲染开始", task_id)
|
||||
render_output_path = temp_path / f"rendered-{task_id}.mp4"
|
||||
|
||||
# 使用 RenderAdapter 统一渲染入口(复用 BGM/ASR/分辨率/缩略图逻辑)
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
from worker_app.db import SessionLocal
|
||||
if engine == ENGINE_LEGACY:
|
||||
render_duration, _ = _render_with_legacy_engine(
|
||||
task_id=task_id,
|
||||
virtual_clips=virtual_clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=temp_path,
|
||||
output_path=render_output_path,
|
||||
)
|
||||
else:
|
||||
logger.info("[task_id=%s] [渲染] unified 引擎 FFmpeg 渲染开始", task_id)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adapter = RenderAdapter(db)
|
||||
render_result = adapter.render_from_memory(
|
||||
# ── 准备 BGM 音频 ──
|
||||
bgm_path: str | None = None
|
||||
plan_config = virtual_plan.config or {}
|
||||
bgm_config = plan_config.get("bgm", {}) or {}
|
||||
if bgm_config.get("enabled", False):
|
||||
try:
|
||||
bgm_path = _prepare_bgm_track(
|
||||
bgm_config=bgm_config,
|
||||
temp_path=temp_path,
|
||||
task_id=task_id,
|
||||
)
|
||||
except Exception as bgm_err:
|
||||
logger.warning("[task_id=%s] [BGM] 准备失败,跳过BGM: %s", task_id, bgm_err)
|
||||
bgm_path = None
|
||||
|
||||
render_service = UnifiedRenderService(
|
||||
plan=virtual_plan,
|
||||
clips=virtual_clips,
|
||||
asset_path_map=asset_path_map,
|
||||
plan_id=f"gen_{task_id}",
|
||||
job_id=task_id,
|
||||
work_dir=temp_path,
|
||||
output_width=OUTPUT_WIDTH,
|
||||
output_height=OUTPUT_HEIGHT,
|
||||
output_fps=int(OUTPUT_FPS),
|
||||
asr_service=get_asr_service(),
|
||||
bgm_path=bgm_path,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not render_result.success:
|
||||
raise RuntimeError(f"渲染失败: {render_result.error_message}")
|
||||
|
||||
render_output_path = render_result.output_path
|
||||
render_duration = render_result.duration
|
||||
render_result = render_service.render()
|
||||
render_output_path = render_result.output_path
|
||||
render_duration = render_result.duration
|
||||
|
||||
render_elapsed = time.monotonic() - render_start
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] RenderAdapter 完成: 耗时=%.1fs, 时长=%.2fs",
|
||||
"[task_id=%s] [渲染] %s 引擎完成: 耗时=%.1fs, 时长=%.2fs",
|
||||
task_id,
|
||||
engine,
|
||||
render_elapsed,
|
||||
render_duration,
|
||||
)
|
||||
|
||||
# 配音混音(素材库音频,后处理混音)
|
||||
# 配音混音
|
||||
if voice_path:
|
||||
final_path = temp_path / f"final-{task_id}.mp4"
|
||||
try:
|
||||
|
||||
@@ -280,7 +280,6 @@ def ingest_asset(job_id: str) -> dict:
|
||||
# 先从 OSS 下载文件到本地临时目录,再提取元数据
|
||||
# (storage_key 是 OSS 内部路径,不能直接传给 ffprobe/Pillow)
|
||||
local_file = None
|
||||
thumbnail_url = None
|
||||
try:
|
||||
suffix = Path(job.storage_key).suffix or ".bin"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
@@ -292,27 +291,6 @@ def ingest_asset(job_id: str) -> dict:
|
||||
metadata, extract_success = {}, False
|
||||
else:
|
||||
metadata, extract_success = extract_media_metadata(str(local_file), media_type)
|
||||
|
||||
# 视频类型:生成缩略图(文件还在的时候生成)
|
||||
thumbnail_url = None
|
||||
if media_type == "video" and extract_success:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
|
||||
if thumbnail_url:
|
||||
logger.info(
|
||||
"素材缩略图生成成功: job_id=%s url=%s",
|
||||
job_id,
|
||||
thumbnail_url[:80],
|
||||
)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"素材缩略图生成失败(不影响主流程): job_id=%s error=%s",
|
||||
job_id,
|
||||
thumb_err,
|
||||
)
|
||||
finally:
|
||||
if local_file and local_file.exists():
|
||||
try:
|
||||
@@ -381,7 +359,6 @@ def ingest_asset(job_id: str) -> dict:
|
||||
codec=metadata.get("codec") or None,
|
||||
status=AssetStatus.READY,
|
||||
file_hash=job.file_hash,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
# 统一渲染引擎效果层全模式验证报告
|
||||
|
||||
> 背景:#608 删除 legacy 渲染引擎后,所有模式统一走 UnifiedRenderService。
|
||||
> 本报告验证四种模式(一键生成/剪辑计划/模板/手动编辑器)下所有效果层的覆盖情况。
|
||||
> 验证时间:2026-07-20
|
||||
|
||||
---
|
||||
|
||||
## 一、验证范围
|
||||
|
||||
### 四种渲染模式
|
||||
| 模式 | 入口路径 | 调用链 |
|
||||
|------|---------|--------|
|
||||
| 一键生成(旧) | `worker.generate_video` | `generation.py` → 直接构造 `UnifiedRenderService` |
|
||||
| 剪辑计划 | `worker.render_edit_plan` | `edit_plan_generation.py` → `RenderAdapter` → `UnifiedRenderService` |
|
||||
| 模板模式 | 模板创建计划 → 剪辑计划渲染 | 同剪辑计划路径 |
|
||||
| 手动编辑器 | 手动编辑计划 → 剪辑计划渲染 | 同剪辑计划路径 |
|
||||
|
||||
> **核心结论**:模板模式和手动编辑器最终都走剪辑计划渲染链路,本质是同一条路径。
|
||||
> 差异只在「一键生成(旧)」和「剪辑计划」两条链路之间。
|
||||
|
||||
---
|
||||
|
||||
## 二、效果层覆盖矩阵
|
||||
|
||||
### 2.1 Clip 级效果(两条链路一致,均通过 UnifiedRenderService 内部处理)
|
||||
|
||||
| 效果 | filter_complex | pass_through(直通) | 备注 |
|
||||
|------|:---:|:---:|------|
|
||||
| **裁剪 trim** | ✅ | ✅ | 直通用 trim+duration,filter_complex 用 trim |
|
||||
| **调速 speed** | ✅ | ✅ | 视频 setpts,音频 atempo |
|
||||
| **倒放 reverse** | ✅ | ✅ | reverse 滤镜 + areverse |
|
||||
| **分辨率适配** | ✅ | ✅ | scale + pad/crop,按角色策略不同 |
|
||||
| **调色 color_grade** | ✅ | ✅ | brightness/contrast/saturation等 |
|
||||
| **绿幕抠像 chroma_key** | ✅ | ✅ | colorkey 滤镜 |
|
||||
| **帧率归一化 fps** | ✅ | ✅ | fps 滤镜统一到 output_fps |
|
||||
| **像素格式 format** | ✅ | ✅ | yuv420p |
|
||||
|
||||
### 2.2 层间/全局效果(filter_complex 路径)
|
||||
|
||||
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|
||||
|------|:---:|:---:|------|
|
||||
| **转场 xfade** | ✅ | ✅ | 多clip场景自动启用;直通模式下自动禁用直通走filter_complex |
|
||||
| **画中画 PiP** | ✅ | ✅ | overlay + corner_voice 图层 |
|
||||
| **贴纸 stickers** | ✅ | ✅ | plan.config.stickers;有贴纸时禁用直通 |
|
||||
| **水印 watermark** | ✅ | ✅ | plan.config.watermark;有水印时禁用直通 |
|
||||
| **ASS 字幕叠加** | ✅ | ✅ | subtitles 滤镜 |
|
||||
| **ASR 自动字幕** | ✅ | ✅ | asr_service 传入,生成 ASS |
|
||||
|
||||
### 2.3 音频效果
|
||||
|
||||
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|
||||
|------|:---:|:---:|------|
|
||||
| **BGM 混音** | ✅ | ✅ | 各自准备 BGM 文件,都走 UnifiedRenderService.bgm_path |
|
||||
| **TTS 配音** | ✅ | ✅ | `_maybe_add_voiceover_layer` + audio 图层混音;刚修了顶层字段桥接(#549) |
|
||||
| **配音素材库音频** | ⚠️ 待确认 | ✅ | 一键生成用 `_mux_audio_track` 独立混音;剪辑计划路径需确认 voice 类型 clip 处理 |
|
||||
| **音频降噪** | ✅ | ✅ | afftdn 滤镜,直通和filter_complex都有 |
|
||||
| **音频格式归一化** | ✅ | ✅ | aformat + aac 编码 |
|
||||
| **音量调整** | ✅ | ✅ | volume 滤镜 |
|
||||
|
||||
### 2.4 后处理
|
||||
|
||||
| 效果 | 剪辑计划路径 | 一键生成(旧) | 备注 |
|
||||
|------|:---:|:---:|------|
|
||||
| **片头片尾 intro/outro** | ✅ | ✅ | plan.config.intro_outro |
|
||||
| **封面抽帧** | ✅ | ✅ | 渲染后抽帧上传 |
|
||||
| **输出分辨率** | ✅ | ✅ | 剪辑计划从 config.export 读;一键生成用常量 1280x720 |
|
||||
|
||||
---
|
||||
|
||||
## 三、发现的问题与待修复项
|
||||
|
||||
### P1 级问题(功能缺失)
|
||||
|
||||
#### 1. 一键生成(旧路径)TTS 配音配置路径不匹配 — **已修复 #549**
|
||||
- **根因**:前端传 `config.voice_id` + `config.custom_text`(顶层),后端从 `config.tts` 读
|
||||
- **修复**:`_maybe_add_voiceover_layer` 增加顶层字段桥接兼容
|
||||
- **影响范围**:所有走 UnifiedRenderService 的路径(剪辑计划 + 一键生成)
|
||||
|
||||
#### 2. 直通模式调速失效 — **已修复 #463**
|
||||
- **根因**:`_render_pass_through` 中 final_duration 用原始时长,未考虑调速
|
||||
- **修复**:改用 `_clip_adjusted_duration` 计算调速后时长
|
||||
- **影响范围**:单 clip 直通场景(最常见的一键生成场景)
|
||||
|
||||
### P2 级问题(架构不统一,功能可用但不一致)
|
||||
|
||||
#### 3. 一键生成(旧)配音素材库音频走独立混音链路,不走 audio 图层
|
||||
- **现状**:`generation.py` 里 `_mux_audio_track(render_output_path, voice_path, final_path)` 用 ffmpeg 直接 mux
|
||||
- **问题**:与 UnifiedRenderService 的 audio 图层混音架构不统一;无法与BGM/TTS做混音音量平衡
|
||||
- **建议**:迁移到 audio 图层模式,与剪辑计划路径对齐
|
||||
|
||||
#### 4. 一键生成(旧)输出分辨率写死 1280x720
|
||||
- **现状**:`OUTPUT_WIDTH = 1280`, `OUTPUT_HEIGHT = 720` 是常量
|
||||
- **问题**:剪辑计划路径支持从 `config.export.resolution` 读取输出分辨率
|
||||
- **建议**:一键生成也支持从 plan.config 读取分辨率配置
|
||||
|
||||
#### 5. _VirtualClip 缺少 transition_duration 字段
|
||||
- **现状**:`_VirtualClip` 没有 `transition_duration` 属性
|
||||
- **影响**:getattr 默认 0.0,转场效果等于没转场(但不会报错)
|
||||
- **建议**:补全字段,与 EditPlanClip 对齐
|
||||
|
||||
### P3 级问题(性能优化)
|
||||
|
||||
#### 6. 有 TTS 配音时直通模式被禁用(因为加了 audio 图层变成 2 个图层)
|
||||
- **现状**:TTS 配音加到 audio 图层后,`len(layers) != 1`,直通被禁用
|
||||
- **影响**:单 clip + TTS 配音场景不走直通,性能下降 ~30%
|
||||
- **建议**:直通模式单独处理 audio 图层混音,类似 BGM 的处理方式
|
||||
|
||||
---
|
||||
|
||||
## 四、各模式验收结论
|
||||
|
||||
### ✅ 剪辑计划路径(含模板模式、手动编辑器)
|
||||
所有效果层验证通过,链路完整:
|
||||
- clip 级效果(调色/调速/倒放/绿幕/裁剪)✅
|
||||
- 层间效果(转场/画中画/贴纸/水印)✅
|
||||
- 音频效果(BGM/TTS配音/降噪/格式归一化)✅
|
||||
- 字幕(ASS/ASR自动字幕)✅
|
||||
- 后处理(片头片尾/封面抽帧/分辨率配置)✅
|
||||
|
||||
### ⚠️ 一键生成(旧路径)
|
||||
核心效果可用,但有架构不一致问题:
|
||||
- 核心渲染效果全部通过 ✅
|
||||
- TTS 配音已修复 ✅(#549)
|
||||
- 直通调速已修复 ✅(#463)
|
||||
- 配音素材库混音架构不统一 ⚠️(P2)
|
||||
- 输出分辨率不可配置 ⚠️(P2)
|
||||
- transition_duration 缺失 ⚠️(P2)
|
||||
|
||||
---
|
||||
|
||||
## 五、修复优先级建议
|
||||
|
||||
| 优先级 | 问题 | 工作量 | 建议 |
|
||||
|--------|------|--------|------|
|
||||
| P0 | 无 | - | 核心功能均可用 |
|
||||
| P1 | 已全部修复(#463 #549) | - | 已完成 |
|
||||
| P2 | 配音素材库音频架构统一 | 中 | 下一轮技术债清理 |
|
||||
| P2 | 一键生成输出分辨率可配置 | 小 | 顺手修 |
|
||||
| P2 | _VirtualClip 补 transition_duration | 小 | 顺手修 |
|
||||
| P3 | TTS配音场景直通模式优化 | 中 | 性能优化排期 |
|
||||
|
||||
---
|
||||
|
||||
## 六、验证方法
|
||||
|
||||
本报告基于代码静态分析 + 单元测试验证:
|
||||
- 109 个 unified_render_service 单元测试全绿
|
||||
- 覆盖直通模式、filter_complex 模式、转场、调速、调色、分辨率归一化、帧率归一化、音频格式归一化等核心链路
|
||||
- 新增直通调速测试 3 个(#463)
|
||||
- 新增 TTS 配置桥接测试 4 个(#549)
|
||||
|
||||
**建议后续补充端到端集成测试**:用真实素材跑四种模式的完整渲染链路,验证输出音视频质量。
|
||||
Executable → Regular
-3
@@ -76,7 +76,6 @@ class SQLAlchemyEditTemplateRepository:
|
||||
preview_url=template.preview_url,
|
||||
sort_weight=template.sort_weight,
|
||||
status=template.status,
|
||||
version=template.version,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
@@ -96,7 +95,6 @@ class SQLAlchemyEditTemplateRepository:
|
||||
model.preview_url = template.preview_url
|
||||
model.sort_weight = template.sort_weight
|
||||
model.status = template.status
|
||||
model.version = template.version
|
||||
model.updated_at = template.updated_at
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
@@ -137,7 +135,6 @@ class SQLAlchemyEditTemplateRepository:
|
||||
preview_url=model.preview_url or "",
|
||||
sort_weight=model.sort_weight or 0,
|
||||
status=EditTemplateStatus(model.status) if model.status else EditTemplateStatus.ACTIVE,
|
||||
version=model.version or 1,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@@ -134,31 +134,10 @@ class EditTemplateModel(Base):
|
||||
preview_url = Column(String(1000), nullable=False, default="")
|
||||
sort_weight = Column(Integer, nullable=False, default=0, index=True)
|
||||
status = Column(String(20), nullable=False, default="active", index=True)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditTemplateVersionModel(Base):
|
||||
"""模板发布版本快照 ORM 模型
|
||||
|
||||
每次发布保存完整快照,支持版本历史查询和回滚。
|
||||
"""
|
||||
|
||||
__tablename__ = "edit_template_versions"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
template_id = Column(String(32), nullable=False, index=True)
|
||||
version = Column(Integer, nullable=False)
|
||||
name = Column(String(200), nullable=False, default="")
|
||||
editing_mode = Column(String(30), nullable=False, default="one_take")
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
clip_configs = Column(JSON, nullable=False, default=list)
|
||||
change_note = Column(String(500), nullable=False, default="")
|
||||
published_by = Column(String(36), nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditPlanModel(Base):
|
||||
"""Phase 8 剪辑计划 ORM 模型
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""SQLAlchemy implementation of EditTemplateVersionRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.template_version import EditTemplateVersion
|
||||
|
||||
|
||||
class SQLAlchemyTemplateVersionRepository:
|
||||
"""模板版本仓储实现(SQLAlchemy)。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._db = db
|
||||
|
||||
def create(self, version: EditTemplateVersion) -> EditTemplateVersion:
|
||||
"""保存新版本快照"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel
|
||||
|
||||
model = EditTemplateVersionModel(
|
||||
id=version.id,
|
||||
template_id=version.template_id,
|
||||
version=version.version,
|
||||
name=version.name,
|
||||
editing_mode=version.editing_mode,
|
||||
config=version.config,
|
||||
clip_configs=version.clip_configs,
|
||||
change_note=version.change_note,
|
||||
published_by=version.published_by,
|
||||
created_at=version.created_at,
|
||||
)
|
||||
self._db.add(model)
|
||||
self._db.flush()
|
||||
return version
|
||||
|
||||
def get_by_version(self, template_id: str, version: int) -> EditTemplateVersion | None:
|
||||
"""按版本号获取快照"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel
|
||||
|
||||
model = (
|
||||
self._db.query(EditTemplateVersionModel)
|
||||
.filter(
|
||||
EditTemplateVersionModel.template_id == template_id,
|
||||
EditTemplateVersionModel.version == version,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_entity(model)
|
||||
|
||||
def list_by_template(self, template_id: str, limit: int = 50) -> List[EditTemplateVersion]:
|
||||
"""列出模板的所有历史版本(按版本号倒序)"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel
|
||||
|
||||
models = (
|
||||
self._db.query(EditTemplateVersionModel)
|
||||
.filter(EditTemplateVersionModel.template_id == template_id)
|
||||
.order_by(EditTemplateVersionModel.version.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [self._to_entity(m) for m in models]
|
||||
|
||||
def _to_entity(self, model) -> EditTemplateVersion:
|
||||
return EditTemplateVersion(
|
||||
id=model.id,
|
||||
template_id=model.template_id,
|
||||
version=model.version,
|
||||
name=model.name or "",
|
||||
editing_mode=model.editing_mode or "one_take",
|
||||
config=model.config or {},
|
||||
clip_configs=model.clip_configs or [],
|
||||
change_note=model.change_note or "",
|
||||
published_by=model.published_by or "",
|
||||
created_at=model.created_at,
|
||||
)
|
||||
Executable → Regular
-8
@@ -51,7 +51,6 @@ class EditTemplate:
|
||||
preview_url: str = ""
|
||||
sort_weight: int = 0
|
||||
status: EditTemplateStatus = EditTemplateStatus.ACTIVE
|
||||
version: int = 1
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -67,7 +66,6 @@ class EditTemplate:
|
||||
preview_url: str = "",
|
||||
sort_weight: int = 0,
|
||||
status: EditTemplateStatus = EditTemplateStatus.ACTIVE,
|
||||
version: int = 1,
|
||||
) -> EditTemplate:
|
||||
"""创建新模板实例"""
|
||||
clean_name = name.strip()
|
||||
@@ -88,7 +86,6 @@ class EditTemplate:
|
||||
preview_url=preview_url.strip(),
|
||||
sort_weight=sort_weight,
|
||||
status=status,
|
||||
version=version,
|
||||
)
|
||||
|
||||
def activate(self) -> None:
|
||||
@@ -105,8 +102,3 @@ class EditTemplate:
|
||||
def is_active(self) -> bool:
|
||||
"""模板是否处于激活状态"""
|
||||
return self.status == EditTemplateStatus.ACTIVE
|
||||
|
||||
def bump_version(self) -> None:
|
||||
"""版本号+1,发布时调用"""
|
||||
self.version += 1
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -249,7 +249,7 @@ class Asset:
|
||||
storage_key=storage_key.strip(),
|
||||
mime_type=mime_type.strip(),
|
||||
file_size=file_size,
|
||||
thumbnail_url=str(thumbnail_url) if thumbnail_url else None,
|
||||
thumbnail_url=thumbnail_url,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""EditTemplateVersion — 模板发布版本快照,用于回滚和版本历史."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EditTemplateVersion:
|
||||
"""模板发布版本快照
|
||||
|
||||
每次发布时保存模板当时的完整状态(config + clip_configs),
|
||||
支持回滚到任意历史版本。
|
||||
"""
|
||||
|
||||
id: str
|
||||
template_id: str
|
||||
version: int
|
||||
name: str = ""
|
||||
editing_mode: str = "one_take"
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
clip_configs: list[dict[str, Any]] = field(default_factory=list)
|
||||
change_note: str = ""
|
||||
published_by: str = ""
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
template_id: str,
|
||||
version: int,
|
||||
*,
|
||||
name: str = "",
|
||||
editing_mode: str = "one_take",
|
||||
config: dict[str, Any] | None = None,
|
||||
clip_configs: list[dict[str, Any]] | None = None,
|
||||
change_note: str = "",
|
||||
published_by: str = "",
|
||||
) -> "EditTemplateVersion":
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
template_id=template_id,
|
||||
version=version,
|
||||
name=name,
|
||||
editing_mode=editing_mode,
|
||||
config=config or {},
|
||||
clip_configs=clip_configs or [],
|
||||
change_note=change_note,
|
||||
published_by=published_by,
|
||||
)
|
||||
@@ -1,426 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ACR 镜像清理脚本
|
||||
策略:
|
||||
- 版本tag (v*): 永久保留
|
||||
- 固定tag (latest, main, develop, master): 永久保留
|
||||
- 缓存镜像 (*-cache): 永久保留
|
||||
- PR预览tag (pr-*): 保留 N 天(默认7天)
|
||||
- 普通commit hash tag: 保留最近 N 个(默认20),老的删除
|
||||
|
||||
使用方式:
|
||||
python3 acr_cleanup.py --dry-run # 预览,不实际删除
|
||||
python3 acr_cleanup.py --execute # 实际执行删除
|
||||
python3 acr_cleanup.py --keep 20 --execute # 保留最近20个
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# 配置
|
||||
REGISTRY = os.environ.get("ACR_REGISTRY", "xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com")
|
||||
AUTH_URL = "https://dockerauth.cn-hangzhou.aliyuncs.com/auth"
|
||||
SERVICE = os.environ.get("ACR_SERVICE", "registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa")
|
||||
NAMESPACE = os.environ.get("ACR_NAMESPACE", "xiaoxiakeji")
|
||||
USERNAME = os.environ.get("ACR_USERNAME", "")
|
||||
PASSWORD = os.environ.get("ACR_PASSWORD", "")
|
||||
|
||||
REPOS = [
|
||||
"xiaoxia-saas-api",
|
||||
"xiaoxia-saas-worker",
|
||||
"xiaoxia-saas-web",
|
||||
"api-cache",
|
||||
"worker-cache",
|
||||
"web-cache",
|
||||
]
|
||||
|
||||
# 缓存镜像仓库(所有tag永久保留)
|
||||
CACHE_REPOS = {"api-cache", "worker-cache", "web-cache"}
|
||||
|
||||
# OCI / Docker manifest types
|
||||
ACCEPT_INDEX = "application/vnd.oci.image.index.v1+json"
|
||||
ACCEPT_MANIFEST_OCI = "application/vnd.oci.image.manifest.v1+json"
|
||||
ACCEPT_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
|
||||
|
||||
def get_token(repo, action="pull"):
|
||||
"""获取仓库访问token"""
|
||||
scope = "repository:" + NAMESPACE + "/" + repo + ":" + action
|
||||
token_url = AUTH_URL + "?service=" + SERVICE + "&scope=" + scope
|
||||
req = urllib.request.Request(token_url)
|
||||
req.add_header("Authorization", "Basic " + base64.b64encode((USERNAME + ":" + PASSWORD).encode()).decode())
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return data.get("token", "")
|
||||
|
||||
|
||||
def get_tags(repo, token):
|
||||
"""获取仓库所有tag"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/tags/list?n=1000"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return data.get("tags", []) or []
|
||||
|
||||
|
||||
def http_get_json(url, token, accept_header):
|
||||
"""带Authorization的GET请求,返回(json_data, headers)"""
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
req.add_header("Accept", accept_header)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read()), resp.headers
|
||||
|
||||
|
||||
def get_manifest_info(repo, tag, token):
|
||||
"""
|
||||
获取tag的manifest信息,支持OCI index和普通manifest两种格式。
|
||||
返回: {digest, created, media_type}
|
||||
- digest: 顶层manifest的digest(用于删除)
|
||||
- created: 镜像创建时间
|
||||
"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
|
||||
result = {"digest": "", "created": "", "media_type": "", "error": ""}
|
||||
|
||||
# 先尝试 OCI index 格式(ACR多用这种)
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_INDEX)
|
||||
top_digest = headers.get("Docker-Content-Digest", "")
|
||||
result["digest"] = top_digest
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_INDEX)
|
||||
|
||||
# OCI index:找amd64的manifest,再取config blob
|
||||
manifests = data.get("manifests", [])
|
||||
amd64_manifest = None
|
||||
for m in manifests:
|
||||
arch = m.get("platform", {}).get("architecture", "")
|
||||
if arch == "amd64":
|
||||
amd64_manifest = m
|
||||
break
|
||||
# 没有amd64就用第一个
|
||||
if not amd64_manifest and manifests:
|
||||
amd64_manifest = manifests[0]
|
||||
|
||||
if amd64_manifest:
|
||||
inner_digest = amd64_manifest["digest"]
|
||||
inner_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + inner_digest
|
||||
try:
|
||||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_OCI)
|
||||
except Exception:
|
||||
# 退而求其次用v2格式
|
||||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_V2)
|
||||
|
||||
config_digest = inner_data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
# 再尝试普通 OCI manifest 格式
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_OCI)
|
||||
result["digest"] = headers.get("Docker-Content-Digest", "")
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_OCI)
|
||||
config_digest = data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
# 最后试 Docker v2 格式
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_V2)
|
||||
result["digest"] = headers.get("Docker-Content-Digest", "")
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_V2)
|
||||
config_digest = data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError as e:
|
||||
result["error"] = "HTTP " + str(e.code) + " " + e.read().decode()[:200]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def delete_manifest(repo, digest, token):
|
||||
"""按digest删除manifest(会级联删除所有指向它的tag)"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + digest
|
||||
req = urllib.request.Request(url, method="DELETE")
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
req.add_header("Accept", ACCEPT_INDEX)
|
||||
req.add_header("Accept", ACCEPT_MANIFEST_OCI)
|
||||
req.add_header("Accept", ACCEPT_MANIFEST_V2)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return True, resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, str(e.code) + " " + e.read().decode()[:200]
|
||||
|
||||
|
||||
def parse_time(created_str):
|
||||
"""解析ISO时间字符串"""
|
||||
if not created_str:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
if created_str.endswith("Z"):
|
||||
created_str = created_str[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(created_str)
|
||||
except Exception:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def is_version_tag(tag):
|
||||
"""判断是否是版本tag (v1.2.3, v0.1.0-alpha等)"""
|
||||
return tag.startswith("v") and len(tag) > 1 and tag[1].isdigit()
|
||||
|
||||
|
||||
def is_fixed_tag(tag):
|
||||
"""判断是否是固定tag"""
|
||||
return tag in ("latest", "main", "develop", "master", "dev", "stable")
|
||||
|
||||
|
||||
def is_pr_tag(tag):
|
||||
"""判断是否是PR预览tag"""
|
||||
return tag.startswith("pr-")
|
||||
|
||||
|
||||
def cleanup_repo(repo, keep_count, pr_days, dry_run):
|
||||
"""清理单个仓库"""
|
||||
print("=" * 60)
|
||||
print("仓库:", repo)
|
||||
print("=" * 60)
|
||||
|
||||
# 缓存仓库不清理
|
||||
if repo in CACHE_REPOS:
|
||||
token_pull = get_token(repo, "pull")
|
||||
tags = get_tags(repo, token_pull)
|
||||
print(" 缓存仓库,跳过清理 (共", len(tags), "个tag)")
|
||||
return len(tags), 0
|
||||
|
||||
token_pull = get_token(repo, "pull")
|
||||
tags = get_tags(repo, token_pull)
|
||||
print(" 总tag数:", len(tags))
|
||||
|
||||
# 分类
|
||||
version_tags = []
|
||||
fixed_tags = []
|
||||
pr_tags_list = []
|
||||
commit_tags = []
|
||||
|
||||
for tag in tags:
|
||||
if is_version_tag(tag):
|
||||
version_tags.append(tag)
|
||||
elif is_fixed_tag(tag):
|
||||
fixed_tags.append(tag)
|
||||
elif is_pr_tag(tag):
|
||||
pr_tags_list.append(tag)
|
||||
else:
|
||||
commit_tags.append(tag)
|
||||
|
||||
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
|
||||
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
|
||||
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "天")
|
||||
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "个")
|
||||
|
||||
# 获取所有commit tag的创建时间
|
||||
print()
|
||||
print(" 获取commit tag创建时间...")
|
||||
tag_info_list = []
|
||||
errors = 0
|
||||
for i, tag in enumerate(commit_tags):
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
if info["error"] or not info["digest"]:
|
||||
errors += 1
|
||||
# 取不到信息的tag,放到最后(最旧处理),但标记一下
|
||||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": "", "error": info.get("error", "")})
|
||||
else:
|
||||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": info["created"], "error": ""})
|
||||
if (i + 1) % 20 == 0:
|
||||
print(" 已获取", i + 1, "/", len(commit_tags), "...")
|
||||
|
||||
if errors:
|
||||
print(" 注意:", errors, "个tag获取manifest失败")
|
||||
|
||||
# 按时间倒序排序(空时间放最后)
|
||||
tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True)
|
||||
|
||||
# 确定要删除的commit tag
|
||||
to_delete = []
|
||||
if len(tag_info_list) > keep_count:
|
||||
to_delete = tag_info_list[keep_count:]
|
||||
print(" 保留前", keep_count, "个commit tag,删除", len(to_delete), "个")
|
||||
# 打印保留范围
|
||||
kept = tag_info_list[:keep_count]
|
||||
valid_kept = [t for t in kept if t["created"]]
|
||||
if valid_kept:
|
||||
print(" 最早保留:", valid_kept[-1]["tag"][:12], "(" + valid_kept[-1]["created"][:10] + ")")
|
||||
# 保护当前构建的tag(通过PROTECTED_TAG环境变量传入,如GITHUB_SHA)
|
||||
protected_tag = os.environ.get("PROTECTED_TAG", "").strip()
|
||||
if protected_tag:
|
||||
before = len(to_delete)
|
||||
to_delete = [t for t in to_delete if not t["tag"].startswith(protected_tag)]
|
||||
removed = before - len(to_delete)
|
||||
if removed > 0:
|
||||
print(f" 保护当前构建tag: {protected_tag[:12]} (跳过{removed}个)")
|
||||
|
||||
to_del_valid = [t for t in to_delete if t["digest"]]
|
||||
print(" 可删除(有digest):", len(to_del_valid), "个")
|
||||
else:
|
||||
print(" commit tag数量不足", keep_count, ",无需清理")
|
||||
|
||||
# PR tag按时间清理
|
||||
pr_to_delete = []
|
||||
if pr_tags_list:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
|
||||
print()
|
||||
print(" 检查PR预览tag(超过", pr_days, "天删除)...")
|
||||
for tag in pr_tags_list:
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
created = parse_time(info["created"])
|
||||
if created < cutoff:
|
||||
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
print(" PR tag将删除:", len(pr_to_delete), "个")
|
||||
|
||||
all_to_delete = [t for t in to_delete if t["digest"]] + [t for t in pr_to_delete if t["digest"]]
|
||||
|
||||
if not all_to_delete:
|
||||
print()
|
||||
print(" 无需删除任何tag")
|
||||
return len(tags), 0
|
||||
|
||||
# 执行删除
|
||||
print()
|
||||
if dry_run:
|
||||
print(" [DRY RUN] 将删除", len(all_to_delete), "个tag(预览模式,不实际删除)")
|
||||
# 去重digest
|
||||
unique_digests = set(t["digest"] for t in all_to_delete if t["digest"])
|
||||
print(" 去重后唯一digest数:", len(unique_digests))
|
||||
for item in all_to_delete[:5]:
|
||||
created_str = item.get("created", "")[:10] or "未知"
|
||||
print(" -", item["tag"][:20], "(" + created_str + ")")
|
||||
if len(all_to_delete) > 5:
|
||||
print(" ... 还有", len(all_to_delete) - 5, "个")
|
||||
return len(tags), len(unique_digests)
|
||||
|
||||
token_delete = get_token(repo, "delete")
|
||||
deleted = 0
|
||||
failed = 0
|
||||
# 按digest去重,避免重复删除同一镜像
|
||||
seen_digests = set()
|
||||
unique_delete = []
|
||||
for item in all_to_delete:
|
||||
if item["digest"] and item["digest"] not in seen_digests:
|
||||
seen_digests.add(item["digest"])
|
||||
unique_delete.append(item)
|
||||
|
||||
print(" 开始删除", len(unique_delete), "个唯一manifest...")
|
||||
for item in unique_delete:
|
||||
success, result = delete_manifest(repo, item["digest"], token_delete)
|
||||
if success:
|
||||
deleted += 1
|
||||
print(" 已删除:", item["tag"][:20])
|
||||
else:
|
||||
failed += 1
|
||||
print(" 删除失败:", item["tag"][:20], "-", result)
|
||||
|
||||
print()
|
||||
print(" 删除完成: 成功", deleted, "个,失败", failed, "个")
|
||||
return len(tags), deleted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ACR镜像清理工具")
|
||||
parser.add_argument("--keep", type=int, default=20, help="保留最近N个commit hash tag(默认20)")
|
||||
parser.add_argument("--pr-days", type=int, default=7, help="PR预览tag保留天数(默认7天)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际删除")
|
||||
parser.add_argument("--execute", action="store_true", help="实际执行删除")
|
||||
parser.add_argument("--repo", type=str, default="", help="只清理指定仓库")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 必须指定 --dry-run 或 --execute
|
||||
if not args.dry_run and not args.execute:
|
||||
print("请指定 --dry-run(预览)或 --execute(执行)")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
|
||||
print(" python3 acr_cleanup.py --execute # 实际执行清理")
|
||||
print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个")
|
||||
sys.exit(1)
|
||||
|
||||
# 凭证检查
|
||||
global USERNAME, PASSWORD
|
||||
if not USERNAME or not PASSWORD:
|
||||
# 尝试从docker config读取
|
||||
try:
|
||||
docker_config_path = os.path.expanduser("~/.docker/config.json")
|
||||
with open(docker_config_path) as f:
|
||||
config = json.load(f)
|
||||
auth = config.get("auths", {}).get(REGISTRY, {}).get("auth", "")
|
||||
if auth:
|
||||
creds = base64.b64decode(auth).decode().strip()
|
||||
USERNAME, PASSWORD = creds.split(":", 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not USERNAME or not PASSWORD:
|
||||
print("错误: 缺少ACR凭证,请设置 ACR_USERNAME 和 ACR_PASSWORD 环境变量")
|
||||
print("或确保已执行 docker login", REGISTRY)
|
||||
sys.exit(1)
|
||||
|
||||
dry_run = args.dry_run or not args.execute
|
||||
mode = "预览模式" if dry_run else "执行模式"
|
||||
print("ACR 镜像清理工具 -", mode)
|
||||
print("Registry:", REGISTRY)
|
||||
print("Namespace:", NAMESPACE)
|
||||
print("保留commit tag数:", args.keep)
|
||||
print("PR预览保留天数:", args.pr_days)
|
||||
print()
|
||||
|
||||
repos_to_clean = REPOS
|
||||
if args.repo:
|
||||
repos_to_clean = [args.repo]
|
||||
|
||||
total_deleted = 0
|
||||
total_tags = 0
|
||||
for repo in repos_to_clean:
|
||||
count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run)
|
||||
total_tags += count
|
||||
total_deleted += deleted
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("清理完成")
|
||||
print(" 总tag数:", total_tags)
|
||||
if dry_run:
|
||||
print(" 预览将删除(去重后):", total_deleted, "个manifest")
|
||||
else:
|
||||
print(" 已删除:", total_deleted, "个manifest")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,7 +9,6 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
@@ -24,56 +23,6 @@ def run(cmd, check=True, capture=True, cwd=None):
|
||||
return result
|
||||
|
||||
|
||||
def ensure_git_repo(api_url, repo, token, pr_number):
|
||||
"""确保当前目录是git仓库,并切换到PR源分支。
|
||||
|
||||
checkout脚本用tarball方式下载代码(PR merge后的commit),没有.git目录。
|
||||
这里自动初始化git仓库,fetch PR源分支并强制checkout,
|
||||
使工作区变为PR源分支的代码,确保后续格式化修复基于源分支。
|
||||
"""
|
||||
if os.path.exists(".git"):
|
||||
return
|
||||
|
||||
print("检测到tarball checkout(无.git目录),自动初始化git仓库...")
|
||||
|
||||
# 构造带认证的远端URL
|
||||
server_url = api_url.rsplit("/api/v1", 1)[0]
|
||||
remote_url = f"{server_url.replace('https://', f'https://x-access-token:{token}@')}/{repo}.git"
|
||||
|
||||
# 获取PR的源分支
|
||||
pr_api_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
|
||||
req_obj = urllib.request.Request(pr_api_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_obj) as resp:
|
||||
pr = json.loads(resp.read())
|
||||
head_branch = pr["head"]["ref"]
|
||||
|
||||
print(f"PR源分支: {head_branch}")
|
||||
|
||||
# 初始化git
|
||||
run("git init -q")
|
||||
run(f"git remote add origin {remote_url}")
|
||||
run('git config user.name "CI Bot"')
|
||||
run('git config user.email "ci-bot@xiaoxiajianji.com"')
|
||||
|
||||
# fetch源分支(浅克隆,只要最新commit)
|
||||
print("fetch源分支...")
|
||||
run(f"git fetch --depth=1 origin {head_branch}")
|
||||
|
||||
# 强制checkout到源分支(覆盖tarball内容)
|
||||
# tarball是merge后的commit,源分支才是我们要修改并推送的目标
|
||||
print("切换到源分支...")
|
||||
run(f"git checkout -B --force {head_branch} FETCH_HEAD")
|
||||
|
||||
result = run("git status --porcelain")
|
||||
if result.stdout.strip():
|
||||
n = len(result.stdout.strip().splitlines())
|
||||
print(f"⚠️ 工作区有 {n} 个未追踪文件")
|
||||
else:
|
||||
print("✅ git仓库就绪,工作区clean")
|
||||
|
||||
return head_branch
|
||||
|
||||
|
||||
def get_changed_files(pr_number, api_url, token):
|
||||
"""获取PR中变更的文件列表"""
|
||||
url = f"{api_url}/pulls/{pr_number}/files?limit=100"
|
||||
@@ -142,7 +91,9 @@ def fix_frontend(target_fe_files, scan_mode, repo_root):
|
||||
|
||||
if scan_mode == "incremental":
|
||||
# 增量模式:只格式化变更的前端文件
|
||||
# 转换为相对于 apps/web 的路径或用绝对路径
|
||||
target_str = " ".join(target_fe_files)
|
||||
# 从项目根目录运行,prettier 会找配置文件
|
||||
cmd = f"{prettier_bin} --write {target_str}"
|
||||
else:
|
||||
# 全量模式:格式化整个前端目录
|
||||
@@ -179,10 +130,6 @@ def main():
|
||||
|
||||
repo_root = os.getcwd()
|
||||
|
||||
# 确保git仓库可用(tarball checkout模式下自动初始化)
|
||||
# 返回PR源分支名,供后续推送使用
|
||||
head_branch = ensure_git_repo(api_url, repo, token, pr_number)
|
||||
|
||||
print("=== 检测到代码格式问题,尝试自动修复 ===")
|
||||
print(f"PR #{pr_number}")
|
||||
print(f"扫描模式: {scan_mode}")
|
||||
@@ -202,6 +149,7 @@ def main():
|
||||
".yaml",
|
||||
".yml",
|
||||
)
|
||||
# Python 文件扩展名
|
||||
py_extensions = (".py",)
|
||||
|
||||
# 确定要修复的文件范围
|
||||
@@ -212,13 +160,15 @@ def main():
|
||||
print(f"增量模式: {len(target_py_files)} 个Python文件, {len(target_fe_files)} 个前端文件")
|
||||
else:
|
||||
target_py_files = ["alembic", "apps", "packages", "tests", "scripts"]
|
||||
target_fe_files = ["apps/web"]
|
||||
# 全量模式下 prettier 在前端目录内部运行,无需传文件列表
|
||||
target_fe_files = ["apps/web"] # 标记为有前端文件需要处理
|
||||
print("全量模式,修复所有文件")
|
||||
|
||||
# Python 格式化
|
||||
fix_python(target_py_files, scan_mode)
|
||||
|
||||
# 前端格式化
|
||||
# 全量模式下直接传 web 目录标记
|
||||
if scan_mode != "incremental":
|
||||
fix_frontend(["apps/web"], scan_mode, repo_root)
|
||||
else:
|
||||
@@ -236,53 +186,19 @@ def main():
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
print(f" {line}")
|
||||
|
||||
# 配置git
|
||||
run('git config user.name "CI Bot"')
|
||||
run('git config user.email "ci-bot@xiaoxiajianji.com"')
|
||||
|
||||
# 提交修复
|
||||
run("git add -A")
|
||||
run('git commit -m "style: auto-format with black + isort + prettier [ci skip]"')
|
||||
|
||||
# 推送(head_branch已从ensure_git_repo获取)
|
||||
# 获取来源分支并推送
|
||||
head_branch = get_pr_head_branch(pr_number, f"{api_url}/repos/{repo}", token)
|
||||
print(f"\nPR来源分支: {head_branch}")
|
||||
print("推送格式修复到远端...")
|
||||
|
||||
# 推送前先 rebase 拉取远端最新,避免快进冲突
|
||||
# 最多重试 3 次:rebase → push,失败则重新拉取再试
|
||||
max_retries = 3
|
||||
push_success = False
|
||||
last_error = ""
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
print(f" 尝试 {attempt}/{max_retries}: 拉取最新代码并推送...")
|
||||
|
||||
# 先拉取远端最新 commit 并 rebase
|
||||
fetch_result = run(f"git fetch origin {head_branch}", check=False)
|
||||
if fetch_result.returncode != 0:
|
||||
last_error = f"git fetch 失败: {fetch_result.stderr.strip()}"
|
||||
print(f" {last_error}")
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
rebase_result = run(f"git rebase origin/{head_branch}", check=False)
|
||||
if rebase_result.returncode != 0:
|
||||
last_error = f"git rebase 失败,中止并重置: {rebase_result.stderr.strip()[:200]}"
|
||||
print(f" {last_error}")
|
||||
run("git rebase --abort", check=False)
|
||||
# rebase 失败通常是冲突,重试没用,直接跳出
|
||||
break
|
||||
|
||||
# 推送
|
||||
push_result = run(f'git push origin "HEAD:{head_branch}"', check=False)
|
||||
if push_result.returncode == 0:
|
||||
push_success = True
|
||||
break
|
||||
|
||||
last_error = push_result.stderr.strip() or push_result.stdout.strip()
|
||||
print(f" push 失败: {last_error[:200]}")
|
||||
time.sleep(3)
|
||||
|
||||
if not push_success:
|
||||
print(f"\n❌ 推送失败(已重试 {max_retries} 次)", file=sys.stderr)
|
||||
print(f"最后错误: {last_error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
run(f'git push origin "HEAD:{head_branch}"')
|
||||
|
||||
print()
|
||||
print("✅ 格式已自动修复并推送回分支")
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI Trace Report Script - Reports CI Trace data to AgentLoop from Gitea Actions workflows.
|
||||
|
||||
Usage in CI workflow jobs:
|
||||
- At start: python3 scripts/ci/ci_trace_report.py --status running
|
||||
- At end: python3 scripts/ci/ci_trace_report.py --status ok --start-time $CI_TRACE_START_TIME
|
||||
|
||||
Environment variables (built-in Gitea Actions):
|
||||
GITEA_REPOSITORY / GITHUB_REPOSITORY - repository (owner/repo)
|
||||
GITEA_WORKFLOW / GITHUB_WORKFLOW - workflow name
|
||||
GITEA_JOB / GITHUB_JOB - job ID
|
||||
GITEA_SHA / GITHUB_SHA - commit SHA
|
||||
GITEA_REF_NAME / GITHUB_REF_NAME - branch name
|
||||
GITEA_RUN_ID / GITHUB_RUN_ID - run ID
|
||||
GITEA_ACTOR / GITHUB_ACTOR - trigger actor
|
||||
GITEA_EVENT_NAME / GITHUB_EVENT_NAME - event type
|
||||
PR_NUMBER / GITEA_PR_NUMBER - PR number (if PR triggered)
|
||||
|
||||
AgentLoop configuration (injected via Secrets):
|
||||
AGENTLOOP_LICENSE_KEY - LicenseKey (required)
|
||||
AGENTLOOP_ENDPOINT - Trace endpoint (optional, has default)
|
||||
AGENTLOOP_PROJECT - SLS Project name (optional)
|
||||
AGENTLOOP_WORKSPACE - CMS Workspace name (optional)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
# ========== Default Configuration ==========
|
||||
DEFAULT_ENDPOINT = "https://proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou.cn-hangzhou.log.aliyuncs.com/apm/trace/opentelemetry/v1/traces"
|
||||
DEFAULT_PROJECT = "proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou"
|
||||
DEFAULT_WORKSPACE = "agentloop-13b8d6efb7fde6e9b193eb982ade68e2"
|
||||
|
||||
|
||||
# ========== OTLP Protobuf Manual Encoding ==========
|
||||
|
||||
|
||||
def _encode_varint(value):
|
||||
result = bytearray()
|
||||
while value > 0x7F:
|
||||
result.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
result.append(value & 0x7F)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def _encode_tag(field_number, wire_type):
|
||||
return _encode_varint((field_number << 3) | wire_type)
|
||||
|
||||
|
||||
def _encode_string_field(field_number, value):
|
||||
value_bytes = value.encode("utf-8")
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
|
||||
|
||||
|
||||
def _encode_bytes_field(field_number, value_bytes):
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
|
||||
|
||||
|
||||
def _encode_int_field(field_number, value):
|
||||
return _encode_tag(field_number, 0) + _encode_varint(value & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
|
||||
def _encode_message_field(field_number, message_bytes):
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(message_bytes)) + message_bytes
|
||||
|
||||
|
||||
def _encode_key_value(key, value_str):
|
||||
any_value = _encode_string_field(1, value_str)
|
||||
return _encode_string_field(1, key) + _encode_message_field(2, any_value)
|
||||
|
||||
|
||||
def _encode_status(status_code, status_msg=""):
|
||||
data = _encode_int_field(1, status_code)
|
||||
if status_msg:
|
||||
data += _encode_string_field(2, status_msg)
|
||||
return data
|
||||
|
||||
|
||||
def _encode_span(
|
||||
trace_id_bytes,
|
||||
span_id_bytes,
|
||||
parent_span_id_bytes,
|
||||
name,
|
||||
start_time_unix_nano,
|
||||
end_time_unix_nano,
|
||||
span_kind,
|
||||
attributes,
|
||||
status_code,
|
||||
status_msg="",
|
||||
):
|
||||
data = b""
|
||||
data += _encode_bytes_field(1, trace_id_bytes)
|
||||
data += _encode_bytes_field(2, span_id_bytes)
|
||||
if parent_span_id_bytes:
|
||||
data += _encode_bytes_field(3, parent_span_id_bytes)
|
||||
data += _encode_string_field(4, name)
|
||||
data += _encode_int_field(5, span_kind)
|
||||
data += _encode_int_field(6, start_time_unix_nano)
|
||||
data += _encode_int_field(7, end_time_unix_nano)
|
||||
for key, value in attributes.items():
|
||||
kv = _encode_key_value(key, str(value))
|
||||
data += _encode_message_field(9, kv)
|
||||
status = _encode_status(status_code, status_msg)
|
||||
data += _encode_message_field(12, status)
|
||||
return data
|
||||
|
||||
|
||||
def _encode_resource_spans(service_name, scope_spans_bytes):
|
||||
svc_kv = _encode_key_value("service.name", service_name)
|
||||
resource = _encode_message_field(1, svc_kv)
|
||||
data = _encode_message_field(1, resource)
|
||||
data += _encode_message_field(2, scope_spans_bytes)
|
||||
return data
|
||||
|
||||
|
||||
def _encode_scope_spans(scope_name, spans_bytes_list):
|
||||
scope = _encode_string_field(1, scope_name)
|
||||
data = _encode_message_field(1, scope)
|
||||
for span_bytes in spans_bytes_list:
|
||||
data += _encode_message_field(2, span_bytes)
|
||||
return data
|
||||
|
||||
|
||||
def _encode_traces_data(resource_spans_bytes_list):
|
||||
data = b""
|
||||
for rs_bytes in resource_spans_bytes_list:
|
||||
data += _encode_message_field(1, rs_bytes)
|
||||
return data
|
||||
|
||||
|
||||
# ========== Helper Functions ==========
|
||||
|
||||
|
||||
def _gen_trace_id():
|
||||
return uuid.uuid4().bytes
|
||||
|
||||
|
||||
def _gen_span_id():
|
||||
return uuid.uuid4().bytes[:8]
|
||||
|
||||
|
||||
def _env(name, default=""):
|
||||
"""Get env var with GITEA_/GITHUB_ prefix fallback."""
|
||||
val = os.getenv(name, "")
|
||||
if val:
|
||||
return val
|
||||
if name.startswith("GITEA_"):
|
||||
alt = "GITHUB_" + name[6:]
|
||||
return os.getenv(alt, default)
|
||||
if name.startswith("GITHUB_"):
|
||||
alt = "GITEA_" + name[7:]
|
||||
return os.getenv(alt, default)
|
||||
return default
|
||||
|
||||
|
||||
def _get_pr_number():
|
||||
"""Get PR number from environment or event file."""
|
||||
pr = os.getenv("PR_NUMBER", "") or os.getenv("GITEA_PR_NUMBER", "")
|
||||
if pr:
|
||||
return pr
|
||||
|
||||
event_path = os.getenv("GITHUB_EVENT_PATH", "") or os.getenv("GITEA_EVENT_PATH", "")
|
||||
if event_path and os.path.isfile(event_path):
|
||||
try:
|
||||
with open(event_path, "r") as f:
|
||||
event = json.load(f)
|
||||
if "pull_request" in event and "number" in event["pull_request"]:
|
||||
return str(event["pull_request"]["number"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _get_ci_attributes():
|
||||
"""Collect attributes from CI environment variables."""
|
||||
attrs = {
|
||||
"ci.repo": _env("GITEA_REPOSITORY") or _env("GITHUB_REPOSITORY") or "unknown",
|
||||
"ci.workflow": _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "unknown",
|
||||
"ci.job": _env("GITEA_JOB") or _env("GITHUB_JOB") or "unknown",
|
||||
"ci.commit_sha": _env("GITEA_SHA") or _env("GITHUB_SHA") or "unknown",
|
||||
"ci.branch": _env("GITEA_REF_NAME") or _env("GITHUB_REF_NAME") or "unknown",
|
||||
"ci.run_id": _env("GITEA_RUN_ID") or _env("GITHUB_RUN_ID") or "unknown",
|
||||
"ci.actor": _env("GITEA_ACTOR") or _env("GITHUB_ACTOR") or "unknown",
|
||||
"ci.event": _env("GITEA_EVENT_NAME") or _env("GITHUB_EVENT_NAME") or "unknown",
|
||||
}
|
||||
pr = _get_pr_number()
|
||||
if pr:
|
||||
attrs["ci.pr_number"] = pr
|
||||
return attrs
|
||||
|
||||
|
||||
# ========== Trace Building & Reporting ==========
|
||||
|
||||
|
||||
def build_trace(service_name, trace_name, status, duration_ms, attributes=None):
|
||||
"""Build an OTLP trace payload (protobuf bytes). No external dependencies."""
|
||||
trace_id = _gen_trace_id()
|
||||
end_time = int(time.time() * 1e9)
|
||||
start_time = end_time - int(duration_ms * 1e6)
|
||||
status_code = 1 if status in ("ok", "running") else 2
|
||||
status_msg = "" if status in ("ok", "running") else "Job failed"
|
||||
|
||||
main_attrs = {
|
||||
"agent.trace_name": trace_name,
|
||||
"agent.service": service_name,
|
||||
"ci.trace_status": status,
|
||||
}
|
||||
if attributes:
|
||||
main_attrs.update(attributes)
|
||||
|
||||
main_span = _encode_span(
|
||||
trace_id_bytes=trace_id,
|
||||
span_id_bytes=_gen_span_id(),
|
||||
parent_span_id_bytes=b"",
|
||||
name=trace_name,
|
||||
start_time_unix_nano=start_time,
|
||||
end_time_unix_nano=end_time,
|
||||
span_kind=1,
|
||||
attributes=main_attrs,
|
||||
status_code=status_code,
|
||||
status_msg=status_msg,
|
||||
)
|
||||
|
||||
scope_spans = _encode_scope_spans("ci-trace", [main_span])
|
||||
resource_spans = _encode_resource_spans(service_name, scope_spans)
|
||||
return _encode_traces_data([resource_spans])
|
||||
|
||||
|
||||
def report_ci_trace(
|
||||
service_name,
|
||||
trace_name,
|
||||
status="ok",
|
||||
duration_ms=1000,
|
||||
endpoint=None,
|
||||
license_key=None,
|
||||
project=None,
|
||||
workspace=None,
|
||||
extra_attributes=None,
|
||||
):
|
||||
"""
|
||||
Report CI Trace data. Returns (success: bool, message: str).
|
||||
Never raises exceptions; returns False on failure.
|
||||
"""
|
||||
try:
|
||||
endpoint = endpoint or os.getenv("AGENTLOOP_ENDPOINT", DEFAULT_ENDPOINT)
|
||||
license_key = license_key or os.getenv("AGENTLOOP_LICENSE_KEY", "")
|
||||
project = project or os.getenv("AGENTLOOP_PROJECT", DEFAULT_PROJECT)
|
||||
workspace = workspace or os.getenv("AGENTLOOP_WORKSPACE", DEFAULT_WORKSPACE)
|
||||
|
||||
if not license_key:
|
||||
return False, "[Trace] skipped: AGENTLOOP_LICENSE_KEY not configured"
|
||||
|
||||
attrs = _get_ci_attributes()
|
||||
if extra_attributes:
|
||||
attrs.update(extra_attributes)
|
||||
|
||||
payload = build_trace(
|
||||
service_name=service_name,
|
||||
trace_name=trace_name,
|
||||
status=status,
|
||||
duration_ms=duration_ms,
|
||||
attributes=attrs,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-protobuf",
|
||||
"x-arms-license-key": license_key,
|
||||
"x-arms-project": project,
|
||||
"x-cms-workspace": workspace,
|
||||
}
|
||||
|
||||
req = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
status_code = resp.status
|
||||
resp_body = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
status_code = e.code
|
||||
resp_body = e.read().decode("utf-8", errors="replace")
|
||||
|
||||
if status_code in (200, 202):
|
||||
return True, (f"[Trace] success: {service_name} / {trace_name} " f"({status}, {duration_ms}ms)")
|
||||
else:
|
||||
return False, (f"[Trace] failed: HTTP {status_code} - {resp_body[:200]}")
|
||||
except Exception as e:
|
||||
return False, f"[Trace] error: {type(e).__name__}: {str(e)}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI AgentLoop Trace Reporter")
|
||||
parser.add_argument(
|
||||
"--service",
|
||||
dest="service_name",
|
||||
default=os.getenv("TRACE_SERVICE", ""),
|
||||
help="Service name (also via TRACE_SERVICE env)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
dest="trace_name",
|
||||
default=os.getenv("TRACE_NAME", ""),
|
||||
help="Trace name (also via TRACE_NAME env)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
default=os.getenv("TRACE_STATUS", "ok"),
|
||||
choices=["ok", "error", "running"],
|
||||
help="Status: ok / error / running (default ok)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--start-time",
|
||||
dest="start_time",
|
||||
default=os.getenv("TRACE_START_TIME", ""),
|
||||
help="Start timestamp (seconds) for duration calculation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--duration-ms",
|
||||
dest="duration_ms",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Direct duration in ms; takes precedence over --start-time",
|
||||
)
|
||||
parser.add_argument("--attrs", default="", help="Extra attributes (JSON string)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.service_name:
|
||||
print("[Trace] skipped: no service specified (--service or TRACE_SERVICE)")
|
||||
sys.exit(0)
|
||||
|
||||
duration_ms = args.duration_ms
|
||||
if duration_ms <= 0 and args.start_time:
|
||||
try:
|
||||
start_ts = float(args.start_time)
|
||||
duration_ms = int((time.time() - start_ts) * 1000)
|
||||
except (ValueError, TypeError):
|
||||
duration_ms = 1000
|
||||
if duration_ms <= 0:
|
||||
duration_ms = 1000
|
||||
|
||||
extra_attrs = {}
|
||||
if args.attrs:
|
||||
try:
|
||||
extra_attrs = json.loads(args.attrs)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
trace_name = args.trace_name
|
||||
if not trace_name:
|
||||
wf = _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "CI"
|
||||
job = _env("GITEA_JOB") or _env("GITHUB_JOB") or "job"
|
||||
trace_name = f"{wf} / {job}"
|
||||
|
||||
success, msg = report_ci_trace(
|
||||
service_name=args.service_name,
|
||||
trace_name=trace_name,
|
||||
status=args.status,
|
||||
duration_ms=duration_ms,
|
||||
extra_attributes=extra_attrs,
|
||||
)
|
||||
|
||||
print(msg)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,16 +1,9 @@
|
||||
#!/bin/bash
|
||||
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底)
|
||||
# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题
|
||||
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
|
||||
# 用法: docker_build_push.sh <Dockerfile> <image_tag> <cache_ref> [build_arg...]
|
||||
set -eu
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
if [ "$1" = "--no-cache" ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
shift
|
||||
echo "模式: --no-cache (不使用缓存,全新构建)"
|
||||
fi
|
||||
|
||||
DOCKERFILE="$1"
|
||||
IMAGE_TAG="$2"
|
||||
CACHE_REF="$3"
|
||||
@@ -45,7 +38,6 @@ build_with_cache_retry() {
|
||||
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},ignore-error=true" \
|
||||
@@ -79,7 +71,6 @@ build_with_cache_retry() {
|
||||
# 重试完还是失败,不用本地缓存最后试一次(只从registry读)
|
||||
echo "⚠️ All cached attempts failed, building without local cache..."
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
|
||||
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
|
||||
@@ -100,5 +91,38 @@ echo ""
|
||||
echo "Image pushed: ${IMAGE_TAG}"
|
||||
echo "Local cache updated"
|
||||
|
||||
# DISABLED: registry cache too slow echo ""
|
||||
# DISABLED: registry cache too slow echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
|
||||
# DISABLED: registry cache too slow CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
|
||||
# DISABLED: registry cache too slow
|
||||
# DISABLED: registry cache too slow MAX_RETRIES=3
|
||||
# DISABLED: registry cache too slow SUCCESS=0
|
||||
# DISABLED: registry cache too slow for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
# DISABLED: registry cache too slow echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
|
||||
# DISABLED: registry cache too slow if docker buildx build \
|
||||
# DISABLED: registry cache too slow $BUILD_ARGS \
|
||||
# DISABLED: registry cache too slow --cache-from "${CACHE_FROM_LOCAL}" \
|
||||
# DISABLED: registry cache too slow --cache-to "${CACHE_TO_REGISTRY}" \
|
||||
# DISABLED: registry cache too slow -f "${DOCKERFILE}" \
|
||||
# DISABLED: registry cache too slow -t "${IMAGE_TAG}" \
|
||||
# DISABLED: registry cache too slow --push \
|
||||
# DISABLED: registry cache too slow .; then
|
||||
# DISABLED: registry cache too slow echo "Registry cache synced (attempt $attempt)"
|
||||
# DISABLED: registry cache too slow SUCCESS=1
|
||||
# DISABLED: registry cache too slow break
|
||||
# DISABLED: registry cache too slow else
|
||||
# DISABLED: registry cache too slow echo "Registry cache sync failed (attempt $attempt)"
|
||||
# DISABLED: registry cache too slow if [ $attempt -lt $MAX_RETRIES ]; then
|
||||
# DISABLED: registry cache too slow WAIT=$((attempt * 5))
|
||||
# DISABLED: registry cache too slow echo "Retrying in ${WAIT}s..."
|
||||
# DISABLED: registry cache too slow sleep $WAIT
|
||||
# DISABLED: registry cache too slow fi
|
||||
# DISABLED: registry cache too slow fi
|
||||
# DISABLED: registry cache too slow done
|
||||
# DISABLED: registry cache too slow
|
||||
# DISABLED: registry cache too slow if [ $SUCCESS -eq 0 ]; then
|
||||
# DISABLED: registry cache too slow echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
|
||||
# DISABLED: registry cache too slow fi
|
||||
|
||||
echo ""
|
||||
echo "Build completed: ${IMAGE_TAG}"
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -1,317 +0,0 @@
|
||||
#!/bin/bash
|
||||
# CI Integration Tests Job 主脚本
|
||||
# 包含:依赖安装、ffmpeg安装、Redis启动、PG启动、迁移、测试、清理、覆盖率
|
||||
set -eu
|
||||
|
||||
echo "=== CI Integration Tests 开始 ==="
|
||||
|
||||
# --- 安装依赖 ---
|
||||
echo ""
|
||||
echo "=== 安装 Python 依赖 ==="
|
||||
# pip install 带重试(网络不稳定时自动重试)
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q pytest-rerunfailures && break
|
||||
echo "pip install pytest-rerunfailures 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
pytest --version
|
||||
|
||||
# --- 安装 ffmpeg ---
|
||||
echo ""
|
||||
echo "=== 安装 ffmpeg ==="
|
||||
bash scripts/ci/step_install_ffmpeg.sh
|
||||
|
||||
# --- DooD模式检测:确定宿主机访问地址 ---
|
||||
# DooD模式下,docker run启动的容器跑在宿主机Docker上
|
||||
# 需要用宿主机IP访问映射端口
|
||||
# 检测策略:host.docker.internal -> docker0桥接IP -> 容器IP直连 -> 默认网关 -> 127.0.0.1
|
||||
detect_docker_host() {
|
||||
local test_port="${1:-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")
|
||||
fi
|
||||
|
||||
# 2. docker0 桥接网关 (172.17.0.1)
|
||||
candidates+=("172.17.0.1")
|
||||
|
||||
# 3. 默认网关(容器网络的网关即宿主机)
|
||||
local gw=""
|
||||
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
|
||||
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
|
||||
candidates+=("$gw")
|
||||
fi
|
||||
|
||||
# 4. 宿主机可能的IP:容器同网段的.1或.254
|
||||
local my_ip=""
|
||||
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$my_ip" ]; then
|
||||
# 尝试同网段的常见宿主机IP
|
||||
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
|
||||
candidates+=("${subnet}.1")
|
||||
candidates+=("${subnet}.254")
|
||||
fi
|
||||
|
||||
# 5. 127.0.0.1 最后尝试
|
||||
candidates+=("127.0.0.1")
|
||||
|
||||
# 测试每个候选IP
|
||||
for candidate in "${candidates[@]}"; do
|
||||
if python3 -c "
|
||||
import socket
|
||||
s = socket.socket()
|
||||
s.settimeout(2)
|
||||
try:
|
||||
s.connect(('$candidate', $test_port))
|
||||
s.close()
|
||||
print('ok')
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null | grep -q ok; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# 都失败则返回127.0.0.1
|
||||
echo "127.0.0.1"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取宿主机IP(先尝试用共享PG端口5433测试,再回退到其他端口)
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
# 先用共享PG端口5433探测
|
||||
DOCKER_HOST_IP=$(detect_docker_host 5433)
|
||||
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
|
||||
# 如果共享PG端口探测失败,说明不在DooD或共享PG不可用,再试其他端口
|
||||
DOCKER_HOST_IP=$(detect_docker_host 22)
|
||||
fi
|
||||
echo "检测到DooD模式(/var/run/docker.sock已挂载),宿主机地址: $DOCKER_HOST_IP"
|
||||
else
|
||||
DOCKER_HOST_IP="127.0.0.1"
|
||||
echo "非DooD模式,使用 127.0.0.1"
|
||||
fi
|
||||
PG_HOST="$DOCKER_HOST_IP"
|
||||
REDIS_HOST="$DOCKER_HOST_IP"
|
||||
echo "PG host: $PG_HOST, Redis host: $REDIS_HOST"
|
||||
|
||||
# --- 指数退避TCP连接检查函数 ---
|
||||
# 用法: wait_tcp_ready host port max_attempts
|
||||
wait_tcp_ready() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local max_attempts="${3:-5}"
|
||||
local delay=1
|
||||
local attempt=1
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
|
||||
sleep "$delay"
|
||||
delay=$((delay * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- 启动 Redis ---
|
||||
echo ""
|
||||
echo "=== 启动 Redis ==="
|
||||
REDIS_CONTAINER="ci-redis-${GITHUB_RUN_ID:-$$}"
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" \
|
||||
-P \
|
||||
--health-cmd "redis-cli ping" \
|
||||
--health-interval 2s \
|
||||
--health-timeout 2s \
|
||||
--health-retries 10 \
|
||||
redis:7-alpine
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | cut -d: -f2)
|
||||
echo "Redis port: $REDIS_PORT"
|
||||
export REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}/0"
|
||||
|
||||
# 等待容器健康
|
||||
for i in $(seq 1 15); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "Redis container is ready on port $REDIS_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Redis container health... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" | grep -q healthy
|
||||
|
||||
# TCP连通性检查(指数退避)
|
||||
echo "验证Redis TCP连通性 ($REDIS_HOST:$REDIS_PORT)..."
|
||||
wait_tcp_ready "$REDIS_HOST" "$REDIS_PORT" 5
|
||||
echo "TCP connectivity to Redis confirmed on port $REDIS_PORT"
|
||||
|
||||
# --- 启动/连接 PostgreSQL ---
|
||||
echo ""
|
||||
echo "=== 准备 PostgreSQL ==="
|
||||
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
|
||||
CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
|
||||
|
||||
if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 使用常驻共享PG实例
|
||||
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
|
||||
SHARED_PG_HOST="$PG_HOST"
|
||||
SHARED_PG_PORT="5433"
|
||||
SHARED_PG_USER="postgres"
|
||||
SHARED_PG_PASSWORD="ci_pg_2026!"
|
||||
|
||||
echo "等待共享PG连接就绪..."
|
||||
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
|
||||
|
||||
# 创建独立数据库
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
|
||||
cur.close()
|
||||
conn.close()
|
||||
"
|
||||
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
|
||||
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
|
||||
PG_CONTAINER=""
|
||||
else
|
||||
# 使用临时PG容器
|
||||
echo "使用临时PG容器模式"
|
||||
PG_CONTAINER="ci-pg-${GITHUB_RUN_ID:-$$}"
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
--shm-size=256m \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=xiaoxia_saas \
|
||||
-P \
|
||||
--health-cmd "pg_isready -U postgres" \
|
||||
--health-interval 5s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 12 \
|
||||
postgres:16
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 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 ready on port $PG_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for PostgreSQL container health... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
|
||||
|
||||
# TCP连通性检查(指数退避)
|
||||
echo "验证PostgreSQL TCP连通性 ($PG_HOST:$PG_PORT)..."
|
||||
wait_tcp_ready "$PG_HOST" "$PG_PORT" 5
|
||||
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
|
||||
fi
|
||||
|
||||
# --- 执行迁移 ---
|
||||
echo ""
|
||||
echo "=== 执行 Alembic 迁移 ==="
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ 迁移完成"
|
||||
|
||||
# --- 运行集成测试 ---
|
||||
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 "✅ 集成测试通过"
|
||||
|
||||
# --- 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 ""
|
||||
echo "=== 性能测试摘要 ==="
|
||||
grep "PERF_STATS:" "$PERF_OUTPUT" || echo "PERF_STATS: 未找到统计数据"
|
||||
grep "PERF_RESULT:" "$PERF_OUTPUT" || echo "PERF_RESULT: 未找到详细结果"
|
||||
TOTAL=$(grep -c "PERF_RESULT:" "$PERF_OUTPUT" || echo 0)
|
||||
PASSED=$(grep "PERF_RESULT: PASS" "$PERF_OUTPUT" | wc -l)
|
||||
FAILED=$(grep "PERF_RESULT: FAIL" "$PERF_OUTPUT" | wc -l)
|
||||
echo ""
|
||||
echo "性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标"
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ 警告: $FAILED 个接口性能未达标"
|
||||
fi
|
||||
rm -f "$PERF_OUTPUT"
|
||||
set -e
|
||||
|
||||
# --- 清理 ---
|
||||
echo ""
|
||||
echo "=== 清理 ==="
|
||||
if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 清理共享PG上的测试数据库
|
||||
echo "清理共享PG测试数据库: $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容器
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
echo "✅ PG容器已清理"
|
||||
fi
|
||||
|
||||
# 清理Redis容器
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
echo "✅ Redis容器已清理"
|
||||
|
||||
# --- 覆盖率汇总 ---
|
||||
echo ""
|
||||
echo "=== 覆盖率汇总 ==="
|
||||
set +e
|
||||
python3 scripts/ci_coverage_summary.py
|
||||
set -e
|
||||
|
||||
echo ""
|
||||
echo "=== CI Integration Tests 全部通过 ✅ ==="
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/bin/bash
|
||||
# CI Unit Tests Job 主脚本
|
||||
# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁
|
||||
set -eu
|
||||
|
||||
JOB_NAME="${1:-Unit Tests}"
|
||||
|
||||
echo "=== CI Unit Tests 开始 ==="
|
||||
|
||||
# --- 安装依赖 ---
|
||||
echo ""
|
||||
echo "=== 安装 Python 依赖 ==="
|
||||
# pip install 带重试(网络不稳定时自动重试)
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
pytest --version
|
||||
|
||||
# --- 增量测试选择(仅PR) ---
|
||||
UNIT_TEST_MODE="full"
|
||||
SELECTED_TEST_FILES="tests/unit"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
echo ""
|
||||
echo "=== 增量测试选择 ==="
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
|
||||
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
|
||||
set +e
|
||||
CHANGED_FILES="$CHANGED_FILES" \
|
||||
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
|
||||
python3 scripts/ci/select_unit_tests.py
|
||||
SELECT_EXIT=$?
|
||||
set -e
|
||||
if [ $SELECT_EXIT -eq 0 ]; then
|
||||
UNIT_TEST_MODE="incremental"
|
||||
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
|
||||
SELECTED_TEST_FILES="$TEST_FILES"
|
||||
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
|
||||
else
|
||||
echo "全量模式"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 运行单元测试 + 覆盖率 ---
|
||||
echo ""
|
||||
echo "=== 运行单元测试 (模式: $UNIT_TEST_MODE) ==="
|
||||
|
||||
if [ "$UNIT_TEST_MODE" = "incremental" ]; then
|
||||
echo "=== 增量测试模式 ==="
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest $SELECTED_TEST_FILES -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=10 > /dev/null || true
|
||||
else
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/unit -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=65 > /dev/null
|
||||
fi
|
||||
|
||||
# --- Diff 覆盖率检查(仅PR) ---
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
echo ""
|
||||
echo "=== Diff 覆盖率检查 ==="
|
||||
BASE_BRANCH="${GITHUB_BASE_REF:-develop}"
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
|
||||
PR_CODE_DIR="/tmp/pr-code-$$"
|
||||
mkdir -p "$PR_CODE_DIR"
|
||||
# 备份PR代码(含coverage.xml,diff-cover需要用到
|
||||
find . -maxdepth 1 -mindepth 1 ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
|
||||
rm -rf .git
|
||||
git init > /dev/null 2>&1
|
||||
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
|
||||
git config user.email "ci@local"
|
||||
git config user.name "CI"
|
||||
git fetch origin "$BASE_BRANCH" --depth=200
|
||||
# 先清理工作目录,避免未跟踪文件导致checkout失败
|
||||
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
|
||||
git checkout -b ci-pr-branch "origin/$BASE_BRANCH" > /dev/null 2>&1
|
||||
# 清除base分支源码,用PR代码覆盖
|
||||
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
|
||||
cp -r "$PR_CODE_DIR"/. .
|
||||
rm -rf "$PR_CODE_DIR"
|
||||
git add -A > /dev/null 2>&1
|
||||
git commit -m "ci-tmp" > /dev/null 2>&1
|
||||
|
||||
if [ "$UNIT_TEST_MODE" = "incremental" ]; then
|
||||
THRESHOLD=40
|
||||
echo "增量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
|
||||
else
|
||||
THRESHOLD=60
|
||||
echo "全量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
|
||||
fi
|
||||
|
||||
set +e
|
||||
python3 -m diff_cover.diff_cover_tool coverage.xml \
|
||||
--compare-branch="origin/$BASE_BRANCH" \
|
||||
--fail-under=$THRESHOLD \
|
||||
--html-report diff_coverage.html \
|
||||
2>&1
|
||||
DIFF_EXIT=$?
|
||||
set -e
|
||||
if [ $DIFF_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ 增量覆盖率未达到门槛 (${THRESHOLD}%)"
|
||||
echo " 请为改动的代码添加单元测试后再提交"
|
||||
echo ""
|
||||
echo "=== 覆盖率报告 ==="
|
||||
python3 -m diff_cover.diff_cover_tool coverage.xml \
|
||||
--compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 增量覆盖率达标"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== CI Unit Tests 全部通过 ✅ ==="
|
||||
@@ -1,381 +0,0 @@
|
||||
#!/bin/bash
|
||||
# CI Validate Job 主脚本:代码质量全量检查
|
||||
# 包含:密钥扫描、格式检查、类型检查、安全扫描、依赖漏洞检查、死代码检测、Alembic迁移验证
|
||||
set -eu
|
||||
|
||||
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
|
||||
|
||||
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/8] 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
|
||||
# 防御性过滤:磁盘上不存在的文件(已删除文件)不参与检查,
|
||||
# 避免 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"
|
||||
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"
|
||||
|
||||
# --- 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")
|
||||
fi
|
||||
|
||||
# 2. docker0 桥接网关 (172.17.0.1)
|
||||
candidates+=("172.17.0.1")
|
||||
|
||||
# 3. 默认网关(容器网络的网关即宿主机)
|
||||
local gw=""
|
||||
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
|
||||
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
|
||||
candidates+=("$gw")
|
||||
fi
|
||||
|
||||
# 4. 宿主机可能的IP:容器同网段的.1或.254
|
||||
local my_ip=""
|
||||
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$my_ip" ]; then
|
||||
# 尝试同网段的常见宿主机IP
|
||||
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
|
||||
candidates+=("${subnet}.1")
|
||||
candidates+=("${subnet}.254")
|
||||
fi
|
||||
|
||||
# 5. 127.0.0.1 最后尝试
|
||||
candidates+=("127.0.0.1")
|
||||
|
||||
# 测试每个候选IP
|
||||
for candidate in "${candidates[@]}"; do
|
||||
if python3 -c "
|
||||
import socket
|
||||
s = socket.socket()
|
||||
s.settimeout(2)
|
||||
try:
|
||||
s.connect(('$candidate', $test_port))
|
||||
s.close()
|
||||
print('ok')
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null | grep -q ok; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# 都失败则返回127.0.0.1
|
||||
echo "127.0.0.1"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取宿主机IP(先尝试用共享PG端口5433测试,再回退到其他端口)
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
# 先用共享PG端口5433探测
|
||||
DOCKER_HOST_IP=$(detect_docker_host 5433)
|
||||
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
|
||||
# 如果共享PG端口探测失败,说明不在DooD或共享PG不可用,再试其他端口
|
||||
DOCKER_HOST_IP=$(detect_docker_host 22)
|
||||
fi
|
||||
echo "检测到DooD模式(/var/run/docker.sock已挂载),宿主机地址: $DOCKER_HOST_IP"
|
||||
else
|
||||
DOCKER_HOST_IP="127.0.0.1"
|
||||
echo "非DooD模式,使用 127.0.0.1"
|
||||
fi
|
||||
PG_HOST="$DOCKER_HOST_IP"
|
||||
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
|
||||
}
|
||||
|
||||
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
|
||||
|
||||
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 "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
|
||||
cur.close()
|
||||
conn.close()
|
||||
"
|
||||
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
|
||||
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
|
||||
# 清理数据库
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
cur.close()
|
||||
conn.close()
|
||||
" 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)"
|
||||
echo "✅ 共享PG数据库已清理"
|
||||
else
|
||||
# 使用临时PG容器(默认模式)
|
||||
echo "使用临时PG容器模式"
|
||||
PG_CONTAINER=ci-pg-validate-${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: 所有检查通过 ✅ ==="
|
||||
@@ -158,13 +158,10 @@ def select_tests(changed_files):
|
||||
source_file_changes = []
|
||||
|
||||
for f in changed_files:
|
||||
# 测试文件本身改动(仅保留仍存在的文件,删除的测试文件不加入运行列表)
|
||||
# 测试文件本身改动
|
||||
if f.startswith("tests/unit/test_") and f.endswith(".py"):
|
||||
if (ROOT / f).exists():
|
||||
test_file_changes.append(f)
|
||||
selected.add(f)
|
||||
else:
|
||||
print(f"[skip-deleted] 测试文件已删除,跳过: {f}")
|
||||
test_file_changes.append(f)
|
||||
selected.add(f)
|
||||
# 源码文件改动
|
||||
elif f.endswith(".py"):
|
||||
source_file_changes.append(f)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:Checkout 代码(带重试)
|
||||
# 用法:直接 source 或调用,需要 GITHUB_TOKEN 环境变量
|
||||
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
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端依赖安装(在 docker node 容器中运行)
|
||||
# 用法:step_frontend_install.sh [模式]
|
||||
# 模式: full (默认) - 完整安装所有依赖
|
||||
# vitest - 同full(保持接口兼容)
|
||||
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
|
||||
|
||||
echo "=== 前端依赖安装完成 ==="
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端命令执行(在 docker node 容器中运行)
|
||||
# 用法:step_frontend_run.sh "要执行的命令"
|
||||
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"
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:安装 ffmpeg
|
||||
set +e
|
||||
if command -v ffmpeg > /dev/null 2>&1; then
|
||||
echo "ffmpeg already installed: $(ffmpeg -version | head -1)"
|
||||
exit 0
|
||||
fi
|
||||
if command -v apt-get > /dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq ffmpeg
|
||||
elif command -v yum > /dev/null 2>&1; then
|
||||
yum install -y -q epel-release 2>/dev/null
|
||||
yum install -y -q ffmpeg 2>/dev/null
|
||||
if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then
|
||||
dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null
|
||||
dnf install -y -q ffmpeg 2>/dev/null
|
||||
fi
|
||||
elif command -v dnf > /dev/null 2>&1; then
|
||||
dnf install -y -q ffmpeg 2>/dev/null
|
||||
fi
|
||||
if command -v ffmpeg > /dev/null 2>&1; then
|
||||
echo "ffmpeg installed successfully: $(ffmpeg -version | head -1)"
|
||||
else
|
||||
echo "Warning: ffmpeg installation failed or not available, some tests may be skipped"
|
||||
fi
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:Job 结束计时统计
|
||||
set +eu
|
||||
if [ -n "$JOB_START_TIME" ]; then
|
||||
END_TIME=$(date +%s)
|
||||
DURATION=$((END_TIME - JOB_START_TIME))
|
||||
MINS=$((DURATION / 60))
|
||||
SECS=$((DURATION % 60))
|
||||
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
|
||||
echo "=== Job Duration: ${MINS}m${SECS}s ==="
|
||||
else
|
||||
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
|
||||
echo "=== Job Duration: unknown ==="
|
||||
fi
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:Job 开始计时
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
echo "Job started at $(date)"
|
||||
@@ -1,444 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ===========================================
|
||||
# Production 部署脚本(SSH 模式,支持自动回滚)
|
||||
# ===========================================
|
||||
# 通过 SSH 在 production 服务器上执行
|
||||
#
|
||||
# 环境变量:
|
||||
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
|
||||
# REGISTRY_TOKEN - Registry 访问令牌
|
||||
# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji)
|
||||
# REGISTRY_USER - Registry 用户名(默认 xiaoxia)
|
||||
# ENV_FILE - 环境变量文件路径
|
||||
# GENERATED_DIR - 生成文件目录
|
||||
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false)
|
||||
# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false)
|
||||
|
||||
set -eu
|
||||
|
||||
# ---- 重试工具函数 ----
|
||||
retry_cmd() {
|
||||
local max_attempts=$1
|
||||
local backoff=$2
|
||||
shift 2
|
||||
local attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
echo " attempt $attempt/$max_attempts failed, retrying in ${backoff}s..."
|
||||
sleep $backoff
|
||||
backoff=$((backoff * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo " ERROR: failed after $max_attempts retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
retry_docker_login() {
|
||||
echo "Logging in to registry (up to 3 retries)"
|
||||
export REGISTRY_TOKEN REGISTRY_HOST REGISTRY_USER
|
||||
if retry_cmd 3 5 sh -c 'printf "%s" "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin'; then
|
||||
return 0
|
||||
fi
|
||||
echo "WARN: docker login failed after retries, will try pull anyway"
|
||||
return 0
|
||||
}
|
||||
|
||||
retry_docker_pull() {
|
||||
local image=$1
|
||||
echo "Pulling $image (up to 3 retries)"
|
||||
retry_cmd 3 10 docker pull "$image"
|
||||
}
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}"
|
||||
REGISTRY_TOKEN="${ACR_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
|
||||
|
||||
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "==========================================="
|
||||
echo " Production 部署 - $IMAGE_TAG"
|
||||
echo "==========================================="
|
||||
|
||||
# ---- 记录当前运行的镜像版本(用于回滚) ----
|
||||
echo "Recording current image versions for rollback..."
|
||||
PREV_API_IMAGE=""
|
||||
PREV_WORKER_IMAGE=""
|
||||
PREV_WEB_IMAGE=""
|
||||
for c in xiaoxia-api-production xiaoxia-worker-production xiaoxia-web-production; do
|
||||
if docker inspect "$c" >/dev/null 2>&1; then
|
||||
img=$(docker inspect -f '{{.Config.Image}}' "$c")
|
||||
case "$c" in
|
||||
xiaoxia-api-production) PREV_API_IMAGE="$img" ;;
|
||||
xiaoxia-worker-production) PREV_WORKER_IMAGE="$img" ;;
|
||||
xiaoxia-web-production) PREV_WEB_IMAGE="$img" ;;
|
||||
esac
|
||||
echo " $c -> $img"
|
||||
else
|
||||
echo " $c -> (not running)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 回滚函数 ----
|
||||
rollback() {
|
||||
echo ""
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo " 部署失败,正在自动回滚到上一版本..."
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo ""
|
||||
|
||||
if [ "$SKIP_ROLLBACK" = "true" ]; then
|
||||
echo "SKIP_ROLLBACK=true,跳过自动回滚"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 停止当前(失败的)新容器
|
||||
echo "Stopping new containers..."
|
||||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 恢复 API
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Rolling back API to: $PREV_API_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_API_IMAGE"
|
||||
else
|
||||
echo "No previous API image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Worker
|
||||
if [ -n "$PREV_WORKER_IMAGE" ]; then
|
||||
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WORKER_IMAGE"
|
||||
else
|
||||
echo "No previous Worker image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Web
|
||||
if [ -n "$PREV_WEB_IMAGE" ]; then
|
||||
echo "Rolling back Web to: $PREV_WEB_IMAGE"
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WEB_IMAGE"
|
||||
else
|
||||
echo "No previous Web image to roll back to"
|
||||
fi
|
||||
|
||||
# 等待 API 回滚后恢复健康
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Waiting for rolled-back API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||||
echo "Rolled-back API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "WARN: Rolled-back API did not become healthy within 120s"
|
||||
docker logs --tail 30 xiaoxia-api-production
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " 回滚完成"
|
||||
echo "==========================================="
|
||||
echo "Previous API: ${PREV_API_IMAGE:-none}"
|
||||
echo "Previous Worker: ${PREV_WORKER_IMAGE:-none}"
|
||||
echo "Previous Web: ${PREV_WEB_IMAGE:-none}"
|
||||
echo ""
|
||||
echo "部署失败,已自动回滚到上一版本"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "=========================================="
|
||||
echo " Login to Registry (with retries)"
|
||||
echo "=========================================="
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
retry_docker_login
|
||||
fi
|
||||
|
||||
# ---- Pull 新版本镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Pull images (with retries)"
|
||||
echo "=========================================="
|
||||
retry_docker_pull "$REGISTRY_API"
|
||||
retry_docker_pull "$REGISTRY_WORKER"
|
||||
retry_docker_pull "$REGISTRY_WEB"
|
||||
|
||||
echo "All images pulled."
|
||||
|
||||
# ---- 备份 legacy assets ----
|
||||
echo "Backing up legacy assets from current web container..."
|
||||
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
|
||||
_tmpdir="/tmp/legacy-assets-$$"
|
||||
rm -rf "$_tmpdir"
|
||||
mkdir -p "$_tmpdir"
|
||||
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
|
||||
# 只有目录非空才拷贝,避免覆盖有内容的 legacy assets
|
||||
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
|
||||
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
|
||||
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
|
||||
fi
|
||||
rm -rf "$_tmpdir"
|
||||
else
|
||||
echo "No existing web container, skipping legacy assets backup"
|
||||
fi
|
||||
|
||||
# 清理 7 天前的 legacy assets
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ]; then
|
||||
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
echo "Legacy assets cleanup done (retain 7 days)"
|
||||
fi
|
||||
|
||||
# ---- 检查基础设施容器 ----
|
||||
echo "Checking infrastructure containers..."
|
||||
for c in xiaoxia-postgres-production xiaoxia-redis-production; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 创建网络(不存在则创建) ----
|
||||
docker network create xiaoxia-net-production 2>/dev/null || true
|
||||
|
||||
# ---- 数据库迁移 ----
|
||||
if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
"$REGISTRY_API" sh -c "cd /app && alembic upgrade head" || {
|
||||
echo "ERROR: Database migration failed"
|
||||
echo "Note: Migration failures are NOT automatically rolled back (data safety)"
|
||||
echo "Please manually check and fix the migration, then redeploy"
|
||||
exit 1
|
||||
}
|
||||
echo "Migrations completed."
|
||||
else
|
||||
echo "Skipping migrations (SKIP_MIGRATION=true)"
|
||||
fi
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# ---- 启动 API ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_API" || rollback
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_WORKER" || rollback
|
||||
|
||||
# ---- 启动 Web ----
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
echo "Web container: legacy assets mounted (fallback)"
|
||||
else
|
||||
echo "Web container: no legacy assets to mount"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_WEB" || rollback
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-production
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-production
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Production deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user