Compare commits

..

14 Commits

Author SHA1 Message Date
xiaoxia 1a5bb1ab43 feat(ci): optimize web image build with multi-stage Dockerfile + buildx cache
Tests / test (pull_request) Failing after 0s
Tests / lint (pull_request) Failing after 0s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m12s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-13 15:11:28 +08:00
xiaoxia bf47e69fa4 feat(ci): add node_modules cache for frontend-lint job 2026-07-13 15:10:44 +08:00
xiaoxia b947bf7569 feat(ci): add node_modules cache for frontend-lint job 2026-07-13 15:09:47 +08:00
xiaoxia f110bc6f16 feat: web.Dockerfile add buildkit cache for npm install 2026-07-13 15:08:49 +08:00
xiaoxia 0c9375ff32 ci: remove temporary debug workflow - test-ssh-secret.yml
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 36s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Fix Flake8 E741 for PR232 / fix-flake8 (push) Failing after 3h4m59s
2026-07-12 10:25:12 +08:00
xiaoxia ef344e9ffc cleanup: remove upgrade test file
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 12h43m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h45m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 12h45m50s
2026-07-09 00:08:40 +08:00
xiaoxia 0d4904433e test: trigger workflow after gitea upgrade
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 12h49m24s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 12h53m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h56m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 12h56m8s
2026-07-08 23:58:20 +08:00
CI Test 708662394f Merge develop into main - v0.1.126
Auto Merge PRs / auto-merge (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 29h56m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 29h56m45s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-08 06:57:42 +08:00
CI Test 9b034764ad Merge develop into main - v0.1.125
Auto Merge PRs / auto-merge (push) Failing after 1m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h58m8s
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 19:54:54 +08:00
CI Test 8748b43070 Merge develop into main - v0.1.124
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h17m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h17m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 45h15m38s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 15:36:36 +08:00
CI Test d213a055a1 Merge develop into main - v0.1.123
Auto Merge PRs / auto-merge (push) Failing after 3m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h9m34s
CI/CD Pipeline / Frontend Lint (push) Failing after 47h8m47s
CI/CD Pipeline / Deploy Staging (push) Failing after 47h6m52s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 13:44:46 +08:00
CI Test 2371860f82 Merge develop into main - v0.1.122 (prettier fix)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h37m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h37m23s
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 49h35m42s
CI/CD Pipeline / Deploy Production (push) Failing after 49h33m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 49h27m0s
2026-07-07 11:04:39 +08:00
CI Test dbd956fc6e Merge develop into main - v0.1.122
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h54m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h54m54s
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 10:59:31 +08:00
CI Test 1d59ee5336 Merge develop into main - v0.1.121
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 52h48m47s
CI/CD Pipeline / Frontend Lint (push) Failing after 52h48m2s
CI/CD Pipeline / Deploy Staging (push) Failing after 52h46m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 52h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 08:05:42 +08:00
138 changed files with 2779 additions and 27731 deletions
+3 -8
View File
@@ -44,14 +44,9 @@ OSS_ACCESS_KEY_SECRET=your-access-key-secret
OSS_BUCKET_NAME=xiaoxia-autocut
# ==================== CosyVoice 语音合成配置 ====================
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
# cosyvoice-v3-plus (高质量,系统音色少)
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
COSYVOICE_API_KEY=your-cosyvoice-api-key
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
COSYVOICE_MODEL=cosyvoice-v3-flash
COSYVOICE_VOICE=longxiaochun_v3
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
COSYVOICE_MODEL=cosyvoice-v1
COSYVOICE_VOICE=longxiaochun
COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3
Executable → Regular
+3 -8
View File
@@ -42,15 +42,10 @@ OSS_DIRECT_UPLOAD_MAX_MB=2000
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
# ==================== CosyVoice 语音合成(必须配置)====================
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
# cosyvoice-v3-plus (高质量,系统音色少)
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
COSYVOICE_API_KEY=CHANGE_ME_COSYVOICE_API_KEY
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
COSYVOICE_MODEL=cosyvoice-v3-flash
COSYVOICE_VOICE=longxiaochun_v3
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
COSYVOICE_MODEL=cosyvoice-v1
COSYVOICE_VOICE=longxiaochun
COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3
-1
View File
@@ -2,7 +2,6 @@
max-line-length = 120
exclude =
.git,
.cache,
__pycache__,
.venv,
venv,
-77
View File
@@ -1,77 +0,0 @@
#!/bin/sh
# 飞书通知脚本 — CI 流水线调用
# 用法: send_feishu_notify.sh <success|failure> <job_name>
# 依赖环境变量: CI_NOTIFY_WEBHOOK, GITHUB_REF_NAME, GITHUB_SHA, GITHUB_ACTOR, GITHUB_RUN_ID, GITHUB_REPOSITORY
set -eu
STATUS="$1"
JOB_NAME="$2"
WEBHOOK="${CI_NOTIFY_WEBHOOK:-}"
if [ -z "$WEBHOOK" ]; then
echo "未配置 CI_NOTIFY_WEBHOOK,跳过通知"
exit 0
fi
BRANCH="${GITHUB_REF_NAME:-unknown}"
COMMIT="${GITHUB_SHA:-unknown}"
COMMIT_SHORT="${COMMIT:0:8}"
ACTOR="${GITHUB_ACTOR:-unknown}"
RUN_ID="${GITHUB_RUN_ID:-unknown}"
REPO="${GITHUB_REPOSITORY:-unknown}"
RUN_URL="https://git.xiaoxiajianji.com/${REPO}/actions/runs/${RUN_ID}"
if [ "$STATUS" = "success" ]; then
TITLE="✅ CI告警:${JOB_NAME} 成功"
TEMPLATE="green"
BUTTON_TEXT="查看详情"
BUTTON_TYPE="primary"
NOTE_TEXT="流水线执行成功"
else
TITLE="⚠️ CI告警:${JOB_NAME} 失败"
TEMPLATE="red"
BUTTON_TEXT="查看失败日志"
BUTTON_TYPE="danger"
NOTE_TEXT="请提交者尽快查看修复!"
fi
# 用 printf 拼接 JSON,避免 heredoc 缩进问题
PAYLOAD=$(printf '{
"msg_type": "interactive",
"card": {
"header": {
"title": { "tag": "plain_text", "content": "%s" },
"template": "%s"
},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**项目**: xiaoxia-saas\\n**任务**: %s\\n**分支**: %s\\n**提交**: %s\\n**提交者**: %s\\n**Run**: #%s\\n**[查看日志](%s)**"
}
},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": { "tag": "plain_text", "content": "%s" },
"url": "%s",
"type": "%s"
}
]
},
{
"tag": "note",
"elements": [
{ "tag": "plain_text", "content": "%s" }
]
}
]
}
}' "$TITLE" "$TEMPLATE" "$JOB_NAME" "$BRANCH" "$COMMIT_SHORT" "$ACTOR" "$RUN_ID" "$RUN_URL" "$BUTTON_TEXT" "$RUN_URL" "$BUTTON_TYPE" "$NOTE_TEXT")
RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" "$WEBHOOK" -d "$PAYLOAD")
echo "通知响应: $RESPONSE"
+21
View File
@@ -0,0 +1,21 @@
name: Auto Merge PRs
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
auto-merge:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Auto merge develop PRs
run: |
bash scripts/auto_merge_prs.sh develop
- name: Auto merge main PRs (release only)
run: |
bash scripts/auto_merge_prs.sh main
+121 -486
View File
File diff suppressed because one or more lines are too long
-658
View File
@@ -1,658 +0,0 @@
name: Daily Health Check
on:
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨 3:00
workflow_dispatch:
permissions:
contents: read
jobs:
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke:
name: Production Smoke Test
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.smoke.outputs.report }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Production health check & smoke test
id: smoke
shell: sh
env:
SMOKE_ENV: production
EXISTING_TOKEN: ${{ secrets.PROD_E2E_TOKEN }}
MODULES: health,assets,generation,subscription,nginx
run: |
set +e
START_TIME=$(date +%s)
chmod +x tests/e2e/api_smoke_test.sh
BASE_URL="https://api.xiaoxiajianji.com" \
WEB_URL="https://saas.xiaoxiajianji.com" \
SMOKE_ENV="${SMOKE_ENV}" \
EXISTING_TOKEN="${EXISTING_TOKEN}" \
MODULES="${MODULES}" \
CLEANUP_ENABLED=0 \
PERF_CHECK_ENABLED=1 \
PERF_WARN_THRESHOLD_MS=500 \
PERF_FAIL_THRESHOLD_MS=5000 \
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/prod-smoke.log
SMOKE_EXIT=${PIPESTATUS[0]}
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== 生产冒烟测试报告 =========="
echo "环境: https://api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
# 提取通过/失败数
grep "测试完成:" /tmp/prod-smoke.log || true
if [ "$SMOKE_EXIT" -eq 0 ]; then
echo "结果: PASS"
echo "report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
grep "失败用例:" /tmp/prod-smoke.log || true
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "======================================"
exit $SMOKE_EXIT
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests:
name: Staging API Integration Tests
runs-on: saas
timeout-minutes: 10
outputs:
report: ${{ steps.smoke.outputs.report }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run API smoke test on staging
id: smoke
shell: sh
run: |
set +e
START_TIME=$(date +%s)
chmod +x tests/e2e/api_smoke_test.sh
docker run --rm \
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
-e WEB_URL=https://staging.xiaoxiajianji.com \
-e TEST_USER=18314979086@163.com \
-e TEST_PASSWORD=Ying1234 \
-e CLEANUP_ENABLED=1 \
-e PERF_CHECK_ENABLED=1 \
-e PERF_WARN_THRESHOLD_MS=500 \
-e PERF_FAIL_THRESHOLD_MS=3000 \
-v "$PWD:/workspace" \
-w /workspace \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/staging-api-smoke.log
SMOKE_EXIT=${PIPESTATUS[0]}
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== Staging API 冒烟测试报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
grep "测试完成:" /tmp/staging-api-smoke.log || true
if [ "$SMOKE_EXIT" -eq 0 ]; then
echo "结果: PASS"
echo "api_report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
grep "失败用例:" /tmp/staging-api-smoke.log || true
echo "api_report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "=============================================="
exit $SMOKE_EXIT
- name: Run Staging API Integration Tests (Playwright)
id: e2e_api
shell: sh
run: |
set +e
START_TIME=$(date +%s)
docker run --rm \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc "npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts" 2>&1 | tee /tmp/staging-api-e2e.log
EXIT_CODE=${PIPESTATUS[0]}
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== Staging API 集成测试报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
grep -E "passed|failed|timed out" /tmp/staging-api-e2e.log || true
if [ "$EXIT_CODE" -eq 0 ]; then
echo "结果: PASS"
echo "int_report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
echo "int_report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "=============================================="
exit $EXIT_CODE
- name: Set report output
id: report
shell: sh
run: |
if [ "${{ steps.smoke.outputs.api_report }}" = "PASS" ] && [ "${{ steps.e2e_api.outputs.int_report }}" = "PASS" ]; then
echo "report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e:
name: Staging Browser E2E
runs-on: saas
timeout-minutes: 15
outputs:
report: ${{ steps.smoke.outputs.report }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run Playwright E2E on staging
id: e2e
shell: sh
run: |
set +e
START_TIME=$(date +%s)
docker run --rm --ipc=host \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
-e E2E_BROWSER_CHANNEL=chromium \
-e PLAYWRIGHT_HEADLESS=1 \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1 | tee /tmp/staging-e2e.log
EXIT_CODE=${PIPESTATUS[0]}
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== Staging E2E 测试报告 =========="
echo "环境: https://staging.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
grep -E "passed|failed|timed out" /tmp/staging-e2e.log || true
if [ "$EXIT_CODE" -eq 0 ]; then
echo "结果: PASS"
echo "report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "=========================================="
exit $EXIT_CODE
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
steps:
- name: Run performance baseline checks
id: perf
shell: sh
run: |
set +e
START_TIME=$(date +%s)
echo "=========================================="
echo " 性能基线巡检 - Staging API"
echo " 目标: https://staging-api.xiaoxiajianji.com"
echo "=========================================="
echo ""
TOTAL=0
PASS=0
FAIL=0
WARN=0
WARN_LIST=""
FAIL_LIST=""
# 核心接口配置: 名称|路径|方法|阈值(ms)|失败阈值(ms)
# 核心接口(core): 500ms
# 普通接口(normal): 1000ms
# 重操作接口(heavy): 3000ms
ENDPOINTS="
登录|/api/v1/auth/login|POST|500|3000
获取当前用户|/api/v1/auth/me|GET|500|3000
项目列表|/api/v1/projects|GET|500|3000
素材列表|/api/v1/assets|GET|500|3000
模板列表|/api/v1/templates|GET|500|3000
剪辑计划列表|/api/v1/edit-plans|GET|500|3000
生成任务列表|/api/v1/generation/tasks|GET|500|3000
订阅信息|/api/v1/subscription/current|GET|500|3000
音色列表|/api/v1/voices|GET|1000|5000
健康检查|/health|GET|200|1000
"
# 先登录获取 token
echo "--- 准备: 获取测试 Token ---"
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
if [ "$AUTH_CODE" = "200" ]; then
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null)
if [ -n "$TOKEN" ]; then
echo "Token 获取成功"
else
echo "Token 解析失败,部分接口可能无法测试"
TOKEN=""
fi
else
echo "登录失败 (HTTP $AUTH_CODE),部分接口将跳过鉴权测试"
TOKEN=""
fi
echo ""
echo "--- 开始性能测试 ---"
echo ""
echo "$ENDPOINTS" | while IFS='|' read -r name path method warn_ms fail_ms; do
[ -z "$name" ] && continue
TOTAL=$((TOTAL + 1))
# 构建 curl 命令
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
fi
# 执行请求
RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
if [ "$HTTP_CODE" -ge 500 ] 2>/dev/null; then
FAIL=$((FAIL + 1))
FAIL_LIST="$FAIL_LIST\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms (FAIL)"
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
FAIL=$((FAIL + 1))
FAIL_LIST="$FAIL_LIST\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms"
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms (FAIL)"
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
WARN=$((WARN + 1))
WARN_LIST="$WARN_LIST\n ⚠️ $name - ${ELAPSED_MS}ms > ${warn_ms}ms"
echo "⚠️ $name - ${ELAPSED_MS}ms (WARN, threshold: ${warn_ms}ms)"
PASS=$((PASS + 1))
else
PASS=$((PASS + 1))
echo "✅ $name - ${ELAPSED_MS}ms (OK, threshold: ${warn_ms}ms)"
fi
done
# 由于 while 在子 shell 中执行,用文件传递结果
# 重新跑一次用文件计数方式
echo ""
echo "--- 汇总性能数据 ---"
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== 性能基线巡检报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
echo "======================================"
- name: Generate performance report
id: report
shell: sh
run: |
set +e
echo ""
echo "=========================================="
echo " 性能基线巡检 - 详细报告"
echo "=========================================="
TOTAL=0
PASS=0
FAIL=0
WARN=0
RESULTS=""
START_TIME=$(date +%s)
# 先登录获取 token
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
TOKEN=""
if [ "$AUTH_CODE" = "200" ]; then
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || echo "")
fi
run_perf_test() {
local name="$1" path="$2" method="$3" warn_ms="$4" fail_ms="$5"
TOTAL=$((TOTAL + 1))
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
fi
local RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
local HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
local TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
local ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
if echo "$HTTP_CODE" | grep -q "^[5]"; then
FAIL=$((FAIL + 1))
RESULTS="$RESULTS\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms [FAIL]"
return 1
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
FAIL=$((FAIL + 1))
RESULTS="$RESULTS\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
return 1
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
WARN=$((WARN + 1))
PASS=$((PASS + 1))
RESULTS="$RESULTS\n ⚠️ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [WARN]"
echo "⚠️ $name - ${ELAPSED_MS}ms > 阈值 ${warn_ms}ms [WARN]"
return 0
else
PASS=$((PASS + 1))
RESULTS="$RESULTS\n ✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
echo "✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
return 0
fi
}
echo ""
echo "=== 核心接口 (阈值: 500ms / 3000ms) ==="
run_perf_test "登录" "/api/v1/auth/login" "POST" 500 3000 || true
run_perf_test "获取当前用户" "/api/v1/auth/me" "GET" 500 3000 || true
run_perf_test "项目列表" "/api/v1/projects" "GET" 500 3000 || true
run_perf_test "素材列表" "/api/v1/assets" "GET" 500 3000 || true
run_perf_test "模板列表" "/api/v1/templates" "GET" 500 3000 || true
run_perf_test "剪辑计划列表" "/api/v1/edit-plans" "GET" 500 3000 || true
run_perf_test "生成任务列表" "/api/v1/generation/tasks" "GET" 500 3000 || true
run_perf_test "订阅信息" "/api/v1/subscription/current" "GET" 500 3000 || true
echo ""
echo "=== 普通接口 (阈值: 1000ms / 5000ms) ==="
run_perf_test "音色列表" "/api/v1/voices" "GET" 1000 5000 || true
echo ""
echo "=== 基础接口 (阈值: 200ms / 1000ms) ==="
run_perf_test "健康检查" "/health" "GET" 200 1000 || true
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== 性能基线巡检报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "总接口: ${TOTAL}"
echo "通过: ${PASS}"
echo "失败: ${FAIL}"
echo "警告: ${WARN}"
echo "耗时: ${ELAPSED}s"
echo "======================================"
# 写入结果文件供 report job 使用
echo "${TOTAL}" > /tmp/perf_total
echo "${PASS}" > /tmp/perf_pass
echo "${FAIL}" > /tmp/perf_fail
echo "${WARN}" > /tmp/perf_warn
echo "${ELAPSED}" > /tmp/perf_elapsed
if [ "$FAIL" -gt 0 ]; then
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
echo "perf_detail=fail:${FAIL}:warn:${WARN}" >> "${GITHUB_OUTPUT}"
exit 1
else
echo "report=PASS" >> "${GITHUB_OUTPUT}"
if [ "$WARN" -gt 0 ]; then
echo "perf_detail=pass:warn:${WARN}" >> "${GITHUB_OUTPUT}"
else
echo "perf_detail=pass" >> "${GITHUB_OUTPUT}"
fi
exit 0
fi
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: saas
timeout-minutes: 2
if: always()
needs:
- production-smoke
- staging-api-tests
- staging-e2e
- performance-check
steps:
- name: Print summary report
shell: sh
run: |
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo "║ 每日巡检报告 ║"
echo "╠══════════════════════════════════════════════════════╣"
# 获取各 job 状态
PROD_STATUS="${{ needs.production-smoke.result }}"
STAGING_API_STATUS="${{ needs.staging-api-tests.result }}"
STAGING_E2E_STATUS="${{ needs.staging-e2e.result }}"
PERF_STATUS="${{ needs.performance-check.result }}"
format_result() {
if [ "$1" = "success" ]; then
echo "✅ PASS"
elif [ "$1" = "failure" ]; then
echo "❌ FAIL"
elif [ "$1" = "skipped" ]; then
echo "⏭️ SKIP"
else
echo "❓ UNKNOWN ($1)"
fi
}
echo "║"
echo "║ 生产冒烟测试: $(format_result "$PROD_STATUS")"
echo "║ Staging API: $(format_result "$STAGING_API_STATUS")"
echo "║ Staging E2E: $(format_result "$STAGING_E2E_STATUS")"
echo "║ 性能基线巡检: $(format_result "$PERF_STATUS")"
echo "║"
echo "║ 巡检时间: $(date '+%Y-%m-%d %H:%M:%S UTC')"
echo "║"
# 判断整体状态
ALL_PASS=true
FAILED_ITEMS=""
for status_name in "$PROD_STATUS:生产冒烟" "$STAGING_API_STATUS:Staging API" "$STAGING_E2E_STATUS:Staging E2E" "$PERF_STATUS:性能基线"; do
STATUS=$(echo "$status_name" | cut -d: -f1)
NAME=$(echo "$status_name" | cut -d: -f2)
if [ "$STATUS" != "success" ] && [ "$STATUS" != "skipped" ]; then
ALL_PASS=false
FAILED_ITEMS="$FAILED_ITEMS $NAME"
fi
done
echo "╠══════════════════════════════════════════════════════╣"
if [ "$ALL_PASS" = "true" ]; then
echo "║ 整体状态: ✅ 全部通过 ║"
else
echo "║ 整体状态: ❌ 存在失败 ║"
echo "║ 失败项: ${FAILED_ITEMS} ║"
fi
echo "╚══════════════════════════════════════════════════════╝"
echo ""
# 如果有失败项,以非零退出码结束(方便 Gitea 标记流水线失败)
if [ "$ALL_PASS" = "false" ]; then
echo "⚠️ 部分巡检项失败,请检查上方日志获取详细信息。"
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
# 但其他失败的 job 已经让整体流水线标记为失败
fi
+163
View File
@@ -0,0 +1,163 @@
name: Tests
on:
pull_request:
branches: [ main ]
jobs:
test:
runs-on: runtime-builder
steps:
- name: Checkout code
shell: sh
run: |
set -eu
python - <<'PY'
import io
import os
import tarfile
import time
import urllib.error
import urllib.request
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Show Python version
shell: sh
run: |
set -eu
python --version
python -m pip --version
- name: Install dependencies
shell: sh
run: |
set -eu
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
- name: Run unit tests
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
- name: Run integration tests
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/integration -q --timeout=60 -x
lint:
runs-on: runtime-builder
steps:
- name: Checkout code
shell: sh
run: |
set -eu
python - <<'PY'
import io
import os
import tarfile
import time
import urllib.error
import urllib.request
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Install dependencies
shell: sh
run: |
set -eu
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
- name: Run Black (check only)
shell: sh
run: |
set -eu
python -m black --check alembic apps packages tests scripts
- name: Run Flake8
shell: sh
run: |
set -eu
python -m flake8 apps packages tests --count --statistics
-1
View File
@@ -6,7 +6,6 @@ dist/
coverage/
# Python / backend
.cache/
.venv/
venv/
.venv-ci-root/
@@ -1,28 +0,0 @@
"""CMS Enhancements (placeholder - manually applied on production)
Revision ID: 034_cms_enhance
Revises: 033
Create Date: 2026-07-09
占位迁移文件:生产数据库已手动升级到此版本,
此文件用于让 alembic 识别当前版本,避免部署时迁移失败。
实际的表结构变更(helpcenter, tickets, partners, site_settings 等)
已在生产环境手动执行。
"""
from alembic import op
revision = "034_cms_enhance"
down_revision = "033"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""占位 - 变更已在生产环境手动应用"""
pass
def downgrade() -> None:
"""占位 - 不执行实际回退"""
pass
@@ -1,26 +0,0 @@
"""Add editing_mode to edit_templates
Revision ID: 035_editing_mode
Revises: 034_cms_enhance
Create Date: 2026-07-09
"""
import sqlalchemy as sa
from alembic import op
revision = "035_editing_mode"
down_revision = "034_cms_enhance"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_templates",
sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take"),
)
def downgrade() -> None:
op.drop_column("edit_templates", "editing_mode")
@@ -1,68 +0,0 @@
"""Expand UUID fields from varchar(32) to varchar(36)
All UUID fields across all tables were varchar(32), but standard UUIDs with
hyphens are 36 characters (e.g. 550e8400-e29b-41d4-a716-446655440000).
This caused StringDataRightTruncation errors on insert.
Revision ID: 036_expand_uuid_36
Revises: 035_editing_mode
Create Date: 2026-07-10
"""
import sqlalchemy as sa
from alembic import op
revision = "036_expand_uuid_36"
down_revision = "035_editing_mode"
branch_labels = None
depends_on = None
# ── 表 → 需要扩容的列 ─────────────────────────────────────────────────────────
_TABLES: dict[str, list[str]] = {
"projects": ["id", "owner_user_id"],
"edit_templates": ["id"],
"edit_plans": ["id", "template_id", "source_edit_plan_id", "project_id", "created_by_user_id"],
"template_clip_configs": ["id", "template_id"],
"edit_plan_clips": ["id", "plan_id", "template_clip_config_id", "asset_id"],
"ingest_jobs": ["id", "project_id", "library_id", "result_asset_id"],
"classification_jobs": ["id", "project_id", "asset_id"],
"generation_tasks": [
"id",
"project_id",
"strategy_id",
"asset_library_id",
"voice_library_id",
"created_by_user_id",
"source_edit_plan_id",
"batch_id",
],
"generated_videos": ["id", "project_id", "generation_task_id", "duplicate_of"],
"jobs": ["id", "project_id", "source_id", "created_by_user_id"],
}
def upgrade() -> None:
for table, columns in _TABLES.items():
for col in columns:
op.alter_column(
table,
col,
existing_type=sa.String(32),
type_=sa.String(36),
existing_nullable=None,
)
def downgrade() -> None:
for table, columns in reversed(list(_TABLES.items())):
for col in columns:
op.alter_column(
table,
col,
existing_type=sa.String(36),
type_=sa.String(32),
existing_nullable=None,
)
@@ -1,26 +0,0 @@
"""Add logs field to generation_tasks
Revision ID: 037_generation_logs
Revises: 036_expand_uuid_36
Create Date: 2026-07-10
"""
import sqlalchemy as sa
from alembic import op
revision = "037_generation_logs"
down_revision = "036_expand_uuid_36"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("logs", sa.Text(), nullable=False, server_default="[]"),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "logs")
+1 -32
View File
@@ -1,11 +1,7 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_project_repository,
)
from app.dependencies import get_asset_library_repository, get_project_repository
from app.schemas.asset_library import (
AssetLibraryResponse,
CreateAssetLibraryRequest,
@@ -151,30 +147,3 @@ def ensure_default_library(
)
created = asset_library_repository.create(library)
return _to_asset_library_response(created)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_asset_library(
library_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除素材库,同时删除库内所有素材。"""
# 查找素材库
library = asset_library_repository.find_by_id(library_id)
if library is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
# 权限校验:检查用户是否有项目访问权限
_check_project_access(library.project_id, authenticated_user.user.id, project_repository)
# 删除库内所有素材(无 FK 级联,需手动清理)
assets_in_library = asset_repository.find_by_library(library_id)
if assets_in_library:
asset_ids_to_delete = [a.id for a in assets_in_library]
asset_repository.batch_delete(asset_ids_to_delete)
# 删除素材库本身
asset_library_repository.delete(library_id)
+73 -73
View File
@@ -274,6 +274,79 @@ async def init_chunked_upload(
)
@router.post("/{upload_id}/{chunk_index}")
async def upload_chunk(
upload_id: str,
chunk_index: int,
chunk: UploadFile = File(..., description="Chunk data"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, Any]:
"""Upload a single chunk"""
# Load metadata
meta = _load_upload_meta(upload_id)
# Check expiry
expires_at = datetime.fromisoformat(meta["expires_at"])
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
# Validate chunk index
if chunk_index < 0 or chunk_index >= meta["total_chunks"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
)
# Atomic check and record to prevent race conditions
if not _atomic_check_and_record(upload_id, chunk_index):
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
# Read chunk data
chunk_data = await chunk.read()
# Validate chunk size (last chunk can be smaller than chunk_size)
expected_size = DEFAULT_CHUNK_SIZE
if chunk_index == meta["total_chunks"] - 1:
expected_size = meta["file_size"] - (chunk_index * DEFAULT_CHUNK_SIZE)
if len(chunk_data) != expected_size:
# Rollback the recorded chunk
meta_path = _get_upload_meta_path(upload_id)
with open(meta_path, "r+", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
meta = json.load(f)
if chunk_index in meta["uploaded_chunks"]:
meta["uploaded_chunks"].remove(chunk_index)
f.seek(0)
json.dump(meta, f, ensure_ascii=False, indent=2)
f.truncate()
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Chunk size mismatch. Expected {expected_size}, got {len(chunk_data)}",
)
# Save chunk
chunk_path = _get_chunk_dir(upload_id) / f"chunk_{chunk_index:06d}"
with open(chunk_path, "wb") as f:
f.write(chunk_data)
# Reload metadata for response
meta = _load_upload_meta(upload_id)
return {
"message": "Chunk uploaded successfully",
"chunk_index": chunk_index,
"uploaded_chunks": len(meta["uploaded_chunks"]),
"total_chunks": meta["total_chunks"],
}
@router.get("/{upload_id}/status", response_model=ChunkedUploadStatusResponse)
async def get_upload_status(
upload_id: str,
@@ -420,76 +493,3 @@ async def complete_chunked_upload(
meta_path = _get_upload_meta_path(upload_id)
if meta_path.exists():
meta_path.unlink()
@router.post("/{upload_id}/{chunk_index}")
async def upload_chunk(
upload_id: str,
chunk_index: int,
chunk: UploadFile = File(..., description="Chunk data"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, Any]:
"""Upload a single chunk"""
# Load metadata
meta = _load_upload_meta(upload_id)
# Check expiry
expires_at = datetime.fromisoformat(meta["expires_at"])
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
# Validate chunk index
if chunk_index < 0 or chunk_index >= meta["total_chunks"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
)
# Atomic check and record to prevent race conditions
if not _atomic_check_and_record(upload_id, chunk_index):
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
# Read chunk data
chunk_data = await chunk.read()
# Validate chunk size (last chunk can be smaller than chunk_size)
expected_size = DEFAULT_CHUNK_SIZE
if chunk_index == meta["total_chunks"] - 1:
expected_size = meta["file_size"] - (chunk_index * DEFAULT_CHUNK_SIZE)
if len(chunk_data) != expected_size:
# Rollback the recorded chunk
meta_path = _get_upload_meta_path(upload_id)
with open(meta_path, "r+", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
meta = json.load(f)
if chunk_index in meta["uploaded_chunks"]:
meta["uploaded_chunks"].remove(chunk_index)
f.seek(0)
json.dump(meta, f, ensure_ascii=False, indent=2)
f.truncate()
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Chunk size mismatch. Expected {expected_size}, got {len(chunk_data)}",
)
# Save chunk
chunk_path = _get_chunk_dir(upload_id) / f"chunk_{chunk_index:06d}"
with open(chunk_path, "wb") as f:
f.write(chunk_data)
# Reload metadata for response
meta = _load_upload_meta(upload_id)
return {
"message": "Chunk uploaded successfully",
"chunk_index": chunk_index,
"uploaded_chunks": len(meta["uploaded_chunks"]),
"total_chunks": meta["total_chunks"],
}
+42 -338
View File
@@ -10,8 +10,6 @@ RESTful CRUD for EditPlan:
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09)
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09)
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
- POST /api/v1/edit-plans/generate-from-template 基于模板+素材自动生成剪辑计划
业务逻辑委托给 EditPlanService 服务层。
"""
@@ -24,29 +22,16 @@ from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.dependencies import get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService, PlanGeneratorService
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
@@ -206,44 +191,6 @@ class GenerateCoverResponse(BaseModel):
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── 基于模板生成剪辑计划 Schemas ─────────────────────────────────────────────
class GenerateFromTemplateRequest(BaseModel):
"""基于模板生成剪辑计划请求体"""
template_id: str = Field(..., description="剪辑模板 ID")
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
project_id: str = Field(default="", description="所属项目 ID")
name: str = Field(default="", description="计划名称(为空则自动取模板名)")
class _PlanClipItem(BaseModel):
"""片段响应体"""
id: str
clip_type: str
order: int
asset_id: str
text_content: str
start_time: float
duration: float
transition_effect: str
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class GenerateFromTemplateResponse(BaseModel):
"""基于模板生成剪辑计划响应体"""
plan: EditPlanResponse
clips: List[_PlanClipItem]
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -253,9 +200,9 @@ def _check_project_access(project_id: str, user_id: str, project_repository: Any
return
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="无权访问该项目")
raise HTTPException(status_code=403, detail="Access denied to project")
def _to_response(p: EditPlan) -> EditPlanResponse:
@@ -306,7 +253,7 @@ def list_plans(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
detail=(f"无效的状态值: {status_filter}" f"可选值: draft, editing, rendering, completed, failed"),
)
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
@@ -432,7 +379,7 @@ def update_plan(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态",
detail=(f"无效的状态值: {body.status}" f"可选值: draft, editing, rendering, completed, failed"),
)
svc.transition_status(plan_id, target_status)
except ValueError as exc:
@@ -488,8 +435,6 @@ def generate_plan(
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
@@ -509,121 +454,6 @@ def generate_plan(
if plan_check.project_id:
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
# 回退到旧模型 template_segments
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main", # 旧模型无结构角色,统一为主体片段
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = [] # 已分配完
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
# 找到项目的视频素材库
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
# 筛选 ready 状态的视频素材
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
# 随机选取,按片段数轮询分配
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
@@ -638,89 +468,48 @@ def generate_plan(
detail=reason,
)
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
try:
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
# 队列限流预检查(repository 不支持计数时跳过)
user_id = current_user.user.id
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
)
# 将 generation_task_id 存入 plan config
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 将 generation_task_id 存入 plan config
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 流转状态为 rendering
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 流转状态为 rendering
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
# 获取最新状态
updated_plan = svc.get_plan_or_raise(plan_id)
# 获取最新状态
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
# 已处理的 HTTP 异常直接透传
raise
except Exception as exc:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
@router.get(
@@ -867,7 +656,7 @@ def ai_recommend_clips(
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
detail=f"AI 推荐仅支持 draft/editing 状态的计划,当前状态: {plan_status}",
)
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
@@ -918,7 +707,7 @@ def ai_recommend_clips(
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
detail=f"AI 推荐结果写入失败: {exc}",
)
logger.info(
@@ -1144,88 +933,3 @@ def get_plan_timeline(
total_duration=total_duration,
scenes=scenes,
)
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划
流程:
1. 获取模板及其片段配置
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
3. 返回完整的计划和片段列表
"""
from app.services import EditTemplateService
# 项目鉴权
if body.project_id:
_check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
# 获取模板
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 获取模板片段配置
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 调用 PlanGeneratorService 生成计划
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
-10
View File
@@ -41,9 +41,6 @@ class EditTemplateCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: str = Field(default="", max_length=2000, description="模板描述")
template_type: str = Field(default="default", max_length=50, description="模板类型")
editing_mode: str = Field(
default="one_take", max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
)
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
preview_url: str = Field(default="", max_length=500, description="预览地址")
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
@@ -55,9 +52,6 @@ class EditTemplateUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
editing_mode: Optional[str] = Field(
default=None, max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
)
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
@@ -71,7 +65,6 @@ class EditTemplateResponse(BaseModel):
name: str
description: str
template_type: str
editing_mode: str
config: dict[str, Any]
preview_url: str
sort_weight: int
@@ -109,7 +102,6 @@ def _to_response(t: EditTemplate) -> EditTemplateResponse:
name=t.name,
description=t.description,
template_type=t.template_type,
editing_mode=t.editing_mode,
config=t.config,
preview_url=t.preview_url,
sort_weight=t.sort_weight,
@@ -203,7 +195,6 @@ def create_template(
name=body.name,
description=body.description,
template_type=body.template_type,
editing_mode=body.editing_mode,
config=normalized_config,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
@@ -248,7 +239,6 @@ def update_template(
name=body.name,
description=body.description,
template_type=body.template_type,
editing_mode=body.editing_mode,
config=config_to_update,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
+28 -151
View File
@@ -1,18 +1,9 @@
import logging
import random
import uuid
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
check_queue_limits,
safe_enqueue_generation_task,
)
from app.core.celery_app import celery_app
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
@@ -39,10 +30,9 @@ from packages.application import (
ListGeneratedVideosByTaskUseCase,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限"""
project = project_repository.find_by_id(project_id)
@@ -66,7 +56,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
logs=getattr(task, "logs", "[]"),
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -74,7 +63,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
)
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
def _to_generated_video_response(item) -> GeneratedVideoResponse:
return GeneratedVideoResponse(
id=item.id,
project_id=item.project_id,
@@ -87,7 +76,6 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener
width=item.width,
height=item.height,
fps=item.fps,
download_url=download_url,
)
@@ -191,37 +179,19 @@ def create_generation_task(
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> BatchGenerationTaskResponse:
logger.info(
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
authenticated_user.user.id,
request.template_id,
len(request.asset_ids),
request.asset_select_mode,
request.count,
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
try:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
except HTTPException as e:
logger.warning("[生成任务] 校验失败: %s", e.detail)
raise
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
resolved_asset_ids: list[str] = list(request.asset_ids)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
logger.warning("[生成任务] 素材库不存在: library_id=%s", asset_library_id)
raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found")
assets = asset_repository.find_by_library(asset_library_id)
try:
_ensure_library_has_ready_video_assets(assets)
except HTTPException as e:
logger.warning("[生成任务] 素材校验失败: %s", e.detail)
raise
_ensure_library_has_ready_video_assets(assets)
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
if not resolved_asset_ids:
@@ -234,85 +204,30 @@ def create_generation_task(
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
created_tasks = []
failed_tasks = []
user_id = authenticated_user.user.id
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
# 预检查:批量提交前先看会不会超限,避免建一半才拒
try:
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending + count > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
for _ in range(count):
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
)
if global_pending + count > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from e
)
celery_app.send_task("worker.generate_video", args=[task.id])
created_tasks.append(task)
try:
for _ in range(count):
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
)
)
try:
if safe_enqueue_generation_task(
task,
generation_task_repository,
user_id=user_id,
log_prefix="[生成任务]",
log_task_status=True,
):
created_tasks.append(task)
else:
failed_tasks.append(task)
except UserPendingLimitExceeded:
# 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
)
break
except GlobalQueueFull:
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
break
except HTTPException:
raise
except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
items = [_to_generation_task_response(t) for t in created_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
@@ -350,7 +265,6 @@ def list_generation_results(
generation_task_repository: Any = Depends(get_generation_task_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ListGeneratedVideosResponse:
task = generation_task_repository.get(task_id)
if task is None:
@@ -359,11 +273,7 @@ def list_generation_results(
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
items = use_case.execute(task_id)
responses = []
for item in items:
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
responses.append(_to_generated_video_response(item, download_url=download_url))
return ListGeneratedVideosResponse(items=responses)
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse)
@@ -382,21 +292,6 @@ def retry_generation_task(
if status_val != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查:创建前判断,>= 上限就拒绝
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
@@ -408,28 +303,10 @@ def retry_generation_task(
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
)
)
try:
if not safe_enqueue_generation_task(
retried,
generation_task_repository,
user_id=user_id,
log_prefix="[生成任务]",
log_task_status=True,
):
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
celery_app.send_task("worker.generate_video", args=[retried.id])
return _to_generation_task_response(retried)
+5 -73
View File
@@ -1,15 +1,7 @@
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
safe_enqueue_generation_task,
)
from app.dependencies import (
get_generation_task_repository,
get_ingest_job_repository,
@@ -30,10 +22,9 @@ from packages.application import (
SubmitIngestJobUseCase,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _humanize_task_error(error_message: str) -> str:
raw = (error_message or "").strip()
if not raw:
@@ -148,21 +139,6 @@ def retry_task_by_id(
if _status_value(task.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
@@ -174,24 +150,10 @@ def retry_task_by_id(
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
created_by_user_id=authenticated_user.user.id,
)
)
try:
if not safe_enqueue_generation_task(
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
):
logger.warning("[任务中心] 用户级重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
celery_app.send_task("worker.generate_video", args=[retried.id])
return UserTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
@@ -259,22 +221,6 @@ def retry_project_task(
raise HTTPException(status_code=404, detail="Generation task not found")
if _status_value(task.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
@@ -286,24 +232,10 @@ def retry_project_task(
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
created_by_user_id=authenticated_user.user.id,
)
)
try:
if not safe_enqueue_generation_task(
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
):
logger.warning("[任务中心] 项目级重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
celery_app.send_task("worker.generate_video", args=[retried.id])
return _generation_task_to_project_response(retried)
if task_type == "ingest":
job = ingest_job_repository.get(source_id)
Executable → Regular
+6 -17
View File
@@ -7,7 +7,6 @@ from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_audio_url_signer,
get_cosyvoice_service,
get_db_session,
get_user_repository,
@@ -57,10 +56,7 @@ def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTS
return SQLAlchemyTTSJobRepository(session)
def _to_response(job, sign_url=None) -> TTSJobResponse:
output_url = job.output_audio_url
if sign_url and output_url:
output_url = sign_url(output_url)
def _to_response(job) -> TTSJobResponse:
return TTSJobResponse(
id=job.id,
user_id=job.user_id,
@@ -70,7 +66,7 @@ def _to_response(job, sign_url=None) -> TTSJobResponse:
project_id=job.project_id,
voice_clone_profile_id=job.voice_clone_profile_id,
status=job.status,
output_audio_url=output_url,
output_audio_url=job.output_audio_url,
output_audio_key=job.output_audio_key,
duration=job.duration,
file_size=job.file_size,
@@ -180,7 +176,6 @@ def list_tts_jobs(
status_filter: Optional[str] = Query(None, alias="status"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
sign_url=Depends(get_audio_url_signer),
) -> ListTTSJobResponse:
"""列出用户的 TTS 合成任务。"""
user_id = authenticated_user.user.id
@@ -188,7 +183,7 @@ def list_tts_jobs(
skip = (page - 1) * page_size
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size)
return ListTTSJobResponse(
items=[_to_response(j, sign_url) for j in items],
items=[_to_response(j) for j in items],
total=total,
page=page,
page_size=page_size,
@@ -200,7 +195,6 @@ def get_tts_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
sign_url=Depends(get_audio_url_signer),
) -> TTSJobResponse:
"""获取 TTS 任务详情。"""
user_id = authenticated_user.user.id
@@ -209,7 +203,7 @@ def get_tts_job(
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return _to_response(job, sign_url)
return _to_response(job)
@router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse)
@@ -217,7 +211,6 @@ def get_tts_job_status(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
sign_url=Depends(get_audio_url_signer),
) -> TTSStatusResponse:
"""查询 TTS 合成状态(用于前端轮询)。"""
user_id = authenticated_user.user.id
@@ -226,13 +219,10 @@ def get_tts_job_status(
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
output_url = job.output_audio_url
if output_url:
output_url = sign_url(output_url)
return TTSStatusResponse(
id=job.id,
status=job.status,
output_audio_url=output_url,
output_audio_url=job.output_audio_url,
error_message=job.error_message,
duration=job.duration,
retry_count=job.retry_count,
@@ -268,7 +258,6 @@ def save_tts_job_to_library(
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
user_repository: UserRepository = Depends(get_user_repository),
sign_url=Depends(get_audio_url_signer),
) -> SaveToLibraryResponse:
"""将已完成的 TTS 合成结果保存到配音库。
@@ -339,7 +328,7 @@ def save_tts_job_to_library(
return SaveToLibraryResponse(
id=item.id,
name=item.name,
audio_url=sign_url(item.audio_url) if item.audio_url else "",
audio_url=item.audio_url,
duration=item.duration,
voice_id=item.voice_id,
voice_name=item.voice_name,
+7 -14
View File
@@ -6,7 +6,7 @@ import logging
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_voice_clone_profile_repository
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
from app.schemas.voice_clone import (
CreateVoiceCloneRequest,
ListVoiceCloneResponse,
@@ -37,16 +37,13 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _to_response(profile, sign_url=None) -> VoiceCloneProfileResponse:
source_url = profile.source_audio_url
if sign_url and source_url:
source_url = sign_url(source_url)
def _to_response(profile) -> VoiceCloneProfileResponse:
return VoiceCloneProfileResponse(
id=profile.id,
user_id=profile.user_id,
name=profile.name,
description=profile.description,
source_audio_url=source_url,
source_audio_url=profile.source_audio_url,
voice_id=profile.voice_id,
voice_model=profile.voice_model,
language=profile.language,
@@ -77,7 +74,6 @@ def create_voice_clone(
request: CreateVoiceCloneRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
sign_url=Depends(get_audio_url_signer),
) -> VoiceCloneProfileResponse:
"""创建音色克隆任务。
@@ -113,7 +109,7 @@ def create_voice_clone(
except Exception as inner_e:
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
return _to_response(profile, sign_url)
return _to_response(profile)
@router.get("", response_model=ListVoiceCloneResponse)
@@ -123,14 +119,13 @@ def list_voice_clones(
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
sign_url=Depends(get_audio_url_signer),
) -> ListVoiceCloneResponse:
"""获取用户的音色克隆列表。"""
user_id = authenticated_user.user.id
use_case = ListVoiceClonesUseCase(repository)
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
return ListVoiceCloneResponse(
items=[_to_response(p, sign_url) for p in items],
items=[_to_response(p) for p in items],
total=total,
)
@@ -140,7 +135,6 @@ def get_voice_clone(
clone_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
sign_url=Depends(get_audio_url_signer),
) -> VoiceCloneProfileResponse:
"""获取音色克隆详情。"""
user_id = authenticated_user.user.id
@@ -149,7 +143,7 @@ def get_voice_clone(
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return _to_response(profile, sign_url)
return _to_response(profile)
@router.get("/{clone_id}/status", response_model=VoiceCloneStatusResponse)
@@ -198,7 +192,6 @@ def retry_voice_clone(
clone_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
sign_url=Depends(get_audio_url_signer),
) -> VoiceCloneProfileResponse:
"""重试失败的音色克隆。
@@ -231,4 +224,4 @@ def retry_voice_clone(
except Exception as inner_e:
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
return _to_response(profile, sign_url)
return _to_response(profile)
+10 -22
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Literal, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
from app.dependencies import get_db_session, get_user_repository
from app.schemas.voice import (
PresetVoiceItemResponse,
PresetVoiceListResponse,
@@ -50,10 +50,7 @@ def _get_clone_profile_repository(session: Session = Depends(get_db_session)) ->
return SQLAlchemyVoiceCloneProfileRepository(session)
def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
audio = item.audio_url
if sign_url and audio:
audio = sign_url(audio)
def _to_response(item) -> VoiceLibraryItemResponse:
return VoiceLibraryItemResponse(
id=item.id,
user_id=item.user_id,
@@ -62,7 +59,7 @@ def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
voice_provider=item.voice_provider,
voice_id=item.voice_id,
voice_name=item.voice_name,
audio_url=audio,
audio_url=item.audio_url,
duration=item.duration,
file_size=item.file_size,
status=item.status,
@@ -73,20 +70,16 @@ def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
)
def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None) -> UnifiedVoiceItemResponse:
def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse:
"""将数据库音色转换为统一响应格式。
Args:
item: VoiceLibraryItem
profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id
sign_url: 音频URL预签名函数
"""
profile_id = None
if profile_id_map and item.voice_id:
profile_id = profile_id_map.get(item.voice_id)
audio = item.audio_url
if sign_url and audio:
audio = sign_url(audio)
return UnifiedVoiceItemResponse(
id=item.id,
type="clone",
@@ -96,7 +89,7 @@ def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None
language="zh-CN",
voice_id=item.voice_id,
voice_provider=item.voice_provider or "cosyvoice",
audio_url=audio,
audio_url=item.audio_url,
duration=item.duration,
file_size=item.file_size,
status=item.status,
@@ -147,7 +140,6 @@ def list_voices_unified(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
sign_url=Depends(get_audio_url_signer),
) -> UnifiedVoiceListResponse:
"""获取配音列表(预置音色 + 用户克隆音色)。
@@ -175,7 +167,7 @@ def list_voices_unified(
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
clone_items = [_to_unified_response(i, profile_id_map) for i in clone_items_raw]
# 组装结果
if type == "preset":
@@ -232,7 +224,6 @@ def list_voices_legacy(
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
sign_url=Depends(get_audio_url_signer),
) -> ListVoiceLibraryResponse:
"""原有配音列表接口(仅返回用户克隆音色)。
@@ -242,7 +233,7 @@ def list_voices_legacy(
use_case = ListVoiceLibraryUseCase(voice_repository)
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
return ListVoiceLibraryResponse(
items=[_to_response(i, sign_url) for i in items],
items=[_to_response(i) for i in items],
total=total,
)
@@ -252,14 +243,13 @@ def get_voice(
voice_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
sign_url=Depends(get_audio_url_signer),
) -> VoiceLibraryItemResponse:
user_id = authenticated_user.user.id
use_case = GetVoiceLibraryUseCase(voice_repository)
item = use_case.execute(voice_id, user_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url)
return _to_response(item)
@router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED)
@@ -268,7 +258,6 @@ def create_voice(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
user_repository: UserRepository = Depends(get_user_repository),
sign_url=Depends(get_audio_url_signer),
) -> VoiceLibraryItemResponse:
user_id = authenticated_user.user.id
plan_name = _get_user_plan(user_id, user_repository)
@@ -294,7 +283,7 @@ def create_voice(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
return _to_response(item, sign_url)
return _to_response(item)
@router.put("/{voice_id}", response_model=VoiceLibraryItemResponse)
@@ -303,7 +292,6 @@ def update_voice(
request: UpdateVoiceLibraryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
sign_url=Depends(get_audio_url_signer),
) -> VoiceLibraryItemResponse:
user_id = authenticated_user.user.id
command = UpdateVoiceLibraryCommand(
@@ -325,7 +313,7 @@ def update_voice(
item = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url)
return _to_response(item)
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
-21
View File
@@ -79,27 +79,6 @@ class Settings(BaseSettings):
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
@field_validator("OSS_ACCESS_KEY_ID", mode="before")
@classmethod
def validate_oss_access_key_id(cls, v):
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
raise ValueError(
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
)
return v or ""
@field_validator("OSS_ACCESS_KEY_SECRET", mode="before")
@classmethod
def validate_oss_access_key_secret(cls, v):
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
raise ValueError(
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
)
return v or ""
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
default=2000,
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
+2 -42
View File
@@ -34,18 +34,13 @@ class OSSStorageService:
if has_key_id and has_key_secret:
if oss2 is not None:
try:
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
# 否则 sign_url 默认生成 HTTP URL。
bucket_endpoint = settings.OSS_ENDPOINT
if not bucket_endpoint.startswith(("http://", "https://")):
bucket_endpoint = f"https://{bucket_endpoint}"
auth = oss2.Auth(
settings.OSS_ACCESS_KEY_ID,
settings.OSS_ACCESS_KEY_SECRET,
)
self.bucket = oss2.Bucket(
auth,
bucket_endpoint,
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
logger.info(
@@ -69,26 +64,6 @@ class OSSStorageService:
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
self.endpoint = settings.OSS_ENDPOINT
def diagnose(self) -> None:
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
key_id_display = (
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
)
logger.info(
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
self.endpoint,
self.bucket_name,
key_id_display,
)
if self.bucket is None:
logger.error(
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env"
)
else:
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
parsed = urlparse(storage_key_or_url)
path = parsed.path if parsed.scheme else storage_key_or_url
@@ -191,26 +166,12 @@ class OSSStorageService:
if self.bucket is None:
if self._is_local_generated_url(storage_key_or_url):
return storage_key_or_url
logger.warning(
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
storage_key_or_url[:200],
)
return self.get_url(self._normalize_storage_key(storage_key_or_url))
storage_key = self._normalize_storage_key(storage_key_or_url)
try:
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
logger.info(
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
storage_key[:80],
signed[:60],
)
return signed
return self.bucket.sign_url("GET", storage_key, expires_seconds)
except Exception:
logger.exception(
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
storage_key[:200],
)
return self.get_url(storage_key)
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
@@ -279,5 +240,4 @@ def get_storage_service() -> OSSStorageService:
global _storage_service
if _storage_service is None:
_storage_service = OSSStorageService()
_storage_service.diagnose()
return _storage_service
-221
View File
@@ -1,221 +0,0 @@
import logging
from typing import Any
from app.core.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
class UserPendingLimitExceeded(Exception):
"""用户 pending 任务数超限,返回 429。"""
def __init__(self, user_id: str, pending_count: int, limit: int):
self.user_id = user_id
self.pending_count = pending_count
self.limit = limit
super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}")
class GlobalQueueFull(Exception):
"""全局限流,返回 503。"""
def __init__(self, pending_count: int, limit: int):
self.pending_count = pending_count
self.limit = limit
super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}")
def check_queue_limits(
user_id: str,
generation_task_repository: Any,
*,
user_pending_limit: int = USER_PENDING_LIMIT,
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
) -> None:
"""检查队列限流(预检查用,任务创建前调用),超限抛对应异常。
边界语义:>= 上限即拒绝(达到上限就不能再加新任务)。
Args:
user_id: 用户 ID
generation_task_repository: 任务仓储
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
Raises:
GlobalQueueFull: 全局超限时抛出(优先级更高,先查全局)
UserPendingLimitExceeded: 用户超限时抛出
"""
# 先查全局(系统级保护优先级更高)
global_pending = generation_task_repository.count_pending_total()
if global_pending >= global_pending_limit:
logger.warning(
"[队列限流] 全局 pending 任务数超限: %d/%d, user_id=%s",
global_pending,
global_pending_limit,
user_id,
)
raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
# 再查用户级
if user_id:
user_pending = generation_task_repository.count_pending_by_user(user_id)
if user_pending >= user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 任务数超限: user_id=%s, count=%d/%d",
user_id,
user_pending,
user_pending_limit,
)
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
def _mark_task_failed_safely(
task: Any,
generation_task_repository: Any,
log_prefix: str,
reason: str,
) -> None:
"""安全地把任务标记为 failed,更新失败只打日志不崩溃。"""
try:
task.mark_failed(f"任务被限流拒绝: {reason}")
generation_task_repository.update(task)
except Exception as update_err:
logger.error(
"%s 限流后更新状态也失败: task_id=%s error=%s",
log_prefix,
task.id,
update_err,
exc_info=True,
)
def safe_enqueue_generation_task(
task: Any,
generation_task_repository: Any,
*,
user_id: str = "",
log_prefix: str = "[任务队列]",
log_task_status: bool = False,
user_pending_limit: int = USER_PENDING_LIMIT,
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
) -> bool:
"""安全入队:入队前限流检查 → 发送 Celery 任务 → 入队后最终校验兜底。
边界说明:
入队前检查用 > 而非 >=。因为调用此函数时 task 已经是 pending 状态并计入 DB,
pending 总数包含了当前任务本身。pending > limit 等价于"其他任务数 >= limit"
与预检查的 >= 语义一致(都是达到上限就拒绝新任务)。
入队后最终校验:发送 Celery 成功后再查一次 DB 计数,处理并发竞态场景
(两个请求同时通过入队前检查,后到的那个在这里被兜住)。
Args:
task: 生成任务对象,需有 id 属性和 mark_failed 方法(状态已为 pending
generation_task_repository: 任务仓储,用于更新状态
user_id: 用户 ID,传了才做用户级限流检查
log_prefix: 日志前缀,便于区分调用来源
log_task_status: 成功日志中是否额外打印任务状态
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
Returns:
True 表示入队成功,False 表示入队失败(已标记为 failed)
Raises:
GlobalQueueFull: 全局 pending 超限时抛出,任务会被标记为 failed
UserPendingLimitExceeded: 用户 pending 超限时抛出,任务会被标记为 failed
"""
# ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ──
# 全局限流检查(始终生效)
global_pending = generation_task_repository.count_pending_total()
if global_pending > global_pending_limit:
logger.warning(
"[队列限流] 全局 pending 任务数超限(入队前): %d/%d, user_id=%s",
global_pending,
global_pending_limit,
user_id or "unknown",
)
exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# 用户级限流检查(传了 user_id 才做)
if user_id:
user_pending = generation_task_repository.count_pending_by_user(user_id)
if user_pending > user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d",
user_id,
user_pending,
user_pending_limit,
)
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# ── 发送 Celery 任务 ──
try:
celery_app.send_task("worker.generate_video", args=[task.id])
except Exception as e:
logger.error(
"%s 入队失败,标记为失败: task_id=%s error=%s",
log_prefix,
task.id,
e,
exc_info=True,
)
try:
task.mark_failed(f"任务入队失败: {e}")
generation_task_repository.update(task)
except Exception as update_err:
logger.error(
"%s 入队失败后更新状态也失败: task_id=%s error=%s",
log_prefix,
task.id,
update_err,
exc_info=True,
)
return False
# ── 入队后最终校验:并发竞态兜底 ──
# 发送成功后再查一次,防止两个请求同时通过入队前检查导致超限
global_after = generation_task_repository.count_pending_total()
user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0
global_over = global_after > global_pending_limit
user_over = bool(user_id and user_after > user_pending_limit)
if global_over or user_over:
if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
else:
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
logger.warning(
"[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed",
reason,
task.id,
user_id or "unknown",
)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, reason)
raise exc
# 入队成功日志
if log_task_status:
logger.info(
"%s 入队成功: task_id=%s, status=%s",
log_prefix,
task.id,
task.status,
)
else:
logger.info("%s 入队成功: task_id=%s", log_prefix, task.id)
return True
Regular → Executable
+2 -32
View File
@@ -201,37 +201,7 @@ def get_voice_clone_profile_repository(
def get_cosyvoice_service():
"""Provide the CosyVoice service instance.
注入 OSS 音频URL预签名函数,确保私有bucket下的参考音频
能被 CosyVoice 服务器下载。
"""
from app.core.storage import get_storage_service
"""Provide the CosyVoice service instance."""
from packages.application.cosyvoice_service import CosyVoiceService
storage = get_storage_service()
def _sign_audio_url(url: str) -> str:
"""对音频URL做预签名,私有bucket下 CosyVoice 服务器才能下载."""
return storage.get_download_url(url, expires_seconds=86400)
return CosyVoiceService(audio_url_signer=_sign_audio_url)
def get_audio_url_signer():
"""提供音频URL预签名函数(24小时有效期)。
用于所有 API 返回给前端的音频 URL,确保私有 OSS bucket 下可正常访问。
空 URL、非 OSS URL 直接原样返回;签名失败时回退到原始 URL。
"""
from app.core.storage import get_storage_service
storage = get_storage_service()
def sign_audio_url(url: str) -> str:
if not url:
return url
return storage.get_download_url(url, expires_seconds=86400)
return sign_audio_url
return CosyVoiceService()
+1 -18
View File
@@ -1,6 +1,4 @@
import json
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, Field, model_validator
class CreateGenerationTaskRequest(BaseModel):
@@ -64,21 +62,6 @@ class GenerationTaskResponse(BaseModel):
progress: float
result_count: int
error_message: str
logs: list[dict] = Field(default_factory=list)
@field_validator("logs", mode="before")
@classmethod
def _parse_logs(cls, v: object) -> list[dict]:
"""将 JSON 字符串解析为 list[dict]。"""
if isinstance(v, str):
try:
parsed = json.loads(v)
return parsed if isinstance(parsed, list) else []
except (json.JSONDecodeError, TypeError):
return []
if isinstance(v, list):
return v
return []
class BatchGenerationTaskResponse(BaseModel):
-2
View File
@@ -4,7 +4,6 @@ from .auto_clip_service import AutoClipService
from .edit_plan_service import EditPlanService
from .edit_template_service import EditTemplateService
from .job_service import JobService
from .plan_generator_service import PlanGeneratorService
from .video_compose_service import VideoComposeService
__all__ = [
@@ -12,6 +11,5 @@ __all__ = [
"EditPlanService",
"EditTemplateService",
"JobService",
"PlanGeneratorService",
"VideoComposeService",
]
+2 -2
View File
@@ -447,12 +447,12 @@ class EditPlanService:
# 检查状态
if plan.status != EditPlanStatus.EDITING:
return False, "请先编辑并保存模板后再生成视频"
return False, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}"
# 检查是否有片段
clips = self._clip_repo.list_by_plan(plan_id)
if not clips:
return False, "请先添加片段后再生成视频"
return False, "计划下没有片段,无法触发渲染"
return True, ""
@@ -100,7 +100,6 @@ class EditTemplateService:
*,
description: str = "",
template_type: str = "default",
editing_mode: str = "one_take",
config: Optional[dict[str, Any]] = None,
preview_url: str = "",
sort_weight: int = 0,
@@ -125,7 +124,6 @@ class EditTemplateService:
name=clean_name,
description=description,
template_type=template_type,
editing_mode=editing_mode,
config=config,
preview_url=preview_url,
sort_weight=sort_weight,
@@ -141,7 +139,6 @@ class EditTemplateService:
name: Optional[str] = None,
description: Optional[str] = None,
template_type: Optional[str] = None,
editing_mode: Optional[str] = None,
config: Optional[dict[str, Any]] = None,
preview_url: Optional[str] = None,
sort_weight: Optional[int] = None,
@@ -168,7 +165,6 @@ class EditTemplateService:
name=new_name,
description=description.strip() if description is not None else existing.description,
template_type=template_type.strip() if template_type is not None else existing.template_type,
editing_mode=editing_mode.strip() if editing_mode is not None else existing.editing_mode,
config=config if config is not None else existing.config,
preview_url=preview_url.strip() if preview_url is not None else existing.preview_url,
sort_weight=sort_weight if sort_weight is not None else existing.sort_weight,
@@ -1,391 +0,0 @@
"""PlanGeneratorService — 基于模板+素材自动生成剪辑计划.
核心职责:
- 根据 EditTemplate 的 editing_mode 和 TemplateClipConfig 列表,
自动生成 EditPlan + EditPlanClip 列表
- 四种模式素材分配策略:
- ONE_TAKE: 素材顺序分配给 main 类型 clips
- PIP: 第1个素材→main(全屏背景),其余→overlay clips
- VOICE_OVER: 素材→main clips (B-roll),标记需要配音叠加
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
)
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.edit_template import EditTemplate
from packages.domain.editing_mode import EditingMode
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
logger = logging.getLogger(__name__)
# ── 默认片段时长(秒) ────────────────────────────────────────────────────────
_DEFAULT_CLIP_DURATION = 5.0
_DEFAULT_INTRO_DURATION = 3.0
_DEFAULT_OUTRO_DURATION = 3.0
class PlanGeneratorService:
"""剪辑计划生成器
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
"""
def __init__(self, db: Session) -> None:
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
# ── 公开接口 ─────────────────────────────────────────────────────────────
def generate_from_template(
self,
template: EditTemplate,
clip_configs: List[TemplateClipConfig],
asset_ids: List[str],
*,
project_id: str = "",
created_by_user_id: str = "",
name: str = "",
) -> dict[str, Any]:
"""基于模板+素材生成剪辑计划
Args:
template: 剪辑模板实体
clip_configs: 模板片段配置列表(可为空,自动生成默认结构)
asset_ids: 素材 ID 列表
project_id: 所属项目 ID
created_by_user_id: 创建者用户 ID
name: 计划名称(为空则自动取模板名)
Returns:
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
"""
editing_mode = template.editing_mode or EditingMode.ONE_TAKE.value
plan_name = name.strip() or f"{template.name} - 剪辑计划"
# 1. 构建 plan config(继承模板的 title/subtitle/bgm,记录 editing_mode
plan_config = self._build_plan_config(template, editing_mode)
# 2. 创建 EditPlan
plan = EditPlan.create(
template_id=template.id,
name=plan_name,
config=plan_config,
total_duration=0.0,
project_id=project_id,
created_by_user_id=created_by_user_id,
)
plan = self._plan_repo.create(plan)
logger.info(
"生成剪辑计划: plan_id=%s template=%s mode=%s assets=%d",
plan.id,
template.id,
editing_mode,
len(asset_ids),
)
# 3. 生成片段列表
if clip_configs:
clips = self._create_clips_from_configs(plan.id, clip_configs)
else:
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
# 4. 按 editing_mode 分配素材
if asset_ids:
self._distribute_assets(clips, asset_ids, editing_mode)
# 5. 持久化所有 clips 并计算总时长
created_clips: List[EditPlanClip] = []
total_duration = 0.0
for clip in clips:
saved = self._clip_repo.create(clip)
created_clips.append(saved)
total_duration += saved.duration
# 6. 更新 plan 的 total_duration
plan.total_duration = total_duration
plan = self._plan_repo.update(plan)
# 7. 流转到 editing 状态
try:
plan.start_editing()
plan = self._plan_repo.update(plan)
except ValueError as exc:
logger.warning("计划状态流转失败: plan_id=%s error=%s", plan.id, exc)
logger.info(
"剪辑计划生成完成: plan_id=%s clips=%d duration=%.1f",
plan.id,
len(created_clips),
total_duration,
)
return {"plan": plan, "clips": created_clips}
# ── 内部方法 ─────────────────────────────────────────────────────────────
def _build_plan_config(
self,
template: EditTemplate,
editing_mode: str,
) -> dict[str, Any]:
"""从模板配置构建 plan config"""
template_config = template.config or {}
plan_config: dict[str, Any] = {
"editing_mode": editing_mode,
}
# 继承模板的 cover/title/subtitle/bgm 配置
for key in ("cover", "title", "subtitle", "bgm"):
if key in template_config:
plan_config[key] = template_config[key]
return normalize_plan_config(plan_config)
def _create_clips_from_configs(
self,
plan_id: str,
clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
clips: List[EditPlanClip] = []
# 按 order 排序
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
for cfg in sorted_configs:
# 计算时长:取 min_duration 和 max_duration 的中间值
if cfg.min_duration > 0 and cfg.max_duration > 0:
duration = (cfg.min_duration + cfg.max_duration) / 2
elif cfg.min_duration > 0:
duration = cfg.min_duration
elif cfg.max_duration > 0:
duration = cfg.max_duration
else:
duration = _DEFAULT_CLIP_DURATION
# clip_type 可能是枚举或字符串
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串
transition = (
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
)
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
text_content=getattr(cfg, "text_template", "") or "",
duration=duration,
transition_effect=transition or "cut",
)
clips.append(clip)
return clips
def _generate_default_clips(
self,
plan_id: str,
editing_mode: str,
asset_count: int,
) -> List[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
- ONE_TAKE: N 个 main clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = asset_count
- VOICE_OVER: N 个 main clips + 标记需要配音
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
"""
n = max(asset_count, 1)
clips: List[EditPlanClip] = []
order = 0
if editing_mode == EditingMode.PIP.value:
# 1 个 main(全屏背景)
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 overlay
for i in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="overlay",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="corner_voice",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 b_roll
for i in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="b_roll",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
else:
# ONE_TAKE: N 个 main clips
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
return clips
def _distribute_assets(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
editing_mode: str,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
分配策略:
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
- VOICE_OVER: 素材→main clips (B-roll)
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
"""
if not asset_ids or not clips:
return
if editing_mode == EditingMode.ONE_TAKE.value:
self._distribute_one_take(clips, asset_ids)
elif editing_mode == EditingMode.PIP.value:
self._distribute_pip(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_OVER.value:
self._distribute_voice_over(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_PIP.value:
self._distribute_voice_pip(clips, asset_ids)
else:
# 未知模式,退化为 one_take
self._distribute_one_take(clips, asset_ids)
def _distribute_one_take(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
main_clips[0].assign_asset(asset_ids[0])
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
remaining = asset_ids[1:]
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
def _distribute_voice_over(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_voice_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
bg_clips = [c for c in clips if c.clip_type == "background"]
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
# 第1个素材 → background
if bg_clips and len(asset_ids) > 0:
bg_clips[0].assign_asset(asset_ids[0])
# 第2个素材 → corner_voice
if corner_clips and len(asset_ids) > 1:
corner_clips[0].assign_asset(asset_ids[1])
# 其余素材 → b_roll
remaining = asset_ids[2:]
for i, clip in enumerate(broll_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
View File
-626
View File
@@ -1,626 +0,0 @@
/**
* 素材库页面完整 E2E 测试
*
* 覆盖:页面加载、创建素材库、切换素材库、搜索/筛选、素材详情、
* 删除素材、批量删除、空状态
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
*/
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (
page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 创建项目 */
async function createProject(
request: APIRequestContext,
headers: Record<string, string>,
suffix: string,
): Promise<string> {
const resp = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
});
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
/** 创建素材库 */
async function createLibrary(
request: APIRequestContext,
headers: Record<string, string>,
projectId: string,
name: string,
kind: "video" | "image" = "video",
): Promise<string> {
const resp = await request.post(`${apiBase}/asset-libraries`, {
headers,
data: { project_id: projectId, name, kind },
});
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
/** 创建素材记录 */
async function createAsset(
request: APIRequestContext,
headers: Record<string, string>,
projectId: string,
libraryId: string,
userId: string,
name: string,
status: string = "ready",
): Promise<string> {
const resp = await request.post(`${apiBase}/assets`, {
headers,
data: {
project_id: projectId,
library_id: libraryId,
name,
storage_key: `uploads/e2e/${Date.now()}/${name}`,
mime_type: "video/mp4",
file_size: 1024000,
status,
uploaded_by_user_id: userId,
metadata: { duration: 15.5, resolution: "1080p" },
},
});
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
/** 在浏览器中设置登录态 */
async function setupAuthInBrowser(
page: import("@playwright/test").Page,
token: string,
user: { id: string; email: string; username: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.username,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("素材库页面 - 完整交互测试", () => {
test.describe.configure({ timeout: 180_000 });
// ─── 页面加载 ──────────────────────────────────────
test("素材库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-load",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "默认视频库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
// 页面布局容器
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 左侧素材库列表
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
// 右侧内容区(上传区 + 筛选 + 素材网格)
await expect(page.locator(".xx-assets-content")).toBeVisible();
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
await expect(page.locator(".xx-assets-filters")).toBeVisible();
// 无错误提示
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
timeout: 5_000,
});
});
// ─── 创建素材库 ────────────────────────────────────
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-create",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "初始库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 点击新建素材库
await page.locator(".xx-asset-library-add").click();
// 弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
await expect(modal).toBeVisible();
// 填写表单
const newLibName = `E2E 新建库 ${Date.now()}`;
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
// 类型选择默认是 video,保持即可
// 监听创建请求
const createPromise = page.waitForResponse(
(resp) =>
resp.url().includes("/asset-libraries") &&
resp.request().method() === "POST",
{ timeout: 10_000 },
);
// 点击创建
await modal.getByRole("button", { name: "创建" }).click();
const resp = await createPromise;
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
// 新素材库应出现在列表中
await expect(
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
).toBeVisible({ timeout: 10_000 });
});
// ─── 切换素材库 ────────────────────────────────────
test("切换不同素材库", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-switch",
);
const projectId = await createProject(request, headers, Date.now().toString());
const videoLibName = "视频素材库 A";
const imageLibName = "图片素材库 B";
const videoLibId = await createLibrary(
request,
headers,
projectId,
videoLibName,
"video",
);
const imageLibId = await createLibrary(
request,
headers,
projectId,
imageLibName,
"image",
);
// 在视频库里创建一个素材
await createAsset(
request,
headers,
projectId,
videoLibId,
userId,
"demo_video.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 点击视频库,应显示素材
const videoLibItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: videoLibName });
await videoLibItem.click({ force: true });
await expect(videoLibItem).toHaveClass(/active/);
// 验证视频素材出现
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
// 点击图片库,应切换且不显示视频
const imageLibItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: imageLibName });
await imageLibItem.click({ force: true });
await expect(imageLibItem).toHaveClass(/active/);
// 空状态或图片库内容
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
});
// ─── 素材搜索 ──────────────────────────────────────
test("素材搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-search",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"搜索测试库",
"video",
);
// 创建两个不同名称的素材
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 确保在测试库中
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "搜索测试库" });
await libItem.click({ force: true });
// 两个素材都应可见
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
// 搜索 apple,只显示 apple
await page.getByPlaceholder("搜索素材名称...").fill("apple");
await expect(page.getByText("apple_clip.mp4")).toBeVisible();
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0);
// 清空搜索,两个都显示
await page.getByPlaceholder("搜索素材名称...").fill("");
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
});
// ─── 筛选类型 ──────────────────────────────────────
test("素材类型筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-filter",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"筛选测试库",
"video",
);
// 创建视频素材
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "筛选测试库" });
await libItem.click({ force: true });
// 素材应可见
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
// 筛选类型下拉存在
const filterSelect = page.locator(".xx-assets-filters-left select").first();
await expect(filterSelect).toBeVisible();
});
// ─── 素材详情/播放 ────────────────────────────────
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-detail",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"详情测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "详情测试库" });
await libItem.click({ force: true });
// 找到素材卡片并点击播放按钮
const assetCard = page
.locator(".xx-asset-card")
.filter({ hasText: "play_test.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 点击播放按钮
await assetCard.locator(".xx-asset-play").click({ force: true });
// 播放弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
await expect(modal).toBeVisible();
// 关闭弹窗
await modal.locator(".ant-modal-close").click();
await expect(modal).not.toBeVisible({ timeout: 5_000 });
});
// ─── 删除素材 ──────────────────────────────────────
test("删除素材 - 带确认对话框", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-delete",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"删除测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "删除测试库" });
await libItem.click({ force: true });
const assetCard = page
.locator(".xx-asset-card")
.filter({ hasText: "to_delete.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 悬停显示删除按钮
await assetCard.hover();
// 点击删除
const deleteBtn = assetCard.locator(".xx-asset-delete");
await expect(deleteBtn).toBeVisible();
await deleteBtn.click({ force: true });
// 确认对话框出现
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
await expect(confirmModal).toBeVisible();
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/assets/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
// 点击确认删除
await confirmModal.getByRole("button", { name: "删除" }).click();
const resp = await deletePromise;
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
// 素材应从列表中消失
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
timeout: 10_000,
});
});
// ─── 批量删除素材 ──────────────────────────────────
test("批量删除素材", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-batch",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
projectId,
"批量删除库",
"video",
);
// 创建多个素材
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "批量删除库" });
await libItem.click({ force: true });
// 所有素材应可见
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("batch_2.mp4")).toBeVisible();
await expect(page.getByText("batch_3.mp4")).toBeVisible();
// 点击全选
const selectAllBtn = page.getByRole("button", { name: "全选" });
await expect(selectAllBtn).toBeVisible();
await selectAllBtn.click();
// 批量操作栏出现
const batchBar = page.locator(".xx-assets-batch-bar");
await expect(batchBar).toBeVisible();
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
// 点击批量删除
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
await expect(batchDeleteBtn).toBeVisible();
await batchDeleteBtn.click();
// 确认对话框
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
await expect(confirmPop).toBeVisible();
// 确认删除
await confirmPop.getByRole("button", { name: "删除" }).click();
// 验证素材已删除(通过 API 确认)
await expect
.poll(
async () => {
const resp = await request.get(`${apiBase}/assets`, {
headers,
params: { library_id: libraryId },
});
if (!resp.ok()) return "error";
const data = await resp.json();
const items = data.items || [];
return items.length;
},
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
)
.toBe(0);
});
// ─── 空状态 ────────────────────────────────────────
test("空素材库展示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"assets-empty",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "空素材库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
.filter({ hasText: "空素材库" });
await libItem.click({ force: true });
// 空状态应显示
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
});
// ─── 未登录访问 ────────────────────────────────────
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/assets");
await expect(page).toHaveURL(/\/login/);
});
});
-554
View File
@@ -1,554 +0,0 @@
/**
* 去重流程 E2E 测试
*
* 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、
* 删除记录、重试去重
*/
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (
page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 在浏览器中设置登录态 */
async function setupAuthInBrowser(
page: import("@playwright/test").Page,
token: string,
user: { id: string; email: string; username: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.username,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("去重流程", () => {
test.describe.configure({ timeout: 180_000 });
// ─── 上传页面加载 ──────────────────────────────────
test("去重上传页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-load",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
// 页面容器
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 页面标题
await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible();
// 描述
await expect(
page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"),
).toBeVisible();
});
// ─── 上传区域展示 ──────────────────────────────────
test("上传区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-upload-zone",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 拖拽上传区域
const uploadZone = page.locator(".dup-upload-zone");
await expect(uploadZone).toBeVisible();
// 上传图标和文字
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
// 格式提示
await expect(
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
).toBeVisible();
// 格式标签
await expect(page.locator(".dup-upload-formats")).toBeVisible();
// 选择文件按钮
const selectBtn = page.getByRole("button", { name: "选择文件" });
await expect(selectBtn).toBeVisible();
// 隐藏的文件 input
const fileInput = page.locator('input[type="file"]');
await expect(fileInput).toHaveCount(1);
});
// ─── 格式说明区 ────────────────────────────────────
test("格式说明和提示区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-info",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 右侧说明区
const infoCard = page.locator(".dup-info-card");
await expect(infoCard).toBeVisible();
// 查重说明
await expect(infoCard.getByText("查重说明")).toBeVisible();
// 支持格式
await expect(infoCard.getByText("支持格式")).toBeVisible();
// 温馨提示
await expect(infoCard.getByText("温馨提示")).toBeVisible();
// 格式标签
await expect(page.locator(".dup-format-tags")).toBeVisible();
});
// ─── 去重记录列表页面 ──────────────────────────────
test("去重记录列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-list",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
// 页面容器
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 页面标题
await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible();
// 筛选按钮
await expect(page.locator(".dup-filter")).toBeVisible();
// 上传查重按钮
await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible();
});
test("去重记录列表 - 空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-list-empty",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 空状态(新用户没有记录)
const emptyState = page.locator(".dup-results-empty");
await expect(emptyState).toBeVisible({ timeout: 10_000 });
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible();
});
test("去重记录列表 - 风险等级筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-filter",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 筛选按钮存在
const filterBtns = page.locator(".dup-filter-btn");
await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险
// 验证按钮文本
await expect(filterBtns.nth(0)).toHaveText("全部");
await expect(filterBtns.nth(1)).toHaveText("低风险");
await expect(filterBtns.nth(2)).toHaveText("中风险");
await expect(filterBtns.nth(3)).toHaveText("高风险");
// 默认选中"全部"
await expect(filterBtns.nth(0)).toHaveClass(/active/);
// 点击低风险
await filterBtns.nth(1).click();
await expect(filterBtns.nth(1)).toHaveClass(/active/);
});
test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-nav",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 点击上传查重按钮
await page.getByRole("button", { name: "上传查重" }).click();
await expect(page).toHaveURL(/\/app\/duplication$/);
await expect(page.locator(".dup-upload-zone")).toBeVisible();
});
// ─── 去重详情页 ────────────────────────────────────
test("去重详情页 - 通过 API 创建测试数据后访问", async ({
page,
request,
}) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-detail",
);
// 先上传一个文件进行查重,获取 record id
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
headers,
multipart: {
file: {
name: "e2e_dup_test.mp4",
mimeType: "video/mp4",
buffer: Buffer.from("e2e duplication test data"),
},
},
});
// 如果查重 API 不可用,跳过详情页测试
if (!uploadResp.ok()) {
console.log(
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`,
);
return;
}
const uploadData = await uploadResp.json();
const recordId = uploadData.id;
expect(recordId, "应返回查重记录 ID").toBeTruthy();
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
// 访问详情页
await page.goto(`/app/duplication/${recordId}`);
// 页面应正常渲染
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 验证无错误
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000,
});
});
// ─── 删除记录 ──────────────────────────────────────
test("去重记录删除 - API 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
headers,
multipart: {
file: {
name: "e2e_dup_delete.mp4",
mimeType: "video/mp4",
buffer: Buffer.from("e2e duplication delete test"),
},
},
});
if (!uploadResp.ok()) {
console.log(
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`,
);
return;
}
const uploadData = await uploadResp.json();
const recordId = uploadData.id;
// 验证记录存在
const listResp = await request.get(`${apiBase}/duplication/records`, {
headers,
});
if (listResp.ok()) {
const records = await listResp.json();
const recordExists = Array.isArray(records)
? records.some((r: { id: string }) => r.id === recordId)
: (records.items || []).some((r: { id: string }) => r.id === recordId);
expect(recordExists, "记录应存在于列表中").toBeTruthy();
}
// 删除记录
const deleteResp = await request.delete(
`${apiBase}/duplication/records/${recordId}`,
{ headers },
);
expect(
deleteResp.ok(),
`删除查重记录应成功: ${deleteResp.status()}`,
).toBeTruthy();
// 验证记录已删除
const listAfterResp = await request.get(`${apiBase}/duplication/records`, {
headers,
});
if (listAfterResp.ok()) {
const recordsAfter = await listAfterResp.json();
const recordStillExists = Array.isArray(recordsAfter)
? recordsAfter.some((r: { id: string }) => r.id === recordId)
: (recordsAfter.items || []).some(
(r: { id: string }) => r.id === recordId,
);
expect(recordStillExists, "记录应已被删除").toBeFalsy();
}
});
test("去重记录删除 - UI 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete-ui",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
headers,
multipart: {
file: {
name: "e2e_dup_ui_delete.mp4",
mimeType: "video/mp4",
buffer: Buffer.from("e2e duplication ui delete test"),
},
},
});
if (!uploadResp.ok()) {
console.log(
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`,
);
return;
}
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录卡片应存在
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
if (cardVisible) {
// 删除按钮存在
const deleteBtn = resultCard.getByRole("button").filter({
hasText: "🗑️",
});
await expect(deleteBtn).toBeVisible();
// 删除按钮点击 - 会触发 confirm 对话框
// 这里我们通过监听 confirm 来确认删除
page.once("dialog", async (dialog) => {
expect(dialog.message()).toContain("确定删除");
await dialog.accept();
});
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/duplication/records/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
).catch(() => null);
await deleteBtn.click();
const deleteResp = await deletePromise;
if (deleteResp) {
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy();
}
}
});
// ─── 重试去重 ──────────────────────────────────────
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-retry",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
headers,
multipart: {
file: {
name: "e2e_dup_retry.mp4",
mimeType: "video/mp4",
buffer: Buffer.from("e2e duplication retry test"),
},
},
});
if (!uploadResp.ok()) {
console.log(
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`,
);
return;
}
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录列表中至少有一条记录
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
if (cardVisible) {
// 验证记录卡片基本结构
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible();
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible();
// 检查是否有重试按钮(失败状态才显示)
// 新上传的记录可能是处理中或完成状态,不一定显示重试按钮
// 这里只验证 API 重试接口可用
const uploadData = await uploadResp.json();
const recordId = uploadData.id;
const retryResp = await request.post(
`${apiBase}/duplication/records/${recordId}/retry`,
{ headers },
);
// 重试接口应返回 2xx 或明确的状态码
expect(retryResp.status()).toBeLessThan(500);
}
});
// ─── 未登录访问 ────────────────────────────────────
test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/duplication");
await expect(page).toHaveURL(/\/login/);
});
test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/duplication/results");
await expect(page).toHaveURL(/\/login/);
});
});
-480
View File
@@ -1,480 +0,0 @@
/**
* 剪辑策划页面 E2E 测试
*
* 覆盖:页面加载、模板列表、模式切换、创建/编辑/删除剪辑计划、
* AI推荐片段、详情页、空状态、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
/** 创建一个编辑模板并返回 id */
async function createEditingTemplate(
request: APIRequestContext,
headers: Record<string, string>,
suffix: string,
): Promise<string> {
const resp = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 剪辑计划 ${suffix}`,
mode: "pip",
estimated_duration: 30,
description: "E2E 测试创建的剪辑计划",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
description: "开场片段",
},
{
segment_order: 2,
duration_min: 10,
duration_max: 20,
material_type: "video",
description: "主体内容",
},
],
tags: ["e2e", "test"],
category: "default",
},
});
expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
test.describe("剪辑策划页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/editing-planner");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("剪辑策划页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("剪辑策划页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-load",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证顶栏存在
await expect(page.locator(".ep-top-bar")).toBeVisible();
// 验证模式栏存在
await expect(page.locator(".ep-mode-bar")).toBeVisible();
// 验证主体区域存在
await expect(page.locator(".ep-main-body")).toBeVisible();
});
test("剪辑模式切换正常显示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-mode",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-mode",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证模式按钮存在(画中画、人物口播等)
const modeBtns = page.locator(".ep-mode-btn");
await expect(modeBtns.first()).toBeVisible();
const modeCount = await modeBtns.count();
expect(modeCount).toBeGreaterThanOrEqual(2);
});
});
test.describe("剪辑计划 - API 操作", () => {
test.describe.configure({ timeout: 120_000 });
test("创建剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-create");
const suffix = Date.now().toString(36);
const templateName = `E2E 创建测试 ${suffix}`;
const response = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: templateName,
mode: "pip",
estimated_duration: 30,
description: "测试创建剪辑计划",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 10,
material_type: "video",
},
],
tags: ["e2e"],
},
});
expect(
response.ok(),
`创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data.id, "应返回模板 ID").toBeTruthy();
expect(data.name).toBe(templateName);
expect(data.mode).toBe("pip");
});
test("列出剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-list");
const suffix = Date.now().toString(36);
// 创建 2 个模板
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 列表测试 A ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
],
},
});
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 列表测试 B ${suffix}`,
mode: "voice_over",
estimated_duration: 60,
segments: [
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
],
},
});
const response = await request.get(`${apiBase}/templates`, { headers });
expect(
response.ok(),
`列出模板应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.templates || [];
expect(Array.isArray(items), "返回应为数组").toBeTruthy();
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2);
});
test("获取剪辑计划详情 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-detail");
const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
const response = await request.get(`${apiBase}/templates/${templateId}`, {
headers,
});
expect(
response.ok(),
`获取详情应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
expect(data.id).toBe(templateId);
expect(data.name).toBeTruthy();
expect(data.mode).toBeTruthy();
});
test("编辑剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-update");
const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
const newName = `更新后的剪辑计划 ${Date.now()}`;
const response = await request.patch(`${apiBase}/templates/${templateId}`, {
headers,
data: {
name: newName,
description: "更新后的描述",
},
});
expect(
response.ok(),
`更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data.name).toBe(newName);
// 验证更新后的数据
const verify = await request.get(`${apiBase}/templates/${templateId}`, {
headers,
});
const verifyData = await verify.json();
expect(verifyData.name).toBe(newName);
});
test("删除剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-delete");
const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
// 删除
const deleteResp = await request.delete(
`${apiBase}/templates/${templateId}`,
{ headers },
);
expect(
[200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy();
// 验证已删除
const getResp = await request.get(`${apiBase}/templates/${templateId}`, {
headers,
});
expect([404, 410]).toContain(getResp.status());
});
test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-badmode");
const response = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: "无效 mode 测试",
mode: "invalid_mode",
estimated_duration: 30,
segments: [],
},
});
expect([400, 422]).toContain(response.status());
});
test("获取不存在的剪辑计划 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-404");
const response = await request.get(
`${apiBase}/templates/nonexistent-template-999`,
{ headers },
);
expect(response.status(), "不存在的模板应返回 404").toBe(404);
});
test("未登录创建剪辑计划 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/templates`, {
data: {
name: "未登录测试",
mode: "pip",
estimated_duration: 30,
segments: [],
},
});
expect([401, 403]).toContain(response.status());
});
});
test.describe("剪辑策划页面 - 已模板数据加载", () => {
test.describe.configure({ timeout: 120_000 });
test("已创建的模板在页面中显示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "ep-data");
const suffix = Date.now().toString(36);
await createEditingTemplate(request, headers, suffix);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-data",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证状态栏存在
await expect(page.locator(".ep-status-bar")).toBeVisible();
});
test("撤销/重做按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-undo",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-undo",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证顶栏按钮存在(撤销、重做、保存、生成等)
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn");
await expect(topBarBtns.first()).toBeVisible();
const btnCount = await topBarBtns.count();
expect(btnCount).toBeGreaterThanOrEqual(2);
});
test("生成按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-gen",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E ep-gen",
});
await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000,
});
// 验证主操作按钮存在
await expect(page.locator(".ep-btn-primary")).toBeVisible();
});
});
-670
View File
@@ -1,670 +0,0 @@
/**
* 作品库页面 E2E 测试
*
* 覆盖:作品库列表加载、状态展示、作品详情、视频播放、下载按钮、
* 删除作品、空状态、筛选
*
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
* 真实的生成流程测试见 core-generation.spec.ts。
*/
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (
page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** Mock 产品数据 */
function mockProducts(count: number, statuses: string[] = ["completed"]) {
const products = [];
for (let i = 0; i < count; i++) {
const status = statuses[i % statuses.length];
products.push({
id: `mock-prod-${Date.now()}-${i}`,
title: `测试作品 ${i + 1}`,
status,
duration_seconds: 30 + i * 10,
resolution: "1080x1920",
file_size: (5 + i) * 1024 * 1024,
duplicate_rate: i * 5,
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
thumbnail_url: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
}
return products;
}
/** 在浏览器中设置登录态 */
async function setupAuthInBrowser(
page: import("@playwright/test").Page,
token: string,
user: { id: string; email: string; username: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.username,
is_email_verified: true,
email_verified: true,
},
},
);
}
/** Mock 产品列表 API */
async function mockProductsApi(
page: import("@playwright/test").Page,
products: unknown[],
) {
await page.route("**/api/v1/products", (route) => {
const method = route.request().method();
const url = route.request().url();
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
// 列表
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }),
});
return;
}
// 单个产品详情
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
if (method === "GET" && detailMatch) {
const productId = detailMatch[1];
const product = (products as Array<{ id: string }>).find(
(p) => p.id === productId,
);
if (product) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(product),
});
} else {
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "Not found" }),
});
}
return;
}
// 删除
if (method === "DELETE" && detailMatch) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "deleted" }),
});
return;
}
// 下载链接
if (method === "GET" && url.includes("/download-url")) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
url: "https://example.com/download.mp4",
expires_at: new Date().toISOString(),
}),
});
return;
}
route.continue();
});
}
test.describe("作品库页面", () => {
test.describe.configure({ timeout: 180_000 });
// ─── 页面加载 ──────────────────────────────────────
test("作品库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-load",
);
const products = mockProducts(3, ["completed", "processing", "failed"]);
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
// 页面容器
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
});
// 页面标题
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible();
// 筛选栏
await expect(page.locator(".xx-products-filters")).toBeVisible();
// 卡片网格
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 10_000,
});
// 作品卡片存在
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
});
});
// ─── 状态展示 ──────────────────────────────────────
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-status",
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
});
// 等待卡片加载
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
});
// 验证各状态标签存在
const completedCard = page
.locator(".xx-product-card")
.filter({ hasText: "已完成作品" });
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
"已完成",
);
const processingCard = page
.locator(".xx-product-card")
.filter({ hasText: "处理中作品" });
await expect(
processingCard.locator(".xx-product-status.processing"),
).toHaveText("处理中");
const failedCard = page
.locator(".xx-product-card")
.filter({ hasText: "失败作品" });
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText(
"失败",
);
});
// ─── 作品详情页 ────────────────────────────────────
test("作品详情页打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-detail",
);
const products = mockProducts(1, ["completed"]);
products[0].title = "详情页测试作品";
const productId = products[0].id;
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
// 直接访问详情页
await page.goto(`/app/products/${productId}`);
// 验证 URL
await expect(page).toHaveURL(/\/app\/products\//);
// 页面应正常渲染(无错误)
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000,
});
});
// ─── 视频播放 ──────────────────────────────────────
test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-play",
);
const products = mockProducts(1, ["completed"]);
products[0].title = "播放测试作品";
products[0].video_url = "https://example.com/test-video.mp4";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
// 点击作品卡片打开播放
const productCard = page
.locator(".xx-product-card")
.filter({ hasText: "播放测试作品" });
await expect(productCard).toBeVisible();
// 点击播放按钮
await productCard.locator(".xx-product-play").click({ force: true });
// 播放弹窗出现 - 验证有视频元素或播放器容器
// (通过 Mock 的 video_urlvideo 元素应能渲染)
const videoEl = page.locator("video");
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
// 或弹窗容器可见
const modalVisible = await page
.locator(".ant-modal-content")
.filter({ hasText: "播放测试作品" })
.isVisible()
.catch(() => false);
expect(videoVisible || modalVisible).toBeTruthy();
});
// ─── 下载按钮 ──────────────────────────────────────
test("下载按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-download",
);
const products = mockProducts(1, ["completed"]);
products[0].title = "下载测试作品";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
const productCard = page
.locator(".xx-product-card")
.filter({ hasText: "下载测试作品" });
await expect(productCard).toBeVisible();
// 下载按钮存在且可用(已完成状态)
const downloadBtn = productCard.getByRole("button", { name: "下载" });
await expect(downloadBtn).toBeVisible();
await expect(downloadBtn).not.toBeDisabled();
});
test("处理中作品下载按钮禁用", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-disabled",
);
const products = mockProducts(1, ["processing"]);
products[0].title = "处理中下载测试";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
const productCard = page
.locator(".xx-product-card")
.filter({ hasText: "处理中下载测试" });
await expect(productCard).toBeVisible();
// 处理中的作品下载按钮应禁用
const downloadBtn = productCard.getByRole("button", { name: "下载" });
await expect(downloadBtn).toBeVisible();
const isDisabled = await downloadBtn.isDisabled();
const hasDisabled = await downloadBtn.evaluate(
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
);
expect(isDisabled || hasDisabled).toBeTruthy();
});
// ─── 删除作品 ──────────────────────────────────────
test("删除作品 - API 调用正确", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-delete",
);
const products = mockProducts(1, ["completed"]);
products[0].title = "待删除作品";
let deleteCalled = false;
let deletedId = "";
await page.route("**/api/v1/products", (route) => {
const method = route.request().method();
const url = route.request().url();
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }),
});
return;
}
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
if (method === "DELETE" && detailMatch) {
deleteCalled = true;
deletedId = detailMatch[1];
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "deleted" }),
});
return;
}
if (method === "GET" && detailMatch) {
const productId = detailMatch[1];
const product = products.find((p) => p.id === productId);
route.fulfill({
status: product ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(product || { detail: "Not found" }),
});
return;
}
route.continue();
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
const productCard = page
.locator(".xx-product-card")
.filter({ hasText: "待删除作品" });
await expect(productCard).toBeVisible();
// 验证 DELETE API 存在于 products API 中
// 我们通过检查实际 API 来确认删除功能可用
// (mock 只是为了测试 UI 行为)
expect(deleteCalled).toBe(false); // 初始状态未调用
expect(deletedId).toBe("");
});
test("删除作品 API 端点存在", async ({ request }) => {
const { headers } = await createAuthedUser(request, "products-del-api");
// 测试删除不存在的产品,验证 API 端点存在
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
headers,
});
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
// 404 表示资源不存在但端点存在
expect(resp.status(), "删除 API 端点应存在").not.toBe(405);
expect([200, 204, 403, 404]).toContain(resp.status());
});
// ─── 空状态 ────────────────────────────────────────
test("空状态展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-empty",
);
// Mock 空列表
await mockProductsApi(page, []);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
});
// 空状态应显示
await expect(page.locator(".xx-products-empty")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible();
});
// ─── 搜索筛选 ──────────────────────────────────────
test("作品搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-search",
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
// 两个作品都可见
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("香蕉推广视频")).toBeVisible();
// 搜索"苹果"
await page.getByPlaceholder("搜索成片名称...").fill("苹果");
await expect(page.getByText("苹果宣传视频")).toBeVisible();
await expect(page.getByText("香蕉推广视频")).toHaveCount(0);
// 清空搜索
await page.getByPlaceholder("搜索成片名称...").fill("");
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
});
test("作品状态筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-filter-status",
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
// 两个都可见
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("处理中筛选")).toBeVisible();
// 状态筛选下拉存在
const selects = page.locator(".xx-products-filters-left select");
const count = await selects.count();
if (count >= 2) {
// 第2个 select 是状态筛选
await selects.nth(1).selectOption({ label: "已完成" });
await expect(page.getByText("已完成筛选")).toBeVisible();
await expect(page.getByText("处理中筛选")).toHaveCount(0);
}
});
// ─── 批量操作 ──────────────────────────────────────
test("批量选择和批量操作栏", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-batch",
);
const products = mockProducts(3, ["completed"]);
products[0].title = "批量测试 1";
products[1].title = "批量测试 2";
products[2].title = "批量测试 3";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
});
// 三张卡片
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
});
// 点击第一张卡片的复选框
const firstCard = page.locator(".xx-product-card").first();
const checkbox = firstCard.locator(".xx-product-card-checkbox");
await expect(checkbox).toBeVisible();
await checkbox.click();
// 批量操作栏应出现
const batchBar = page.locator(".xx-products-batch-bar");
await expect(batchBar).toBeVisible({ timeout: 5_000 });
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
// 批量按钮存在
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
// 取消选择
await batchBar.getByRole("button", { name: "取消选择" }).click();
await expect(batchBar).not.toBeVisible({ timeout: 3_000 });
});
// ─── 未登录访问 ────────────────────────────────────
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/products");
await expect(page).toHaveURL(/\/login/);
});
});
-474
View File
@@ -1,474 +0,0 @@
/**
* 个人设置页面 E2E 测试
*
* 覆盖:设置页面加载、个人信息展示、修改昵称/头像、修改密码、
* 账号安全区域、退出登录按钮、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("个人设置页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/profile");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("个人设置页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("设置页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-load",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
});
test("页面标题存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-title",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-title",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证页面包含"个人设置"标题
const heading = page.getByRole("heading", { name: /个人设置/ });
await expect(heading.first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe("个人设置 - 个人信息展示", () => {
test.describe.configure({ timeout: 120_000 });
test("个人信息卡片展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-info",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-info",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证设置卡片存在
await expect(page.locator(".xx-settings-card")).toBeVisible();
});
test("用户名、邮箱字段展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-fields",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-fields",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证表单字段存在
const fields = page.locator(".xx-settings-field");
await expect(fields.first()).toBeVisible();
const fieldCount = await fields.count();
expect(fieldCount).toBeGreaterThanOrEqual(2);
});
test("用户名标签和输入框存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-username",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-username",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证用户名标签
const usernameLabel = page.locator(".xx-settings-label").filter({
hasText: "用户名",
});
await expect(usernameLabel).toBeVisible();
// 验证邮箱标签
const emailLabel = page.locator(".xx-settings-label").filter({
hasText: "邮箱",
});
await expect(emailLabel).toBeVisible();
});
test("显示名称字段可编辑", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-dispname",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-dispname",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 查找显示名称输入框
const displayNameField = page.locator(".xx-settings-field").filter({
has: page.locator(".xx-settings-label", { hasText: "显示名称" }),
});
if (await displayNameField.isVisible()) {
const input = displayNameField.locator("input");
if (await input.isVisible()) {
// 验证输入框存在且可输入
await expect(input).toBeVisible();
const initialValue = await input.inputValue();
await input.fill("新的显示名称");
await expect(input).toHaveValue("新的显示名称");
// 恢复原值
await input.fill(initialValue);
}
}
});
});
test.describe("个人设置 - 修改密码", () => {
test.describe.configure({ timeout: 120_000 });
test("修改密码 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(request, "profile-chpwd");
const newPassword = "NewPass123456!";
const response = await request.post(`${apiBase}/auth/change-password`, {
headers,
data: {
old_password: PASSWORD,
new_password: newPassword,
},
});
// 修改密码可能成功或接口不存在
expect(
response.status() < 500,
`修改密码应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy();
// 如果成功,用新密码登录验证
if (response.ok()) {
const loginResp = await loginWithRetry(request, email, newPassword);
expect(loginResp.ok(), "新密码应能登录").toBeTruthy();
}
});
test("修改密码 - 旧密码错误反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-badpwd");
const response = await request.post(`${apiBase}/auth/change-password`, {
headers,
data: {
old_password: "WrongOldPass123!",
new_password: "NewPass123456!",
},
});
// 如果接口存在,应该返回 400/401
if (response.status() < 500 && response.status() >= 400) {
expect([400, 401]).toContain(response.status());
}
// 接口不存在(404)也正常
});
test("修改密码 - 新密码太弱反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-weakpwd");
const response = await request.post(`${apiBase}/auth/change-password`, {
headers,
data: {
old_password: PASSWORD,
new_password: "123",
},
});
if (response.status() < 500 && response.status() >= 400) {
expect([400, 422]).toContain(response.status());
}
});
test("未登录修改密码 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/change-password`, {
data: {
old_password: "old",
new_password: "new",
},
});
expect([401, 403, 404]).toContain(response.status());
});
});
test.describe("个人设置 - 账号安全", () => {
test.describe.configure({ timeout: 120_000 });
test("获取当前用户信息 - 正向", async ({ request }) => {
const { headers, email, username } = await createAuthedUser(
request,
"profile-me",
);
const response = await request.get(`${apiBase}/auth/me`, { headers });
expect(
response.ok(),
`获取用户信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
expect(data.email).toBe(email);
expect(data.username).toBe(username);
});
test("账号安全区域提示信息存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-security",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-security",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证通知区域存在
const notice = page.locator(".xx-settings-notice");
await expect(notice).toBeVisible();
});
});
test.describe("个人设置 - 退出登录", () => {
test.describe.configure({ timeout: 120_000 });
test("登出 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(request, "profile-logout");
const response = await request.post(`${apiBase}/auth/logout`, {
headers,
});
expect(
response.ok(),
`登出应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
// 登出后 token 应失效
const meResp = await request.get(`${apiBase}/auth/me`, { headers });
expect([401, 403]).toContain(meResp.status());
});
test("登出后页面跳转登录页", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-logout-ui",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-logout-ui",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 清除 localStorage 模拟登出
await page.evaluate(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("auth-storage");
});
// 刷新页面应该重定向到登录页
await page.reload();
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
});
});
test.describe("个人设置 - 保存按钮", () => {
test.describe.configure({ timeout: 120_000 });
test("保存按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-save",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E profile-save",
});
await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000,
});
// 验证按钮存在
const button = page.getByRole("button", { name: /保存|暂未开放/ });
await expect(button.first()).toBeVisible({ timeout: 5_000 });
});
});
-298
View File
@@ -1,298 +0,0 @@
/**
* 注册页面 E2E 测试
*
* 覆盖:页面渲染、表单验证、成功注册、跳转链接
* 每个测试独立,使用随机邮箱避免冲突。
*/
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
test.describe("注册页面", () => {
test.describe.configure({ timeout: 120_000 });
// ─── 页面渲染 ──────────────────────────────────────
test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => {
await page.goto("/register");
// 品牌标识
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
// 标题/描述
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
// 表单字段
await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.getByLabel("用户名")).toBeVisible();
await expect(page.getByLabel("密码")).toBeVisible();
await expect(page.getByLabel("确认密码")).toBeVisible();
// 提交按钮
await expect(
page.locator("button[type='submit']").filter({ hasText: "注册" }),
).toBeVisible();
});
// ─── 表单验证 ──────────────────────────────────────
test("空提交 - 显示必填错误", async ({ page }) => {
await page.goto("/register");
// 直接点击注册按钮
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示必填错误
await expect(page.getByText("请输入邮箱")).toBeVisible();
await expect(page.getByText("请输入用户名")).toBeVisible();
await expect(page.getByText("请输入密码")).toBeVisible();
await expect(page.getByText("请确认密码")).toBeVisible();
});
test("无效邮箱格式 - 显示格式错误", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("邮箱").fill("not-an-email");
await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示邮箱格式错误
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
});
test("密码太短 - 显示长度错误", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"));
await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill("123");
await page.getByLabel("确认密码").fill("123");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示密码长度错误
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
});
test("确认密码不一致 - 显示不一致错误", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"));
await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill("Different123!");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示密码不一致错误
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
});
test("用户名为空 - 显示必填错误", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"));
await page.getByLabel("用户名").fill("");
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await expect(page.getByText("请输入用户名")).toBeVisible();
});
// ─── 成功注册 ──────────────────────────────────────
test("成功注册 - 提交有效表单", async ({ page, request }) => {
const email = uniqueEmail("reg-ui-ok");
const username = uniqueUsername("reguiok");
await page.goto("/register");
await page.getByLabel("邮箱").fill(email);
await page.getByLabel("用户名").fill(username);
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
// 监听注册请求
const registerResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/auth/register") &&
resp.request().method() === "POST",
{ timeout: 15_000 },
);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
const resp = await registerResponse;
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
// 注册成功后应跳转到登录页或显示成功消息
// 页面应停留在可识别的状态(成功提示或跳转)
await expect
.poll(
async () => {
const url = page.url();
// 可能跳转到 login,也可能在当前页显示成功消息
if (url.includes("/login")) return "redirected";
const hasSuccess = await page.getByText(/注册成功/).isVisible();
return hasSuccess ? "success_msg" : url;
},
{ timeout: 10_000 },
)
.toMatch(/redirected|success_msg/);
});
test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => {
const email = uniqueEmail("reg-ui-dup");
const username1 = uniqueUsername("reguidup1");
const username2 = uniqueUsername("reguidup2");
// 先通过 API 注册一个账号
const firstReg = await request.post(`${apiBase}/auth/register`, {
data: {
email,
password: PASSWORD,
username: username1,
display_name: "User 1",
},
});
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy();
// 再在 UI 上用相同邮箱注册
await page.goto("/register");
await page.getByLabel("邮箱").fill(email);
await page.getByLabel("用户名").fill(username2);
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示错误提示(通过 antd message 或表单错误)
await expect
.poll(
async () => {
// 检查是否有错误消息
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
return hasError ? "error_shown" : "waiting";
},
{ timeout: 10_000 },
)
.toBe("error_shown");
});
// ─── 跳转链接 ──────────────────────────────────────
test("跳转到登录页的链接", async ({ page }) => {
await page.goto("/register");
await page.getByRole("link", { name: "立即登录" }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByLabel("邮箱")).toBeVisible();
});
test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => {
await page.goto("/login");
await page.getByRole("link", { name: "立即注册" }).click();
await expect(page).toHaveURL(/\/register/);
});
test("登录页有忘记密码链接", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible();
await page.getByRole("link", { name: /忘记密码/ }).click();
await expect(page).toHaveURL(/\/forgot-password/);
});
// ─── 路由守卫 - 已登录用户访问注册页 ──────────────
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({
page,
request,
}) => {
const email = uniqueEmail("reg-auth");
const username = uniqueUsername("regauth");
// 注册
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
});
// 登录
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), "登录应成功").toBeTruthy();
const loginData = await login.json();
// 设置登录态
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token: loginData.access_token,
user: {
id: loginData.user_id,
user_id: loginData.user_id,
email,
username,
display_name: username,
is_email_verified: true,
email_verified: true,
},
},
);
await page.goto("/register");
// 注册页对已登录用户也可访问(注册页是公开页面)
// 验证页面正常渲染
await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
});
});
-600
View File
@@ -1,600 +0,0 @@
/**
* 订阅完整流程 E2E 测试
*
* 覆盖:订阅套餐页、套餐卡片展示、升级套餐交互、账单列表页、
* 取消订阅(确认流程)、自动续费切换、支付流程、未登录重定向
*
* 注意:subscription.spec.ts 已覆盖 API 基础测试和路由守卫,
* 本文件专注于页面交互和完整流程。
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("订阅套餐页 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("订阅套餐页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-load",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
});
test("套餐卡片网格展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-cards",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-cards",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
// 验证套餐卡片存在
const planCards = page.locator(".xx-plan-card");
await expect(planCards.first()).toBeVisible({ timeout: 10_000 });
const cardCount = await planCards.count();
expect(cardCount).toBeGreaterThanOrEqual(2);
});
test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-cardinfo",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-cardinfo",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-plan-card").first();
await expect(firstCard).toBeVisible({ timeout: 10_000 });
// 验证价格区域存在
await expect(firstCard.locator(".xx-plan-price")).toBeVisible();
// 验证特性列表存在
await expect(firstCard.locator(".xx-features")).toBeVisible();
// 验证订阅按钮存在
await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible();
});
test("推荐套餐有特殊标识", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-recommended",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-recommended",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
// 验证有推荐标签
const featuredCard = page.locator(".xx-plan-card.featured");
if (await featuredCard.isVisible({ timeout: 5_000 })) {
await expect(featuredCard.locator(".xx-badge")).toBeVisible();
}
});
});
test.describe("订阅套餐页 - 升级交互", () => {
test.describe.configure({ timeout: 120_000 });
test("点击升级套餐按钮跳转升级页", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-upgrade-btn",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-upgrade-btn",
});
await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000,
});
// 点击一个订阅按钮
const subscribeBtn = page.locator(".xx-subscribe-btn").first();
if (await subscribeBtn.isVisible({ timeout: 10_000 })) {
await subscribeBtn.click();
// 可能跳转到升级页或打开支付弹窗
const url = page.url();
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
expect(
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
).toBeTruthy();
}
});
test("升级套餐升级页面可访问", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-upgrade-page",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-upgrade-page",
});
await page.goto("/app/subscription/upgrade");
// 升级页面应该可访问(可能跳转到订阅页或显示升级内容)
await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 });
});
});
test.describe("订阅 - 账单列表页", () => {
test.describe.configure({ timeout: 120_000 });
test("账单页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-billing-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-billing-load",
});
await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000,
});
});
test("账单概览区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-billing-overview",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-billing-overview",
});
await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000,
});
// 验证概览区域存在
const overview = page.locator(".xx-billing-overview");
if (await overview.isVisible({ timeout: 5_000 })) {
await expect(overview).toBeVisible();
// 验证套餐信息
await expect(overview.locator(".xx-overview-item").first()).toBeVisible();
}
});
test("自动续费开关存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-autorenew-ui",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E sub-autorenew-ui",
});
await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000,
});
// 验证自动续费区域存在
const autoRenew = page.locator(".xx-billing-auto-renew");
if (await autoRenew.isVisible({ timeout: 5_000 })) {
await expect(autoRenew).toBeVisible();
// 验证开关组件存在
await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible();
}
});
test("账单记录 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-bills-api");
const response = await request.get(
`${apiBase}/subscription/billing-records`,
{ headers },
);
expect(
response.ok(),
`获取账单记录应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
});
});
test.describe("订阅 - 自动续费切换", () => {
test.describe.configure({ timeout: 120_000 });
test("切换自动续费 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-toggle-api");
// 关闭自动续费
const disableResp = await request.post(
`${apiBase}/subscription/toggle-auto-renew`,
{
headers,
data: { enabled: false },
},
);
expect(
disableResp.ok(),
`关闭自动续费应成功: ${await disableResp.text()}`,
).toBeTruthy();
// 重新开启自动续费
const enableResp = await request.post(
`${apiBase}/subscription/toggle-auto-renew`,
{
headers,
data: { enabled: true },
},
);
expect(
enableResp.ok(),
`开启自动续费应成功: ${await enableResp.text()}`,
).toBeTruthy();
});
test("切换自动续费 - 无效参数反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-toggle-bad");
const response = await request.post(
`${apiBase}/subscription/toggle-auto-renew`,
{
headers,
data: {},
},
);
expect([400, 422]).toContain(response.status());
});
});
test.describe("订阅 - 取消订阅", () => {
test.describe.configure({ timeout: 120_000 });
test("取消订阅 API - 免费用户反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-cancel-api");
const response = await request.post(`${apiBase}/subscription/cancel`, {
headers,
});
// 免费用户取消订阅可能返回错误
if (!response.ok()) {
const data = await response.json();
expect(data.error?.message || data.detail || data.message).toBeTruthy();
}
// 如果成功了也没问题(某些实现可能允许)
expect(response.status() < 500).toBeTruthy();
});
test("未登录取消订阅 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/cancel`);
expect([401, 403]).toContain(response.status());
});
});
test.describe("订阅 - 套餐变更", () => {
test.describe.configure({ timeout: 120_000 });
test("升级到 Pro 套餐 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-upgrade-api");
const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers,
data: {
target_plan_id: "pro",
billing_cycle: "monthly",
},
});
expect(
response.ok(),
`升级套餐应成功: ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data).toBeTruthy();
});
test("获取当前订阅信息 - 验证升级", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-current-api");
// 先升级
await request.post(`${apiBase}/subscription/change-plan`, {
headers,
data: {
target_plan_id: "pro",
billing_cycle: "monthly",
},
});
// 获取当前订阅
const response = await request.get(`${apiBase}/subscription/current`, {
headers,
});
expect(
response.ok(),
`获取订阅信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
expect(data.status, "应返回 status").toBeTruthy();
});
test("降级到 Standard 套餐 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-downgrade-api");
// 先升级到 Pro
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
headers,
data: {
target_plan_id: "pro",
billing_cycle: "monthly",
},
});
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy();
// 降级到 Standard
const downgrade = await request.post(
`${apiBase}/subscription/change-plan`,
{
headers,
data: {
target_plan_id: "standard",
billing_cycle: "monthly",
},
},
);
expect(
downgrade.status() < 500,
`降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`,
).toBeTruthy();
});
test("切换到无效套餐 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-badplan-api");
const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers,
data: {
target_plan_id: "nonexistent_plan",
billing_cycle: "monthly",
},
});
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
expect(response.status()).toBeLessThan(500);
});
});
test.describe("订阅 - 支付流程", () => {
test.describe.configure({ timeout: 120_000 });
test("创建支付订单 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-pay-api");
const response = await request.post(`${apiBase}/subscription/create-order`, {
headers,
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
});
// 创建支付订单可能成功或接口不存在
expect(
response.status() < 500,
`创建订单应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy();
if (response.ok()) {
const data = await response.json();
// 应返回订单 ID 或支付链接
expect(data.order_id || data.payment_url || data).toBeTruthy();
}
});
test("未登录创建订单 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/create-order`, {
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
});
expect([401, 403, 404]).toContain(response.status());
});
});
test.describe("订阅 - 套餐列表 API", () => {
test.describe.configure({ timeout: 120_000 });
test("获取套餐列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-plans-api");
const response = await request.get(`${apiBase}/subscription/plans`, {
headers,
});
// 套餐列表可能需要登录也可能公开
if (response.ok()) {
const data = await response.json();
const plans = Array.isArray(data) ? data : data.plans || data.items;
if (Array.isArray(plans)) {
expect(plans.length).toBeGreaterThanOrEqual(2);
}
}
// 如果需要登录也正常
expect(response.status() < 500).toBeTruthy();
});
test("未登录获取套餐列表", async ({ request }) => {
const response = await request.get(`${apiBase}/subscription/plans`);
// 套餐列表可能公开也可能需要登录
expect(response.status() < 500).toBeTruthy();
});
});
-628
View File
@@ -1,628 +0,0 @@
/**
* 模板库页面 E2E 测试
*
* 覆盖:模板列表加载、分类切换、模板详情、收藏/取消收藏、
* 使用模板入口、搜索功能、我的模板tab、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("模板库页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/templates");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("模板库页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("模板库页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-load",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
});
test("模板库头部和搜索栏存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-head",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-head",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
// 验证搜索框
const searchInput = page.locator(".xx-templates-search-input");
await expect(searchInput).toBeVisible({ timeout: 10_000 });
});
test("分类切换按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-cat",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-cat",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
// 验证分类按钮存在
const categoryBtns = page.locator(".xx-templates-cat-btn");
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 });
const count = await categoryBtns.count();
expect(count).toBeGreaterThan(0);
});
});
test.describe("模板库 - 模板展示", () => {
test.describe.configure({ timeout: 120_000 });
test("模板卡片展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-cards");
const suffix = Date.now().toString(36);
// 创建一个模板
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 模板展示 ${suffix}`,
mode: "pip",
estimated_duration: 30,
description: "测试模板展示",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
tags: ["e2e", "展示"],
category: "种草",
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-cards",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
// 等待模板卡片出现
const cards = page.locator(".xx-template-card");
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
const count = await cards.count();
expect(count).toBeGreaterThan(0);
});
test("模板卡片包含名称和类型", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-info");
const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `模板信息测试 ${suffix}`,
mode: "voice_over",
estimated_duration: 60,
description: "测试信息展示",
segments: [
{
segment_order: 1,
duration_min: 10,
duration_max: 30,
material_type: "video",
},
],
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-info",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-template-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证信息区域存在
const info = firstCard.locator(".xx-template-info");
await expect(info).toBeVisible();
}
});
test("模板预览弹窗功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-preview");
const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `预览测试模板 ${suffix}`,
mode: "pip",
estimated_duration: 30,
description: "预览测试描述",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
description: "片段一",
},
],
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-preview",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
// 点击第一个模板卡片打开预览
const firstCard = page.locator(".xx-template-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
await firstCard.click();
// 预览弹窗应该出现
const modal = page.locator(".xx-template-modal");
if (await modal.isVisible({ timeout: 5_000 })) {
await expect(modal).toBeVisible();
// 验证预览内容存在
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
}
}
});
});
test.describe("模板库 - 分类切换", () => {
test.describe.configure({ timeout: 120_000 });
test("切换分类筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-switch",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-switch",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
const categoryBtns = page.locator(".xx-templates-cat-btn");
const firstBtn = categoryBtns.first();
if (await firstBtn.isVisible({ timeout: 10_000 })) {
await firstBtn.click();
// 验证按钮被选中
await expect(firstBtn).toHaveClass(/active/);
}
});
});
test.describe("模板库 - 搜索", () => {
test.describe.configure({ timeout: 120_000 });
test("搜索框可输入并筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-search");
const suffix = Date.now().toString(36);
const templateName = `E2E 搜索测试模板 ${suffix}`;
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: templateName,
mode: "pip",
estimated_duration: 30,
description: "搜索测试专用模板",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-search",
});
await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000,
});
const searchInput = page.locator(".xx-templates-search-input");
if (await searchInput.isVisible({ timeout: 10_000 })) {
await searchInput.fill(suffix);
// 验证页面正常响应
await expect(page.locator(".xx-templates-page")).toBeVisible();
}
});
});
test.describe("模板库 - API 操作", () => {
test.describe.configure({ timeout: 120_000 });
test("获取模板列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-api-list");
const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `API 列表测试 ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
const response = await request.get(`${apiBase}/templates`, { headers });
expect(
response.ok(),
`获取模板列表应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.templates || [];
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy();
expect(items.length).toBeGreaterThan(0);
});
test("收藏/取消收藏模板 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-fav");
const suffix = Date.now().toString(36);
// 创建模板
const createResp = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `收藏测试 ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
const templateId = created.id;
// 收藏
const favResp = await request.post(
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
// 收藏可能成功或接口不存在
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy();
// 取消收藏
const unfavResp = await request.delete(
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
});
test("获取模板详情 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-api-detail");
const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `详情测试 ${suffix}`,
mode: "voice_over",
estimated_duration: 60,
description: "详情测试描述",
segments: [
{
segment_order: 1,
duration_min: 10,
duration_max: 30,
material_type: "video",
description: "测试片段",
},
],
},
});
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
const detailResp = await request.get(
`${apiBase}/templates/${created.id}`,
{ headers },
);
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(created.id);
expect(detail.name).toBe(`详情测试 ${suffix}`);
});
test("使用模板接口 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-use");
const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `使用测试 ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
// 使用模板(生成)
const genResp = await request.post(
`${apiBase}/templates/${created.id}/generate`,
{ headers, data: {} },
);
// 生成可能成功或返回业务错误
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy();
});
test("未登录获取模板列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/templates`);
expect([401, 403]).toContain(response.status());
});
});
test.describe("模板库 - 我的模板 Tab", () => {
test.describe.configure({ timeout: 120_000 });
test("我的模板页面可访问", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-my",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-my",
});
await page.goto("/app/my-templates");
await expect(page.locator(".mt-page")).toBeVisible({
timeout: 20_000,
});
});
test("我的模板页面展示已创建的模板", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "tpl-my-data");
const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `我的模板测试 ${suffix}`,
mode: "pip",
estimated_duration: 30,
description: "我的模板展示测试",
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: "video",
},
],
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E tpl-my-data",
});
await page.goto("/app/my-templates");
await expect(page.locator(".mt-page")).toBeVisible({
timeout: 20_000,
});
// 验证卡片容器存在
const cards = page.locator(".mt-card");
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
});
});
-538
View File
@@ -1,538 +0,0 @@
/**
* 标题库完整交互 E2E 测试
*
* 覆盖:创建新标题(完整流程)、编辑标题、删除标题、分类/标签筛选、
* 搜索功能、批量操作、空状态
*
* 注意:core-titles.spec.ts 已覆盖基础加载和API创建/列表,
* 本文件专注于完整交互和边界场景。
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
/** 创建一个标题并返回 id */
async function createTitle(
request: APIRequestContext,
headers: Record<string, string>,
suffix: string,
overrides: Record<string, unknown> = {},
): Promise<string> {
const resp = await request.post(`${apiBase}/titles`, {
headers,
data: {
name: `E2E 标题 ${suffix}`,
text: `这是一个 E2E 测试标题内容 ${suffix}`,
category: "default",
tags: ["e2e", "test"],
...overrides,
},
});
expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
return data.id;
}
test.describe("标题库 - 空状态", () => {
test.describe.configure({ timeout: 120_000 });
test("新用户标题页面显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"title-empty",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-empty",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
// 新用户应该能看到页面主体
await expect(page.locator(".xx-titles-page")).toBeVisible();
});
});
test.describe("标题库 - 搜索功能", () => {
test.describe.configure({ timeout: 120_000 });
test("搜索框存在且可输入", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "title-search");
const suffix = Date.now().toString(36);
await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-search",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
// 查找搜索框
const searchInput = page.locator(
"input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']",
);
if (await searchInput.first().isVisible({ timeout: 10_000 })) {
await searchInput.first().fill("测试搜索");
await expect(searchInput.first()).toHaveValue("测试搜索");
}
});
});
test.describe("标题库 - API 完整操作", () => {
test.describe.configure({ timeout: 120_000 });
test("创建标题 - 完整参数", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-create-full");
const suffix = Date.now().toString(36);
const response = await request.post(`${apiBase}/titles`, {
headers,
data: {
name: `完整参数测试 ${suffix}`,
text: `这是一个完整参数的标题测试 ${suffix}`,
category: "种草",
tags: ["e2e", "完整测试", "种草"],
status: "active",
},
});
expect(
response.ok(),
`创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data.id, "应返回标题 ID").toBeTruthy();
expect(data.name).toBe(`完整参数测试 ${suffix}`);
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`);
});
test("编辑标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update");
const titleId = await createTitle(request, headers, Date.now().toString(36));
const newName = `更新后的标题 ${Date.now()}`;
const newText = "这是更新后的标题内容";
const response = await request.patch(`${apiBase}/titles/${titleId}`, {
headers,
data: {
name: newName,
text: newText,
category: "知识",
},
});
expect(
response.ok(),
`更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
const data = await response.json();
expect(data.name).toBe(newName);
// 验证更新
const verify = await request.get(`${apiBase}/titles/${titleId}`, {
headers,
});
const verifyData = await verify.json();
expect(verifyData.name).toBe(newName);
expect(verifyData.text).toBe(newText);
});
test("删除标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-delete");
const titleId = await createTitle(request, headers, Date.now().toString(36));
// 删除
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
headers,
});
expect(
[200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy();
// 验证已删除
const getResp = await request.get(`${apiBase}/titles/${titleId}`, {
headers,
});
expect([404, 410]).toContain(getResp.status());
});
test("批量导入标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-batch");
const suffix = Date.now().toString(36);
const titles = [
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
];
const response = await request.post(`${apiBase}/titles/batch-import`, {
headers,
data: { titles },
});
// 批量导入可能成功或接口不存在
expect(
response.status() < 500,
`批量导入应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy();
if (response.ok()) {
const data = await response.json();
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
}
});
test("创建标题 - 名称为空反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-empty-name");
const response = await request.post(`${apiBase}/titles`, {
headers,
data: {
name: "",
text: "有内容但名称为空",
category: "default",
},
});
expect([400, 422]).toContain(response.status());
});
test("创建标题 - 缺少必要字段反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-missing");
const response = await request.post(`${apiBase}/titles`, {
headers,
data: {
name: "缺少 text 字段",
// 缺少 text 字段
},
});
expect([400, 422]).toContain(response.status());
});
test("获取不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-404");
const response = await request.get(
`${apiBase}/titles/nonexistent-title-999`,
{ headers },
);
expect(response.status(), "不存在的标题应返回 404").toBe(404);
});
test("更新不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update-404");
const response = await request.patch(
`${apiBase}/titles/nonexistent-title-999`,
{
headers,
data: { name: "不存在的标题", text: "测试" },
},
);
expect(response.status(), "更新不存在的标题应返回 404").toBe(404);
});
test("删除不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-del-404");
const response = await request.delete(
`${apiBase}/titles/nonexistent-title-999`,
{ headers },
);
expect(
[404, 200, 204].includes(response.status()),
"删除不存在的标题应返回 404 或幂等 2xx",
).toBeTruthy();
});
test("未登录创建标题 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/titles`, {
data: {
name: "未登录测试",
text: "未登录创建标题",
category: "default",
},
});
expect([401, 403]).toContain(response.status());
});
test("未登录删除标题 - 反向", async ({ request }) => {
const response = await request.delete(`${apiBase}/titles/some-id`);
expect([401, 403]).toContain(response.status());
});
});
test.describe("标题库 - 分类/标签筛选", () => {
test.describe.configure({ timeout: 120_000 });
test("标题分类 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-cat");
// 获取标题列表,检查分类字段
const response = await request.get(`${apiBase}/titles`, { headers });
expect(response.ok()).toBeTruthy();
const data = await response.json();
const items = data.items || data.titles || [];
expect(Array.isArray(items)).toBeTruthy();
// 如果有标题,验证有分类字段
if (items.length > 0) {
expect(items[0].category !== undefined).toBeTruthy();
}
});
test("按分类筛选标题", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-filter-cat");
const suffix = Date.now().toString(36);
// 创建不同分类的标题
await request.post(`${apiBase}/titles`, {
headers,
data: {
name: `种草标题 ${suffix}`,
text: "种草内容",
category: "种草",
},
});
await request.post(`${apiBase}/titles`, {
headers,
data: {
name: `知识标题 ${suffix}`,
text: "知识内容",
category: "知识",
},
});
// 按分类筛选
const response = await request.get(`${apiBase}/titles`, {
headers,
params: { category: "种草" },
});
// 筛选可能支持也可能不支持
expect(
response.ok(),
`筛选请求应成功,实际: ${response.status()}`,
).toBeTruthy();
});
});
test.describe("标题库 - 页面交互", () => {
test.describe.configure({ timeout: 120_000 });
test("标题卡片展示完整信息", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "title-card");
const suffix = Date.now().toString(36);
await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-card",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-title-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证标题文本
const titleText = firstCard.locator(".xx-title-card-text");
if (await titleText.isVisible()) {
await expect(titleText).toBeVisible();
}
// 验证统计信息
const titleStat = firstCard.locator(".xx-title-card-stat");
if (await titleStat.isVisible()) {
await expect(titleStat).toBeVisible();
}
}
});
test("标题卡片可点击查看详情", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "title-detail");
const suffix = Date.now().toString(36);
await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-detail",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-title-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
await firstCard.click();
// 点击后页面应该有响应(可能是弹窗或跳转)
await expect(page.locator(".xx-titles-page")).toBeVisible();
}
});
});
test.describe("标题库 - 批量操作", () => {
test.describe.configure({ timeout: 120_000 });
test("多选复选框存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "title-batch-ui");
const suffix = Date.now().toString(36);
// 创建多个标题
await createTitle(request, headers, `${suffix}-1`);
await createTitle(request, headers, `${suffix}-2`);
await createTitle(request, headers, `${suffix}-3`);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E title-batch-ui",
});
await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000,
});
// 检查是否有批量操作相关 UI
const checkboxes = page.locator(".xx-title-card input[type='checkbox']");
// 页面正常加载即可,批量操作是可选功能
await expect(page.locator(".xx-titles-page")).toBeVisible();
});
});
-504
View File
@@ -1,504 +0,0 @@
/**
* 声音克隆页面 E2E 测试
*
* 覆盖:克隆页面加载、上传区域展示、克隆列表、克隆状态展示、
* 克隆详情、删除克隆、重试克隆、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("声音克隆页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voice-clone");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("声音克隆页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("声音克隆页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-load",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
});
test("页面标题和描述存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-title",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-title",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 验证页面标题包含"克隆"或"音色"相关文字
const pageTitle = page.getByRole("heading", { level: 1 });
// 只要页面正常加载即可,标题可能在 PageHead 组件中
await expect(page.locator(".vc-page")).toBeVisible();
});
test("克隆新音色按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-newbtn",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-newbtn",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 验证克隆新音色按钮存在
const cloneBtn = page.getByRole("button", { name: /克隆新音色|新建|创建/ });
// 按钮可能在不同位置,只要页面加载成功即可
await expect(page.locator(".vc-page")).toBeVisible();
});
});
test.describe("声音克隆 - 空状态", () => {
test.describe.configure({ timeout: 120_000 });
test("无克隆音色时显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-empty",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-empty",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 新用户应该显示空状态
const emptyState = page.locator(".vc-empty");
if (await emptyState.isVisible({ timeout: 10_000 })) {
await expect(emptyState.locator(".vc-empty-title")).toBeVisible();
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible();
}
});
});
test.describe("声音克隆 - API 操作", () => {
test.describe.configure({ timeout: 120_000 });
test("获取克隆列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-list");
const response = await request.get(`${apiBase}/voice-clones`, {
headers,
});
expect(
response.ok(),
`获取克隆列表应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.voice_clones || [];
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy();
});
test("创建音色克隆 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-create");
const suffix = Date.now().toString(36);
// 创建一个克隆任务(上传音频文件)
const response = await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `E2E 克隆音色 ${suffix}`,
description: "E2E 测试创建的克隆音色",
file: {
name: `sample_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("fake audio data for e2e test"),
},
},
});
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误
// 只要不是 500 错误即可
expect(
response.status() < 500,
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy();
if (response.ok()) {
const data = await response.json();
expect(data.id, "应返回克隆 ID").toBeTruthy();
expect(data.status, "应返回状态").toBeTruthy();
}
});
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-detail");
// 先获取列表看看有没有数据
const listResp = await request.get(`${apiBase}/voice-clones`, {
headers,
});
expect(listResp.ok()).toBeTruthy();
const listData = await listResp.json();
const items = listData.items || listData.voice_clones || [];
if (items.length > 0) {
const cloneId = items[0].id;
const detailResp = await request.get(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(cloneId);
}
// 如果没有数据,测试也通过(新用户正常情况)
});
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-del");
// 先创建一个克隆
const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `待删除 ${suffix}`,
file: {
name: `del_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("delete me"),
},
},
});
if (createResp.ok()) {
const created = await createResp.json();
const cloneId = created.id;
// 删除
const deleteResp = await request.delete(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect(
[200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy();
// 验证已删除
const getResp = await request.get(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect([404, 410]).toContain(getResp.status());
}
// 如果创建失败(比如音频格式问题),测试也通过
});
test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-retry");
// 先获取列表
const listResp = await request.get(`${apiBase}/voice-clones`, {
headers,
});
expect(listResp.ok()).toBeTruthy();
const listData = await listResp.json();
const items = listData.items || listData.voice_clones || [];
// 找一个失败状态的克隆进行重试
const failedClone = items.find(
(item: { status: string }) => item.status === "failed",
);
if (failedClone) {
const retryResp = await request.post(
`${apiBase}/voice-clones/${failedClone.id}/retry`,
{ headers },
);
expect(
retryResp.ok(),
`重试应返回 2xx,实际: ${retryResp.status()}`,
).toBeTruthy();
}
// 如果没有失败的克隆,测试通过
});
test("获取不存在的克隆详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-404");
const response = await request.get(
`${apiBase}/voice-clones/nonexistent-clone-999`,
{ headers },
);
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
});
test("未登录获取克隆列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`);
expect([401, 403]).toContain(response.status());
});
test("未登录创建克隆 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/voice-clones`, {
multipart: {
name: "未登录测试",
file: {
name: "test.wav",
mimeType: "audio/wav",
buffer: Buffer.from("test"),
},
},
});
expect([401, 403]).toContain(response.status());
});
});
test.describe("声音克隆 - 克隆列表展示", () => {
test.describe.configure({ timeout: 120_000 });
test("克隆卡片网格布局展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-grid",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-grid",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 验证网格容器或空状态存在
const grid = page.locator(".vc-grid");
const empty = page.locator(".vc-empty");
// 至少一个应该可见
const gridVisible = await grid.isVisible().catch(() => false);
const emptyVisible = await empty.isVisible().catch(() => false);
expect(gridVisible || emptyVisible).toBeTruthy();
});
test("克隆状态标签展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } =
await createAuthedUser(request, "vc-status");
const suffix = Date.now().toString(36);
// 创建一个克隆任务
await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `E2E 状态测试 ${suffix}`,
file: {
name: `status_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("status test data"),
},
},
});
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-status",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 如果有卡片,验证状态标签存在
const cards = page.locator(".vc-card");
if ((await cards.count()) > 0) {
const firstCard = cards.first();
const statusPill = firstCard.locator(".vc-status-pill");
if (await statusPill.isVisible()) {
await expect(statusPill).toBeVisible();
}
}
});
});
test.describe("声音克隆 - 上传区域", () => {
test.describe.configure({ timeout: 120_000 });
test("克隆弹窗上传区域可打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-upload",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-upload",
});
await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
});
// 尝试点击克隆新音色按钮
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
if (await cloneBtn.isVisible()) {
await cloneBtn.click();
// 弹窗应该出现
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
if (await modal.first().isVisible({ timeout: 5_000 })) {
await expect(modal.first()).toBeVisible();
}
}
});
});
-432
View File
@@ -1,432 +0,0 @@
/**
* 音色库页面 E2E 测试
*
* 覆盖:音色列表加载、预设音色展示、我的音色展示、音色详情查看、
* 音色播放试听、搜索/筛选功能、创建自定义音色入口、未登录重定向
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url());
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
});
await route.fulfill({ response });
});
};
function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
}
function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: APIRequestContext,
email: string,
password: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000));
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
});
}
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label);
const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
});
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json();
return {
headers: { Authorization: `Bearer ${loginData.access_token}` },
email,
username,
userId: regData.user_id,
accessToken: loginData.access_token,
};
}
/** 设置页面认证状态(localStorage */
async function setupAuth(
page: Page,
token: string,
user: { id: string; email: string; username: string; display_name: string },
) {
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
);
},
{
token,
user: {
id: user.id,
user_id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
is_email_verified: true,
email_verified: true,
},
},
);
}
test.describe("音色库页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voices");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("音色库页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 });
test("音色库页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-load",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-load",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
});
test("页面头部和搜索栏存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-head",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-head",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 验证搜索框存在
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe("音色库 - 预设音色", () => {
test.describe.configure({ timeout: 120_000 });
test("预设音色列表 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-preset");
const response = await request.get(`${apiBase}/voices/preset`, {
headers,
});
// 预设音色接口可能返回数组或包装对象
expect(
response.ok(),
`获取预设音色应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.voices || data;
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy();
});
test("预设音色卡片在页面中展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-cards",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-cards",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 等待音色卡片加载(预设音色应该有数据)
const voiceCards = page.locator(".xx-voice-card");
// 等待至少一张卡片出现
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 });
const count = await voiceCards.count();
expect(count).toBeGreaterThan(0);
});
test("音色卡片包含名称和信息", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-info",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-info",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-voice-card").first();
await expect(firstCard).toBeVisible({ timeout: 15_000 });
// 验证音色名称存在
await expect(firstCard.locator(".xx-voice-name")).toBeVisible();
// 验证头像存在
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible();
});
});
test.describe("音色库 - 我的克隆音色", () => {
test.describe.configure({ timeout: 120_000 });
test("克隆音色列表 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-cln-api");
const response = await request.get(`${apiBase}/voice-clones`, {
headers,
});
expect(
response.ok(),
`获取克隆音色应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json();
const items = data.items || data.voice_clones || [];
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy();
});
test("空状态展示 - 无克隆音色时", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-empty",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-empty",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 切换到"我的克隆"tab(如果有tab的话)
const clonedTab = page.getByText("我的克隆").first();
if (await clonedTab.isVisible()) {
await clonedTab.click();
}
// 页面至少应该是可访问的
await expect(page.locator(".xx-voices-page")).toBeVisible();
});
test("创建克隆音色入口存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-create",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-create",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮)
const createBtn = page.getByRole("button", {
name: /克隆|新建|创建|\+/,
});
// 不强制断言一定存在,因为不同页面结构可能不同
// 只验证页面正常加载即可
await expect(page.locator(".xx-voices-page")).toBeVisible();
});
});
test.describe("音色库 - 搜索和筛选", () => {
test.describe.configure({ timeout: 120_000 });
test("搜索框存在且可输入", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-search",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-search",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 查找搜索输入框
const searchInput = page.locator(
"input[placeholder*='搜索'], input[type='search'], .xx-voices-search input",
);
const firstInput = searchInput.first();
if (await firstInput.isVisible({ timeout: 5_000 })) {
await firstInput.fill("测试搜索");
await expect(firstInput).toHaveValue("测试搜索");
}
});
test("性别/语言筛选选项存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-filter",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-filter",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
// 验证筛选相关元素存在(可能是下拉选择器或标签)
const filterSelect = page.locator("select, .xx-voices-filter");
// 页面正常加载即通过
await expect(page.locator(".xx-voices-page")).toBeVisible();
});
});
test.describe("音色库 - 播放试听", () => {
test.describe.configure({ timeout: 120_000 });
test("音色播放按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-play",
);
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E voice-play",
});
await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000,
});
const firstCard = page.locator(".xx-voice-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证播放按钮存在
const playBtn = firstCard.locator(".xx-voice-play-btn");
if (await playBtn.isVisible()) {
await expect(playBtn).toBeVisible();
}
}
});
});
test.describe("音色库 - API 边界测试", () => {
test.describe.configure({ timeout: 120_000 });
test("未登录获取预设音色 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voices/preset`);
// 预设音色可能不需要登录,也可能需要,两种情况都接受
// 但如果需要登录,应返回 401/403
if (!response.ok()) {
expect([401, 403]).toContain(response.status());
}
});
test("未登录获取克隆音色 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`);
expect([401, 403]).toContain(response.status());
});
test("获取不存在的克隆音色详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-404");
const response = await request.get(
`${apiBase}/voice-clones/nonexistent-999`,
{ headers },
);
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
});
});
+415 -2
View File
@@ -14,7 +14,10 @@
"axios": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-router-dom": "^6.24.0",
"recharts": "^3.8.1",
"zod": "^3.23.8",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -1412,6 +1415,42 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
@@ -1785,6 +1824,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
@@ -1954,6 +2005,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1999,6 +2113,12 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
@@ -2812,6 +2932,15 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2929,6 +3058,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -2973,6 +3223,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
@@ -3135,6 +3391,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.47.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -3405,6 +3671,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@@ -3951,8 +4223,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -4014,6 +4284,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5563,6 +5842,52 @@
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
"version": "7.79.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
"integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5605,6 +5930,36 @@
"react-dom": ">=16.8"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -5619,6 +5974,21 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -5626,6 +5996,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -6009,6 +6385,12 @@
"node": ">=12.22"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6242,6 +6624,28 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -6576,6 +6980,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+3 -40
View File
@@ -263,14 +263,10 @@ export const uploadAssetDirect = async (data: {
);
directForm.append("file", data.file);
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
// 使用 XMLHttpRequest 以获取上传进度fetch 不支持)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(prepared.method, prepared.upload_url);
// 超时 10 分钟
xhr.timeout = 10 * 60 * 1000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100));
@@ -280,43 +276,10 @@ export const uploadAssetDirect = async (data: {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
// 解析 OSS 返回的 XML 错误信息
let ossError = "";
try {
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/);
const msgMatch = xhr.responseText.match(
/<Message>([^<]+)<\/Message>/,
);
if (codeMatch || msgMatch) {
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`;
}
} catch {
// 无法解析响应体
}
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`;
console.error("[OSS Upload] 直传失败:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
status: xhr.status,
statusText: xhr.statusText,
});
reject(new Error(detail));
reject(new Error(`OSS direct upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => {
console.error("[OSS Upload] 网络错误:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
});
reject(new Error("OSS 上传网络错误,请检查网络连接"));
};
xhr.ontimeout = () => {
console.error("[OSS Upload] 上传超时:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
});
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"));
};
xhr.onerror = () => reject(new Error("OSS direct upload failed"));
xhr.send(directForm);
});
+1 -20
View File
@@ -122,27 +122,8 @@ apiClient.interceptors.response.use(
}
// 提取后端返回的错误信息(detail / message / msg
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
const data = error.response?.data;
const rawServerMsg = data?.detail || data?.message || data?.msg;
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
// 嵌套对象:递归提取
if (typeof obj.message === "object" && obj.message !== null)
return safeExtractString(obj.message);
if (typeof obj.msg === "object" && obj.msg !== null)
return safeExtractString(obj.msg);
return JSON.stringify(val);
}
return "";
};
const serverMsg = safeExtractString(rawServerMsg);
const serverMsg = data?.detail || data?.message || data?.msg;
let handled = false;
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
+4 -8
View File
@@ -68,14 +68,10 @@ export interface CreateTitleRequest {
/** 获取当前用户的所有标题 */
export const getTitles = async (): Promise<TitleItem[]> => {
const response = await apiClient.get<
{ items: BackendTitleResponse[] } | BackendTitleResponse[]
>("/titles");
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
const items = Array.isArray(response.data)
? response.data
: response.data.items || [];
return items.map(toTitleItem);
const response = await apiClient.get<{ items: BackendTitleResponse[] }>(
"/titles",
);
return (response.data.items || []).map(toTitleItem);
};
/** 创建标题 */
+6 -71
View File
@@ -69,20 +69,11 @@ const inferKind = (mimeType: string): AssetKind => {
return "image";
};
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
/** 根据 quality_score 推断前端状态 */
const inferStatus = (
score?: number,
classificationStatus?: string,
assetStatus?: string,
): { status: StatusType; label: string } => {
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
if (assetStatus === "ready") {
if (score == null) return { status: "info", label: "待诊断" };
if (score >= 70) return { status: "ok", label: "合格" };
if (score >= 40) return { status: "warn", label: "待优化" };
return { status: "bad", label: "不合格" };
}
// 素材未就绪:classification 正在处理中
if (
classificationStatus === "processing" ||
classificationStatus === "pending"
@@ -115,7 +106,6 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
const { status, label } = inferStatus(
item.quality_score ?? undefined,
item.classification_status ?? undefined,
item.status ?? undefined,
);
const metadata = item.metadata || {};
const kind = inferKind(item.mime_type || "");
@@ -229,16 +219,7 @@ const AssetCard: React.FC<{
onToggle: () => void;
onDiagnose: () => void;
onPlay: () => void;
onDelete: () => void;
}> = ({
asset,
selected,
diagnosing,
onToggle,
onDiagnose,
onPlay,
onDelete,
}) => (
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
<div
className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`}
onClick={onToggle}
@@ -269,24 +250,6 @@ const AssetCard: React.FC<{
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
@@ -385,8 +348,6 @@ const AssetLibrary: React.FC = () => {
/* 状态 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterType, setFilterType] = useState<string>("all");
@@ -460,11 +421,11 @@ const AssetLibrary: React.FC = () => {
const handleUpload = async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`);
return;
return false;
}
if (!effectiveLibId) {
message.warning("请先选择或创建一个素材库");
return;
return false;
}
setUploading(true);
@@ -483,14 +444,12 @@ const AssetLibrary: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "";
console.error("[handleUpload] 上传失败:", err);
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`);
// 错误时延迟关闭弹窗,让用户能看到错误提示
await new Promise((r) => setTimeout(r, 1500));
} finally {
setUploading(false);
setUploadProgress(0);
}
return false;
};
/* 新建素材库 */
@@ -543,24 +502,6 @@ const AssetLibrary: React.FC = () => {
};
/* 批量删除 */
/* 单个素材删除 */
const handleSingleDelete = async (assetId: string) => {
try {
await deleteAsset(assetId);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
// 从选中集合中移除
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(assetId);
return next;
});
message.success("素材已删除");
} catch {
message.error("删除失败,请重试");
}
};
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds);
let successCount = 0;
@@ -694,12 +635,7 @@ const AssetLibrary: React.FC = () => {
<div className="xx-assets-content">
{/* 上传区域 */}
<Upload.Dragger
beforeUpload={(file) => {
// 同步返回 false 阻止 antd 默认上传行为
// 异步上传由 handleUpload 处理
handleUpload(file as File);
return false;
}}
beforeUpload={handleUpload}
showUploadList={false}
multiple
accept="video/*,image/*"
@@ -820,7 +756,6 @@ const AssetLibrary: React.FC = () => {
onToggle={() => toggleSelect(asset.id)}
onDiagnose={() => handleDiagnose(asset)}
onPlay={() => setPlayingAsset(asset)}
onDelete={() => handleSingleDelete(asset.id)}
/>
))}
</div>
-29
View File
@@ -282,35 +282,6 @@
transform: scale(1.1);
}
/* 删除按钮 */
.xx-asset-delete {
position: absolute;
bottom: var(--space-sm, 8px);
right: var(--space-sm, 8px);
width: 28px;
height: 28px;
border-radius: var(--radius-full, 999px);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
color: #fff;
font-size: 13px;
cursor: pointer;
opacity: 0;
transition: var(--transition-all, all 0.2s ease);
z-index: 2;
}
.xx-asset-card:hover .xx-asset-delete {
opacity: 1;
}
.xx-asset-delete:hover {
background: rgba(255, 77, 79, 0.85);
transform: scale(1.1);
}
/* 选中态 */
.xx-asset-card-selected {
border-color: var(--primary-color) !important;
@@ -298,13 +298,6 @@ const EditingPlanner: React.FC = () => {
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode);
// 切换纯单类型模式时,自动转换所有已有片段的类型
if (mode === "voice_over") {
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })));
} else if (mode === "pip") {
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })));
}
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
};
const handleClipSelect = (clipId: string) => {
@@ -9,7 +9,6 @@ import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
} from "react";
import type { ClipData, ClipType } from "../types";
@@ -55,34 +54,18 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
right: 0,
});
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"], // voice_pip / one_take / 默认
[currentMode],
);
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice";
if (currentMode === "pip") return "pip";
return "voice";
}, [currentMode]);
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType);
const [addType, setAddType] = useState<ClipType>("voice");
const [addDuration, setAddDuration] = useState<number>(5);
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType);
}
}, [currentMode, addType, availableTypes, defaultAddType]);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] =
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"]; // voice_pip 或默认
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
@@ -109,14 +92,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
const handleTogglePicker = () => {
if (!showAddPicker) {
// 打开面板时,默认选中当前模式下的第一个可用类型
const defaultType =
currentMode === "pip"
? "pip"
: currentMode === "voice_over"
? "voice"
: "voice";
setAddType(defaultType);
updatePickerPosition();
}
setShowAddPicker((v) => !v);
+16 -120
View File
@@ -25,11 +25,7 @@ import {
} from "@ant-design/icons";
import type { AssetItem } from "@/api/assets";
import { getAssets, getAssetLibraries } from "@/api/assets";
import {
createEditPlan,
generateEditPlan,
updateEditPlan,
} from "@/api/editPlans";
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
@@ -128,9 +124,9 @@ const GeneratePage: React.FC = () => {
/* ── 标题 ── */
const [title, setTitle] = useState("");
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
queryKey: ["generate-titles"],
queryFn: () => getTitles(),
staleTime: 30_000,
staleTime: 60_000,
});
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
useEffect(() => {
@@ -518,9 +514,6 @@ const GeneratePage: React.FC = () => {
source_edit_plan_id: editPlanId || undefined,
});
// 后端要求计划处于 editing 状态才能触发渲染,自动转换状态
await updateEditPlan(plan.id, { status: "editing" });
await generateEditPlan(plan.id);
const poll = async () => {
@@ -540,8 +533,7 @@ const GeneratePage: React.FC = () => {
if (data.plan_status === "failed") {
setGenerating(false);
// 提取后端返回的错误详情,便于排查
// 注意:后端返回的 error_message/error/message 可能是对象而非字符串
const rawMsg =
const errorMsg =
data.error_message ||
data.error ||
data.message ||
@@ -549,21 +541,6 @@ const GeneratePage: React.FC = () => {
(c: { status: string }) => c.status === "failed",
)?.error_message ||
"视频生成失败,请联系管理员或重试";
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构)
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
if (obj.message && typeof obj.message === "object")
return safeExtract(obj.message);
return JSON.stringify(val);
}
return String(val ?? "");
};
const errorMsg = safeExtract(rawMsg);
console.error("[生成失败] planId:", plan.id, "响应:", data);
setGenerateError(errorMsg);
message.error(errorMsg);
@@ -601,36 +578,19 @@ const GeneratePage: React.FC = () => {
const axiosErr = err as {
response?: {
data?: {
message?: string | object;
error?: string | object;
detail?: string | object;
msg?: string | object;
message?: string;
error?: string;
detail?: string;
msg?: string;
};
};
message?: string;
};
// 安全提取错误消息:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
const extractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
// 嵌套对象:递归提取
if (typeof obj.message === "object" && obj.message !== null)
return extractString(obj.message);
if (typeof obj.msg === "object" && obj.msg !== null)
return extractString(obj.msg);
return JSON.stringify(val);
}
return "";
};
const backendMsg =
extractString(axiosErr.response?.data?.message) ||
extractString(axiosErr.response?.data?.error) ||
extractString(axiosErr.response?.data?.detail) ||
extractString(axiosErr.response?.data?.msg) ||
axiosErr.response?.data?.message ||
axiosErr.response?.data?.error ||
axiosErr.response?.data?.detail ||
axiosErr.response?.data?.msg ||
axiosErr.message ||
"";
console.error(
@@ -639,71 +599,9 @@ const GeneratePage: React.FC = () => {
"完整错误:",
axiosErr,
);
// 确保 errorMsg 一定是字符串(后端可能返回 {code, message} 嵌套对象)
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
// 嵌套对象:递归提取
if (typeof obj.message === "object")
return safeExtractErr(obj.message);
return JSON.stringify(val);
}
return String(val ?? "");
};
const rawError = safeExtractErr(backendMsg);
// 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念)
const translateError = (msg: string): string => {
if (!msg) return "生成失败,请检查网络后重试或联系管理员";
// 状态机相关错误
if (
msg.includes("editing") ||
msg.includes("draft") ||
msg.includes("状态")
) {
return "正在准备生成,请稍候再试";
}
// 参数校验错误
if (
msg.includes("template_id") ||
msg.includes("not found") ||
msg.includes("不存在")
) {
return "所选模板或素材不可用,请重新选择";
}
if (
msg.includes("asset") &&
(msg.includes("not found") || msg.includes("missing"))
) {
return "素材数据异常,请返回素材库重新检查";
}
// 网络/超时
if (
msg.includes("timeout") ||
msg.includes("network") ||
msg.includes("ECONN")
) {
return "网络连接超时,请检查网络后重试";
}
// 配额/限制
if (
msg.includes("quota") ||
msg.includes("limit") ||
msg.includes("exceed")
) {
return "已达到生成次数上限,请稍后再试或联系客服";
}
// 兜底:返回原始消息(如果已经是中文人话)或默认提示
if (msg.length > 0 && msg.length < 100 && !msg.includes("{"))
return msg;
return "生成失败,请稍后重试或联系管理员";
};
const finalMsg = translateError(rawError);
setGenerateError(finalMsg);
message.error(finalMsg);
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
setGenerateError(errorMsg);
message.error(errorMsg);
}
}, [
title,
@@ -1604,9 +1502,7 @@ const GeneratePage: React.FC = () => {
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{typeof generateError === "string"
? generateError
: JSON.stringify(generateError)}
{generateError}
</Text>
</div>
</div>
+182 -84
View File
@@ -2,11 +2,10 @@
* V21
* 220px+ 3
* AI //
* APIGET/POST/PUT/DELETE /titles
* 使 mock API
*/
import React, { useMemo, useState, useCallback } from "react";
import { Modal as AntModal, message, Popconfirm } from "antd";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
PlusOutlined,
SearchOutlined,
@@ -20,13 +19,6 @@ import {
FileTextOutlined,
} from "@ant-design/icons";
import { Button, Input, Select } from "@/components/ui";
import {
getTitles,
createTitle,
updateTitle,
deleteTitle,
type TitleItem,
} from "@/api/titles";
import "./titles.css";
/* ============================================================
@@ -64,16 +56,143 @@ const MOCK_CATEGORIES: CategoryItem[] = [
{ id: "cat-5", name: "教育学习", count: 2 },
];
/** 后端 TitleItem → 前端 TitleData 映射 */
const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
});
const MOCK_TITLES: TitleData[] = [
{
id: "t-1",
content: "这家隐藏在巷子里的小店,味道绝了!",
type: "hot",
industry: "food",
usageCount: 128,
isFavorited: true,
createdAt: "2026-06-28",
},
{
id: "t-2",
content: "2026 年最值得入手的 5 款蓝牙耳机",
type: "hot",
industry: "tech",
usageCount: 96,
isFavorited: false,
createdAt: "2026-06-27",
},
{
id: "t-3",
content: "周末在家做了一道妈妈的味道",
type: "normal",
industry: "food",
usageCount: 42,
isFavorited: false,
createdAt: "2026-06-26",
},
{
id: "t-4",
content: "用 AI 帮我写了一周的小红书文案,效果惊人",
type: "hot",
industry: "tech",
usageCount: 215,
isFavorited: true,
createdAt: "2026-06-25",
},
{
id: "t-5",
content: "今天穿了一套被路人要链接的衣服",
type: "creative",
industry: "beauty",
usageCount: 67,
isFavorited: false,
createdAt: "2026-06-24",
},
{
id: "t-6",
content: "分享我的早起 5 点俱乐部 30 天打卡体验",
type: "normal",
industry: "education",
usageCount: 38,
isFavorited: true,
createdAt: "2026-06-23",
},
{
id: "t-7",
content: "这个平价面霜居然比大牌还好用?",
type: "hot",
industry: "beauty",
usageCount: 183,
isFavorited: false,
createdAt: "2026-06-22",
},
{
id: "t-8",
content: "一个人的旅行也可以很精彩",
type: "normal",
industry: "travel",
usageCount: 55,
isFavorited: false,
createdAt: "2026-06-21",
},
{
id: "t-9",
content: "考研上岸!我的备考时间管理方法全公开",
type: "hot",
industry: "education",
usageCount: 147,
isFavorited: true,
createdAt: "2026-06-20",
},
{
id: "t-10",
content: "把旧 T 恤改造成时尚单品,零成本!",
type: "creative",
industry: "beauty",
usageCount: 29,
isFavorited: false,
createdAt: "2026-06-19",
},
{
id: "t-11",
content: "这家咖啡馆的氛围感也太好了吧",
type: "normal",
industry: "food",
usageCount: 74,
isFavorited: false,
createdAt: "2026-06-18",
},
{
id: "t-12",
content: "手机摄影技巧:拍出电影感画面",
type: "creative",
industry: "tech",
usageCount: 61,
isFavorited: false,
createdAt: "2026-06-17",
},
{
id: "t-13",
content: "带娃旅行必备清单,少带一样都崩溃",
type: "hot",
industry: "travel",
usageCount: 109,
isFavorited: false,
createdAt: "2026-06-16",
},
{
id: "t-14",
content: "30 天学会一门新语言?我的实验记录",
type: "creative",
industry: "education",
usageCount: 33,
isFavorited: false,
createdAt: "2026-06-15",
},
{
id: "t-15",
content: "今天做了一道让全家惊艳的菜",
type: "normal",
industry: "food",
usageCount: 48,
isFavorited: false,
createdAt: "2026-06-14",
},
];
/* ============================================================
*
@@ -248,48 +367,12 @@ const TitleCard: React.FC<{
*
* ============================================================ */
const TitleLibrary: React.FC = () => {
const queryClient = useQueryClient();
/* 分类数据 */
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
/* 标题数据 — 真实 API */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
});
const titles: TitleData[] = useMemo(
() => apiTitles.map(toTitleData),
[apiTitles],
);
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: () => message.error("创建标题失败"),
});
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) =>
updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: () => message.error("更新标题失败"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: () => message.error("删除标题失败"),
});
/* 标题数据 */
const [titles, setTitles] = useState<TitleData[]>(MOCK_TITLES);
/* 筛选 */
const [searchText, setSearchText] = useState("");
@@ -380,9 +463,13 @@ const TitleLibrary: React.FC = () => {
searchText,
]);
/* 收藏切换(暂不支持,待后端 API */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线");
/* 收藏切换 */
const handleToggleFavorite = useCallback((id: string) => {
setTitles((prev) =>
prev.map((t) =>
t.id === id ? { ...t, isFavorited: !t.isFavorited } : t,
),
);
}, []);
/* 复制 */
@@ -406,13 +493,15 @@ const TitleLibrary: React.FC = () => {
message.warning("标题内容不能为空");
return;
}
if (editingId) {
updateMutation.mutate({ id: editingId, content: editText.trim() });
}
setTitles((prev) =>
prev.map((t) =>
t.id === editingId ? { ...t, content: editText.trim() } : t,
),
);
setEditingId(null);
setEditText("");
message.success("标题已更新");
}, [editingId, editText, updateMutation]);
}, [editingId, editText]);
const handleCancelEdit = useCallback(() => {
setEditingId(null);
@@ -420,13 +509,10 @@ const TitleLibrary: React.FC = () => {
}, []);
/* 删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id);
message.success("标题已删除");
},
[deleteMutation],
);
const handleDelete = useCallback((id: string) => {
setTitles((prev) => prev.filter((t) => t.id !== id));
message.success("标题已删除");
}, []);
/* 新建分类 */
const handleCreateCategory = () => {
@@ -461,14 +547,20 @@ const TitleLibrary: React.FC = () => {
message.warning("请输入标题内容");
return;
}
createMutation.mutate(newTitleContent.trim(), {
onSuccess: () => {
setCreateTitleModalOpen(false);
setNewTitleContent("");
setNewTitleType("normal");
message.success("标题创建成功");
},
});
const title: TitleData = {
id: `t-${Date.now()}`,
content: newTitleContent.trim(),
type: newTitleType,
industry: "general",
usageCount: 0,
isFavorited: false,
createdAt: new Date().toISOString().slice(0, 10),
};
setTitles((prev) => [title, ...prev]);
setCreateTitleModalOpen(false);
setNewTitleContent("");
setNewTitleType("normal");
message.success("标题创建成功");
};
/* AI 生成标题 */
@@ -497,11 +589,17 @@ const TitleLibrary: React.FC = () => {
/* 采纳 AI 生成的标题 */
const handleAdoptAITitle = (text: string) => {
createMutation.mutate(text, {
onSuccess: () => {
message.success("标题已采纳并添加到标题库");
},
});
const title: TitleData = {
id: `t-${Date.now()}`,
content: text,
type: "creative",
industry: "general",
usageCount: 0,
isFavorited: false,
createdAt: new Date().toISOString().slice(0, 10),
};
setTitles((prev) => [title, ...prev]);
message.success("标题已采纳并添加到标题库");
};
/* 复制 AI 生成的标题 */
+2 -737
View File
@@ -30,7 +30,6 @@ import {
ReloadOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import { Modal, Upload, message } from "antd";
import { Button, Input, Select, Tooltip } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
@@ -45,12 +44,6 @@ import {
toVoiceClone,
type VoiceClone,
} from "@/api/voiceClone";
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
import {
uploadAssetDirect,
getAssetLibraries,
createAsset,
} from "@/api/assets";
import CloneModal from "@/components/voice/CloneModal";
import "./voices.css";
@@ -600,41 +593,6 @@ const CloneCardSkeleton: React.FC = () => (
</div>
);
/* ── 辅助函数 ─────────────────────────────────────────── */
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const getAudioDuration = (file: File): Promise<number> =>
new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener("loadedmetadata", () => {
resolve(audio.duration);
URL.revokeObjectURL(url);
});
audio.addEventListener("error", () => {
resolve(0);
URL.revokeObjectURL(url);
});
audio.src = url;
});
const buildVoiceMetadata = (data: {
gender?: string;
description?: string;
duration?: number;
}): Record<string, unknown> => {
const metadata: Record<string, unknown> = {};
if (data.gender) metadata.gender = data.gender;
if (data.description) metadata.description = data.description;
if (data.duration) metadata.duration = Math.round(data.duration);
return metadata;
};
/* ============================================================
*
* ============================================================ */
@@ -655,27 +613,6 @@ const VoiceLibrary: React.FC = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
const [cloneModalOpen, setCloneModalOpen] = useState(false);
/* ── 上传音频弹窗状态 ── */
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadName, setUploadName] = useState("");
const [uploadGender, setUploadGender] = useState<VoiceGender>("female");
const [uploadDesc, setUploadDesc] = useState("");
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
/* ── AI 配音弹窗状态 ── */
const [ttsOpen, setTtsOpen] = useState(false);
const [ttsText, setTtsText] = useState("");
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
const [ttsSpeed, setTtsSpeed] = useState(1.0);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
const [ttsStatus, setTtsStatus] = useState<
"idle" | "synthesizing" | "done" | "error"
>("idle");
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdSeq;
setToasts((prev) => [...prev, { id, message, type }]);
@@ -708,130 +645,6 @@ const VoiceLibrary: React.FC = () => {
},
});
/* ── 上传音频 mutation ── */
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File;
name: string;
gender: VoiceGender;
description: string;
}) => {
setUploadProgress(0);
try {
/* 获取或创建默认配音素材库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
const lib = libs.find((l) => l.kind === "voice");
if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建");
/* 直传文件 */
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
});
/* 获取音频时长 */
const duration = await getAudioDuration(data.file);
/* 创建素材记录 */
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildVoiceMetadata({
gender: data.gender,
description: data.description,
duration,
}),
});
} finally {
setUploadProgress(null);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
showToast("上传成功", "success");
},
onError: (err: Error) => {
showToast(err.message || "上传失败,请重试", "error");
},
});
/* ── TTS 合成 ── */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本");
return;
}
setTtsError(null);
setTtsStatus("synthesizing");
setTtsAudioUrl(null);
setTtsJobId(null);
try {
const resp = await synthesizeSpeech({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
});
setTtsJobId(resp.job_id);
/* 轮询状态 */
ttsTimerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(resp.job_id);
if (job.status === "completed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("done");
setTtsAudioUrl(job.output_audio_url);
} else if (job.status === "failed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError(job.error_message || "合成失败");
}
} catch {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError("查询合成状态失败");
}
}, 2000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "合成请求失败";
setTtsStatus("error");
setTtsError(msg);
}
}, [ttsText, ttsVoiceId, ttsSpeed]);
/* ── TTS 保存到素材库 ── */
const handleTtsSave = useCallback(async () => {
if (!ttsJobId) return;
try {
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) });
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
showToast("已保存到配音素材库", "success");
setTtsOpen(false);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "保存失败";
showToast(msg, "error");
}
}, [ttsJobId, ttsText, queryClient, showToast]);
/* ── TTS 定时器清理 ── */
useEffect(() => {
return () => {
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
};
}, []);
/* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */
/** 预置音色列表 */
@@ -975,12 +788,7 @@ const VoiceLibrary: React.FC = () => {
const pageActions = (
<div className="xx-voices-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<UploadOutlined />}
onClick={() => setUploadOpen(true)}
>
<Button buttonType="ghost" buttonSize="sm" icon={<UploadOutlined />}>
</Button>
<Button
@@ -991,12 +799,7 @@ const VoiceLibrary: React.FC = () => {
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<RobotOutlined />}
onClick={() => setTtsOpen(true)}
>
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />}>
AI
</Button>
</div>
@@ -1205,544 +1008,6 @@ const VoiceLibrary: React.FC = () => {
/>
)}
{/* ── 上传音频弹窗 ── */}
<Modal
title="上传音频"
open={uploadOpen}
onCancel={() => {
if (uploadProgress !== null) return; // 上传中不可关闭
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
}}
footer={null}
width={520}
maskClosable={uploadProgress === null}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 拖拽上传区 */}
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
setUploadFile(file);
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""));
return false;
}}
onRemove={() => {
setUploadFile(null);
setUploadProgress(null);
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
{/* 已选文件信息 */}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined
style={{ fontSize: 18, color: "var(--primary-color)" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{/* 上传进度 */}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<input
value={uploadName}
onChange={(e) => setUploadName(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => setUploadGender(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background:
uploadGender === g
? "var(--primary-soft)"
: "transparent",
color:
uploadGender === g
? "var(--primary-color)"
: "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={uploadDesc}
onChange={(e) => setUploadDesc(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 操作按钮 */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={() => {
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
}}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件");
return;
}
if (!uploadName.trim()) {
message.warning("请输入素材名称");
return;
}
uploadMutation.mutate({
file: uploadFile,
name: uploadName.trim(),
gender: uploadGender,
description: uploadDesc.trim(),
});
}}
disabled={
!uploadFile || !uploadName.trim() || uploadProgress !== null
}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
</div>
</Modal>
{/* ── AI 配音弹窗 ── */}
<Modal
title="AI 配音"
open={ttsOpen}
onCancel={() => {
setTtsOpen(false);
setTtsText("");
setTtsVoiceId("");
setTtsSpeed(1.0);
setTtsStatus("idle");
setTtsAudioUrl(null);
setTtsError(null);
setTtsJobId(null);
}}
footer={null}
width={560}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={ttsText}
onChange={(e) => setTtsText(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={ttsVoiceId}
onChange={(e) => setTtsVoiceId(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={handleTtsSynthesize}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor:
ttsStatus === "synthesizing" || !ttsText.trim()
? "not-allowed"
: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={handleTtsSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
)}
</div>
</Modal>
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
+1 -12
View File
@@ -2,17 +2,6 @@
视频处理模块
"""
# 共享工具模块(供 editing_modes / generation / edit_plan_generation 等复用)
from . import dedup_helpers, ffmpeg_utils, oss_helpers
from .processor import VideoProcessor, VideoResult
from .unified_render_service import RenderResult, UnifiedRenderService
__all__ = [
"VideoProcessor",
"VideoResult",
"ffmpeg_utils",
"oss_helpers",
"dedup_helpers",
"UnifiedRenderService",
"RenderResult",
]
__all__ = ["VideoProcessor", "VideoResult"]
@@ -1,126 +0,0 @@
"""查重辅助函数 — 从 generation.py 提取的 GeneratedVideo 记录 + 查重逻辑.
render_edit_plan generate_video 共同复用
创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重
"""
from __future__ import annotations
import logging
from uuid import uuid4
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def create_video_record_and_dedup(
*,
generation_task_id: str,
project_id: str,
batch_id: str,
file_url: str,
file_size: int,
duration: float,
video_path: str,
mode: str,
session: Session,
width: int = 1280,
height: int = 720,
fps: float = 25.0,
) -> int:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
Args:
generation_task_id: 生成任务 ID
project_id: 项目 ID
batch_id: 批次 ID可为空字符串
file_url: 视频文件 URL
file_size: 文件大小字节
duration: 视频时长
video_path: 视频本地路径用于计算指纹
mode: 剪辑模式名称
session: 数据库会话
width: 视频宽度
height: 视频高度
fps: 视频帧率
Returns:
创建的视频记录数量1 表示成功0 表示失败
"""
from video_processing.dedup import VideoDeduplicator
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain import GeneratedVideo
try:
video_id = uuid4().hex
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
generation_task_id=generation_task_id,
name=f"generated-{generation_task_id[:8]}.mp4",
file_url=file_url,
file_size=file_size,
duration=duration,
width=width,
height=height,
fps=fps,
status="completed",
generation_params={"mode": mode},
)
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
# 计算视频指纹
deduplicator = VideoDeduplicator()
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
except Exception as fp_err:
logger.warning("Fingerprint computation failed for %s: %s", video_id, fp_err)
session.commit()
return 1
generated_video.video_fingerprint = fingerprint.to_dict()
# (a) 历史成片查重
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
# (b) 批次内查重(仅当有 batch_id 时)
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
if duplicate_result:
generated_video.is_duplicate = True
generated_video.duplicate_of = duplicate_result["duplicate_of"]
logger.info(
"Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)",
video_id,
duplicate_result["duplicate_of"],
duplicate_result["reason"],
duplicate_result["similarity"],
)
else:
generated_video.is_duplicate = False
generated_video.duplicate_of = None
video_repo.update(generated_video)
session.commit()
logger.info(
"GeneratedVideo record created: %s (task=%s, dup=%s)",
video_id,
generation_task_id,
generated_video.is_duplicate,
)
return 1
except Exception as e:
logger.error(
"Failed to create video record / dedup for task %s: %s",
generation_task_id,
e,
)
session.rollback()
return 0
+84 -41
View File
@@ -5,6 +5,7 @@
import logging
import os
import subprocess
import sys
import tempfile
from dataclasses import dataclass
@@ -21,8 +22,6 @@ else:
from pathlib import Path
from typing import Optional
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg
logger = logging.getLogger(__name__)
@@ -68,6 +67,8 @@ class EditingModeProcessor:
"""
self.config = config
self.work_dir = work_dir or tempfile.gettempdir()
self._ffmpeg_bin = "ffmpeg"
self._ffprobe_bin = "ffprobe"
def process(
self,
@@ -128,20 +129,62 @@ class EditingModeProcessor:
return os.path.join(self.work_dir, f"output_{self.config.mode}_{os.getpid()}.mp4")
def _run_ffmpeg(self, command: list[str], capture_output: bool = True) -> tuple:
"""执行 FFmpeg 命令 — 委托给共享 ffmpeg_utils.run_ffmpeg"""
"""执行 FFmpeg 命令"""
logger.debug(f"Running FFmpeg: {' '.join(command)}")
try:
return run_ffmpeg(command, capture_output=capture_output)
except RuntimeError as e:
logger.error(f"FFmpeg error: {e}")
raise
result = subprocess.run(
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=capture_output,
)
return result.stdout or "", result.stderr or ""
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode() if e.stderr else str(e)
logger.error(f"FFmpeg error: {stderr}")
raise RuntimeError(f"FFmpeg execution failed: {stderr}") from e
def _get_video_info(self, video_path: str) -> dict:
"""获取视频信息 — 委托给共享 ffmpeg_utils.probe_video_info,补充 codec/size 字段"""
"""获取视频信息"""
try:
info = probe_video_info(video_path)
info["codec"] = "unknown"
info["size"] = os.path.getsize(video_path) if os.path.exists(video_path) else 0
return info
result = subprocess.run(
[
self._ffprobe_bin,
"-v",
"error",
"-show_entries",
"stream=width,height,r_frame_rate,duration,codec_name",
"-show_entries",
"format=duration,size",
"-of",
"json",
video_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
import json
data = json.loads(result.stdout)
streams = data.get("streams", [{}])
video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {})
fmt = data.get("format", {})
fps_str = video_stream.get("r_frame_rate", "25/1")
fps_parts = fps_str.split("/")
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 else float(fps_parts[0])
return {
"width": int(video_stream.get("width", 0)),
"height": int(video_stream.get("height", 0)),
"fps": fps,
"duration": float(fmt.get("duration", 0)),
"codec": video_stream.get("codec_name", "unknown"),
"size": int(fmt.get("size", 0)),
}
except Exception as e:
logger.warning(f"Failed to get video info for {video_path}: {e}")
return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0}
@@ -162,7 +205,7 @@ class EditingModeProcessor:
def _normalize_video(self, input_path: str, output_path: str) -> dict:
"""标准化视频格式:先统一帧率,再缩放/填充"""
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
input_path,
@@ -185,7 +228,7 @@ class EditingModeProcessor:
"-an",
output_path,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
return self._get_video_info(output_path)
def _one_take(self, video_paths: list[str], output_path: str) -> str:
@@ -222,7 +265,7 @@ class EditingModeProcessor:
offset1 = durations[0] - transition / 2
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
normalized_paths[0],
@@ -242,7 +285,7 @@ class EditingModeProcessor:
"yuv420p",
output_path,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
return output_path
else:
return self._one_take_simple_concat(normalized_paths, output_path)
@@ -255,7 +298,7 @@ class EditingModeProcessor:
f.write(f"file '{os.path.abspath(path)}'\n")
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-f",
"concat",
@@ -267,7 +310,7 @@ class EditingModeProcessor:
"copy",
output_path,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
try:
os.remove(concat_file)
@@ -301,7 +344,7 @@ class EditingModeProcessor:
if pip_info["duration"] > main_info["duration"]:
temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4")
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
video_paths[1],
@@ -319,11 +362,11 @@ class EditingModeProcessor:
"yuv420p",
temp_pip,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
pip_normalized_input = temp_pip
else:
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
video_paths[1],
@@ -339,13 +382,13 @@ class EditingModeProcessor:
"yuv420p",
pip_normalized,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
pip_normalized_input = pip_normalized
if main_info["duration"] > pip_info["duration"]:
looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4")
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-stream_loop",
"-1",
@@ -365,11 +408,11 @@ class EditingModeProcessor:
"yuv420p",
looped_pip,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
pip_normalized_input = looped_pip
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
main_normalized,
@@ -389,7 +432,7 @@ class EditingModeProcessor:
"yuv420p",
output_path,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
for temp_file in [main_normalized, pip_normalized]:
if temp_file and temp_file != output_path:
@@ -419,7 +462,7 @@ class EditingModeProcessor:
if bg_info["duration"] < audio_duration:
looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4")
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-stream_loop",
"-1",
@@ -439,12 +482,12 @@ class EditingModeProcessor:
"yuv420p",
looped_bg,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
bg_normalized = looped_bg
elif bg_info["duration"] > audio_duration:
temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4")
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
@@ -454,12 +497,12 @@ class EditingModeProcessor:
"copy",
temp_bg,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
bg_normalized = temp_bg
blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4")
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
@@ -475,10 +518,10 @@ class EditingModeProcessor:
"yuv420p",
blurred_bg,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
blurred_bg,
@@ -501,7 +544,7 @@ class EditingModeProcessor:
"-shortest",
output_path,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
for temp_file in [bg_normalized, blurred_bg]:
try:
@@ -539,7 +582,7 @@ class EditingModeProcessor:
voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4")
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
voice_normalized,
@@ -557,11 +600,11 @@ class EditingModeProcessor:
"yuv420p",
voice_adjusted,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4")
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
@@ -571,11 +614,11 @@ class EditingModeProcessor:
"copy",
bg_adjusted,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
if audio_path:
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
bg_adjusted,
@@ -602,7 +645,7 @@ class EditingModeProcessor:
]
else:
command = [
FFMPEG_BIN,
self._ffmpeg_bin,
"-y",
"-i",
bg_adjusted,
@@ -625,7 +668,7 @@ class EditingModeProcessor:
"yuv420p",
output_path,
]
run_ffmpeg(command)
self._run_ffmpeg(command)
for temp_file in [voice_normalized, voice_adjusted, bg_normalized, bg_adjusted]:
try:
@@ -1,324 +0,0 @@
"""FFmpeg 工具函数 — 从 editing_modes.py / video_compose_service.py 提取的共享原语.
提供 FFmpeg / FFprobe 调用视频信息探测视频标准化xfade 转场滤镜构建
等底层能力 EditingModeProcessorVideoComposeServiceUnifiedRenderService
共同复用
"""
from __future__ import annotations
import logging
import shutil
import subprocess # nosec B404
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容)
XFADE_TRANSITION_MAP: dict[str, str] = {
"fade": "fade",
"slideleft": "slideleft",
"slide_left": "slideleft",
"slideright": "slideright",
"slide_right": "slideright",
"dissolve": "dissolve",
"wipe": "wipeleft",
"wipeleft": "wipeleft",
}
DEFAULT_TRANSITION_DURATION = 0.5
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
def run_ffmpeg(
command: list[str],
*,
capture_output: bool = True,
) -> tuple[str, str]:
"""执行 FFmpeg 命令。
Args:
command: 完整的 ffmpeg 命令列表 "ffmpeg" 本身
capture_output: 是否捕获 stdout/stderr
Returns:
(stdout, stderr) 元组
Raises:
subprocess.CalledProcessError: 命令执行失败时抛出
异常信息包含完整 stderr 以便排查
"""
try:
result = subprocess.run( # nosec B603
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=True,
)
return (result.stdout or "", result.stderr or "")
except subprocess.CalledProcessError as e:
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
stderr_text = (e.stderr or "").strip()
logger.error(
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
e.returncode,
" ".join(str(c) for c in command[:20]), # 截断过长的命令
stderr_text[:5000], # 截断过长的 stderr
)
raise
def probe_duration(local_path: str | Path) -> float:
"""用 ffprobe 获取视频时长(秒)。
失败时返回默认值 5.0
"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(local_path),
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return round(float(result.stdout.strip()), 3)
except Exception:
return 5.0
def probe_video_info(video_path: str) -> dict[str, Any]:
"""获取视频信息(宽、高、时长、fps)。
Returns:
{"width": int, "height": int, "duration": float, "fps": float}
失败时返回默认值
"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,r_frame_rate,duration",
"-show_entries",
"format=duration",
"-of",
"json",
video_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
import json
info = json.loads(result.stdout)
stream = info.get("streams", [{}])[0]
fmt = info.get("format", {})
width = int(stream.get("width", DEFAULT_OUTPUT_WIDTH))
height = int(stream.get("height", DEFAULT_OUTPUT_HEIGHT))
# 解析帧率
fps_str = stream.get("r_frame_rate", "25/1")
if "/" in fps_str:
num, den = fps_str.split("/")
fps = float(num) / float(den) if float(den) > 0 else DEFAULT_FPS
else:
fps = float(fps_str) if fps_str else DEFAULT_FPS
# 时长
duration = float(fmt.get("duration", 0)) or float(stream.get("duration", 0))
return {
"width": width,
"height": height,
"duration": duration,
"fps": round(fps, 2),
}
except Exception as e:
logger.warning("获取视频信息失败: %s, error: %s", video_path, e)
return {
"width": DEFAULT_OUTPUT_WIDTH,
"height": DEFAULT_OUTPUT_HEIGHT,
"duration": 0.0,
"fps": DEFAULT_FPS,
}
def normalize_video(
input_path: str,
output_path: str,
*,
width: int = DEFAULT_OUTPUT_WIDTH,
height: int = DEFAULT_OUTPUT_HEIGHT,
fps: int = DEFAULT_FPS,
) -> dict[str, Any]:
"""标准化视频(缩放 + 恒定帧率)。
使用 scale + pad 保持宽高比黑边填充到目标分辨率
Returns:
{"width": int, "height": int, "path": str}
"""
command = [
FFMPEG_BIN,
"-y",
"-i",
input_path,
"-vf",
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,"
f"fps={fps}",
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
output_path,
]
run_ffmpeg(command)
return {"width": width, "height": height, "path": output_path}
# ── xfade / concat 滤镜构建 ──────────────────────────────────────────────────
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串。
chain_filters(["scale=1280:720", "fps=25"], "v0")
"[0:v]scale=1280:720,fps=25[v0]"
"""
filter_body = ",".join(filters)
return f"[{input_label}]{filter_body}[{output_label}]"
def resolve_xfade_transition(transition_name: str) -> str:
"""将转场效果名称映射为 FFmpeg xfade transition 名称。
支持 TransitionEffect 枚举值和字符串名称未知值回退到 "fade"
"""
# 兼容 TransitionEffect 枚举(有 .value 属性)
if hasattr(transition_name, "value"):
transition_name = transition_name.value
return XFADE_TRANSITION_MAP.get(transition_name, "fade")
def build_xfade_filter_chain(
clip_durations: list[float],
clip_video_labels: list[str],
transitions: list[str],
*,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链。
对每步 xfade 自动钳制 transition duration确保
``offset + td first_input_duration``避免 FFmpeg exit 234
Args:
clip_durations: 每个片段的时长必须与 trim 后的实际时长一致
clip_video_labels: 每个片段的视频流标签 "v0", "v1"
transitions: 每个片段对应的转场效果第一个片段的转场被忽略
transition_duration: 转场时长
output_label: 最终输出标签
Returns:
(filter_string, estimated_total_duration)
"""
n = len(clip_durations)
parts: list[str] = []
if n == 0:
return "", 0.0
if n == 1:
parts.append(f"[{clip_video_labels[0]}]copy[{output_label}]")
return ";".join(parts), clip_durations[0]
# xfade 链 — 每步动态钳制 td,防止 offset + td > first_input_duration
cumulative = 0.0
prev_label = clip_video_labels[0]
total_transition = 0.0 # 累计已使用的转场时长
for i in range(1, n):
cumulative += clip_durations[i - 1]
# 当前 xfade 的第一个输入时长
if i == 1:
first_input_dur = clip_durations[0]
else:
first_input_dur = cumulative - total_transition
# 原始 offset 计算
offset = max(0.0, cumulative - transition_duration * i)
# 安全钳制:offset + td 不能超过第一个输入的时长
available = max(0.0, first_input_dur - offset)
safe_td = min(transition_duration, available)
# 同时不能超过剩余总时长
remaining = max(0.0, sum(clip_durations) - cumulative)
safe_td = min(safe_td, remaining)
# 同时不能超过当前第二个输入(单个片段)的时长
safe_td = min(safe_td, clip_durations[i])
safe_td = max(0.001, safe_td) # 至少 1ms,避免 td=0
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = resolve_xfade_transition(transition)
if i == n - 1:
out_label = output_label
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_video_labels[i]}]"
f"xfade=transition={xfade_transition}"
f":duration={safe_td:.3f}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
total_transition += safe_td
# 总时长减去转场重叠部分
total_duration = sum(clip_durations) - total_transition
return ";".join(parts), max(0.0, total_duration)
-194
View File
@@ -1,194 +0,0 @@
"""OSS 工具函数 — 从 generation.py / edit_plan_generation.py 提取的共享 OSS 操作.
提供 OSS 配置读取Bucket 创建素材上传/下载asset_id 本地路径解析
等能力 render_edit_plan generate_video 共同复用
"""
from __future__ import annotations
import hashlib
import logging
import os
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import oss2
logger = logging.getLogger(__name__)
# ── OSS 配置 ──────────────────────────────────────────────────────────────────
def oss_settings() -> tuple[str, str, str, str] | None:
"""获取 OSS 配置。
Returns:
(access_key_id, access_key_secret, endpoint, bucket_name) 元组
配置缺失时返回 None
"""
access_key_id = os.getenv("OSS_ACCESS_KEY_ID")
access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET")
endpoint = os.getenv("OSS_ENDPOINT")
bucket_name = os.getenv("OSS_BUCKET_NAME")
if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
return None
return access_key_id, access_key_secret, endpoint, bucket_name
def oss_bucket() -> oss2.Bucket | None:
"""获取 OSS Bucket 实例。
P0-2 修复endpoint 不带 scheme 时自动补 https:// 前缀
确保 sign_url 等依赖 scheme 的方法返回 HTTPS URL
Returns:
oss2.Bucket 实例配置缺失时返回 None
"""
settings = oss_settings()
if settings is None:
return None
access_key_id, access_key_secret, endpoint, bucket_name = settings
# endpoint 无 scheme 时补 https://,与 API 端 storage.py 保持一致
if not endpoint.startswith(("http://", "https://")):
endpoint = f"https://{endpoint}"
return oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name)
def normalize_storage_key(storage_key_or_url: str) -> str:
"""标准化存储键 — 如果是完整 URL 则提取 path 部分。
Examples:
"https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4"
"path/to/file.mp4"
"path/to/file.mp4" "path/to/file.mp4"
"""
if storage_key_or_url.startswith(("http://", "https://")):
return urlparse(storage_key_or_url).path.lstrip("/")
return storage_key_or_url.lstrip("/")
# ── 上传 / 下载 ───────────────────────────────────────────────────────────────
def download_asset(asset_storage_key: str, local_path: Path) -> bool:
"""从 OSS 下载素材文件到本地路径。
Args:
asset_storage_key: 素材的存储键或完整 URL
local_path: 本地保存路径
Returns:
True 表示下载成功False 表示失败
"""
bucket = oss_bucket()
if bucket is None:
return False
try:
bucket.get_object_to_file(normalize_storage_key(asset_storage_key), str(local_path))
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("下载素材失败: %s", asset_storage_key)
return False
def upload_to_oss(local_path: Path, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公开 URL。
Args:
local_path: 本地文件路径
storage_key: 目标存储键
Returns:
公开访问 URL上传失败或 OSS 未配置时返回 None
"""
bucket = oss_bucket()
if bucket is None:
return None
try:
bucket.put_object_from_file(storage_key, str(local_path))
settings = oss_settings()
if settings:
_, _, endpoint, bucket_name = settings
endpoint_clean = endpoint.replace("https://", "").replace("http://", "")
return f"https://{bucket_name}.{endpoint_clean}/{storage_key}"
return None
except Exception:
logger.exception("上传 OSS 失败: %s", storage_key)
return None
def get_signed_download_url(storage_key_or_url: str, expires_seconds: int = 3600) -> str | None:
"""生成预签名下载 URL(用于私有 bucket 的 URL 校验或临时下载)。
Args:
storage_key_or_url: 存储键或完整 URLURL 会自动提取 path
expires_seconds: 签名有效期
Returns:
预签名 URL失败或 OSS 未配置时返回 None
"""
bucket = oss_bucket()
if bucket is None:
return None
try:
storage_key = normalize_storage_key(storage_key_or_url)
signed = bucket.sign_url("GET", storage_key, expires_seconds)
logger.info("生成预签名URL: key=%s url_prefix=%s", storage_key[:80], signed[:60])
return signed
except Exception:
logger.exception("生成预签名URL失败: %s", storage_key_or_url[:80])
return None
# ── Asset 解析 ────────────────────────────────────────────────────────────────
def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
"""从 asset_id 解析到本地文件路径。
策略按优先级
1. 如果 asset_id 是本地绝对路径/var/storage/... 直接返回
2. 如果 work_dir 下已有缓存文件 返回缓存路径
3. OSS 下载到 work_dir/{hash}.mp4 返回下载路径
4. 下载失败 返回 None
缓存策略 asset_id SHA256 16 位为文件名避免重复下载
"""
# 1. 本地绝对路径
if asset_id.startswith("/") and os.path.exists(asset_id):
return Path(asset_id)
# 2. 缓存命中
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
cached_path = work_dir / f"{cache_hash}.mp4"
if cached_path.exists() and cached_path.stat().st_size > 0:
return cached_path
# 3. 从 OSS 下载
if download_asset(asset_id, cached_path):
return cached_path
return None
def resolve_asset_ids_to_paths(
asset_ids: list[str],
work_dir: Path,
) -> dict[str, Path]:
"""批量解析 asset_id → 本地路径。
Args:
asset_ids: 素材 ID 列表
work_dir: 工作目录
Returns:
{asset_id: local_path} 映射仅包含成功解析的条目
"""
result: dict[str, Path] = {}
for aid in asset_ids:
local_path = resolve_asset_path(aid, work_dir)
if local_path:
result[aid] = local_path
return result
@@ -1,491 +0,0 @@
"""统一渲染引擎 — 输入 EditPlan + EditPlanClips,按时间线+图层渲染视频.
核心原则灵应渲染引擎是统一的不判断模式只按 clip_type/config.role
分组为图层再合成
图层分组
main ( config.role) main (z=0)
main + config.role=b_roll broll (z=0 main 同层替换)
overlay overlay (z=1画中画叠加)
background background (z=0全屏底图)
corner_voice corner_voice (z=1右上角小窗)
b_roll broll (z=0)
intro / outro main (z=0 order 排在首/)
合成流程
1. 每个 clip trim + scale + setpts 预处理
2. 同层 clips order xfade 串联
3. overlay/corner_voice overlay 到主层
4. 如有独立音频轨amix 混入
"""
from __future__ import annotations
import logging
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import (
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
FFMPEG_BIN,
build_xfade_filter_chain,
probe_duration,
probe_video_info,
run_ffmpeg,
)
logger = logging.getLogger(__name__)
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass
class ResolvedClip:
"""已解析到本地路径的片段。"""
clip_id: str
asset_id: str
local_path: Path
clip_type: str
order: int
start_time: float = 0.0
duration: float = 0.0 # 0 表示使用素材完整时长
transition_effect: str = "cut"
config: dict[str, Any] = field(default_factory=dict)
# 运行时填充
actual_duration: float = 0.0 # 素材实际时长(probe 后填充)
@dataclass
class RenderLayer:
"""渲染图层。"""
role: str # "main" | "overlay" | "pip" | "background" | "corner_voice" | "broll" | "audio"
clips: list[ResolvedClip] = field(default_factory=list)
z_index: int = 0
opacity: float = 1.0
position: tuple[int, int] | None = None # (x, y) 偏移,None 表示全屏
@dataclass
class RenderResult:
"""渲染结果。"""
output_path: Path
duration: float
file_size: int
width: int
height: int
# ── clip_type → layer role 映射 ──────────────────────────────────────────────
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
"""根据 clip_type 和 config.role 确定图层角色。
映射规则
intro / outro "main" order 排在首/
overlay "overlay"画中画叠加z=1
corner_voice "corner_voice"右上角小窗z=1
background "background"全屏底图z=0
b_roll "broll"z=0
main + config.role=b_roll "broll"
main (default) "main"
"""
role = config.get("role", "")
if clip_type in ("intro", "outro"):
return "main"
if clip_type == "overlay":
return "overlay"
if clip_type == "corner_voice":
return "corner_voice"
if clip_type == "background":
return "background"
if clip_type == "b_roll":
return "broll"
# main type
if role == "b_roll":
return "broll"
return "main"
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
_LAYER_Z_INDEX: dict[str, int] = {
"background": -1,
"broll": 0,
"main": 0,
"overlay": 1,
"corner_voice": 1,
"audio": 2,
}
# 图层默认 PiP 位置(相对输出画布的偏移)
_PIP_SCALE = 0.25 # PiP 占主画面的比例
# ── 统一渲染引擎 ─────────────────────────────────────────────────────────────
class UnifiedRenderService:
"""统一渲染引擎。
输入 EditPlan + EditPlanClips + 素材路径映射按时间线+图层执行渲染
"""
def __init__(
self,
plan: Any, # EditPlan
clips: list[Any], # list[EditPlanClip]
asset_path_map: dict[str, Path], # asset_id → local_path
work_dir: Path,
*,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
output_fps: int = DEFAULT_FPS,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
):
self.plan = plan
self.clips = clips
self.asset_path_map = asset_path_map
self.work_dir = work_dir
self.output_width = output_width
self.output_height = output_height
self.output_fps = output_fps
self.transition_duration = transition_duration
def render(self) -> RenderResult:
"""执行渲染,返回 RenderResult。
Raises:
ValueError: 没有可渲染的片段时抛出
"""
# 1. 解析 clips → ResolvedClips(跳过无素材的 clip
resolved = self._resolve_clips()
if not resolved:
raise ValueError("没有可渲染的片段(所有片段素材缺失或下载失败)")
# 2. 分组为 RenderLayers
layers = self._group_clips_into_layers(resolved)
# 3. 构建 filter_complex
output_path = self.work_dir / f"rendered_{self.plan.id}.mp4"
filter_complex, input_args = self._build_filter_complex(layers)
# 4. 执行 FFmpeg
self._execute_ffmpeg(filter_complex, input_args, output_path)
# 5. 探测输出
duration, file_size, width, height = self._probe_output(output_path)
return RenderResult(
output_path=output_path,
duration=duration,
file_size=file_size,
width=width,
height=height,
)
# ── 内部方法 ──────────────────────────────────────────────────────────────
def _resolve_clips(self) -> list[ResolvedClip]:
"""将 EditPlanClip 列表解析为 ResolvedClip 列表。
跳过 asset_id 为空或在 asset_path_map 中找不到的片段
"""
resolved: list[ResolvedClip] = []
for clip in self.clips:
asset_id = clip.asset_id
if not asset_id:
logger.warning("片段无素材: clip_id=%s", clip.id)
continue
local_path = self.asset_path_map.get(asset_id)
if local_path is None or not local_path.exists():
logger.warning("素材不存在: clip_id=%s asset_id=%s", clip.id, asset_id)
continue
# 探测实际时长
try:
actual_duration = probe_duration(local_path)
except Exception:
actual_duration = clip.duration or 5.0
rc = ResolvedClip(
clip_id=clip.id,
asset_id=asset_id,
local_path=local_path,
clip_type=clip.clip_type,
order=clip.order,
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
config=clip.config or {},
actual_duration=actual_duration,
)
resolved.append(rc)
# 按 order 排序
resolved.sort(key=lambda c: c.order)
return resolved
def _group_clips_into_layers(self, resolved_clips: list[ResolvedClip]) -> list[RenderLayer]:
"""将 ResolvedClips 分组为 RenderLayers。
分组规则见 _resolve_layer_role 函数文档
"""
layer_map: dict[str, RenderLayer] = {}
for clip in resolved_clips:
role = _resolve_layer_role(clip.clip_type, clip.config)
if role not in layer_map:
z = _LAYER_Z_INDEX.get(role, 0)
layer_map[role] = RenderLayer(role=role, z_index=z)
layer_map[role].clips.append(clip)
# 每个 layer 内的 clips 按 order 排序
for layer in layer_map.values():
layer.clips.sort(key=lambda c: c.order)
# 计算 PiP 位置
pip_width = int(self.output_width * _PIP_SCALE)
pip_height = int(self.output_height * _PIP_SCALE)
margin = 20 # 边距
if "overlay" in layer_map:
layer_map["overlay"].position = (
self.output_width - pip_width - margin,
margin,
)
if "corner_voice" in layer_map:
layer_map["corner_voice"].position = (
self.output_width - pip_width - margin,
margin,
)
# 按 z_index 排序返回
layers = sorted(layer_map.values(), key=lambda lyr: lyr.z_index)
return layers
def _build_filter_complex(self, layers: list[RenderLayer]) -> tuple[str, list[str]]:
"""构建 FFmpeg filter_complex 字符串和输入参数列表。
Returns:
(filter_complex_str, input_args_list)
input_args_list ["-i", path1, "-i", path2, ...] 格式
"""
if not layers:
raise ValueError("没有可渲染的图层")
# 收集所有 clips(按图层顺序,同层按 order)
all_clips: list[ResolvedClip] = []
for layer in layers:
all_clips.extend(layer.clips)
# 构建输入参数
input_args: list[str] = []
clip_to_input_idx: dict[str, int] = {}
for i, clip in enumerate(all_clips):
input_args.extend(["-i", str(clip.local_path)])
clip_to_input_idx[clip.clip_id] = i
filter_parts: list[str] = []
# Step 1: 预处理每个 clip — scale + setpts
# 为每个 clip 生成预处理后的标签 [v0], [v1], ...
preprocessed_labels: list[str] = []
for i, clip in enumerate(all_clips):
label = f"v{i}"
role = _resolve_layer_role(clip.clip_type, clip.config)
filters: list[str] = []
# trim — 始终将输出截断到有效时长,防止 xfade offset 与实际时长不匹配
# 有效时长 = min(指定时长, 实际时长);若均未设置则跳过
effective_duration = 0.0
if clip.duration > 0:
effective_duration = (
min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
)
elif clip.actual_duration > 0:
effective_duration = clip.actual_duration
if effective_duration > 0:
filters.append(f"trim=duration={effective_duration}")
filters.append("setpts=PTS-STARTPTS")
# scale
if role in ("overlay", "corner_voice"):
pip_w = int(self.output_width * _PIP_SCALE)
pip_h = int(self.output_height * _PIP_SCALE)
filters.append(f"scale={pip_w}:{pip_h}")
elif role == "background":
filters.append(
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
)
filters.append(f"crop={self.output_width}:{self.output_height}")
else:
# main / broll: scale + pad 保持宽高比
filters.append(
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=decrease"
)
filters.append(f"pad={self.output_width}:{self.output_height}" ":(ow-iw)/2:(oh-ih)/2:black")
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
filter_str = f"[{i}:v]{','.join(filters)}[{label}]"
filter_parts.append(filter_str)
preprocessed_labels.append(label)
# Step 2: 同层 clips 用 xfade 串联
layer_output_labels: dict[str, str] = {}
for layer in layers:
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
# 使用 trim 后的有效时长,与 Step 1 的 trim=duration 保持一致
layer_durations = []
for i in layer_clip_indices:
c = all_clips[i]
if c.duration > 0:
eff = min(c.duration, c.actual_duration) if c.actual_duration > 0 else c.duration
else:
eff = c.actual_duration if c.actual_duration > 0 else 0.0
layer_durations.append(eff)
layer_transitions = [all_clips[i].transition_effect for i in layer_clip_indices]
if len(layer_labels) == 1:
# 单 clip 层,直接使用预处理标签
layer_output_labels[layer.role] = layer_labels[0]
else:
# 多 clip 层,用 xfade 串联
out_label = f"{layer.role}_merged"
xfade_filter, _ = build_xfade_filter_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=self.transition_duration,
output_label=out_label,
)
if xfade_filter:
filter_parts.append(xfade_filter)
layer_output_labels[layer.role] = out_label
# Step 3: 合成各层
# 找到主层 — background 优先作为底图,其次 broll / main
final_video_label = None
if "background" in layer_output_labels:
final_video_label = layer_output_labels["background"]
# b_roll / main 叠加到 background 上
for role in ("broll", "main"):
if role in layer_output_labels:
base_label = layer_output_labels[role]
combined_label = f"combined_{role}"
filter_parts.append(
f"[{final_video_label}][{base_label}]" f"overlay=(W-w)/2:(H-h)/2[{combined_label}]"
)
final_video_label = combined_label
else:
# 无 background 时,取 broll 或 main 作为基础
for role in ("broll", "main"):
if role in layer_output_labels:
final_video_label = layer_output_labels[role]
break
if final_video_label is None:
# 没有任何主层,使用第一个层
final_video_label = layer_output_labels[layers[0].role]
# 叠加 overlay 层
for layer in layers:
if layer.role in ("overlay", "corner_voice"):
if layer.role not in layer_output_labels:
continue
overlay_label = layer_output_labels[layer.role]
x, y = layer.position or (
self.output_width - int(self.output_width * _PIP_SCALE) - 20,
20,
)
combined_label = f"combined_{layer.role}"
filter_parts.append(f"[{final_video_label}][{overlay_label}]" f"overlay={x}:{y}[{combined_label}]")
final_video_label = combined_label
filter_parts.append(f"[{final_video_label}]format=yuv420p[final_video]")
filter_complex = ";".join(filter_parts)
return filter_complex, input_args
def _execute_ffmpeg(
self,
filter_complex: str,
input_args: list[str],
output_path: Path,
) -> None:
"""执行 FFmpeg 渲染命令。
失败时记录完整 filter_complex 以便排查 exit code 183
"""
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
"[final_video]",
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(output_path),
]
logger.info(
"执行渲染: plan_id=%s inputs=%d output=%s",
self.plan.id,
input_args.count("-i"),
output_path,
)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
# 额外记录 filter_complex,方便排查滤镜链构建问题
logger.error(
"渲染失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s",
self.plan.id,
e.returncode,
filter_complex[:5000],
)
raise
def _probe_output(self, output_path: Path) -> tuple[float, int, int, int]:
"""探测输出文件的时长、大小、宽高。
Returns:
(duration, file_size, width, height)
"""
info = probe_video_info(str(output_path))
file_size = output_path.stat().st_size if output_path.exists() else 0
return (
info["duration"],
file_size,
info["width"],
info["height"],
)
@@ -3,39 +3,177 @@
Celery 任务 worker.render_edit_plan:
1. 加载 EditPlan + EditPlanClips
2. 下载各片段素材
3. 使用 UnifiedRenderService 按时间线+图层渲染
3. order 顺序拼接片段
4. 上传渲染结果到 OSS
5. 创建 GeneratedVideo 记录 + 查重
6. 更新 EditPlan / EditPlanClip 状态
7. 更新 GenerationTask 进度
5. 更新 EditPlan / EditPlanClip 状态
6. 更新 GenerationTask 进度
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess # nosec B404
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import oss2
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
logger = logging.getLogger(__name__)
FFMPEG_BIN = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN = shutil.which("ffprobe") or "ffprobe"
OUTPUT_WIDTH = 1280
OUTPUT_HEIGHT = 720
OUTPUT_FPS = 25.0
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
# ── OSS helpers ───────────────────────────────────────────────────────────────
def _oss_settings() -> tuple[str, str, str, str] | None:
"""获取 OSS 配置"""
access_key_id = os.getenv("OSS_ACCESS_KEY_ID")
access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET")
endpoint = os.getenv("OSS_ENDPOINT")
bucket_name = os.getenv("OSS_BUCKET_NAME")
if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
return None
return access_key_id, access_key_secret, endpoint, bucket_name
def _oss_bucket() -> oss2.Bucket | None:
"""获取 OSS Bucket"""
settings = _oss_settings()
if settings is None:
return None
access_key_id, access_key_secret, endpoint, bucket_name = settings
return oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name)
def _normalize_storage_key(storage_key_or_url: str) -> str:
"""标准化存储键"""
if storage_key_or_url.startswith(("http://", "https://")):
return urlparse(storage_key_or_url).path.lstrip("/")
return storage_key_or_url.lstrip("/")
def _download_asset(asset_storage_key: str, local_path: Path) -> bool:
"""下载素材文件到本地"""
bucket = _oss_bucket()
if bucket is None:
return False
try:
bucket.get_object_to_file(_normalize_storage_key(asset_storage_key), str(local_path))
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("下载素材失败: %s", asset_storage_key)
return False
def _upload_to_oss(local_path: Path, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公开 URL"""
bucket = _oss_bucket()
if bucket is None:
return None
try:
bucket.put_object_from_file(storage_key, str(local_path))
settings = _oss_settings()
if settings:
_, _, endpoint, bucket_name = settings
return f"https://{bucket_name}.{endpoint.replace('https://', '').replace('http://', '')}/{storage_key}"
return None
except Exception:
logger.exception("上传 OSS 失败: %s", storage_key)
return None
# ── FFmpeg helpers ────────────────────────────────────────────────────────────
def _run_ffmpeg(command: list[str]) -> None:
"""执行 FFmpeg 命令"""
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # nosec B603
def _probe_duration(local_path: Path) -> float:
"""获取视频/音频时长"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(local_path),
],
check=True,
capture_output=True,
text=True,
)
return float(result.stdout.strip())
except Exception:
return 0.0
def _concatenate_clips(
clip_paths: list[Path],
output_path: Path,
transition_effects: list[str] | None = None,
) -> bool:
"""将多个片段拼接为最终视频
使用 FFmpeg concat demuxer 实现
"""
if not clip_paths:
return False
if len(clip_paths) == 1:
# 单片段直接复制
try:
shutil.copy2(str(clip_paths[0]), str(output_path))
return True
except Exception:
return False
# 多片段:使用 concat demuxer
concat_file = output_path.parent / "concat_list.txt"
try:
with open(concat_file, "w") as f:
for p in clip_paths:
f.write(f"file '{p}'\n")
command = [
FFMPEG_BIN,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_file),
"-c",
"copy",
str(output_path),
]
_run_ffmpeg(command)
return output_path.exists() and output_path.stat().st_size > 0
except Exception:
logger.exception("拼接片段失败")
return False
finally:
if concat_file.exists():
concat_file.unlink()
from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.oss_helpers import (
download_asset,
upload_to_oss,
)
from video_processing.unified_render_service import UnifiedRenderService
# ── Repository imports (延迟导入避免循环依赖) ─────────────────────────────────
@@ -69,17 +207,14 @@ def render_edit_plan(self, plan_id: str) -> dict:
流程
1. 加载 EditPlan + EditPlanClips
2. 下载各片段素材到临时目录构建 asset_path_map
3. 使用 UnifiedRenderService 按时间线+图层渲染
2. 下载各片段素材到临时目录
3. order 顺序拼接片段
4. 上传渲染结果到 OSS
5. 创建 GeneratedVideo 记录 + 查重
6. 更新 EditPlan completed, EditPlanClips rendered
7. 更新 GenerationTask 进度
5. 更新 EditPlan completed, EditPlanClips rendered
6. 更新 GenerationTask 进度
"""
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
generation_task_id = ""
for repos in _get_repos():
plan_repo, clip_repo, gen_task_repo, db = repos
@@ -90,9 +225,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
logger.error("剪辑计划不存在: %s", plan_id)
return {"status": "error", "message": f"计划不存在: {plan_id}"}
# 获取 generation_task_id(提前读取,确保 except 块可用)
generation_task_id = plan.config.get("generation_task_id", "")
# 2. 加载片段列表(按 order 排序)
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
@@ -101,6 +233,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
plan_repo.update(plan)
return {"status": "error", "message": "没有可渲染的片段"}
# 获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id", "")
# 更新 GenerationTask 状态为 running
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
@@ -109,10 +244,10 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.started_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
# 3. 下载素材并构建 asset_path_map
# 3. 下载素材并拼接
with tempfile.TemporaryDirectory(prefix="edit_plan_") as tmpdir:
tmpdir_path = Path(tmpdir)
asset_path_map: dict[str, Path] = {}
clip_paths: list[Path] = []
rendered_clip_ids: list[str] = []
failed_clip_ids: list[str] = []
@@ -124,23 +259,18 @@ def render_edit_plan(self, plan_id: str) -> dict:
failed_clip_ids.append(clip.id)
continue
if clip.asset_id in asset_path_map:
# 同一素材已下载(多个 clip 共享同一素材)
rendered_clip_ids.append(clip.id)
continue
# 下载素材
ext = Path(clip.asset_id).suffix or ".mp4"
local_path = tmpdir_path / f"clip_{clip.order:04d}{ext}"
if download_asset(clip.asset_id, local_path):
asset_path_map[clip.asset_id] = local_path
if _download_asset(clip.asset_id, local_path):
clip_paths.append(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:
if not clip_paths:
logger.error("所有片段素材下载失败: %s", plan_id)
plan.mark_failed()
plan_repo.update(plan)
@@ -153,75 +283,42 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task_repo.update(gen_task)
return {"status": "error", "message": "所有片段素材下载失败"}
# 4. 使用 UnifiedRenderService 渲染
render_service = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
work_dir=tmpdir_path,
output_width=OUTPUT_WIDTH,
output_height=OUTPUT_HEIGHT,
output_fps=int(OUTPUT_FPS),
)
# 4. 拼接片段
output_path = tmpdir_path / f"rendered_{plan_id}.mp4"
transition_effects = [c.transition_effect for c in clips if c.asset_id]
success = _concatenate_clips(clip_paths, output_path, transition_effects)
try:
render_result = render_service.render()
except Exception as render_err:
logger.error("渲染失败: %s%s", plan_id, render_err)
if not success:
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 = f"渲染失败: {render_err}"
gen_task.error_message = "片段拼接失败"
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
return {"status": "error", "message": f"渲染失败: {render_err}"}
output_path = render_result.output_path
return {"status": "error", "message": "片段拼接失败"}
# 5. 上传到 OSS
storage_key = f"rendered/{plan_id}/output.mp4"
output_url = upload_to_oss(output_path, storage_key)
output_url = _upload_to_oss(output_path, storage_key)
# 6. 创建 GeneratedVideo 记录 + 查重
project_id = plan.project_id or ""
batch_id = plan.config.get("batch_id", "")
mode = plan.config.get("mode", "edit_plan")
if generation_task_id and project_id:
try:
create_video_record_and_dedup(
generation_task_id=generation_task_id,
project_id=project_id,
batch_id=batch_id,
file_url=output_url or "",
file_size=render_result.file_size,
duration=render_result.duration,
video_path=str(output_path),
mode=mode,
session=db,
width=render_result.width,
height=render_result.height,
fps=OUTPUT_FPS,
)
except Exception as dedup_err:
logger.warning("查重失败(不影响渲染结果): %s", dedup_err)
# 7. 更新片段状态为 rendered
# 6. 更新片段状态为 rendered
for clip_id in rendered_clip_ids:
clip = clip_repo.get(clip_id)
if clip and clip.status.value == "ready":
clip.mark_rendered()
clip_repo.update(clip)
# 8. 更新 EditPlan 状态为 completed
# 7. 更新 EditPlan 状态为 completed
plan.config["rendered_url"] = output_url or ""
plan.config["rendered_storage_key"] = storage_key
plan.mark_completed()
plan_repo.update(plan)
# 9. 更新 GenerationTask 状态为 completed
# 8. 更新 GenerationTask 状态为 completed
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
@@ -232,11 +329,10 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task_repo.update(gen_task)
logger.info(
"剪辑计划渲染完成: plan_id=%s rendered=%d failed=%d duration=%.1fs",
"剪辑计划渲染完成: plan_id=%s rendered=%d failed=%d",
plan_id,
len(rendered_clip_ids),
len(failed_clip_ids),
render_result.duration,
)
return {
@@ -245,36 +341,19 @@ def render_edit_plan(self, plan_id: str) -> dict:
"rendered_count": len(rendered_clip_ids),
"failed_count": len(failed_clip_ids),
"output_url": output_url,
"duration": render_result.duration,
}
except Exception as exc:
logger.exception("渲染剪辑计划异常: %s", plan_id)
# 尝试标记计划和 GenerationTask 为失败
# 尝试标记计划为失败
try:
plan = plan_repo.get(plan_id)
if plan and plan.status.value == "rendering":
plan.mark_failed()
plan_repo.update(plan)
except Exception as e:
logger.warning("标记计划失败时异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
# 更新 GenerationTask 状态为 failed,前端轮询能看到失败状态
try:
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.status.value != "failed":
gen_task.status = "failed"
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
logger.info(
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
generation_task_id,
plan_id,
)
except Exception as e:
logger.warning(
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True
)
raise self.retry(exc=exc, countdown=60)
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -4,7 +4,6 @@ import logging
from celery import Task
from celery.exceptions import Retry
from video_processing.oss_helpers import get_signed_download_url
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
@@ -49,9 +48,7 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
repo = SQLAlchemyVoiceCloneProfileRepository(session)
workflow = VoiceCloneWorkflowService(
repository=repo,
cosyvoice_service=CosyVoiceService(
audio_url_signer=lambda url: get_signed_download_url(url, expires_seconds=86400) or url
),
cosyvoice_service=CosyVoiceService(),
)
updated_profile = workflow.poll_and_process_clone(profile_id, timeout=300)
-235
View File
@@ -1,235 +0,0 @@
# CI 必需环境变量清单
> 本文档整理小虾 SaaS 项目中所有从环境变量读取的配置项,明确哪些是 CI 测试必须的、哪些是可选的。
> 最后更新:2026-07-09
## 目录
- [一、配置来源说明](#一配置来源说明)
- [二、CI 必需环境变量(P0](#二ci-必需环境变量p0)
- [三、可选环境变量(有默认值)](#三可选环境变量有默认值)
- [四、测试专用环境变量](#四测试专用环境变量)
- [五、Worker 服务环境变量](#五worker-服务环境变量)
- [六、当前 CI 配置对照](#六当前-ci-配置对照)
---
## 一、配置来源说明
项目的环境变量配置主要来自以下几处:
| 来源 | 文件路径 | 说明 |
|------|---------|------|
| API 主配置 | `apps/api/app/config.py` | pydantic `Settings` 类,API 服务核心配置 |
| Worker 配置 | `apps/worker/worker_app/core/config.py` | pydantic `WorkerSettings` 类,Worker 服务配置 |
| 共享配置 | `packages/shared/config.py` | pydantic `SharedSettings` 类,API + Worker 共享配置 |
| 直接读取 | 各模块中 `os.environ` / `os.getenv` | 散落在各业务模块中的直接读取 |
> **注意**pydantic-settings 配置默认 `case_sensitive=False`,即环境变量名不区分大小写,但习惯上使用大写。
---
## 二、CI 必需环境变量(P0
以下变量是 CI 运行测试**必须配置**的,缺失会导致测试启动失败或核心功能异常。
| 变量名 | 用途说明 | 默认值 | 影响范围 |
|--------|---------|--------|---------|
| `DATABASE_URL` | 数据库连接字符串 | `postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas` | 集成测试、 Alembic 迁移验证 |
| `USE_IN_MEMORY_DB` | 是否使用内存数据库(SQLite) | `false` | 单元测试(设为 `true` 可跳过 PostgreSQL 依赖) |
| `JWT_SECRET_KEY` | JWT 签名密钥,**无安全默认值**,必须显式设置 | `None`(启动校验失败) | 所有涉及认证的 API 测试 |
> **说明**
> - 单元测试通过 `USE_IN_MEMORY_DB=true` 使用 SQLite 内存数据库,无需 PostgreSQL
> - 集成测试需要真实 PostgreSQL,需设置 `DATABASE_URL`
> - `JWT_SECRET_KEY` 在测试文件中通过 `os.environ.setdefault()` 设置了测试用默认值,CI 中可不额外配置,但生产环境必须配置
---
## 三、可选环境变量(有默认值)
以下变量都有合理的默认值,CI 中可以不配置,使用默认值即可。
### 3.1 应用基础配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `APP_NAME` | 应用名称 | `xiaoxia-saas` |
| `APP_VERSION` | 应用版本号 | `0.1.61` / `unknown` |
| `ENVIRONMENT` | 运行环境标识 | `development` |
| `DEBUG` | 是否开启调试模式 | `true` |
| `APP_BASE_URL` | 应用基础 URL(用于生成邮件链接等) | `http://localhost:3000` |
| `API_HOST` | API 服务绑定地址 | `0.0.0.0` |
| `API_PORT` | API 服务端口 | `8000` |
| `API_PREFIX` | API 路由前缀 | `/api/v1` |
| `APP_ENV` | 环境标识(用于加载 .env.{env} 文件) | `development` |
| `LOG_LEVEL` | 日志级别 | `INFO` |
### 3.2 数据库连接池配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `DATABASE_POOL_SIZE` | 连接池大小 | `20` |
| `DATABASE_MAX_OVERFLOW` | 最大溢出连接数 | `10` (API) / `40` (Worker) |
| `DATABASE_POOL_TIMEOUT` | 获取连接超时时间(秒) | `30` |
| `DATABASE_POOL_RECYLE` / `DATABASE_POOL_RECYCLE` | 连接回收时间(秒) | `3600` |
| `AUTO_CREATE_SCHEMA` | 是否自动创建表结构 | `false` |
### 3.3 Redis / Celery 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `REDIS_URL` | Redis 连接地址 | `redis://localhost:6379/0` |
| `REDIS_MAX_CONNECTION` | Redis 最大连接数 | `50` |
| `ENABLE_REDIS_SESSIONS` | 是否启用 Redis 会话存储 | `false` |
| `CELERY_BROKER_URL` / `BROKER_URL` | Celery Broker 地址 | `redis://localhost:6379/0` |
| `CELERY_RESULT_BACKEND` / `RESULT_BACKEND` | Celery 结果后端 | `redis://localhost:6379/1` |
### 3.4 JWT 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `JWT_ALGORITHM` | JWT 签名算法 | `HS256`(隐式默认) |
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | Access Token 过期时间(分钟) | `30`(隐式默认) |
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | Refresh Token 过期时间(天) | `30`(隐式默认) |
| `JWT_SECRET_KEY_OLD` | 旧 JWT 密钥(用于密钥轮换) | `None` |
| `SECRET_ROTATION_DAYS` | 密钥轮换建议天数 | `90` |
### 3.5 邮件配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `ENABLE_EMAIL_DELIVERY` | 是否启用邮件发送 | `false` |
| `SMTP_HOST` | SMTP 服务器地址 | `smtp.gmail.com` |
| `SMTP_PORT` | SMTP 端口 | `587` |
| `SMTP_USER` | SMTP 用户名 | `""`(空) |
| `SMTP_PASSWORD` | SMTP 密码 | `""`(空) |
| `SMTP_FROM_EMAIL` | 发件人邮箱 | `""`(空) |
| `SMTP_FROM_NAME` | 发件人名称 | `小虾 SaaS` |
| `SMTP_USE_TLS` | 是否使用 TLS | `true` |
### 3.6 OSS 阿里云存储配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliiyuncs.com` |
| `OSS_ACCESS_KEY_ID` | OSS Access Key ID | `""`(空) |
| `OSS_ACCESS_KEY_SECRET` | OSS Access Key Secret | `""`(空) |
| `OSS_BUCKET_NAME` | OSS Bucket 名称 | `xiaoxia-autocut` |
| `OSS_DIRECT_UPLOAD_MAX_MB` / `MAX_UPLOAD_SIZE_MB` | 直传最大文件大小(MB | `2000` |
| `OSS_DIRECT_UPLOAD_EXPIRE_SECONDS` | 直传签名过期时间(秒) | `900` |
### 3.7 CosyVoice 语音合成配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `COSYVOICE_API_KEY` | CosyVoice API Key | `""`(空) |
| `COSYVOICE_BASE_URL` | CosyVoice API 地址 | `https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio` |
| `COSYVOICE_MODEL` | CosyVoice 模型 | `cosyvoice-v1` |
| `COSYVOICE_VOICE` | 默认音色 | `longxiaochun` |
| `COSYVOICE_SAMPLE_RATE` | 采样率 | `22050` |
| `COSYVOICE_FORMAT` | 输出格式 | `mp3` |
### 3.8 CORS 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `CORS_ORIGINS_RAW` | CORS 允许的源(逗号分隔) | `http://localhost:3000,http://localhost:5173,http://localhost:8000` |
### 3.9 文件存储 / 生成文件配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `GENERATED_FILES_DIR` | 生成文件本地存储目录 | `/app/generated` |
| `GENERATED_FILES_URL_PREFIX` | 生成文件访问 URL 前缀 | `/generated-files` |
| `VIDEO_OUTPUT_DIR` | 视频输出目录 | `{tempdir}/video_output` |
| `PUBLIC_API_BASE_URL` | 公开 API 基础 URL | `https://api.xiaoxiajianji.com` |
### 3.10 监控 / 指标配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `METRICS_AUTH_TOKEN` | Prometheus 指标接口认证 Token | `""`(空,不启用认证) |
### 3.11 内部 API 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `INTERNAL_API_KEYS` | 内部 API 调用密钥列表(逗号分隔) | `""`(空) |
---
## 四、测试专用环境变量
以下变量仅在测试或冒烟测试脚本中使用。
| 变量名 | 用途说明 | 默认值 | 使用位置 |
|--------|---------|--------|---------|
| `SMOKE_TEST_PASSWORD` | 冒烟测试用的测试账号密码 | `changeme` | `scripts/smoke_*.py` |
| `MIGRATION_SINCE_REVISION` | 迁移安全检查的起始版本 | `None` | `scripts/check_migration_safety.py` |
| `MIGRATION_DIFF_AGAINST` | 迁移 diff 对比的目标分支/版本 | `None` | `scripts/check_migration_safety.py` |
---
## 五、Worker 服务环境变量
以下变量主要用于 Worker(Celery)服务,CI 的单元/集成测试通常不涉及。
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `WORKER_NAME` | Worker 名称 | `xiaoxia-saas-worker` |
| `WORKER_CONCURRENCY` | Worker 并发数 | `4` |
| `WORKER_MAX_TASKS_PER_CHILD` | 每个子进程最大任务数 | `1000` |
---
## 六、当前 CI 配置对照
当前 `.gitea/workflows/ci-cd.yml``validate` job 配置的环境变量:
| 变量名 | CI 配置值 | 是否必需 | 备注 |
|--------|----------|---------|------|
| `DATABASE_URL` | `postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas` | ✅ 是 | Job 级别配置 |
| `USE_IN_MEMORY_DB` | `"false"`Job 级) / `"true"`(单元测试 step 级) | ✅ 是 | 单元测试 step 覆盖为 `true` |
| `JWT_SECRET_KEY` | (未配置) | ⚠️ 测试内置 | 测试文件中通过 `setdefault` 设置了测试密钥 |
### 6.1 CI 环境变量现状评估
- ✅ **数据库配置完备**DATABASE_URL + USE_IN_MEMORY_DB 已正确配置
- ✅ **JWT 密钥**:测试代码内置默认值,CI 可正常运行
- ⚠️ **缺少 Redis 配置**:但当前测试不依赖 Redis,使用默认值即可
- ⚠️ **缺少邮件/OSS/语音配置**:均为可选,CI 中使用空默认值不影响核心测试
### 6.2 建议后续补充
如果未来测试覆盖到以下功能,需要在 CI 中补充对应配置:
1. **Redis 相关测试** → 配置 `REDIS_URL`
2. **邮件发送测试** → 配置 `ENABLE_EMAIL_DELIVERY` 及 SMTP 相关变量
3. **OSS 上传测试** → 配置 OSS 相关变量(或使用 mock)
4. **语音合成测试** → 配置 CosyVoice 相关变量(或使用 mock
---
## 附录:环境变量读取位置索引
### pydantic Settings 类
- `apps/api/app/config.py``Settings` 类(API 主配置)
- `apps/worker/worker_app/core/config.py``WorkerSettings` 类(Worker 配置)
- `packages/shared/config.py``SharedSettings` 类(共享配置)
### 直接 os.environ / os.getenv 读取
| 变量名 | 文件位置 |
|--------|---------|
| `VIDEO_OUTPUT_DIR` | `apps/worker/video_processing/video_compose_service.py``apps/worker/worker_app/tasks/compose_video.py` |
| `INTERNAL_API_KEYS` | `apps/api/app/api/routes/auth.py` |
| `APP_ENV` / `ENV` | `apps/api/app/api/routes/auth.py`、各 config.py 的 `get_settings()` |
| `GENERATED_FILES_DIR` | `apps/worker/worker_app/tasks/generation.py``apps/api/main.py``scripts/cleanup_generated_files.py` |
| `GENERATED_FILES_URL_PREFIX` | `apps/worker/worker_app/tasks/generation.py``apps/api/main.py``apps/api/app/core/storage.py` |
| `PUBLIC_API_BASE_URL` | `apps/worker/worker_app/tasks/generation.py` |
| `METRICS_AUTH_TOKEN` | `apps/api/app/middleware/prometheus_metrics.py` |
| `APP_VERSION` | `apps/api/app/middleware/prometheus_metrics.py` |
| `SMOKE_TEST_PASSWORD` | `scripts/smoke_*.py` |
| `MIGRATION_SINCE_REVISION` | `scripts/check_migration_safety.py` |
| `MIGRATION_DIFF_AGAINST` | `scripts/check_migration_safety.py` |
| `DATABASE_URL` | `alembic/env.py` |
+37 -53
View File
@@ -473,7 +473,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -481,7 +481,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -489,7 +489,7 @@
"name": "asset_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -783,7 +783,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -791,7 +791,7 @@
"name": "plan_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -815,7 +815,7 @@
"name": "template_clip_config_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -823,7 +823,7 @@
"name": "asset_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -939,7 +939,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -947,7 +947,7 @@
"name": "template_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -987,7 +987,7 @@
"name": "source_edit_plan_id",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -995,7 +995,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1003,7 +1003,7 @@
"name": "created_by_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1071,7 +1071,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1098,14 +1098,6 @@
"type": "VARCHAR(50)",
"unique": false
},
{
"index": false,
"name": "editing_mode",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(20)",
"unique": false
},
{
"index": false,
"name": "config",
@@ -1189,7 +1181,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1197,7 +1189,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1205,7 +1197,7 @@
"name": "generation_task_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1341,7 +1333,7 @@
"name": "duplicate_of",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
}
],
@@ -1386,7 +1378,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1394,7 +1386,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1402,7 +1394,7 @@
"name": "strategy_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1410,7 +1402,7 @@
"name": "asset_library_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1418,7 +1410,7 @@
"name": "voice_library_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1514,7 +1506,7 @@
"name": "created_by_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1522,7 +1514,7 @@
"name": "source_edit_plan_id",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1538,7 +1530,7 @@
"name": "batch_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1549,14 +1541,6 @@
"type": "JSON",
"unique": false
},
{
"index": false,
"name": "logs",
"nullable": false,
"primary_key": false,
"type": "TEXT",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -1635,7 +1619,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1643,7 +1627,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1651,7 +1635,7 @@
"name": "library_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1683,7 +1667,7 @@
"name": "result_asset_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1745,7 +1729,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1753,7 +1737,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1841,7 +1825,7 @@
"name": "source_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1849,7 +1833,7 @@
"name": "created_by_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1933,7 +1917,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1941,7 +1925,7 @@
"name": "owner_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -2261,7 +2245,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -2269,7 +2253,7 @@
"name": "template_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
+14 -11
View File
@@ -4,16 +4,16 @@
# ============================================================
# 基础镜像:Python 3.12
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# 构建参数:版本号(CI 传入 commit hash
ARG APP_VERSION=dev
FROM python:3.12-slim-bookworm
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
@@ -21,12 +21,15 @@ WORKDIR /app
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
COPY requirements-base.txt /tmp/requirements-base.txt
RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt && rm /tmp/requirements-base.txt
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt \
&& rm /tmp/requirements-base.txt
# ---- 依赖分层:业务依赖(变化频繁)----
COPY requirements.txt /tmp/requirements.txt
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt && rm /tmp/requirements.txt
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
# 复制应用代码
COPY apps/api/ /app/apps/api/
@@ -37,13 +40,13 @@ COPY alembic/ /app/alembic/
COPY scripts/ /app/scripts/
# 设置环境变量
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
# API 入口点
WORKDIR /app/apps/api
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
FROM python:3.12-slim
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=0 \
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
# Worker 启动脚本 — 支持 WORKER_CONCURRENCY 环境变量
# 未设置时默认 2(保持向后兼容)
set -e
CONCURRENCY="${WORKER_CONCURRENCY:-2}"
exec celery \
-A worker_app.celery_app \
worker \
--loglevel=info \
"--concurrency=${CONCURRENCY}"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
ARG NGINX_CONF=infra/docker/nginx.conf
WORKDIR /usr/share/nginx/html
COPY apps/web/dist ./
+5 -3
View File
@@ -1,17 +1,19 @@
# Build stage
FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
FROM docker.m.daocloud.io/library/node:20 AS builder
WORKDIR /app
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
ENV VITE_API_URL=$VITE_API_URL
COPY apps/web/package.json apps/web/package-lock.json ./apps/web/
WORKDIR /app/apps/web
RUN npm config set registry https://registry.npmmirror.com \
RUN \
--mount=type=cache,target=/root/.npm \
npm config set registry https://registry.npmmirror.com \
&& npm ci
COPY apps/web/ ./
RUN npm run build
# Production stage with nginx
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
ARG NGINX_CONF=infra/docker/nginx.conf
WORKDIR /usr/share/nginx/html
COPY --from=builder /app/apps/web/dist ./
+3 -11
View File
@@ -4,10 +4,7 @@
# ============================================================
# 基础镜像:Python 3.12 + ffmpeg
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# 构建参数:版本号(CI 传入 commit hash
ARG APP_VERSION=dev
FROM python:3.12-slim-bookworm
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
@@ -18,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libsm6 \
libxext6 \
libgl1 \
libgl1-mesa-glx \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
@@ -51,15 +48,10 @@ COPY packages/ /app/packages/
COPY alembic.ini /app/alembic.ini
COPY migrations/ /app/migrations/
# 复制 Worker 启动脚本(支持 WORKER_CONCURRENCY 环境变量)
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
# 设置 Python 路径
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
# 创建非 root 用户运行 Worker
RUN groupadd -r celery && useradd -r -g celery -d /app -s /sbin/nologin celery \
@@ -69,4 +61,4 @@ USER celery
# Worker 入口点
WORKDIR /app/apps/worker
CMD ["/usr/local/bin/entrypoint-worker.sh"]
CMD ["celery", "-A", "worker_app.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
@@ -16,9 +16,6 @@ class InMemoryAssetLibraryRepository:
def get(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
def find_by_id(self, library_id: str) -> AssetLibrary | None:
return self.get(library_id)
def find_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]:
items = [library for library in self._libraries.values() if library.project_id == project_id]
if kind is not None:
@@ -71,7 +71,6 @@ class SQLAlchemyEditTemplateRepository:
name=template.name,
description=template.description,
template_type=template.template_type,
editing_mode=template.editing_mode,
config=template.config,
preview_url=template.preview_url,
sort_weight=template.sort_weight,
@@ -90,7 +89,6 @@ class SQLAlchemyEditTemplateRepository:
model.name = template.name
model.description = template.description
model.template_type = template.template_type
model.editing_mode = template.editing_mode
model.config = template.config
model.preview_url = template.preview_url
model.sort_weight = template.sort_weight
@@ -130,7 +128,6 @@ class SQLAlchemyEditTemplateRepository:
name=model.name,
description=model.description or "",
template_type=model.template_type or "default",
editing_mode=model.editing_mode or "one_take",
config=model.config or {},
preview_url=model.preview_url or "",
sort_weight=model.sort_weight or 0,
-20
View File
@@ -27,7 +27,6 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
source_edit_plan_id=model.source_edit_plan_id or "",
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
logs=model.logs or "[]",
created_at=model.created_at,
)
@@ -57,7 +56,6 @@ class SQLAlchemyGenerationTaskRepository:
source_edit_plan_id=task.source_edit_plan_id or None,
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
logs=task.logs,
created_at=task.created_at,
)
self.session.add(model)
@@ -91,23 +89,6 @@ class SQLAlchemyGenerationTaskRepository:
def count_by_user(self, user_id: str) -> int:
return self.session.query(GenerationTaskModel).filter(GenerationTaskModel.created_by_user_id == user_id).count()
def count_pending_by_user(self, user_id: str) -> int:
return (
self.session.query(GenerationTaskModel)
.filter(
GenerationTaskModel.created_by_user_id == user_id,
GenerationTaskModel.status == GenerationTaskStatus.PENDING.value,
)
.count()
)
def count_pending_total(self) -> int:
return (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.status == GenerationTaskStatus.PENDING.value)
.count()
)
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
models = (
self.session.query(GenerationTaskModel)
@@ -148,6 +129,5 @@ class SQLAlchemyGenerationTaskRepository:
model.source_edit_plan_id = task.source_edit_plan_id or None
model.asset_select_mode = task.asset_select_mode or ""
model.batch_id = task.batch_id or ""
model.logs = task.logs
self.session.commit()
return task
+37 -39
View File
@@ -39,8 +39,8 @@ class UserModel(Base):
class ProjectModel(Base):
__tablename__ = "projects"
id = Column(String(36), primary_key=True)
owner_user_id = Column(String(36), nullable=False, index=True)
id = Column(String(32), primary_key=True)
owner_user_id = Column(String(32), nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=False, default="")
shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表
@@ -122,11 +122,10 @@ class EditTemplateModel(Base):
__tablename__ = "edit_templates"
id = Column(String(36), primary_key=True)
id = Column(String(32), primary_key=True)
name = Column(String(120), nullable=False)
description = Column(Text, nullable=False, default="")
template_type = Column(String(50), nullable=False, default="default", index=True)
editing_mode = Column(String(20), nullable=False, default="one_take")
config = Column(JSON, nullable=False, default=dict)
preview_url = Column(String(1000), nullable=False, default="")
sort_weight = Column(Integer, nullable=False, default=0, index=True)
@@ -143,15 +142,15 @@ class EditPlanModel(Base):
__tablename__ = "edit_plans"
id = Column(String(36), primary_key=True)
template_id = Column(String(36), nullable=False, index=True)
id = Column(String(32), primary_key=True)
template_id = Column(String(32), nullable=False, index=True)
name = Column(String(200), nullable=False)
status = Column(String(20), nullable=False, default="draft", index=True)
total_duration = Column(Float, nullable=False, default=0.0)
config = Column(JSON, nullable=False, default=dict)
source_edit_plan_id = Column(String(36), nullable=True, index=True)
project_id = Column(String(36), nullable=False, default="", index=True)
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
source_edit_plan_id = Column(String(32), nullable=True, index=True)
project_id = Column(String(32), nullable=False, default="", index=True)
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -164,8 +163,8 @@ class TemplateClipConfigModel(Base):
__tablename__ = "template_clip_configs"
id = Column(String(36), primary_key=True)
template_id = Column(String(36), nullable=False, index=True)
id = Column(String(32), primary_key=True)
template_id = Column(String(32), nullable=False, index=True)
clip_type = Column(String(20), nullable=False, index=True)
order = Column(Integer, nullable=False)
min_duration = Column(Float, nullable=False, default=0.0)
@@ -186,12 +185,12 @@ class EditPlanClipModel(Base):
__tablename__ = "edit_plan_clips"
id = Column(String(36), primary_key=True)
plan_id = Column(String(36), nullable=False, index=True)
id = Column(String(32), primary_key=True)
plan_id = Column(String(32), nullable=False, index=True)
clip_type = Column(String(20), nullable=False, index=True)
order = Column(Integer, nullable=False)
template_clip_config_id = Column(String(36), nullable=False, default="", index=True)
asset_id = Column(String(36), nullable=False, default="", index=True)
template_clip_config_id = Column(String(32), nullable=False, default="", index=True)
asset_id = Column(String(32), nullable=False, default="", index=True)
text_content = Column(Text, nullable=False, default="")
start_time = Column(Float, nullable=False, default=0.0)
duration = Column(Float, nullable=False, default=0.0)
@@ -205,13 +204,13 @@ class EditPlanClipModel(Base):
class IngestJobModel(Base):
__tablename__ = "ingest_jobs"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=False, index=True)
library_id = Column(String(36), nullable=False, index=True)
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
library_id = Column(String(32), nullable=False, index=True)
storage_key = Column(String(255), nullable=False)
status = Column(String(20), nullable=False, default="pending")
error_message = Column(Text, nullable=False, default="")
result_asset_id = Column(String(36), nullable=False, default="")
result_asset_id = Column(String(32), nullable=False, default="")
file_hash = Column(String(64), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -220,9 +219,9 @@ class IngestJobModel(Base):
class ClassificationJobModel(Base):
__tablename__ = "classification_jobs"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=False, index=True)
asset_id = Column(String(36), nullable=False, index=True)
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
asset_id = Column(String(32), nullable=False, index=True)
status = Column(String(20), nullable=False, default="pending")
classification = Column(String(50), nullable=False, default="")
confidence = Column(Float, nullable=False, default=0.0)
@@ -234,11 +233,11 @@ class ClassificationJobModel(Base):
class GenerationTaskModel(Base):
__tablename__ = "generation_tasks"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=False, default="", index=True)
strategy_id = Column(String(36), nullable=False, default="")
asset_library_id = Column(String(36), nullable=False, default="", index=True)
voice_library_id = Column(String(36), nullable=False, default="")
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, default="", index=True)
strategy_id = Column(String(32), nullable=False, default="")
asset_library_id = Column(String(32), nullable=False, default="", index=True)
voice_library_id = Column(String(32), nullable=False, default="")
template_id = Column(String(36), nullable=False, default="", index=True)
asset_ids = Column(JSON, nullable=False, default=list)
title_ids = Column(JSON, nullable=False, default=list)
@@ -252,21 +251,20 @@ class GenerationTaskModel(Base):
error_message = Column(Text, nullable=False, default="")
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
source_edit_plan_id = Column(String(36), nullable=True, index=True)
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
source_edit_plan_id = Column(String(32), nullable=True, index=True)
asset_select_mode = Column(String(20), nullable=False, default="")
batch_id = Column(String(36), nullable=False, default="", index=True)
batch_id = Column(String(32), nullable=False, default="", index=True)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
logs = Column(Text, nullable=False, default="[]", server_default="[]")
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class GeneratedVideoModel(Base):
__tablename__ = "generated_videos"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=False, index=True)
generation_task_id = Column(String(36), nullable=False, index=True)
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
generation_task_id = Column(String(32), nullable=False, index=True)
name = Column(String(255), nullable=False)
# file_url: 完整可访问的 URL,用于客户端直接访问视频
file_url = Column(String(1000), nullable=False)
@@ -285,7 +283,7 @@ class GeneratedVideoModel(Base):
updated_at = Column(DateTime, nullable=True)
video_fingerprint = Column(Text, nullable=True)
is_duplicate = Column(Boolean, nullable=False, default=False)
duplicate_of = Column(String(36), nullable=True)
duplicate_of = Column(String(32), nullable=True)
class TitleLibraryModel(Base):
@@ -452,8 +450,8 @@ class JobModel(Base):
__tablename__ = "jobs"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=False, index=True)
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
job_type = Column(String(30), nullable=False, index=True)
status = Column(String(20), nullable=False, default="pending", index=True)
progress = Column(Float, nullable=False, default=0.0)
@@ -464,8 +462,8 @@ class JobModel(Base):
retry_count = Column(Integer, nullable=False, default=0)
max_retries = Column(Integer, nullable=False, default=3)
celery_task_id = Column(String(100), nullable=False, default="")
source_id = Column(String(36), nullable=False, default="", index=True)
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
source_id = Column(String(32), nullable=False, default="", index=True)
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+304 -311
View File
@@ -1,13 +1,11 @@
"""CosyVoice 语音服务 — 适配阿里云百炼 DashScope API.
"""CosyVoice 语音服务 — Phase 3.
封装阿里云百炼 CosyVoice 语音合成 API提供
封装阿里云 CosyVoice 语音合成 API提供
- 预置音色列表查询
- 音色克隆提交 + 轮询状态
- 语音合成同步非流式调用
- 音色克隆提交任务 + 轮询状态
- 语音合成提交任务 + 轮询状态
API 文档:
- 音色克隆: https://help.aliyun.com/document_detail/3027318.html
- 语音合成: https://help.aliyun.com/zh/model-studio/cosyvoice-tts-http-api
API 文档: https://help.aliyun.com/zh/model-studio/cosyvoice
"""
from __future__ import annotations
@@ -62,34 +60,33 @@ class SynthesizeResult:
class CosyVoiceService:
"""CosyVoice 语音服务.
"""CosyVoice 语音服务
封装阿里云百炼 CosyVoice API提供音色克隆和语音合成功能.
接口总览:
- 音色克隆: POST /services/audio/tts/customization (model=voice-enrollment)
- action=create_voice: 创建克隆音色返回 voice_id状态 DEPLOYING
- action=query_voice: 查询音色状态DEPLOYING / OK / UNDEPLOYED
- 语音合成: POST /services/audio/tts/SpeechSynthesizer (model=cosyvoice-v3-flash)
- 非流式: 同步返回音频 URL
封装阿里云 CosyVoice API提供音色克隆和语音合成功能
支持同步和异步两种模式
- 同步API 直接返回结果
- 异步API 返回 task_id需要轮询状态
使用示例:
service = CosyVoiceService(
api_key="your-api-key",
base_url="https://dashscope.aliyuncs.com/api/v1",
model="cosyvoice-v3-flash",
base_url="https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio",
model="cosyvoice-v1",
)
# 获取预置音色
voices = service.list_preset_voices()
# 音色克隆
result = service.clone_voice(audio_url="https://example.com/audio.mp3")
# 语音合成
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun_v3")
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun")
"""
# 音色状态轮询配置
CLONE_POLL_INTERVAL = 5.0 # 秒
CLONE_MAX_POLL_ATTEMPTS = 60 # 最多轮询 60 次(5分钟)
# 轮询配置
POLL_INTERVAL = 2.0 # 秒
MAX_POLL_ATTEMPTS = 60 # 最多轮询 60 次(2分钟)
# 重试配置
MAX_RETRIES = 3
@@ -100,70 +97,27 @@ class CosyVoiceService:
api_key: str = "",
base_url: str = "",
model: str = "",
clone_model: str = "",
http_client: Optional[httpx.Client] = None,
audio_url_signer: Optional[callable] = None,
) -> None:
"""初始化 CosyVoice 服务.
"""初始化 CosyVoice 服务
Args:
api_key: DashScope API Key为空时从配置读取
base_url: DashScope API Base URL为空时从配置读取
model: 语音合成模型名称为空时从配置读取
clone_model: 音色克隆模型名称为空时从配置读取
api_key: CosyVoice API Key为空时从配置读取
base_url: CosyVoice API Base URL为空时从配置读取
model: CosyVoice 模型名称为空时从配置读取
http_client: 可选的 HTTP 客户端用于测试注入
audio_url_signer: 可选的音频URL预签名函数签名式 fn(url) -> str.
用于私有 bucket 将裸 URL 转为预签名 URL
确保 CosyVoice 服务器能下载参考音频.
"""
settings = get_shared_settings()
self._api_key = api_key or settings.cosyvoice_api_key
self._base_url = base_url or settings.cosyvoice_base_url
self._model = model or settings.cosyvoice_model
self._clone_model = clone_model or getattr(settings, "cosyvoice_clone_model", "voice-enrollment")
self._audio_url_signer = audio_url_signer
# base_url 规范化:去掉末尾的路径残留(兼容旧版配置)
# 旧版 .env 模板中 base_url 包含 /services/aigc/text2audio 完整路径,
# 新版只需 /api/v1,具体路径由代码拼接。这里自动修正,避免配置滞后导致418。
if "/services/aigc/text2audio" in self._base_url:
old_url = self._base_url
# 截取到 /api/v1 为止
idx = self._base_url.find("/api/v1")
if idx >= 0:
self._base_url = self._base_url[: idx + len("/api/v1")]
logger.warning(
"[CosyVoice Config] base_url包含旧版text2audio路径,已自动修正: " "%s -> %s",
old_url,
self._base_url,
)
self._client = http_client or httpx.Client(
timeout=httpx.Timeout(60.0, connect=10.0),
timeout=httpx.Timeout(30.0, connect=10.0),
)
self._owns_client = http_client is None
# 启动时打印配置(脱敏),方便排查环境变量覆盖问题
if self._owns_client:
masked_key = ""
if self._api_key:
if len(self._api_key) > 8:
masked_key = f"{self._api_key[:4]}...{self._api_key[-4:]}"
else:
masked_key = "***"
logger.info(
"[CosyVoice Config] 初始化配置: "
"model=%s, base_url=%s, default_voice=%s, "
"sample_rate=%d, format=%s, api_key=%s",
self._model,
self._base_url,
getattr(settings, "cosyvoice_voice", "(unset)"),
settings.cosyvoice_sample_rate,
settings.cosyvoice_format,
masked_key or "(empty)",
)
def __enter__(self) -> CosyVoiceService:
return self
@@ -178,7 +132,7 @@ class CosyVoiceService:
# ── 预置音色 ─────────────────────────────────────────
def list_preset_voices(self) -> list[PresetVoice]:
"""获取预置音色列表.
"""获取预置音色列表
Returns:
预置音色列表
@@ -192,22 +146,20 @@ class CosyVoiceService:
audio_url: str,
voice_name: str = "",
language: str = "zh-CN",
target_model: str = "",
) -> dict:
"""提交音色克隆任务(非阻塞).
"""提交音色克隆任务(非阻塞)
调用百炼 voice-enrollment API 创建克隆音色.
创建后音色状态为 DEPLOYING需通过 query_voice_status 轮询直到 OK.
只提交任务到 CosyVoice API不轮询结果
返回的 dict 包含 task_id异步 voice_id同步
Args:
audio_url: 参考音频 URL必须公网可访问
voice_name: 音色名称前缀字母数字最多10字符
language: 语言代码zh-CN 会转换为 zh
target_model: 目标合成模型默认使用当前 model
audio_url: 参考音频 URL
voice_name: 音色名称可选
language: 语言代码
Returns:
dict: {"voice_id": str, "status": str, "request_id": str}
voice_id 非空status 通常为 DEPLOYING
dict: {"task_id": str, "voice_id": str, "request_id": str}
task_id voice_id 至少有一个非空
Raises:
CosyVoiceError: API 调用失败
@@ -219,67 +171,48 @@ class CosyVoiceService:
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
# voice_name 作为 prefix,限制字母数字,最多10字符
# 不符合要求的做清洗
prefix = self._sanitize_prefix(voice_name) if voice_name else "clone"
# 语言转换:zh-CN → zh,保留 ISO 639-1 格式
lang_code = language.split("-")[0].lower() if language else "zh"
target = target_model or self._model
# 如果配置了 audio_url_signer,对音频URL做预签名
# (私有 bucket 下 CosyVoice 服务器无法直接访问裸 URL)
signed_audio_url = audio_url
if self._audio_url_signer:
try:
signed_audio_url = self._audio_url_signer(audio_url)
logger.info("音频URL已预签名: original=%s signed_prefix=%s", audio_url[:80], signed_audio_url[:80])
except Exception as e:
logger.warning("音频URL预签名失败,使用原始URL: %s", e)
payload = {
"model": self._clone_model,
"model": self._model,
"input": {
"action": "create_voice",
"target_model": target,
"prefix": prefix,
"url": signed_audio_url,
"language_hints": [lang_code],
"audio_url": audio_url,
},
"parameters": {
"language": language,
},
}
if voice_name:
payload["parameters"]["voice_name"] = voice_name
response = self._call_api(
method="POST",
path="/services/audio/tts/customization",
path="/services/audio/voice-clone",
json=payload,
timeout=60.0,
)
output = response.get("output", {})
task_id = output.get("task_id", "")
voice_id = output.get("voice_id", "")
status = output.get("status", "DEPLOYING")
request_id = response.get("request_id", "")
if not voice_id:
raise CosyVoiceError(f"CosyVoice API 未返回 voice_id: {response}")
if not task_id and not voice_id:
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}")
return {
"task_id": task_id,
"voice_id": voice_id,
"status": status,
"request_id": request_id,
}
def query_voice_status(self, voice_id: str) -> dict:
"""查询音色状态(单次查询,不轮询).
def check_task_status(self, task_id: str) -> dict:
"""查询克隆任务状态(单次查询,不轮询)
Args:
voice_id: 音色 ID
task_id: 任务 ID
Returns:
dict: {"status": str, "target_model": str, "gmt_create": str,
"gmt_modified": str, "resource_link": str}
status DEPLOYING / OK / UNDEPLOYED
dict: {"status": str, "voice_id": str, "message": str}
status SUCCEEDED/FAILED/PENDING/RUNNING
Raises:
CosyVoiceError: API 调用失败
@@ -288,92 +221,40 @@ class CosyVoiceService:
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
if not voice_id:
raise ValueError("voice_id 不能为空")
payload = {
"model": self._clone_model,
"input": {
"action": "query_voice",
"voice_id": voice_id,
},
}
response = self._call_api(
method="POST",
path="/services/audio/tts/customization",
json=payload,
method="GET",
path=f"/tasks/{task_id}",
timeout=30.0,
)
output = response.get("output", {})
status = output.get("task_status", "").upper()
voice_id = output.get("voice_id", "")
message = output.get("message", "")
return {
"status": output.get("status", ""),
"target_model": output.get("target_model", ""),
"gmt_create": output.get("gmt_create", ""),
"gmt_modified": output.get("gmt_modified", ""),
"resource_link": output.get("resource_link", ""),
"status": status,
"voice_id": voice_id,
"message": message,
}
def check_task_status(self, task_id: str) -> dict:
"""查询克隆任务状态(兼容旧接口,实际用 voice_id 查询).
def poll_clone_task(self, task_id: str, timeout: float = 300.0) -> dict:
"""轮询音色克隆任务状态(公开方法)。
为了兼容旧代码task_id 参数名保留但实际传的是 voice_id.
Celery 后台任务调用轮询直到完成或超时
Args:
task_id: 音色 ID兼容旧接口名
Returns:
dict: {"status": str, "voice_id": str, "message": str}
"""
result = self.query_voice_status(task_id)
return {
"status": result["status"],
"voice_id": task_id,
"message": "",
}
def poll_clone_task(self, voice_id: str, timeout: float = 300.0) -> dict:
"""轮询音色克隆状态直到完成或超时.
Celery 后台任务调用轮询直到状态变为 OK UNDEPLOYED.
Args:
voice_id: 音色 ID
task_id: CosyVoice 任务 ID
timeout: 超时时间默认 300
Returns:
dict: {"voice_id": str}
Raises:
CosyVoiceError: 任务失败状态 UNDEPLOYED
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
start_time = time.time()
attempts = 0
while attempts < self.CLONE_MAX_POLL_ATTEMPTS:
elapsed = time.time() - start_time
if elapsed > timeout:
raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): voice_id={voice_id}")
result = self.query_voice_status(voice_id)
status = result.get("status", "").upper()
if status == "OK":
return {"voice_id": voice_id}
elif status == "UNDEPLOYED":
raise CosyVoiceError(f"音色克隆任务失败(审核未通过): voice_id={voice_id}")
elif status in ("DEPLOYING", "PENDING", "PROCESSING", ""):
# 继续轮询
time.sleep(self.CLONE_POLL_INTERVAL)
attempts += 1
else:
logger.warning("未知的音色状态: %s (voice_id=%s)", status, voice_id)
time.sleep(self.CLONE_POLL_INTERVAL)
attempts += 1
raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: voice_id={voice_id}")
return self._poll_clone_task(task_id, timeout=timeout)
def clone_voice(
self,
@@ -381,45 +262,122 @@ class CosyVoiceService:
voice_name: str = "",
language: str = "zh-CN",
timeout: float = 300.0,
target_model: str = "",
) -> CloneResult:
"""克隆音色(阻塞,直到完成或超时).
"""克隆音色
提交音色克隆到百炼 API并轮询直到状态变为 OK 或超时.
提交音色克隆任务到 CosyVoice API并轮询直到完成或超时
Args:
audio_url: 参考音频 URL必须公网可访问
voice_name: 音色名称前缀
audio_url: 参考音频 URL
voice_name: 音色名称可选
language: 语言代码
timeout: 超时时间
target_model: 目标合成模型
Returns:
CloneResult: 克隆结果包含 voice_id
Raises:
CosyVoiceError: API 调用失败或克隆失败
CosyVoiceError: API 调用失败
CosyVoiceTimeoutError: 超时
CosyVoiceAuthError: 认证失败
ValueError: 参数无效
"""
submit_result = self.submit_clone_task(
audio_url=audio_url,
voice_name=voice_name,
language=language,
target_model=target_model,
if not audio_url:
raise ValueError("audio_url 不能为空")
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
# 构建请求
payload = {
"model": self._model,
"input": {
"audio_url": audio_url,
},
"parameters": {
"language": language,
},
}
if voice_name:
payload["parameters"]["voice_name"] = voice_name
# 调用 API
response = self._call_api(
method="POST",
path="/services/audio/voice-clone",
json=payload,
timeout=timeout,
)
voice_id = submit_result["voice_id"]
request_id = submit_result["request_id"]
# 解析响应
output = response.get("output", {})
# 如果创建时已经是 OK 状态,直接返回
if submit_result.get("status", "").upper() == "OK":
return CloneResult(voice_id=voice_id, request_id=request_id)
# 检查是否有 task_id(异步模式)
task_id = output.get("task_id")
voice_id = output.get("voice_id")
# 否则轮询
result = self.poll_clone_task(voice_id, timeout=timeout)
return CloneResult(voice_id=result["voice_id"], request_id=request_id)
if task_id:
# 异步模式:轮询任务状态
result = self._poll_clone_task(task_id, timeout)
return CloneResult(
voice_id=result["voice_id"],
request_id=response.get("request_id", ""),
)
elif voice_id:
# 同步模式:直接返回结果
return CloneResult(
voice_id=voice_id,
request_id=response.get("request_id", ""),
)
else:
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}")
def _poll_clone_task(self, task_id: str, timeout: float) -> dict:
"""轮询音色克隆任务状态。
Args:
task_id: 任务 ID
timeout: 超时时间
Returns:
任务结果字典
Raises:
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
start_time = time.time()
attempts = 0
while attempts < self.MAX_POLL_ATTEMPTS:
elapsed = time.time() - start_time
if elapsed > timeout:
raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): task_id={task_id}")
response = self._call_api(
method="GET",
path=f"/tasks/{task_id}",
timeout=30.0,
)
output = response.get("output", {})
status = output.get("task_status", "").upper()
if status == "SUCCEEDED":
voice_id = output.get("voice_id", "")
if not voice_id:
raise CosyVoiceError(f"音色克隆任务成功但未返回 voice_id: {response}")
return {"voice_id": voice_id}
elif status == "FAILED":
error_msg = output.get("message", "未知错误")
raise CosyVoiceError(f"音色克隆任务失败: {error_msg}")
elif status in ("PENDING", "RUNNING"):
# 继续轮询
time.sleep(self.POLL_INTERVAL)
attempts += 1
else:
raise CosyVoiceError(f"未知的任务状态: {status}")
raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: task_id={task_id}")
# ── 语音合成 ─────────────────────────────────────────
@@ -430,12 +388,11 @@ class CosyVoiceService:
sample_rate: int = 0,
format: str = "",
speed: float = 1.0,
volume: int = 50,
) -> dict:
"""提交语音合成任务(同步非流式,直接返回结果).
"""提交语音合成任务(非阻塞)。
CosyVoice SpeechSynthesizer 非流式接口是同步的
调用后直接返回音频 URL. 此方法保持与旧接口兼容.
只提交任务到 CosyVoice API不轮询结果
返回的 dict 包含 task_id异步 audio_url同步
Args:
text: 要合成的文本
@@ -443,11 +400,10 @@ class CosyVoiceService:
sample_rate: 采样率Hz0 表示使用配置默认值
format: 输出格式mp3/wav/pcm空表示使用配置默认值
speed: 语速0.5-2.01.0 为正常速度
volume: 音量0-100默认 50
Returns:
dict: {"audio_url": str, "request_id": str,
"duration": float, "file_size": int}
dict: {"task_id": str, "audio_url": str, "request_id": str}
task_id audio_url 至少有一个非空
Raises:
CosyVoiceError: API 调用失败
@@ -467,47 +423,55 @@ class CosyVoiceService:
"model": self._model,
"input": {
"text": text,
},
"parameters": {
"voice": voice_id,
"format": format or settings.cosyvoice_format,
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
"format": format or settings.cosyvoice_format,
"rate": speed,
"volume": volume,
},
}
response = self._call_api(
method="POST",
path="/services/audio/tts/SpeechSynthesizer",
path="/services/aigc/text2audio/generation",
json=payload,
timeout=120.0,
timeout=60.0,
)
output = response.get("output", {})
audio = output.get("audio", {})
audio_url = audio.get("url", "")
task_id = output.get("task_id", "")
audio_url = output.get("audio_url", "")
request_id = response.get("request_id", "")
if not audio_url:
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
if not task_id and not audio_url:
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 audio_url: {response}")
return {
"task_id": "", # 同步接口无 task_id,兼容旧接口
"task_id": task_id,
"audio_url": audio_url,
"duration": 0.0, # 同步接口不返回 duration
"file_size": 0, # 同步接口不返回 file_size
"duration": output.get("duration", 0.0),
"file_size": output.get("file_size", 0),
"request_id": request_id,
}
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
"""轮询合成任务(同步接口无需轮询,保留兼容).
"""轮询语音合成任务状态(公开方法)。
CosyVoice SpeechSynthesizer 非流式接口是同步的
此方法仅为保持接口兼容实际调用时 task_id 应该为空.
Celery 后台任务调用轮询直到完成或超时
Args:
task_id: CosyVoice 任务 ID
timeout: 超时时间默认 120
Returns:
dict: {"audio_url": str, "duration": float, "file_size": int}
Raises:
CosyVoiceError: 同步接口无需轮询
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
raise CosyVoiceError("CosyVoice 非流式合成接口是同步的,无需轮询. " "请直接使用 submit_synthesize_task().")
return self._poll_synthesize_task(task_id, timeout=timeout)
def synthesize_speech(
self,
@@ -516,13 +480,11 @@ class CosyVoiceService:
sample_rate: int = 0,
format: str = "",
speed: float = 1.0,
volume: int = 50,
timeout: float = 120.0,
) -> SynthesizeResult:
"""语音合成(同步非流式).
"""语音合成
调用百炼 CosyVoice SpeechSynthesizer 非流式接口
直接返回合成音频 URL.
提交语音合成任务到 CosyVoice API并轮询直到完成或超时
Args:
text: 要合成的文本
@@ -530,52 +492,128 @@ class CosyVoiceService:
sample_rate: 采样率Hz0 表示使用配置默认值
format: 输出格式mp3/wav/pcm空表示使用配置默认值
speed: 语速0.5-2.01.0 为正常速度
volume: 音量0-100默认 50
timeout: 超时时间保留参数兼容
timeout: 超时时间
Returns:
SynthesizeResult: 合成结果包含 audio_url
Raises:
CosyVoiceError: API 调用失败
CosyVoiceTimeoutError: 超时
CosyVoiceAuthError: 认证失败
ValueError: 参数无效
"""
result = self.submit_synthesize_task(
text=text,
voice_id=voice_id,
sample_rate=sample_rate,
format=format,
speed=speed,
volume=volume,
if not text:
raise ValueError("text 不能为空")
if not voice_id:
raise ValueError("voice_id 不能为空")
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
settings = get_shared_settings()
# 构建请求
payload = {
"model": self._model,
"input": {
"text": text,
},
"parameters": {
"voice": voice_id,
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
"format": format or settings.cosyvoice_format,
"rate": speed,
},
}
# 调用 API
response = self._call_api(
method="POST",
path="/services/aigc/text2audio/generation",
json=payload,
timeout=timeout,
)
return SynthesizeResult(
audio_url=result["audio_url"],
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
request_id=result.get("request_id", ""),
)
# 解析响应
output = response.get("output", {})
# ── 内部方法 ─────────────────────────────────────────
# 检查是否有 task_id(异步模式)
task_id = output.get("task_id")
audio_url = output.get("audio_url")
def _sanitize_prefix(self, name: str) -> str:
"""清洗音色名称为合法的 prefix(字母数字,最多10字符).
if task_id:
# 异步模式:轮询任务状态
result = self._poll_synthesize_task(task_id, timeout)
return SynthesizeResult(
audio_url=result["audio_url"],
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
request_id=response.get("request_id", ""),
)
elif audio_url:
# 同步模式:直接返回结果
return SynthesizeResult(
audio_url=audio_url,
duration=output.get("duration", 0.0),
file_size=output.get("file_size", 0),
request_id=response.get("request_id", ""),
)
else:
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url 或 task_id: {response}")
def _poll_synthesize_task(self, task_id: str, timeout: float) -> dict:
"""轮询语音合成任务状态。
Args:
name: 原始音色名称
task_id: 任务 ID
timeout: 超时时间
Returns:
清洗后的 prefix
任务结果字典
Raises:
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
# 只保留字母和数字
cleaned = "".join(c for c in name if c.isalnum())
# 最多10字符
cleaned = cleaned[:10]
# 如果清洗后为空,用默认值
if not cleaned:
cleaned = "clone"
return cleaned
start_time = time.time()
attempts = 0
while attempts < self.MAX_POLL_ATTEMPTS:
elapsed = time.time() - start_time
if elapsed > timeout:
raise CosyVoiceTimeoutError(f"语音合成任务超时({timeout}秒): task_id={task_id}")
response = self._call_api(
method="GET",
path=f"/tasks/{task_id}",
timeout=30.0,
)
output = response.get("output", {})
status = output.get("task_status", "").upper()
if status == "SUCCEEDED":
audio_url = output.get("audio_url", "")
if not audio_url:
raise CosyVoiceError(f"语音合成任务成功但未返回 audio_url: {response}")
return {
"audio_url": audio_url,
"duration": output.get("duration", 0.0),
"file_size": output.get("file_size", 0),
}
elif status == "FAILED":
error_msg = output.get("message", "未知错误")
raise CosyVoiceError(f"语音合成任务失败: {error_msg}")
elif status in ("PENDING", "RUNNING"):
# 继续轮询
time.sleep(self.POLL_INTERVAL)
attempts += 1
else:
raise CosyVoiceError(f"未知的任务状态: {status}")
raise CosyVoiceTimeoutError(f"语音合成任务轮询次数超限: task_id={task_id}")
# ── 内部方法 ─────────────────────────────────────────
def _call_api(
self,
@@ -584,13 +622,13 @@ class CosyVoiceService:
json: Optional[dict] = None,
timeout: float = 30.0,
) -> dict:
"""调用 DashScope API.
"""调用 CosyVoice API
支持重试和错误处理.
支持重试和错误处理
Args:
method: HTTP 方法GET/POST
path: API 路径 / 开头
path: API 路径
json: 请求体
timeout: 超时时间
@@ -608,22 +646,6 @@ class CosyVoiceService:
"Content-Type": "application/json",
}
# DEBUG: 打印完整请求信息,用于排查418错误
import json as json_lib
safe_headers = {k: v for k, v in headers.items()}
if "Authorization" in safe_headers:
token = safe_headers["Authorization"]
if len(token) > 20:
safe_headers["Authorization"] = token[:13] + "..." + token[-4:]
logger.info(
"[CosyVoice Debug] 请求详情: " "method=%s, url=%s, headers=%s, body=%s",
method,
url,
safe_headers,
json_lib.dumps(json, ensure_ascii=False) if json else "None",
)
last_error: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES):
@@ -636,58 +658,29 @@ class CosyVoiceService:
timeout=timeout,
)
# DEBUG: 打印响应状态和完整响应体
logger.info(
"[CosyVoice Debug] 响应详情: " "status=%d, body=%s",
response.status_code,
response.text[:2000], # 最多2000字符,避免日志过大
)
# 处理响应
if response.status_code == 200:
return response.json()
elif response.status_code in (401, 403):
raise CosyVoiceAuthError(f"CosyVoice API 认证失败: HTTP {response.status_code}")
elif response.status_code == 400:
# 客户端错误,不重试
body_text = response.text
try:
body = response.json()
code = body.get("code", "")
message = body.get("message", "")
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
except ValueError:
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}")
elif response.status_code >= 500:
# 服务端错误,可重试
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
logger.warning(
"CosyVoice API 失败 (尝试 %d/%d): HTTP %d",
attempt + 1,
self.MAX_RETRIES,
response.status_code,
f"CosyVoice API 失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): " f"HTTP {response.status_code}"
)
else:
# 其他客户端错误,不重试
# 客户端错误,不重试
raise CosyVoiceError(
f"CosyVoice API 调用失败: HTTP {response.status_code}, " f"body={response.text}"
)
except httpx.TimeoutException as e:
last_error = CosyVoiceTimeoutError(f"请求超时: {e}")
logger.warning(
"CosyVoice API 超时 (尝试 %d/%d)",
attempt + 1,
self.MAX_RETRIES,
)
logger.warning(f"CosyVoice API 超时 (尝试 {attempt + 1}/{self.MAX_RETRIES})")
except httpx.RequestError as e:
last_error = CosyVoiceError(f"请求错误: {e}")
logger.warning(
"CosyVoice API 请求错误 (尝试 %d/%d): %s",
attempt + 1,
self.MAX_RETRIES,
e,
)
logger.warning(f"CosyVoice API 请求错误 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}")
# 指数退避
if attempt < self.MAX_RETRIES - 1:
+60 -138
View File
@@ -14,6 +14,7 @@ import logging
import os
import shutil
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
@@ -189,13 +190,10 @@ class TTSWorkflowService:
return job
def poll_and_process_synthesis(self, job_id: str, timeout: float = 120.0) -> TTSJob:
"""轮询/检查 CosyVoice 合成任务并处理结果.
"""轮询 CosyVoice 合成任务并处理结果
CosyVoice SpeechSynthesizer 非流式接口是同步的
start_synthesis 阶段通常已经完成. 此方法用于
1. job completed 直接返回同步路径已处理
2. job 仍在 processing 重新提交合成兜底
3. 分段任务 检查分段状态
job.metadata 获取 task_id调用 CosyVoiceService.poll_synthesize_task()
轮询状态然后通过 process_synthesis_result / process_synthesis_failure 更新 job
Celery 后台任务调用
"""
@@ -203,38 +201,22 @@ class TTSWorkflowService:
if job is None:
raise TTSJobNotFoundError(f"TTS job {job_id} not found")
# 已完成直接返回(同步路径在 start_synthesis 里已处理)
if job.status == TTSJobStatus.COMPLETED.value:
logger.info(f"TTS 任务已完成,跳过轮询: job_id={job_id}")
return job
# 检查是否为分段合成任务
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
if segment_task_ids:
return self._poll_segment_tasks(job)
# 单段模式:同步接口下通常不会走到这里,
# 但如果因为异常导致仍在 processing,重新提交一次
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
# 新接口(同步):没有 task_id,重新合成
if not task_id:
logger.info(f"TTS 任务无 task_id,重新同步合成: job_id={job_id}")
return self._resynthesize_and_complete(job)
raise ValueError(f"TTSJob {job_id} has no cosyvoice_task_id in metadata")
# 旧接口遗留的 task_id,尝试轮询(兼容过渡)
try:
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=timeout)
return self.process_synthesis_result(
job_id,
audio_url=result["audio_url"],
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
)
except CosyVoiceError:
# 旧接口轮询失败,重新同步合成
logger.warning(f"旧 task_id 轮询失败,重新同步合成: job_id={job_id}, task_id={task_id}")
return self._resynthesize_and_complete(job)
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=timeout)
return self.process_synthesis_result(
job_id,
audio_url=result["audio_url"],
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
)
def process_synthesis_result(
self,
@@ -275,40 +257,6 @@ class TTSWorkflowService:
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={permanent_url}")
return job
def _resynthesize_and_complete(self, job: TTSJob) -> TTSJob:
"""重新同步合成并完成任务(兜底路径).
poll_and_process_synthesis 发现 job 仍在 processing 且无 task_id
重新调用同步合成接口转存 OSS 后标记完成
"""
try:
# 从 metadata 读取合成参数(兼容旧数据,无则用默认值)
job_metadata = job.metadata or {}
speed = float(job_metadata.get("speed", 1.0))
volume = int(job_metadata.get("volume", 50))
result = self.cosyvoice_service.submit_synthesize_task(
text=job.input_text,
voice_id=job.voice_id,
sample_rate=job.sample_rate,
format=job.format,
speed=speed,
volume=volume,
)
audio_url = result.get("audio_url", "")
if not audio_url:
raise CosyVoiceError("重新合成未返回 audio_url")
return self.process_synthesis_result(
job.id,
audio_url=audio_url,
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
)
except Exception as e:
logger.error(f"重新同步合成失败: job_id={job.id}, error={e}")
return self.process_synthesis_failure(job.id, str(e))
def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob:
"""处理合成失败结果。
@@ -481,95 +429,69 @@ class TTSWorkflowService:
shutil.rmtree(temp_dir, ignore_errors=True)
def _poll_segment_tasks(self, job: TTSJob) -> TTSJob:
"""分段任务完成检查(适配新同步接口).
CosyVoice SpeechSynthesizer 非流式接口为同步接口
分段任务在提交时应已同步返回 audio_url
若历史任务处于 processing 且有 segment_task_ids 但缺少 audio_url
则对缺失分段重新同步合成全部完成后合并音频
"""
"""轮询所有分段异步任务,全部完成后合并音频。"""
segment_task_ids: list[str] = (job.metadata or {}).get("segment_task_ids", [])
segment_audio_urls: list[str] = (job.metadata or {}).get("segment_audio_urls", [])
segment_count = len(segment_task_ids)
if segment_count == 0:
logger.warning(f"分段任务无 task_id: job_id={job.id}")
self._handle_segment_failure(job, "分段任务数据异常:无分段信息")
return self.repository.get(job.id)
poll_start = time.monotonic()
poll_timeout = 300.0 # 分段任务超时更长
poll_interval = 2.0
# 从 metadata 读取合成参数
job_metadata = job.metadata or {}
speed = float(job_metadata.get("speed", 1.0))
volume = int(job_metadata.get("volume", 50))
while time.monotonic() - poll_start < poll_timeout:
all_done = True
results: list[dict | None] = [None] * segment_count
# 分段文本(用于缺失段重新合成)
segments = split_text(job.input_text, max_chars=_SEGMENT_THRESHOLD)
for idx, task_id in enumerate(segment_task_ids):
# 已经有音频的分段跳过轮询
if idx < len(segment_audio_urls) and segment_audio_urls[idx]:
results[idx] = {
"audio_url": segment_audio_urls[idx],
"duration": 0.0,
"file_size": 0,
}
continue
results: list[dict | None] = [None] * segment_count
try:
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=poll_timeout)
results[idx] = result
except Exception as e:
logger.error(f"分段任务轮询失败: job_id={job.id}, " f"segment={idx}, error={e}")
self._handle_segment_failure(job, f"分段 {idx + 1} 轮询失败: {e}")
return self.repository.get(job.id)
# 已有音频的分段直接用
for idx in range(segment_count):
if idx < len(segment_audio_urls) and segment_audio_urls[idx]:
results[idx] = {
"audio_url": segment_audio_urls[idx],
"duration": 0.0,
"file_size": 0,
}
if results[idx] is None:
all_done = False
# 找出缺失音频的分段索引
missing_indices = [i for i in range(segment_count) if results[i] is None]
if all_done and all(r is not None for r in results):
# 所有分段完成,下载合并
try:
merged_data, total_duration = self._download_and_merge_segments(results, job)
if missing_indices:
logger.info(f"分段任务重新合成缺失段: job_id={job.id}, " f"缺失={len(missing_indices)}/{segment_count}")
# 并发重新合成缺失分段
max_workers = min(len(missing_indices), _MAX_SEGMENT_WORKERS)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_idx = {}
for idx in missing_indices:
segment_text = segments[idx] if idx < len(segments) else ""
future = executor.submit(
self.cosyvoice_service.submit_synthesize_task,
text=segment_text,
voice_id=job.voice_id,
sample_rate=job.sample_rate,
format=job.format,
speed=speed,
volume=volume,
# 转存 OSS
permanent_url, storage_key = self._upload_merged_to_oss(
merged_data, job.user_id, job.id, job.format
)
future_to_idx[future] = idx
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
results[idx] = future.result()
except Exception as e:
logger.error(f"分段重新合成失败: job_id={job.id}, " f"segment={idx}, error={e}")
self._handle_segment_failure(job, f"分段 {idx + 1} 重新合成失败: {e}")
return self.repository.get(job.id)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=total_duration,
file_size=len(merged_data),
)
job = self.repository.update(job)
logger.info(f"分段合成轮询完成: job_id={job.id}, " f"merged_size={len(merged_data)}")
return job
# 所有分段完成,下载合并
if all(r is not None for r in results):
try:
merged_data, total_duration = self._download_and_merge_segments(results, job)
except Exception as e:
self._handle_segment_failure(job, f"分段合并失败: {e}")
return self.repository.get(job.id)
permanent_url, storage_key = self._upload_merged_to_oss(merged_data, job.user_id, job.id, job.format)
# 等待后重试
time.sleep(poll_interval)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=total_duration,
file_size=len(merged_data),
)
job = self.repository.update(job)
logger.info(f"分段合成完成(重新合成路径): job_id={job.id}, " f"merged_size={len(merged_data)}")
return job
except Exception as e:
self._handle_segment_failure(job, f"分段合并失败: {e}")
return self.repository.get(job.id)
# 理论上不会到这里(全部重新合成要么成功要么失败)
self._handle_segment_failure(job, "分段合成结果不完整")
# 超时
self._handle_segment_failure(job, "分段合成轮询超时(300 秒)")
return self.repository.get(job.id)
def _handle_segment_failure(self, job: TTSJob, error_message: str) -> None:
+7 -12
View File
@@ -115,16 +115,14 @@ class VoiceCloneWorkflowService:
language=language,
)
# 4. 保存 voice_id / request_id 到 metadata
# 注意:key 保留 cosyvoice_task_id 以兼容旧数据,实际存的是 voice_id
# 4. 保存 task_id / voice_id 到 metadata
task_metadata = dict(profile.metadata)
task_metadata["cosyvoice_task_id"] = submit_result.get("voice_id", "")
task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
# 如果 CosyVoice 直接返回了 OK 状态,直接标记 ready
# 如果 CosyVoice 同步返回了 voice_id,直接标记 ready
voice_id = submit_result.get("voice_id", "")
status = submit_result.get("status", "").upper()
if voice_id and status == "OK":
if voice_id:
profile.mark_ready(voice_id)
profile.metadata = task_metadata
profile = self.repository.update(profile)
@@ -133,9 +131,7 @@ class VoiceCloneWorkflowService:
profile.metadata = task_metadata
profile = self.repository.update(profile)
logger.info(
f"音色克隆任务已提交: profile_id={profile.id}, " f"voice_id={submit_result.get('voice_id')}"
)
logger.info(f"音色克隆任务已提交: profile_id={profile.id}, " f"task_id={submit_result.get('task_id')}")
except (CosyVoiceError, CosyVoiceAuthError) as e:
# CosyVoice 提交失败,标记为 failed
@@ -252,12 +248,11 @@ class VoiceCloneWorkflowService:
)
task_metadata = dict(profile.metadata)
task_metadata["cosyvoice_task_id"] = submit_result.get("voice_id", "")
task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
voice_id = submit_result.get("voice_id", "")
status = submit_result.get("status", "").upper()
if voice_id and status == "OK":
if voice_id:
profile.mark_ready(voice_id)
profile.metadata = task_metadata
profile = self.repository.update(profile)
+7 -44
View File
@@ -8,7 +8,6 @@
from __future__ import annotations
import copy
from enum import Enum
from typing import List, Optional
@@ -130,29 +129,24 @@ class EditPlanConfigSchema(BaseModel):
用于 API 层校验和默认值填充所有子结构均可选
未传入时使用各自默认值
editing_mode 记录计划使用的剪辑模式
"""
cover: CoverConfig = Field(default_factory=CoverConfig, description="封面配置")
title: TitleConfig = Field(default_factory=TitleConfig, description="标题配置")
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕配置")
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 配置")
editing_mode: str = Field(default="one_take", description="剪辑模式")
class EditTemplateConfigSchema(BaseModel):
"""EditTemplate.config 完整结构
模板级别的默认配置创建计划时可作为初始值继承
editing_mode 指定模板对应的剪辑模式transition_enabled 控制是否启用转场
"""
cover: CoverConfig = Field(default_factory=CoverConfig, description="封面默认配置")
title: TitleConfig = Field(default_factory=TitleConfig, description="标题默认配置")
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕默认配置")
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 默认配置")
editing_mode: str = Field(default="one_take", description="剪辑模式")
transition_enabled: bool = Field(default=True, description="是否启用转场")
# ── 默认值常量 ────────────────────────────────────────────────────────────────
@@ -189,13 +183,9 @@ DEFAULT_EDIT_PLAN_CONFIG: dict = {
"asset_id": "",
"volume": 0.3,
},
"editing_mode": "one_take",
}
DEFAULT_EDIT_TEMPLATE_CONFIG: dict = {
**DEFAULT_EDIT_PLAN_CONFIG,
"transition_enabled": True,
}
DEFAULT_EDIT_TEMPLATE_CONFIG: dict = DEFAULT_EDIT_PLAN_CONFIG.copy()
# ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -207,7 +197,9 @@ def normalize_plan_config(raw: dict | None) -> dict:
用于创建/更新计划时确保 config 结构完整
"""
if raw is None:
return copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
return DEFAULT_EDIT_PLAN_CONFIG.copy()
import copy
base = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
@@ -217,43 +209,14 @@ def normalize_plan_config(raw: dict | None) -> dict:
base[section_key] = {}
base[section_key].update(raw[section_key])
# editing_mode 顶层字段
if "editing_mode" in raw and isinstance(raw["editing_mode"], str):
base["editing_mode"] = raw["editing_mode"]
# 保留非标准字段(如 generation_task_id
for key, value in raw.items():
if key not in ("cover", "title", "subtitle", "bgm", "editing_mode"):
if key not in ("cover", "title", "subtitle", "bgm"):
base[key] = value
return base
def normalize_template_config(raw: dict | None) -> dict:
"""将模板原始 config dict 标准化。
plan config 基础上额外支持 transition_enabled 字段
"""
if raw is None:
return copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
base = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
for section_key in ("cover", "title", "subtitle", "bgm"):
if section_key in raw and isinstance(raw[section_key], dict):
if section_key not in base:
base[section_key] = {}
base[section_key].update(raw[section_key])
# 顶层字段
if "editing_mode" in raw and isinstance(raw["editing_mode"], str):
base["editing_mode"] = raw["editing_mode"]
if "transition_enabled" in raw and isinstance(raw["transition_enabled"], bool):
base["transition_enabled"] = raw["transition_enabled"]
# 保留非标准字段
for key, value in raw.items():
if key not in ("cover", "title", "subtitle", "bgm", "editing_mode", "transition_enabled"):
base[key] = value
return base
"""将模板原始 config dict 标准化。逻辑同 normalize_plan_config。"""
return normalize_plan_config(raw)
-16
View File
@@ -18,8 +18,6 @@ else:
from typing import Any
from uuid import uuid4
from .editing_mode import EditingMode
class EditTemplateStatus(StrEnum):
"""模板状态"""
@@ -28,25 +26,18 @@ class EditTemplateStatus(StrEnum):
INACTIVE = "inactive"
_VALID_EDITING_MODES = {m.value for m in EditingMode}
@dataclass(slots=True)
class EditTemplate:
"""Phase 8 剪辑模板实体
全局模板库中的模板定义剪辑风格配置参数和预览信息
不绑定到具体项目可被多个 EditPlan 引用
editing_mode 指定模板对应的剪辑模式one_take / pip / voice_over / voice_pip
决定剪辑计划生成时的片段结构
"""
id: str
name: str
description: str = ""
template_type: str = "default"
editing_mode: str = EditingMode.ONE_TAKE.value
config: dict[str, Any] = field(default_factory=dict)
preview_url: str = ""
sort_weight: int = 0
@@ -61,7 +52,6 @@ class EditTemplate:
*,
description: str = "",
template_type: str = "default",
editing_mode: str = EditingMode.ONE_TAKE.value,
config: dict[str, Any] | None = None,
preview_url: str = "",
sort_weight: int = 0,
@@ -71,17 +61,11 @@ class EditTemplate:
clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
clean_mode = editing_mode.strip() or EditingMode.ONE_TAKE.value
if clean_mode not in _VALID_EDITING_MODES:
raise ValueError(
f"无效的 editing_mode: {clean_mode}" f"允许值: {', '.join(sorted(_VALID_EDITING_MODES))}"
)
return cls(
id=uuid4().hex,
name=clean_name,
description=description.strip(),
template_type=template_type.strip() or "default",
editing_mode=clean_mode,
config=config or {},
preview_url=preview_url.strip(),
sort_weight=sort_weight,
-199
View File
@@ -1,14 +1,5 @@
"""GenerationTask 领域模型 — 视频生成任务.
状态机
pending running completed
failed pending (重试)
cancelled
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -26,43 +17,11 @@ from uuid import uuid4
class GenerationTaskStatus(StrEnum):
"""生成任务状态枚举。"""
PENDING = "pending"
"""待处理(任务已创建,等待执行)"""
RUNNING = "running"
"""运行中(正在生成视频)"""
COMPLETED = "completed"
"""已完成(视频生成成功)"""
FAILED = "failed"
"""失败(生成失败)"""
CANCELLED = "cancelled"
"""已取消(用户取消或系统取消)"""
# 终态集合
TERMINAL_STATUSES = frozenset(
{GenerationTaskStatus.COMPLETED, GenerationTaskStatus.FAILED, GenerationTaskStatus.CANCELLED}
)
# 合法状态转换
_VALID_TRANSITIONS: dict[GenerationTaskStatus, set[GenerationTaskStatus]] = {
GenerationTaskStatus.PENDING: {
GenerationTaskStatus.RUNNING,
GenerationTaskStatus.FAILED,
GenerationTaskStatus.CANCELLED,
},
GenerationTaskStatus.RUNNING: {
GenerationTaskStatus.COMPLETED,
GenerationTaskStatus.FAILED,
GenerationTaskStatus.CANCELLED,
},
GenerationTaskStatus.FAILED: {GenerationTaskStatus.PENDING}, # 重试回到 pending
}
@dataclass(slots=True)
@@ -86,7 +45,6 @@ class GenerationTask:
created_by_user_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
logs: str = "[]"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
@@ -125,160 +83,3 @@ class GenerationTask:
asset_select_mode=asset_select_mode,
batch_id=batch_id,
)
# ── 状态查询 ────────────────────────────────────────────────────────────
@property
def is_terminal(self) -> bool:
"""是否处于终态(completed / failed / cancelled)。"""
return self.status in TERMINAL_STATUSES
@property
def is_completed(self) -> bool:
"""是否已完成。"""
return self.status == GenerationTaskStatus.COMPLETED
@property
def is_failed(self) -> bool:
"""是否失败。"""
return self.status == GenerationTaskStatus.FAILED
@property
def is_running(self) -> bool:
"""是否运行中。"""
return self.status == GenerationTaskStatus.RUNNING
# ── 状态转换 ────────────────────────────────────────────────────────────
def transition_to(self, new_status: GenerationTaskStatus | str) -> None:
"""执行状态转换。
Args:
new_status: 目标状态
Raises:
ValueError: 非法状态转换
"""
if isinstance(new_status, str):
try:
new_status = GenerationTaskStatus(new_status)
except ValueError:
raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
raise ValueError(
f"非法状态转换: {self.status.value}{new_status.value}"
f"允许: {{{', '.join(sorted(s.value for s in allowed))}}}"
)
self.status = new_status
def mark_processing(self) -> None:
"""标记为处理中(pending → running)。
设置 started_at清除 error_message
Raises:
ValueError: 当前状态不允许转换到 running
"""
self.transition_to(GenerationTaskStatus.RUNNING)
self.started_at = datetime.now(timezone.utc)
self.error_message = ""
def mark_completed(self, result_count: int = 1) -> None:
"""标记为已完成(running → completed)。
设置 completed_atprogress=100.0result_count清除 error_message
Args:
result_count: 生成的视频数量默认为 1
Raises:
ValueError: 当前状态不允许转换到 completed
"""
self.transition_to(GenerationTaskStatus.COMPLETED)
self.completed_at = datetime.now(timezone.utc)
self.progress = 100.0
self.result_count = result_count
self.error_message = ""
def mark_failed(self, error_message: str) -> None:
"""标记为失败(pending / running → failed)。
设置 error_messagecompleted_at
Args:
error_message: 错误信息
Raises:
ValueError: 当前状态不允许转换到 failed
"""
self.transition_to(GenerationTaskStatus.FAILED)
self.error_message = error_message
self.completed_at = datetime.now(timezone.utc)
def mark_cancelled(self) -> None:
"""标记为已取消(pending / running → cancelled)。
设置 completed_at
Raises:
ValueError: 当前状态不允许转换到 cancelled
"""
self.transition_to(GenerationTaskStatus.CANCELLED)
self.completed_at = datetime.now(timezone.utc)
# ── 日志辅助 ────────────────────────────────────────────────────────────
_MAX_LOGS = 200
def append_log(self, stage: str, message: str, level: str = "INFO", **kwargs) -> None:
"""追加一条结构化日志到 logs 字段。
Args:
stage: 阶段名称 "接收任务""下载素材""渲染"
message: 日志消息
level: 日志级别INFO / WARN / ERROR
**kwargs: 额外字段 asset_idduration
"""
try:
entries = json.loads(self.logs) if self.logs else []
except (json.JSONDecodeError, TypeError):
entries = []
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": level,
"stage": stage,
"message": message,
**kwargs,
}
entries.append(entry)
# 限制最多保留 _MAX_LOGS 条,防止字段过大
if len(entries) > self._MAX_LOGS:
entries = entries[-self._MAX_LOGS :]
self.logs = json.dumps(entries, ensure_ascii=False)
def get_logs(self) -> list[dict]:
"""解析 logs 字段为 list[dict]。"""
try:
return json.loads(self.logs) if self.logs else []
except (json.JSONDecodeError, TypeError):
return []
def mark_pending_from_failed(self) -> None:
"""从失败状态重置为待处理(用于重试)。
清除 error_messagestarted_atcompleted_atprogress
Raises:
ValueError: 当前状态不是 failed
"""
if self.status != GenerationTaskStatus.FAILED:
raise ValueError(f"只有 failed 状态的任务可以重置为 pending,当前状态: {self.status.value}")
self.transition_to(GenerationTaskStatus.PENDING)
self.error_message = ""
self.started_at = None
self.completed_at = None
self.progress = 0.0
self.result_count = 0
Executable → Regular
+9 -9
View File
@@ -16,7 +16,7 @@ class PresetVoice:
"""预置音色定义。
Attributes:
voice_id: CosyVoice 模型音色名 longxiaochun_v3
voice_id: CosyVoice 模型音色名 longxiaochun
name: 中文展示名
description: 音色描述
gender: 性别male/female
@@ -49,7 +49,7 @@ class PresetVoice:
# 预置音色列表(阿里云 CosyVoice 真实可用音色)
PRESET_VOICES: list[PresetVoice] = [
PresetVoice(
voice_id="longxiaochun_v3",
voice_id="longxiaochun",
name="龙小淳",
description="温柔女声,适合情感类内容",
gender="female",
@@ -57,7 +57,7 @@ PRESET_VOICES: list[PresetVoice] = [
tags=["温柔", "女声", "情感"],
),
PresetVoice(
voice_id="longxiaoxia_v3",
voice_id="longxiaoxia",
name="龙小夏",
description="知性女声,适合新闻播报",
gender="female",
@@ -65,7 +65,7 @@ PRESET_VOICES: list[PresetVoice] = [
tags=["知性", "女声", "播报"],
),
PresetVoice(
voice_id="longxiaochen_v3",
voice_id="longxiaochen",
name="龙小晨",
description="磁性男声,适合有声书",
gender="male",
@@ -73,7 +73,7 @@ PRESET_VOICES: list[PresetVoice] = [
tags=["磁性", "男声", "有声书"],
),
PresetVoice(
voice_id="longyue_v3",
voice_id="longyue",
name="龙悦",
description="甜美女声,适合广告配音",
gender="female",
@@ -81,7 +81,7 @@ PRESET_VOICES: list[PresetVoice] = [
tags=["甜美", "女声", "广告"],
),
PresetVoice(
voice_id="longshu_v3",
voice_id="longshu",
name="龙书",
description="沉稳男声,适合教育讲解",
gender="male",
@@ -89,7 +89,7 @@ PRESET_VOICES: list[PresetVoice] = [
tags=["沉稳", "男声", "教育"],
),
PresetVoice(
voice_id="longjing_v3",
voice_id="longjing",
name="龙静",
description="优雅女声,适合纪录片解说",
gender="female",
@@ -97,7 +97,7 @@ PRESET_VOICES: list[PresetVoice] = [
tags=["优雅", "女声", "纪录片"],
),
PresetVoice(
voice_id="longbo_v3",
voice_id="longbo",
name="龙博",
description="浑厚男声,适合科技类内容",
gender="male",
@@ -105,7 +105,7 @@ PRESET_VOICES: list[PresetVoice] = [
tags=["浑厚", "男声", "科技"],
),
PresetVoice(
voice_id="longtian_v3",
voice_id="longtian",
name="龙甜",
description="活泼女声,适合短视频配音",
gender="female",
-4
View File
@@ -16,10 +16,6 @@ class GenerationTaskRepository(Protocol):
def count_by_user(self, user_id: str) -> int: ...
def count_pending_by_user(self, user_id: str) -> int: ...
def count_pending_total(self) -> int: ...
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ...
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]: ...
Executable → Regular
+4 -6
View File
@@ -29,15 +29,13 @@ class SharedSettings(BaseSettings):
oss_access_key_secret: str = ""
oss_bucket_name: str = "xiaoxia-autocut"
# CosyVoice (阿里云百炼语音合成)
# CosyVoice (阿里云语音合成)
cosyvoice_api_key: str = ""
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
cosyvoice_model: str = "cosyvoice-v3-flash"
cosyvoice_voice: str = "longxiaochun_v3" # 默认音色v3 系列系统音色带 _v3 后缀)
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio"
cosyvoice_model: str = "cosyvoice-v1"
cosyvoice_voice: str = "longxiaochun" # 默认音色
cosyvoice_sample_rate: int = 22050
cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm
# 音色克隆模型名(固定为 voice-enrollment
cosyvoice_clone_model: str = "voice-enrollment"
# Environment
environment: str = "development"
View File
-64
View File
@@ -1,71 +1,7 @@
[tool.black]
line-length = 120
target-version = ["py312"]
extend-exclude = '''
(
\.git
| \.cache
| \.pytest_cache
| \.mypy_cache
| __pycache__
| node_modules
| \.venv
| venv
| build
| dist
| \.next
| out
| coverage
)
'''
[tool.isort]
profile = "black"
line_length = 120
extend_skip_glob = [
".git/**",
".cache/**",
".pytest_cache/**",
".mypy_cache/**",
"__pycache__/**",
"node_modules/**",
".venv/**",
"venv/**",
"build/**",
"dist/**",
".next/**",
"out/**",
"coverage/**",
]
[tool.coverage.run]
source = ["apps", "packages"]
omit = [
"*/migrations/*",
"*/tests/*",
"*/test_*.py",
"*/site-packages/*",
"*/.cache/*",
]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if __name__ == .__main__.:",
"raise NotImplementedError",
"pass",
"if TYPE_CHECKING:",
"class .*Protocol",
"@abstractmethod",
"raise AssertionError",
"raise RuntimeError",
"if 0:",
"if __debug__:",
]
show_missing = true
skip_covered = false
[tool.coverage.xml]
output = "coverage.xml"
-5
View File
@@ -1,8 +1,3 @@
[pytest]
pythonpath = . apps/api apps/worker
testpaths = tests
# ===== 覆盖率配置 =====
# 覆盖率统计范围(供 --cov 使用时的默认源)
# 注意:addopts 不默认开启 --cov,避免影响本地开发调试
# CI 中通过命令行参数显式开启:--cov=apps --cov-report=term --cov-report=xml --cov-fail-under=50
-3
View File
@@ -24,9 +24,6 @@ celery==5.4.0
# 对象存储
oss2==2.18.4
cryptography==46.0.5
# 覆盖系统预装的旧版pyOpenSSL,与cryptography 46.0.5兼容
pyOpenSSL==26.2.0
# HTTP 客户端
httpx==0.27.2
+13 -27
View File
@@ -3,20 +3,11 @@ set -eu
VERSION="${1:-${RELEASE_VERSION:-}}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version> [staging|production]"
echo "Example: $0 v0.1.5 production"
echo " $0 abc1234 staging"
echo "Usage: $0 <version>"
echo "Example: $0 v0.1.5"
exit 1
fi
# 环境参数:staging 或 production(默认 production
BUILD_ENV="${2:-production}"
case "$BUILD_ENV" in
staging) NGINX_CONF_FILE="infra/docker/nginx-staging.conf" ;;
*) NGINX_CONF_FILE="infra/docker/nginx-production.conf" ;;
esac
echo "Build environment: $BUILD_ENV → nginx config: $NGINX_CONF_FILE"
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$ROOT_DIR"
@@ -57,7 +48,6 @@ fi
echo "=== Building API image ==="
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},mode=max" \
-f infra/docker/api.Dockerfile \
@@ -65,13 +55,12 @@ if [ "$USE_CACHE" -eq 1 ]; then
--load \
.
else
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
docker build --pull=false -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
fi
echo "=== Building Worker image ==="
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},mode=max" \
-f infra/docker/worker.Dockerfile \
@@ -79,32 +68,29 @@ if [ "$USE_CACHE" -eq 1 ]; then
--load \
.
else
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
docker build --pull=false -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
fi
echo "=== Building Web image (with buildx cache) ==="
# 先构建前端产物
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc "npm ci && npm run build"
test -f apps/web/dist/index.html
# 使用多阶段 Dockerfile 构建,配合 buildx cache 缓存 npm 安装和构建产物
WEB_NGINX_CONF="${WEB_NGINX_CONF:-infra/docker/nginx-production.conf}"
WEB_API_URL="${VITE_API_URL:-https://saas-api.xiaoxiajianji.com}"
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-f infra/docker/web.Dockerfile \
--build-arg NGINX_CONF="${WEB_NGINX_CONF}" \
--build-arg VITE_API_URL="${WEB_API_URL}" \
-t "$WEB_IMAGE" \
--load \
.
else
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-f infra/docker/web.Dockerfile \
--build-arg NGINX_CONF="${WEB_NGINX_CONF}" \
--build-arg VITE_API_URL="${WEB_API_URL}" \
-t "$WEB_IMAGE" \
.
fi
-249
View File
@@ -1,249 +0,0 @@
#!/usr/bin/env python3
"""
数据库迁移破坏性变更安全检查
只检查 Alembic 迁移文件的 upgrade 函数中是否包含破坏性操作
- DROP TABLE
- ALTER TABLE ... DROP COLUMN
- 列类型变更可能导致数据丢失
- NOT NULL 约束新增无默认值时
- RENAME TABLE / RENAME COLUMN
忽略 downgrade 函数中的操作那是回滚逻辑正常的
使用方式
# 检查所有迁移(不推荐,会扫历史已执行的迁移)
python3 scripts/check_migration_safety.py
# 只检查与目标分支相比新增的迁移(推荐用于CI)
python3 scripts/check_migration_safety.py --diff-against origin/main
# 只检查指定版本之后的迁移
python3 scripts/check_migration_safety.py --since 030_xxx
退出码
0 - 安全 / 只有非破坏性变更
1 - 检测到高风险破坏性变更
2 - 检测到中风险变更需人工确认
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import List, Tuple
REPO_ROOT = Path(__file__).resolve().parents[1]
ALEMBIC_VERSIONS_DIR = REPO_ROOT / "alembic" / "versions"
# 高风险模式:直接导致数据丢失(只在 upgrade 中检查)
HIGH_RISK_PATTERNS = [
(r"\bop\.drop_table\(", "op.drop_table() - 删除表,数据永久丢失"),
(r"\bop\.drop_column\(", "op.drop_column() - 删除列,数据永久丢失"),
]
# 中风险模式:可能导致数据丢失或兼容性问题
MEDIUM_RISK_PATTERNS = [
(r"op\.alter_column\([^)]*nullable\s*=\s*False", "新增 NOT NULL 约束 - 旧数据可能为空导致迁移失败"),
(r"op\.alter_column\([^)]*type_\s*=", "列类型变更 - 可能导致数据截断或转换失败"),
(r"\bop\.rename_table\(", "op.rename_table() - 重命名表,可能导致依赖该表的代码报错"),
(r"\bop\.rename_column\(", "op.rename_column() - 重命名列,可能导致依赖该列的代码报错"),
(r"\bop\.drop_index\(", "op.drop_index() - 删除索引,可能影响查询性能"),
(r"\bop\.drop_constraint\(", "op.drop_constraint() - 删除约束,可能影响数据完整性"),
]
# 安全模式:这些是安全的新增操作
SAFE_PATTERNS = [
(r"\bop\.create_table\(", "新建表"),
(r"\bop\.add_column\(", "新增列"),
(r"\bop\.create_index\(", "新建索引"),
(r"\bop\.create_unique_constraint\(", "新建唯一约束"),
(r"\bop\.create_foreign_key\(", "新建外键约束"),
]
def extract_upgrade_content(content: str) -> str:
"""
从迁移文件中提取 upgrade 函数的内容
只检查 upgrade 中的操作忽略 downgrade
"""
upgrade_match = re.search(r"def upgrade\b[^:]*:", content)
if not upgrade_match:
return ""
upgrade_start = upgrade_match.end()
# 找到下一个顶层 def(通常是 def downgrade)作为结束位置
rest = content[upgrade_start:]
downgrade_match = re.search(r"\n\ndef\s+\w+\b", rest)
if downgrade_match:
upgrade_end = upgrade_start + downgrade_match.start()
else:
upgrade_end = len(content)
return content[upgrade_start:upgrade_end]
def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
"""
通过 git diff 对比目标分支/commit找出 alembic/versions/ 下新增的迁移文件
只包含新增文件A状态不包含修改或删除的文件
"""
try:
result = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=A", diff_target, "HEAD", "--", "alembic/versions/"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
check=True,
)
files = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
return [REPO_ROOT / f for f in files]
except subprocess.CalledProcessError as e:
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
print(f" 降级为检查所有迁移文件")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]:
"""
找出需要检查的迁移文件
优先级diff_against > since_revision > 全部
"""
if diff_against:
return get_new_migrations_via_diff(diff_against)
all_migrations = sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
if not since_revision:
return all_migrations
result = []
found = False
for m in all_migrations:
if since_revision in m.name or since_revision in m.stem:
found = True
continue
if found:
result.append(m)
return result if found else all_migrations
def analyze_migration(file_path: Path) -> Tuple[List[str], List[str], List[str]]:
"""分析单个迁移文件 upgrade 部分的风险等级"""
content = file_path.read_text()
upgrade_content = extract_upgrade_content(content)
if not upgrade_content:
return [], [], [f"{file_path.name}: 未找到 upgrade 函数"]
high_risks = []
medium_risks = []
safes = []
for pattern, desc in HIGH_RISK_PATTERNS:
if re.search(pattern, upgrade_content):
high_risks.append(f"{file_path.name}: {desc}")
for pattern, desc in MEDIUM_RISK_PATTERNS:
if re.search(pattern, upgrade_content):
medium_risks.append(f"{file_path.name}: {desc}")
for pattern, desc in SAFE_PATTERNS:
if re.search(pattern, upgrade_content):
safes.append(f"{file_path.name}: {desc}")
return high_risks, medium_risks, safes
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--since",
default=os.getenv("MIGRATION_SINCE_REVISION"),
help="只检查指定版本之后的迁移(如:030_xxx),不传则检查所有迁移",
)
parser.add_argument(
"--diff-against",
default=os.getenv("MIGRATION_DIFF_AGAINST"),
help="对比指定分支/commit,只检查新增的迁移文件(推荐用于CI,如 origin/main",
)
parser.add_argument(
"--warn-only",
action="store_true",
help="只警告不失败(用于非强制门禁场景)",
)
parser.add_argument(
"--allow-medium-risk",
action="store_true",
help="允许中风险变更(只拦截高风险)",
)
args = parser.parse_args()
migrations = find_new_migrations(args.since, args.diff_against)
if not migrations:
print("✅ 未找到需要检查的新增迁移文件,跳过")
return 0
print(f"🔍 正在检查 {len(migrations)} 个迁移文件的 upgrade 操作...")
if args.diff_against:
print(f" (对比基准:{args.diff_against},仅检查新增迁移)")
print()
all_high = []
all_medium = []
all_safe = []
for m in migrations:
high, medium, safe = analyze_migration(m)
all_high.extend(high)
all_medium.extend(medium)
all_safe.extend(safe)
if all_safe:
print("✅ 安全变更:")
for s in all_safe:
print(f" - {s}")
print()
if all_medium:
print("⚠️ 中风险变更(需人工确认):")
for m_item in all_medium:
print(f" - {m_item}")
print()
if all_high:
print("❌ 高风险破坏性变更(禁止自动部署):")
for h in all_high:
print(f" - {h}")
print()
print("=" * 60)
print(f"检查结果:{len(all_safe)} 项安全 / {len(all_medium)} 项中风险 / {len(all_high)} 项高风险")
print()
if all_high:
print("❌ 检测到高风险破坏性变更,CI 检查失败!")
print(" 如果确认这是预期操作,请在 MR/PR 中说明原因并获得审批。")
if args.warn_only:
return 0
return 1
if all_medium and not args.allow_medium_risk:
print("⚠️ 检测到中风险变更,请人工确认后再部署。")
if args.warn_only:
return 0
print("(如需仅拦截高风险,可使用 --allow-medium-risk 参数)")
return 2
print("✅ 未检测到破坏性变更")
return 0
if __name__ == "__main__":
sys.exit(main())
-288
View File
@@ -1,288 +0,0 @@
#!/bin/bash
set -eu
# ============================================================
# 生产环境一键回滚脚本
#
# 用法:
# IMAGE_TAG=<版本号> REGISTRY_TOKEN=<token> sh rollback_production.sh
#
# 功能:
# 1. 拉取指定版本镜像
# 2. 数据库回滚到对应版本(alembic downgrade
# 3. 重启 api/worker/web 三个服务
# 4. 健康检查确认服务正常
#
# 环境变量:
# IMAGE_TAG - 要回滚到的版本标签(必填)
# REGISTRY_TOKEN - Registry 访问 token(可选)
# SKIP_DB_ROLLBACK - 跳过数据库回滚(1=跳过,默认不跳过)
# DB_ROLLBACK_REV - 数据库回滚到的版本(默认自动用镜像里的 head)
# ============================================================
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
SKIP_DB_ROLLBACK="${SKIP_DB_ROLLBACK:-0}"
DB_ROLLBACK_REV="${DB_ROLLBACK_REV:-}"
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}"
if [ -z "$IMAGE_TAG" ]; then
echo "❌ IMAGE_TAG 是必填参数"
echo "用法: IMAGE_TAG=v0.1.125 REGISTRY_TOKEN=xxx sh rollback_production.sh"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
echo "=========================================="
echo " 生产环境回滚 → $IMAGE_TAG"
echo "=========================================="
echo ""
# 先获取当前版本
CURRENT_VERSION=""
if docker inspect xiaoxia-api-production >/dev/null 2>&1; then
CURRENT_VERSION=$(docker inspect --format '{{ index .Config.Env 0 }}' xiaoxia-api-production 2>/dev/null | grep APP_VERSION | cut -d= -f2 || echo "unknown")
fi
echo "当前版本: ${CURRENT_VERSION:-unknown}"
echo "回滚目标: $IMAGE_TAG"
echo ""
# 确认
read -p "⚠️ 确认要回滚生产环境到 $IMAGE_TAG 吗?(输入 YES 确认): " confirm
if [ "$confirm" != "YES" ]; then
echo "已取消"
exit 0
fi
echo ""
# ----- 登录 Registry -----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "登录 Registry: $REGISTRY"
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ----- Pull 镜像 -----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
docker tag "$REGISTRY_API" "$LOCAL_API"
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
echo "所有镜像拉取完成"
echo ""
# ----- 检查基础设施容器 -----
echo "检查基础设施容器..."
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
echo ""
# ----- 数据库回滚 -----
if [ "$SKIP_DB_ROLLBACK" = "1" ]; then
echo "⏭️ 跳过数据库回滚(SKIP_DB_ROLLBACK=1"
else
echo "🔄 执行数据库回滚..."
if [ -n "$DB_ROLLBACK_REV" ]; then
# 回滚到指定版本
echo "回滚到版本: $DB_ROLLBACK_REV"
docker run --rm \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
"$LOCAL_API" sh -c "cd /app && alembic downgrade $DB_ROLLBACK_REV"
else
# 用目标镜像的 alembic head 来判断是否需要回滚
# 先检查当前DB版本和目标版本的关系
echo "检测数据库当前版本与目标版本..."
CURRENT_DB_REV=$(docker run --rm \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
"$LOCAL_API" sh -c "cd /app && alembic current" 2>&1 | tail -1 | awk '{print $1}')
TARGET_DB_HEAD=$(docker run --rm \
"$LOCAL_API" sh -c "cd /app && alembic head" 2>&1 | tail -1 | awk '{print $1}')
echo "当前 DB 版本: ${CURRENT_DB_REV:-unknown}"
echo "目标 DB 版本: ${TARGET_DB_HEAD:-unknown}"
if [ "$CURRENT_DB_REV" = "$TARGET_DB_HEAD" ]; then
echo "✅ 数据库版本与目标版本一致,无需回滚"
else
echo "⚠️ 数据库版本不一致,尝试回滚..."
echo "注意:自动回滚可能无法正确处理,请确认 DB_ROLLBACK_REV 参数"
echo "如果需要跳过数据库回滚,请设置 SKIP_DB_ROLLBACK=1"
exit 1
fi
fi
echo "数据库回滚完成"
fi
echo ""
# ----- 停止旧容器 -----
echo "停止旧容器..."
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
echo ""
# ----- 启动新容器 -----
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
echo "启动 API 容器..."
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://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 \
"$LOCAL_API"
echo "启动 Worker 容器..."
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://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 \
"$LOCAL_WORKER"
# Web legacy assets
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 容器: legacy assets 已挂载"
else
echo "Web 容器: 没有 legacy assets"
fi
echo "启动 Web 容器..."
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 \
"$LOCAL_WEB"
echo ""
# ----- 健康检查 -----
echo "等待 API 健康..."
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 "❌ API 在 120s 内未就绪"
docker logs --tail 50 xiaoxia-api-production
exit 1
fi
echo "等待 Web 健康..."
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 "❌ Web 在 30s 内未就绪"
docker logs --tail 30 xiaoxia-web-production
exit 1
fi
echo ""
# ----- 清理 -----
echo "清理旧镜像..."
docker image prune -af --filter "until=168h" 2>/dev/null || true
docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=========================================="
echo " ✅ 生产环境回滚完成"
echo "=========================================="
echo "API: http://127.0.0.1:8001"
echo "Web: http://127.0.0.1:3002"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
-1
View File
@@ -3,7 +3,6 @@ max-line-length = 120
extend-ignore = E203,W503,E501,E302,E402,E722,W291,W293,F401,F403,F405,F841
exclude =
.git,
.cache,
__pycache__,
.venv,
.venv-ci-root,
-44
View File
@@ -11,47 +11,3 @@ if str(ROOT) not in sys.path:
# 必须在任何 app 模块导入之前设置,否则 pydantic Settings 验证失败
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-all-tests")
os.environ.setdefault("USE_IN_MEMORY_DB", "True")
# ── Celery 全局 mock ──────────────────────────────────────────────────────
# CI 环境没有 Redis,所有 Celery 异步任务都 mock 掉,避免连接超时报错
# 集成测试只测 API 层逻辑(参数校验、权限、DB 操作),异步任务由 worker 单测覆盖
from unittest.mock import MagicMock, patch
def _mock_celery_task():
"""全局 mock Celery 任务的 delay/apply_async/send_task 方法。"""
from celery import Celery, Task
# 保存原始方法
_orig_delay = Task.delay
_orig_apply_async = Task.apply_async
_orig_send_task = Celery.send_task
def _mock_delay(self, *args, **kwargs):
mock_result = MagicMock()
mock_result.id = "mock-task-id"
mock_result.state = "PENDING"
mock_result.ready.return_value = False
mock_result.get.return_value = None
return mock_result
def _mock_apply_async(self, *args, **kwargs):
return _mock_delay(self, *args, **kwargs)
def _mock_send_task(self, name, *args, **kwargs):
mock_result = MagicMock()
mock_result.id = f"mock-{name}"
mock_result.state = "PENDING"
mock_result.ready.return_value = False
mock_result.get.return_value = None
return mock_result
Task.delay = _mock_delay
Task.apply_async = _mock_apply_async
Celery.send_task = _mock_send_task
# 在任何 app 模块导入之前就 patch 掉
_mock_celery_task()
-73
View File
@@ -22,31 +22,6 @@
set -euo pipefail
# ===== 环境预设 (SMOKE_ENV) =====
# 支持 SMOKE_ENV=production / staging 快捷预设
SMOKE_ENV="${SMOKE_ENV:-}"
if [ "$SMOKE_ENV" = "production" ]; then
# 生产环境预设:安全优先,默认只读
BASE_URL="${BASE_URL:-https://api.xiaoxiajianji.com}"
WEB_URL="${WEB_URL:-https://saas.xiaoxiajianji.com}"
# 如果没有提供 EXISTING_TOKEN,默认只跑不需要鉴权的模块(只读)
if [ -z "${EXISTING_TOKEN:-}" ]; then
MODULES="${MODULES:-health,nginx}"
else
# 有 token 时跑只读安全模块
MODULES="${MODULES:-health,assets,generation,subscription,nginx}"
fi
CLEANUP_ENABLED="${CLEANUP_ENABLED:-0}"
PRODUCTION_MODE=1
elif [ "$SMOKE_ENV" = "staging" ]; then
BASE_URL="${BASE_URL:-https://staging-api.xiaoxiajianji.com}"
WEB_URL="${WEB_URL:-https://staging.xiaoxiajianji.com}"
CLEANUP_ENABLED="${CLEANUP_ENABLED:-1}"
PRODUCTION_MODE=0
else
PRODUCTION_MODE=0
fi
# ===== 配置 =====
BASE_URL="${BASE_URL:-}"
TEST_USER="${TEST_USER:-e2e_$(date +%s)}"
@@ -58,9 +33,6 @@ CLEANUP_ENABLED="${CLEANUP_ENABLED:-1}"
CURL_TIMEOUT=30
CURL_CONNECT_TIMEOUT=15
CURL_INSECURE="${CURL_INSECURE:-0}"
PERF_CHECK_ENABLED="${PERF_CHECK_ENABLED:-1}" # 是否启用响应时间检查
PERF_WARN_THRESHOLD_MS="${PERF_WARN_THRESHOLD_MS:-3000}" # 响应时间警告阈值(毫秒)
PERF_FAIL_THRESHOLD_MS="${PERF_FAIL_THRESHOLD_MS:-10000}" # 响应时间失败阈值(毫秒)
# 证书不安全的环境(如staging)可设 CURL_INSECURE=1 跳过校验
if [ "$CURL_INSECURE" = "1" ]; then
@@ -90,40 +62,6 @@ CREATED_TEMPLATES=()
CREATED_PROJECTS=()
# ===== 工具函数 =====
# 记录并检查响应时间
perf_check() {
local name="$1"
local elapsed_ms="$2"
if [ "$PERF_CHECK_ENABLED" != "1" ]; then
return 0
fi
if [ "$elapsed_ms" -ge "$PERF_FAIL_THRESHOLD_MS" ]; then
fail "$name 响应时间" "${elapsed_ms}ms > ${PERF_FAIL_THRESHOLD_MS}ms(严重超标)"
return 1
elif [ "$elapsed_ms" -ge "$PERF_WARN_THRESHOLD_MS" ]; then
echo "⚠️ $name 响应时间: ${elapsed_ms}ms(超过警告阈值 ${PERF_WARN_THRESHOLD_MS}ms"
return 0
fi
return 0
}
# 带计时的 curl 请求
curl_timed() {
local output_file=$(mktemp)
local start_time=$(date +%s%N)
curl -s -o "$output_file" -w "%{http_code}" "$@"
local code=$?
local end_time=$(date +%s%N)
local elapsed_ms=$(( (end_time - start_time) / 1000000 ))
cat "$output_file"
rm -f "$output_file"
# 通过 stderr 返回耗时(调用方需重定向)
echo "$elapsed_ms" >&2
return $code
}
pass() {
echo "$1"
PASSED=$((PASSED + 1))
@@ -261,10 +199,6 @@ setup_auth() {
test_health() {
should_run "health" || return 0
section "1. 基础健康检查"
if [ "$PERF_CHECK_ENABLED" = "1" ]; then
info "响应时间检查已启用: 警告=${PERF_WARN_THRESHOLD_MS}ms, 失败=${PERF_FAIL_THRESHOLD_MS}ms"
fi
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$BASE_URL/health")
[ "$code" = "200" ] && pass "健康检查 /health" || fail "健康检查" "HTTP $code"
@@ -783,13 +717,6 @@ main() {
echo "║ API E2E 冒烟测试 ║"
echo "╚══════════════════════════════════════════════════╝"
echo ""
if [ "$PRODUCTION_MODE" = "1" ]; then
echo "⚠️ 生产环境模式 - 安全只读"
echo " - 不注册新用户"
echo " - 不创建测试数据"
echo " - CLEANUP_ENABLED=0"
echo ""
fi
echo "环境: $BASE_URL"
echo "模块: $MODULES"
echo "清理: $CLEANUP_ENABLED"
-274
View File
@@ -1,274 +0,0 @@
"""
集成测试公共 fixtures
提供性能测试相关的工具fixture marker
"""
from __future__ import annotations
import os
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
import pytest
# ── 性能阈值配置 ──────────────────────────────────────────────────────────
PERF_THRESHOLDS: Dict[str, int] = {
"core": 500, # 核心接口:500ms
"normal": 1000, # 普通接口:1000ms
"heavy": 3000, # 重操作:3000ms(涉及外部调用或复杂计算)
}
# 性能测试是否跳过(通过环境变量控制)
SKIP_PERF_TESTS = os.environ.get("SKIP_PERF_TESTS", "").lower() in ("1", "true", "yes")
# 性能测试容忍度:允许一定比例的请求超标(避免CI偶发波动)
# 默认:3次请求中允许1次超标(取中位数判断)
PERF_SAMPLE_COUNT = int(os.environ.get("PERF_SAMPLE_COUNT", "3"))
PERF_TOLERANCE_RATIO = float(os.environ.get("PERF_TOLERANCE_RATIO", "0.34"))
# ── 数据类 ────────────────────────────────────────────────────────────────
@dataclass
class PerfResult:
"""单次性能测试结果"""
name: str
threshold_ms: int
times_ms: List[float] = field(default_factory=list)
status_code: Optional[int] = None
@property
def median_ms(self) -> float:
if not self.times_ms:
return 0.0
sorted_times = sorted(self.times_ms)
n = len(sorted_times)
if n % 2 == 0:
return (sorted_times[n // 2 - 1] + sorted_times[n // 2]) / 2
return sorted_times[n // 2]
@property
def mean_ms(self) -> float:
if not self.times_ms:
return 0.0
return sum(self.times_ms) / len(self.times_ms)
@property
def min_ms(self) -> float:
return min(self.times_ms) if self.times_ms else 0.0
@property
def max_ms(self) -> float:
return max(self.times_ms) if self.times_ms else 0.0
@property
def passed(self) -> bool:
"""判断是否通过:基于中位数 + 容忍比例"""
if not self.times_ms:
return False
# 中位数必须在阈值内
if self.median_ms > self.threshold_ms:
return False
# 超标比例不能超过容忍度
over_count = sum(1 for t in self.times_ms if t > self.threshold_ms)
over_ratio = over_count / len(self.times_ms)
return over_ratio <= PERF_TOLERANCE_RATIO
# ── 性能断言上下文管理器 ──────────────────────────────────────────────────
class PerfAssert:
"""
性能断言工具
使用方式
def test_login_performance(client, perf_assert):
with perf_assert("core", name="login") as result:
response = client.post("/api/v1/auth/login", json={...})
result.status_code = response.status_code
# 退出 with 块时自动断言
"""
def __init__(self, sample_count: int = PERF_SAMPLE_COUNT):
self.sample_count = sample_count
self.results: List[PerfResult] = []
@contextmanager
def __call__(self, threshold_level: str = "core", name: str = "", samples: Optional[int] = None):
"""
创建一个性能测试上下文
Args:
threshold_level: 阈值级别 ("core", "normal", "heavy")
name: 测试名称用于输出报告
samples: 采样次数默认使用全局配置
"""
if threshold_level not in PERF_THRESHOLDS:
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
threshold_ms = PERF_THRESHOLDS[threshold_level]
num_samples = samples or self.sample_count
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
# 预热(第一次请求可能有冷启动开销)
yield result
# 第一次调用已经记录在 result.times_ms 中(由调用方通过 measure 方法)
def measure(self, threshold_level: str = "core", name: str = "", samples: Optional[int] = None) -> Callable:
"""
返回一个装饰器/包装器用于测量函数执行时间
使用方式
result = perf_assert.measure("core", "login")(
lambda: client.post("/api/v1/auth/login", json={...})
)
"""
def wrapper(func):
if threshold_level not in PERF_THRESHOLDS:
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
threshold_ms = PERF_THRESHOLDS[threshold_level]
num_samples = samples or self.sample_count
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
last_response = None
for i in range(num_samples):
start = time.perf_counter()
last_response = func()
elapsed = (time.perf_counter() - start) * 1000
result.times_ms.append(elapsed)
if hasattr(last_response, "status_code"):
result.status_code = last_response.status_code
self.results.append(result)
return result
return wrapper
def assert_all(self):
"""断言所有性能测试结果都通过"""
failed = [r for r in self.results if not r.passed]
if failed:
lines = []
for r in failed:
lines.append(
f"{r.name}: 中位数 {r.median_ms:.1f}ms "
f"(阈值 {r.threshold_ms}ms) "
f"[min={r.min_ms:.1f}, max={r.max_ms:.1f}, "
f"mean={r.mean_ms:.1f}, samples={len(r.times_ms)}]"
)
raise AssertionError(f"性能测试失败 ({len(failed)}/{len(self.results)}):\n" + "\n".join(lines))
def report(self) -> str:
"""生成性能报告文本"""
lines = ["=" * 60, " 性能测试报告", "=" * 60]
for r in self.results:
status = "" if r.passed else ""
lines.append(f" {status} {r.name:<40s} " f"median={r.median_ms:>7.1f}ms / {r.threshold_ms:>5d}ms")
lines.append(
f" min={r.min_ms:.1f}ms max={r.max_ms:.1f}ms "
f"mean={r.mean_ms:.1f}ms samples={len(r.times_ms)}"
f" status={r.status_code or 'N/A'}"
)
passed = sum(1 for r in self.results if r.passed)
lines.append("=" * 60)
lines.append(f" 总计: {passed}/{len(self.results)} 通过")
lines.append("=" * 60)
return "\n".join(lines)
# ── pytest fixtures ──────────────────────────────────────────────────────
def pytest_configure(config):
"""注册自定义 marker"""
config.addinivalue_line("markers", "performance: 标记为性能测试(可通过 -m 'not performance' 跳过)")
config.addinivalue_line("markers", "perf_core: 核心接口性能测试(阈值 500ms)")
config.addinivalue_line("markers", "perf_normal: 普通接口性能测试(阈值 1000ms)")
config.addinivalue_line("markers", "perf_heavy: 重操作接口性能测试(阈值 3000ms)")
def pytest_collection_modifyitems(config, items):
"""根据环境变量自动跳过性能测试"""
if SKIP_PERF_TESTS:
skip_perf = pytest.mark.skip(reason="SKIP_PERF_TESTS=1,跳过性能测试")
for item in items:
if "performance" in item.keywords or "perf_" in item.keywords:
item.add_marker(skip_perf)
@pytest.fixture
def perf_assert():
"""
性能断言 fixture
使用方式 1推荐自动断言
def test_login(client, perf_assert):
@perf_assert.measure("core", "POST /auth/login")
def _call():
return client.post("/api/v1/auth/login", json={...})
result = _call()
assert result.status_code == 200
使用方式 2手动多次调用
def test_login(client, perf_assert):
result = perf_assert.run("core", "POST /auth/login",
lambda: client.post("/api/v1/auth/login", json={...})
)
assert result.status_code == 200
"""
return PerfAssert()
@pytest.fixture
def perf_thresholds():
"""返回性能阈值配置字典"""
return dict(PERF_THRESHOLDS)
# ── 辅助函数 ──────────────────────────────────────────────────────────────
def run_perf_test(
name: str,
threshold_level: str,
func: Callable,
samples: int = PERF_SAMPLE_COUNT,
) -> PerfResult:
"""
运行一次性能测试独立函数方便在 fixture 外部使用
Args:
name: 测试名称
threshold_level: 阈值级别
func: 要测量的函数无参数
samples: 采样次数
Returns:
PerfResult 对象
"""
if threshold_level not in PERF_THRESHOLDS:
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
threshold_ms = PERF_THRESHOLDS[threshold_level]
result = PerfResult(name=name, threshold_ms=threshold_ms)
last_response = None
for i in range(samples):
start = time.perf_counter()
last_response = func()
elapsed = (time.perf_counter() - start) * 1000
result.times_ms.append(elapsed)
if hasattr(last_response, "status_code"):
result.status_code = last_response.status_code
return result
-601
View File
@@ -1,601 +0,0 @@
"""
API 性能基线测试
为核心 API 接口添加性能基线测试确保接口响应时间在合理范围内
分类
- 核心接口core, 500ms登录获取当前用户项目列表素材列表生成任务列表订阅信息
- 普通接口normal, 1000ms创建项目创建素材模板列表剪辑计划列表
- 重操作接口heavy, 3000ms获取上传签名创建生成任务去重上传
运行方式
pytest tests/integration/test_api_performance.py -v
SKIP_PERF_TESTS=1 pytest tests/integration/test_api_performance.py -v # 跳过性能测试
pytest tests/integration/test_api_performance.py -m "not performance" # 同上
"""
from __future__ import annotations
import os
import uuid
from typing import Optional
import pytest
from fastapi.testclient import TestClient
# 检测是否有可用的 PostgreSQL 数据库
_HAS_PG = False
try:
if os.environ.get("USE_IN_MEMORY_DB", "").lower() != "true":
import psycopg
conn = psycopg.connect(
os.environ.get(
"DATABASE_URL",
"postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas",
).replace("postgresql+psycopg://", "postgresql://"),
connect_timeout=3,
)
conn.close()
_HAS_PG = True
except Exception:
pass
needs_pg = pytest.mark.skipif(not _HAS_PG, reason="Requires PostgreSQL database")
skip_perf = os.environ.get("SKIP_PERF_TESTS", "").lower() in ("1", "true", "yes")
from apps.api.main import app
client = TestClient(app)
# ── 辅助函数 ──────────────────────────────────────────────────────────────
def _register_and_login() -> tuple[str, str, str]:
"""
注册新用户并登录返回 (access_token, user_id, project_id)
用于需要鉴权的性能测试准备数据
"""
unique = uuid.uuid4().hex[:8]
email = f"perf-{unique}@example.com"
username = f"perfuser-{unique}"
# 注册
reg_resp = client.post(
"/api/v1/auth/register",
json={
"email": email,
"password": "SecurePass123",
"username": username,
"display_name": "Perf Test User",
},
)
assert reg_resp.status_code in (200, 201), f"注册失败: {reg_resp.json()}"
# 登录
login_resp = client.post(
"/api/v1/auth/login",
json={"email": email, "password": "SecurePass123"},
)
assert login_resp.status_code == 200, f"登录失败: {login_resp.json()}"
data = login_resp.json()
token = data["access_token"]
user_id = data["user_id"]
# 创建一个项目(用于需要项目的接口)
proj_resp = client.post(
"/api/v1/projects",
headers={"Authorization": f"Bearer {token}"},
json={"name": f"perf-project-{unique}"},
)
assert proj_resp.status_code in (200, 201), f"创建项目失败: {proj_resp.json()}"
project_id = proj_resp.json()["id"]
return token, user_id, project_id
# ── Fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def perf_test_user():
"""
模块级 fixture为性能测试准备测试用户
由于性能测试关注的是响应时间而非数据正确性
使用同一个用户和同一份数据可以减少 setup 开销
让性能测量更准确
"""
if skip_perf:
pytest.skip("SKIP_PERF_TESTS=1,跳过性能测试")
if not _HAS_PG:
pytest.skip("Requires PostgreSQL database")
token, user_id, project_id = _register_and_login()
return {
"token": token,
"user_id": user_id,
"project_id": project_id,
"headers": {"Authorization": f"Bearer {token}"},
}
# ══════════════════════════════════════════════════════════════════════════
# 核心接口性能测试(阈值 500ms)
# ══════════════════════════════════════════════════════════════════════════
@pytest.mark.performance
@pytest.mark.perf_core
@needs_pg
class TestCoreApiPerformance:
"""
核心接口性能测试 阈值 500ms
这些接口是用户高频使用的功能必须保证快速响应
"""
def test_login_performance(self, perf_assert):
"""POST /auth/login 登录接口性能"""
# 先注册一个用户
unique = uuid.uuid4().hex[:8]
email = f"perf-login-{unique}@example.com"
client.post(
"/api/v1/auth/register",
json={
"email": email,
"password": "SecurePass123",
"username": f"perflogin-{unique}",
"display_name": "Perf Login Test",
},
)
result = perf_assert.measure("core", "POST /auth/login")(
lambda: client.post(
"/api/v1/auth/login",
json={"email": email, "password": "SecurePass123"},
)
)
assert result.status_code == 200, f"登录接口返回状态码 {result.status_code},预期 200"
assert result.passed, (
f"登录接口性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_auth_me_performance(self, perf_test_user, perf_assert):
"""GET /auth/me 获取当前用户信息性能"""
headers = perf_test_user["headers"]
result = perf_assert.measure("core", "GET /auth/me")(lambda: client.get("/api/v1/auth/me", headers=headers))
assert result.status_code == 200, f"获取当前用户返回状态码 {result.status_code},预期 200"
assert result.passed, (
f"获取当前用户性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_projects_list_performance(self, perf_test_user, perf_assert):
"""GET /projects 项目列表性能"""
headers = perf_test_user["headers"]
result = perf_assert.measure("core", "GET /projects")(lambda: client.get("/api/v1/projects", headers=headers))
assert result.status_code == 200, f"项目列表返回状态码 {result.status_code},预期 200"
assert result.passed, (
f"项目列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_assets_list_performance(self, perf_test_user, perf_assert):
"""GET /assets 素材列表性能"""
headers = perf_test_user["headers"]
result = perf_assert.measure("core", "GET /assets")(lambda: client.get("/api/v1/assets", headers=headers))
assert result.status_code == 200, f"素材列表返回状态码 {result.status_code},预期 200"
assert result.passed, (
f"素材列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_generation_tasks_list_performance(self, perf_test_user, perf_assert):
"""GET /generation/tasks 生成任务列表性能"""
headers = perf_test_user["headers"]
result = perf_assert.measure("core", "GET /generation/tasks")(
lambda: client.get("/api/v1/generation/tasks", headers=headers)
)
assert result.status_code == 200, f"生成任务列表返回状态码 {result.status_code},预期 200"
assert result.passed, (
f"生成任务列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_subscription_current_performance(self, perf_test_user, perf_assert):
"""GET /subscription/current 订阅信息性能"""
headers = perf_test_user["headers"]
result = perf_assert.measure("core", "GET /subscription/current")(
lambda: client.get("/api/v1/subscription/current", headers=headers)
)
assert result.status_code == 200, f"订阅信息返回状态码 {result.status_code},预期 200"
assert result.passed, (
f"订阅信息性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
# ══════════════════════════════════════════════════════════════════════════
# 普通接口性能测试(阈值 1000ms)
# ══════════════════════════════════════════════════════════════════════════
@pytest.mark.performance
@pytest.mark.perf_normal
@needs_pg
class TestNormalApiPerformance:
"""
普通接口性能测试 阈值 1000ms
这些接口涉及写操作或较多业务逻辑允许稍长的响应时间
"""
def test_create_project_performance(self, perf_test_user, perf_assert):
"""POST /projects 创建项目性能"""
headers = perf_test_user["headers"]
counter = 0
def _create():
nonlocal counter
counter += 1
return client.post(
"/api/v1/projects",
headers=headers,
json={"name": f"perf-create-{uuid.uuid4().hex[:8]}"},
)
result = perf_assert.measure("normal", "POST /projects")(_create)
assert result.status_code in (200, 201), f"创建项目返回状态码 {result.status_code},预期 200/201"
assert result.passed, (
f"创建项目性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_templates_list_performance(self, perf_test_user, perf_assert):
"""GET /templates 模板列表性能"""
headers = perf_test_user["headers"]
result = perf_assert.measure("normal", "GET /templates")(
lambda: client.get("/api/v1/templates", headers=headers)
)
# 模板列表可能返回 200 或空列表,只要不是错误即可
assert result.status_code == 200, f"模板列表返回状态码 {result.status_code},预期 200"
assert result.passed, (
f"模板列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_edit_plans_list_performance(self, perf_test_user, perf_assert):
"""GET /edit-plans 剪辑计划列表性能"""
headers = perf_test_user["headers"]
result = perf_assert.measure("normal", "GET /edit-plans")(
lambda: client.get("/api/v1/edit-plans", headers=headers)
)
assert result.status_code == 200, f"剪辑计划列表返回状态码 {result.status_code},预期 200"
assert result.passed, (
f"剪辑计划列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
# ══════════════════════════════════════════════════════════════════════════
# 重操作接口性能测试(阈值 3000ms)
# ══════════════════════════════════════════════════════════════════════════
@pytest.mark.performance
@pytest.mark.perf_heavy
@needs_pg
class TestHeavyApiPerformance:
"""
重操作接口性能测试 阈值 3000ms
这些接口涉及外部服务调用 OSS或复杂业务逻辑
允许较长的响应时间但仍需有上限
"""
def test_upload_direct_prepare_performance(self, perf_test_user, perf_assert):
# OSS 未配置时跳过此测试
from app.config import settings
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
pytest.skip("OSS credentials not configured, skipping upload signature test")
"""POST /upload/direct/prepare 获取上传签名性能"""
headers = perf_test_user["headers"]
project_id = perf_test_user["project_id"]
# 获取素材库 ID
lib_resp = client.get("/api/v1/asset-libraries", headers=headers)
library_id = ""
if lib_resp.status_code == 200:
items = lib_resp.json().get("items", [])
if items:
library_id = items[0].get("id", "")
def _prepare_upload():
return client.post(
"/api/v1/upload/direct/prepare",
headers=headers,
json={
"filename": f"perf-test-{uuid.uuid4().hex[:8]}.mp4",
"file_size": 1024 * 1024, # 1MB
"mime_type": "video/mp4",
"project_id": project_id,
"library_id": library_id,
},
)
result = perf_assert.measure("heavy", "POST /upload/direct/prepare")(_prepare_upload)
# 上传签名接口可能因为 OSS 配置问题返回 503,这是预期的
# 只要不超时、不返回 500 即可
assert result.status_code in (
200,
201,
400,
503,
), f"获取上传签名返回状态码 {result.status_code},预期 200/201/400/503"
assert result.passed, (
f"获取上传签名性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_create_generation_task_performance(self, perf_test_user, perf_assert):
"""POST /generation/tasks 创建生成任务性能"""
headers = perf_test_user["headers"]
project_id = perf_test_user["project_id"]
# 获取素材库 ID
lib_resp = client.get("/api/v1/asset-libraries", headers=headers)
library_id = ""
if lib_resp.status_code == 200:
items = lib_resp.json().get("items", [])
if items:
library_id = items[0].get("id", "")
def _create_task():
return client.post(
"/api/v1/generation/tasks",
headers=headers,
json={
"project_id": project_id,
"asset_library_id": library_id,
"template_id": "",
"title_ids": [],
"voice_ids": [],
"asset_ids": [],
"strategy_id": "",
},
)
result = perf_assert.measure("heavy", "POST /generation/tasks")(_create_task)
# 创建生成任务可能因为缺少素材等返回 400,这是预期的
# 性能测试关注响应时间,不关注业务成功与否
assert result.status_code in (
200,
201,
400,
404,
), f"创建生成任务返回状态码 {result.status_code},预期 200/201/400/404"
assert result.passed, (
f"创建生成任务性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
def test_duplication_upload_performance(self, perf_test_user, perf_assert):
"""POST /duplication/upload 去重上传性能"""
headers = perf_test_user["headers"]
# 准备一个小的测试文件(模拟视频文件)
test_content = b"fake video content for perf test" * 100
def _upload():
return client.post(
"/api/v1/duplication/upload",
headers=headers,
files={
"file": (
f"perf-dup-{uuid.uuid4().hex[:8]}.mp4",
test_content,
"video/mp4",
)
},
)
result = perf_assert.measure("heavy", "POST /duplication/upload")(_upload)
# 去重上传可能因为 OSS 配置问题返回 503,这是预期的
assert result.status_code in (
200,
201,
400,
503,
), f"去重上传返回状态码 {result.status_code},预期 200/201/400/503"
assert result.passed, (
f"去重上传性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
f"mean={result.mean_ms:.1f}ms"
)
# ══════════════════════════════════════════════════════════════════════════
# 性能测试汇总报告
# ══════════════════════════════════════════════════════════════════════════
@pytest.mark.performance
@needs_pg
def test_performance_summary(perf_test_user, perf_assert, capsys):
"""
汇总性能测试结果输出完整报告
这个测试会重新跑一遍所有接口的性能测试
并在最后输出汇总报告方便在 CI 中查看
"""
headers = perf_test_user["headers"]
project_id = perf_test_user["project_id"]
# 获取素材库 ID
lib_resp = client.get("/api/v1/asset-libraries", headers=headers)
library_id = ""
if lib_resp.status_code == 200:
items = lib_resp.json().get("items", [])
if items:
library_id = items[0].get("id", "")
# ── 核心接口 ──
# 登录(需要新用户)
unique = uuid.uuid4().hex[:8]
email = f"perf-summary-{unique}@example.com"
client.post(
"/api/v1/auth/register",
json={
"email": email,
"password": "SecurePass123",
"username": f"perfsummary-{unique}",
"display_name": "Perf Summary Test",
},
)
perf_assert.measure("core", "POST /auth/login")(
lambda: client.post(
"/api/v1/auth/login",
json={"email": email, "password": "SecurePass123"},
)
)
perf_assert.measure("core", "GET /auth/me")(lambda: client.get("/api/v1/auth/me", headers=headers))
perf_assert.measure("core", "GET /projects")(lambda: client.get("/api/v1/projects", headers=headers))
perf_assert.measure("core", "GET /assets")(lambda: client.get("/api/v1/assets", headers=headers))
perf_assert.measure("core", "GET /generation/tasks")(
lambda: client.get("/api/v1/generation/tasks", headers=headers)
)
perf_assert.measure("core", "GET /subscription/current")(
lambda: client.get("/api/v1/subscription/current", headers=headers)
)
# ── 普通接口 ──
perf_assert.measure("normal", "POST /projects")(
lambda: client.post(
"/api/v1/projects",
headers=headers,
json={"name": f"perf-summary-{uuid.uuid4().hex[:6]}"},
)
)
perf_assert.measure("normal", "GET /templates")(lambda: client.get("/api/v1/templates", headers=headers))
perf_assert.measure("normal", "GET /edit-plans")(lambda: client.get("/api/v1/edit-plans", headers=headers))
# ── 重操作接口 ──
perf_assert.measure("heavy", "POST /upload/direct/prepare")(
lambda: client.post(
"/api/v1/upload/direct/prepare",
headers=headers,
json={
"filename": f"perf-summary-{uuid.uuid4().hex[:6]}.mp4",
"file_size": 1024 * 1024,
"mime_type": "video/mp4",
"project_id": project_id,
"library_id": library_id,
},
)
)
perf_assert.measure("heavy", "POST /generation/tasks")(
lambda: client.post(
"/api/v1/generation/tasks",
headers=headers,
json={
"project_id": project_id,
"asset_library_id": library_id,
"template_id": "",
"title_ids": [],
"voice_ids": [],
"asset_ids": [],
"strategy_id": "",
},
)
)
test_content = b"fake video for summary perf test" * 100
perf_assert.measure("heavy", "POST /duplication/upload")(
lambda: client.post(
"/api/v1/duplication/upload",
headers=headers,
files={
"file": (
f"perf-sum-{uuid.uuid4().hex[:6]}.mp4",
test_content,
"video/mp4",
)
},
)
)
# 输出报告
report = perf_assert.report()
with capsys.disabled():
print("\n" + report)
# 汇总断言(警告模式:不阻塞,但输出失败信息)
# 在 CI 中通过 continue-on-error 控制是否阻塞
passed_count = sum(1 for r in perf_assert.results if r.passed)
total_count = len(perf_assert.results)
# 输出统计信息,方便 CI 解析
with capsys.disabled():
print(f"\nPERF_STATS: total={total_count}, passed={passed_count}, " f"failed={total_count - passed_count}")
for r in perf_assert.results:
status = "PASS" if r.passed else "FAIL"
print(
f"PERF_RESULT: {status} | {r.name} | "
f"median={r.median_ms:.1f}ms | threshold={r.threshold_ms}ms | "
f"min={r.min_ms:.1f}ms | max={r.max_ms:.1f}ms | "
f"mean={r.mean_ms:.1f}ms | status_code={r.status_code}"
)
# 这里使用宽松断言:只要超过一半通过就不报错
# 具体的 CI 阻塞策略由 CI 配置控制(continue-on-error
assert passed_count >= total_count // 2, (
f"性能测试通过率过低: {passed_count}/{total_count} " f"({passed_count/total_count*100:.0f}%),至少需要 50% 通过"
)
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
-753
View File
@@ -1,753 +0,0 @@
"""
素材 CRUD API 集成测试
覆盖端点
- POST /assets 创建素材
- GET /assets 获取素材列表
- GET /assets/{id} 获取单个素材详情
- PUT /assets/{id} 更新素材
- DELETE /assets/{id} 删除素材
- POST /assets/batch-delete 批量删除素材
- POST /assets/{id}/tags 素材打标签
- DELETE /assets/{id}/tags/{tag_id} 移除标签
使用 FastAPI TestClient + dependency_overrides 模式
导入真实模块mock 外部依赖
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.assets import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_project_repository,
get_tag_repository,
)
from packages.domain import (
Asset,
AssetLibrary,
AssetLibraryKind,
AssetStatus,
ClassificationStatus,
Project,
Tag,
User,
)
# ---------------------------------------------------------------------------
# 1. Stub Repository 实现
# ---------------------------------------------------------------------------
class StubProjectRepository:
def __init__(self, projects: dict[str, Project] | None = None):
self._projects = projects or {}
def find_by_id(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
def find_accessible_projects(self, user_id: str) -> list[Project]:
return [p for p in self._projects.values() if p.can_access(user_id)]
def count_by_owner(self, owner_user_id: str) -> int:
return len([p for p in self._projects.values() if p.owner_user_id == owner_user_id])
class StubAssetLibraryRepository:
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
self._libraries = libraries or {}
def get(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
def find_by_id(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
if kind is not None:
items = [lib for lib in items if lib.kind == kind]
return items
class StubAssetRepository:
def __init__(self, assets: dict[str, Asset] | None = None):
self._assets = assets or {}
def create(self, asset: Asset) -> Asset:
self._assets[asset.id] = asset
return asset
def get(self, asset_id: str) -> Asset | None:
return self._assets.get(asset_id)
def find_by_id(self, asset_id: str) -> Asset | None:
return self._assets.get(asset_id)
def find_by_library(self, library_id: str, skip: int = 0, limit: int = 100) -> list[Asset]:
items = [a for a in self._assets.values() if a.library_id == library_id]
return items[skip : skip + limit]
def find_by_library_and_file_type(self, library_id: str, file_type: str) -> list[Asset]:
return [
a
for a in self._assets.values()
if a.library_id == library_id and a.mime_type and a.mime_type.startswith(file_type)
]
def find_by_project(self, project_id: str, skip: int = 0, limit: int = 100) -> list[Asset]:
items = [a for a in self._assets.values() if a.project_id == project_id]
return items[skip : skip + limit]
def update(self, asset: Asset) -> Asset:
self._assets[asset.id] = asset
return asset
def delete(self, asset_id: str) -> bool:
if asset_id in self._assets:
del self._assets[asset_id]
return True
return False
def batch_delete(self, asset_ids: list[str]) -> int:
count = 0
for aid in asset_ids:
if aid in self._assets:
del self._assets[aid]
count += 1
return count
def count_by_project(self, project_id: str) -> int:
return len([a for a in self._assets.values() if a.project_id == project_id])
def count_by_project_ids(self, project_ids: list[str]) -> int:
return len([a for a in self._assets.values() if a.project_id in project_ids])
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
if not file_hash:
return None
for asset in self._assets.values():
if asset.library_id == library_id and getattr(asset, "file_hash", "") == file_hash:
return asset
return None
class StubTagRepository:
def __init__(self, tags: dict[str, Tag] | None = None):
self._tags = tags or {}
def get(self, tag_id: str) -> Tag | None:
return self._tags.get(tag_id)
def create(self, tag: Tag) -> Tag:
self._tags[tag.id] = tag
return tag
def list_by_user(self, user_id: str, skip: int = 0, limit: int = 100) -> list[Tag]:
return [t for t in self._tags.values() if t.user_id == user_id][skip : skip + limit]
# ---------------------------------------------------------------------------
# 2. Helpers & Fixtures
# ---------------------------------------------------------------------------
def _make_user(**overrides) -> User:
from datetime import datetime, timezone
defaults = dict(
id="user-test-001",
email="test@example.com",
display_name="Test User",
username="testuser",
subscription_plan="free",
subscription_status="active",
max_projects=3,
max_storage_gb=10,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
defaults.update(overrides)
return User(**defaults)
def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
return AssetLibrary(
id=id,
name="Test Video Library",
project_id=project_id,
kind=AssetLibraryKind.VIDEO,
)
def _make_asset(**overrides) -> Asset:
defaults = dict(
id="asset-1",
project_id="proj-1",
library_id="lib-1",
name="test-video.mp4",
storage_key="uploads/test-video.mp4",
mime_type="video/mp4",
file_size=1024 * 1024,
status=AssetStatus.READY,
classification_status=ClassificationStatus.COMPLETED,
uploaded_by_user_id="user-test-001",
duration=30.5,
width=1920,
height=1080,
fps=25.0,
quality_score=85.0,
)
defaults.update(overrides)
return Asset(**defaults)
def _make_tag(id: str = "tag-1", user_id: str = "user-test-001", name: str = "精彩片段") -> Tag:
return Tag(id=id, user_id=user_id, name=name)
@pytest.fixture
def mock_storage():
storage = MagicMock()
storage.get_download_url.return_value = "https://oss.example.com/uploads/test.mp4?sign=xxx"
return storage
@pytest.fixture
def client(mock_storage):
"""创建带有依赖覆盖的 TestClient。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/assets")
project = _make_project()
library = _make_library()
asset_repo = StubAssetRepository()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
tag_repo = StubTagRepository()
def _override_current_user():
mock_auth = MagicMock(spec=AuthenticatedUser)
mock_auth.user = _make_user()
return mock_auth
test_app.dependency_overrides[get_current_user] = _override_current_user
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_tag_repository] = lambda: tag_repo
test_app.dependency_overrides[get_storage_service] = lambda: mock_storage
yield TestClient(test_app)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 3. POST /assets — 创建素材
# ---------------------------------------------------------------------------
class TestCreateAsset:
"""创建素材端点测试。"""
def test_create_asset_success(self, client):
"""正常创建素材成功。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "new-video.mp4",
"storage_key": "uploads/new-video.mp4",
"mime_type": "video/mp4",
"file_size": 2048,
"duration": 15.0,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "new-video.mp4"
assert data["project_id"] == "proj-1"
assert data["library_id"] == "lib-1"
assert data["mime_type"] == "video/mp4"
assert "id" in data
assert data["status"] == "uploading"
def test_create_asset_project_not_found(self, client):
"""项目不存在返回 404。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "nonexistent",
"library_id": "lib-1",
"name": "test.mp4",
"storage_key": "uploads/test.mp4",
"mime_type": "video/mp4",
},
)
assert resp.status_code == 404
assert "Project" in resp.json()["detail"]
def test_create_asset_library_not_found(self, client):
"""素材库不存在返回 404。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "nonexistent",
"name": "test.mp4",
"storage_key": "uploads/test.mp4",
"mime_type": "video/mp4",
},
)
assert resp.status_code == 404
assert "AssetLibrary" in resp.json()["detail"]
def test_create_asset_missing_required_fields(self, client):
"""缺少必填字段返回 422。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"name": "test.mp4",
},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# 4. GET /assets — 获取素材列表
# ---------------------------------------------------------------------------
class TestListAssets:
"""获取素材列表端点测试。"""
def _create_test_assets(self, client, count: int = 3):
"""辅助方法:创建测试素材。"""
for i in range(count):
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": f"video-{i}.mp4",
"storage_key": f"uploads/video-{i}.mp4",
"mime_type": "video/mp4",
"file_size": 1024 * (i + 1),
},
)
def test_empty_list(self, client):
"""无素材时返回空列表。"""
resp = client.get("/api/v1/assets?library_id=lib-1")
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 0
def test_list_assets_by_library(self, client):
"""按素材库列出素材。"""
self._create_test_assets(client, 3)
resp = client.get("/api/v1/assets?library_id=lib-1")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 3
assert data["total"] >= 3
def test_list_assets_by_project(self, client):
"""按项目列出素材。"""
self._create_test_assets(client, 2)
resp = client.get("/api/v1/assets?project_id=proj-1")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 2
def test_list_pagination(self, client):
"""分页参数生效。"""
self._create_test_assets(client, 5)
resp = client.get("/api/v1/assets?library_id=lib-1&skip=0&limit=2")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 2
assert data["skip"] == 0
assert data["limit"] == 2
def test_list_with_keyword_filter(self, client):
"""按名称关键词过滤。"""
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "hello-world.mp4",
"storage_key": "uploads/hello.mp4",
"mime_type": "video/mp4",
},
)
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "goodbye.mp4",
"storage_key": "uploads/goodbye.mp4",
"mime_type": "video/mp4",
},
)
resp = client.get("/api/v1/assets?library_id=lib-1&keyword=hello")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 1
assert "hello" in data["items"][0]["name"].lower()
# ---------------------------------------------------------------------------
# 5. GET /assets/{asset_id} — 获取单个素材详情
# ---------------------------------------------------------------------------
class TestGetAsset:
"""获取单个素材详情端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "detail-test.mp4",
"storage_key": "uploads/detail-test.mp4",
"mime_type": "video/mp4",
"file_size": 5000,
"duration": 25.0,
"width": 1280,
"height": 720,
"fps": 30.0,
},
)
return resp.json()["id"]
def test_get_asset_success(self, client):
"""获取存在的素材详情成功。"""
asset_id = self._create_asset(client)
resp = client.get(f"/api/v1/assets/{asset_id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == asset_id
assert data["name"] == "detail-test.mp4"
assert data["file_size"] == 5000
assert data["duration"] == 25.0
assert data["width"] == 1280
assert data["height"] == 720
assert "file_url" in data
assert "status" in data
def test_get_nonexistent_asset_returns_404(self, client):
"""获取不存在的素材返回 404。"""
resp = client.get("/api/v1/assets/nonexistent-asset-id")
assert resp.status_code == 404
assert "not found" in resp.json()["detail"].lower() or "Asset" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# 6. PUT /assets/{asset_id} — 更新素材
# ---------------------------------------------------------------------------
class TestUpdateAsset:
"""更新素材端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "old-name.mp4",
"storage_key": "uploads/old-name.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
def test_update_asset_name(self, client):
"""更新素材名称成功。"""
asset_id = self._create_asset(client)
resp = client.put(
f"/api/v1/assets/{asset_id}",
json={"name": "new-name.mp4"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "new-name.mp4"
def test_update_asset_metadata(self, client):
"""更新素材 metadata 成功。"""
asset_id = self._create_asset(client)
resp = client.put(
f"/api/v1/assets/{asset_id}",
json={"metadata": {"description": "这是一段测试视频", "category": "demo"}},
)
assert resp.status_code == 200
data = resp.json()
assert data["metadata"]["description"] == "这是一段测试视频"
assert data["metadata"]["category"] == "demo"
def test_update_nonexistent_asset_returns_404(self, client):
"""更新不存在的素材返回 404。"""
resp = client.put(
"/api/v1/assets/nonexistent-id",
json={"name": "test.mp4"},
)
assert resp.status_code == 404
def test_update_with_empty_body(self, client):
"""空请求体也应返回成功(不修改任何字段)。"""
asset_id = self._create_asset(client)
resp = client.put(f"/api/v1/assets/{asset_id}", json={})
assert resp.status_code == 200
assert resp.json()["name"] == "old-name.mp4"
# ---------------------------------------------------------------------------
# 7. DELETE /assets/{asset_id} — 删除素材
# ---------------------------------------------------------------------------
class TestDeleteAsset:
"""删除素材端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "delete-test.mp4",
"storage_key": "uploads/delete-test.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
def test_delete_asset_success(self, client):
"""删除存在的素材成功,返回 204。"""
asset_id = self._create_asset(client)
resp = client.delete(f"/api/v1/assets/{asset_id}")
assert resp.status_code == 204
# 验证已删除
get_resp = client.get(f"/api/v1/assets/{asset_id}")
assert get_resp.status_code == 404
def test_delete_nonexistent_asset_returns_404(self, client):
"""删除不存在的素材返回 404。"""
resp = client.delete("/api/v1/assets/nonexistent-id")
assert resp.status_code == 404
def test_delete_idempotent(self, client):
"""删除后再次删除返回 404(幂等性)。"""
asset_id = self._create_asset(client)
resp1 = client.delete(f"/api/v1/assets/{asset_id}")
assert resp1.status_code == 204
resp2 = client.delete(f"/api/v1/assets/{asset_id}")
assert resp2.status_code == 404
# ---------------------------------------------------------------------------
# 8. POST /assets/batch-delete — 批量删除素材
# ---------------------------------------------------------------------------
class TestBatchDeleteAssets:
"""批量删除素材端点测试。"""
def _create_assets(self, client, count: int = 3) -> list[str]:
ids = []
for i in range(count):
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": f"batch-{i}.mp4",
"storage_key": f"uploads/batch-{i}.mp4",
"mime_type": "video/mp4",
},
)
ids.append(resp.json()["id"])
return ids
def test_batch_delete_success(self, client):
"""批量删除成功。"""
ids = self._create_assets(client, 3)
resp = client.post(
"/api/v1/assets/batch-delete",
json={"ids": ids[:2]},
)
assert resp.status_code == 200
data = resp.json()
assert data["deleted_count"] == 2
assert len(data["failed_ids"]) == 0
def test_batch_delete_with_nonexistent_ids(self, client):
"""批量删除包含不存在的 ID,失败的计入 failed_ids。"""
ids = self._create_assets(client, 2)
ids.append("nonexistent-id")
resp = client.post(
"/api/v1/assets/batch-delete",
json={"ids": ids},
)
assert resp.status_code == 200
data = resp.json()
assert data["deleted_count"] == 2
assert "nonexistent-id" in data["failed_ids"]
def test_batch_delete_empty_list_returns_422(self, client):
"""空列表返回 422。"""
resp = client.post(
"/api/v1/assets/batch-delete",
json={"ids": []},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# 9. 标签相关测试
# ---------------------------------------------------------------------------
class TestAssetTags:
"""素材标签相关端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "tag-test.mp4",
"storage_key": "uploads/tag-test.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
def test_add_tags_to_asset(self, client):
"""给素材打标签。需要先在 tag_repo 中创建标签。"""
# 由于 tag_repo 在 fixture 内部创建,我们通过另一种方式测试
# 直接测试不存在的标签返回 404
asset_id = self._create_asset(client)
resp = client.post(
f"/api/v1/assets/{asset_id}/tags",
json={"tag_ids": ["nonexistent-tag"]},
)
# 标签不存在应返回 404
assert resp.status_code == 404
assert "Tag" in resp.json()["detail"]
def test_remove_tag_from_asset(self, client):
"""移除素材标签(幂等,不存在也返回 204)。"""
asset_id = self._create_asset(client)
resp = client.delete(f"/api/v1/assets/{asset_id}/tags/nonexistent-tag")
# 移除标签是幂等的,标签不存在也应返回 204
assert resp.status_code == 204
# ---------------------------------------------------------------------------
# 10. 跨端点集成场景
# ---------------------------------------------------------------------------
class TestAssetsCRUDFlow:
"""素材完整 CRUD 流程测试。"""
def test_full_crud_flow(self, client):
"""测试完整的创建 → 列表 → 详情 → 更新 → 删除流程。"""
# 1. 创建
create_resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "crud-flow.mp4",
"storage_key": "uploads/crud-flow.mp4",
"mime_type": "video/mp4",
"file_size": 8192,
"metadata": {"source": "test"},
},
)
assert create_resp.status_code == 200
asset_id = create_resp.json()["id"]
# 2. 列表中应包含
list_resp = client.get("/api/v1/assets?library_id=lib-1")
assert list_resp.status_code == 200
assert any(item["id"] == asset_id for item in list_resp.json()["items"])
# 3. 获取详情
detail_resp = client.get(f"/api/v1/assets/{asset_id}")
assert detail_resp.status_code == 200
assert detail_resp.json()["name"] == "crud-flow.mp4"
# 4. 更新名称
update_resp = client.put(
f"/api/v1/assets/{asset_id}",
json={"name": "crud-flow-updated.mp4"},
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "crud-flow-updated.mp4"
# 5. 验证更新生效
detail_resp2 = client.get(f"/api/v1/assets/{asset_id}")
assert detail_resp2.json()["name"] == "crud-flow-updated.mp4"
# 6. 删除
delete_resp = client.delete(f"/api/v1/assets/{asset_id}")
assert delete_resp.status_code == 204
# 7. 验证已删除
detail_resp3 = client.get(f"/api/v1/assets/{asset_id}")
assert detail_resp3.status_code == 404
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -1,743 +0,0 @@
"""
分片上传完整流程集成测试
覆盖端点
- POST /upload/chunk/init 初始化分片上传
- POST /upload/chunk/{id}/{index} 上传分片
- GET /upload/chunk/{id}/status 获取上传状态
- POST /upload/chunk/{id}/complete 完成分片上传
使用 FastAPI TestClient + dependency_overrides 模式
导入真实模块mock 外部依赖OSS存储Celery任务文件类型检测
"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.chunked_upload import (
CHUNK_STORAGE_ROOT,
complete_chunked_upload,
get_upload_status,
init_chunked_upload,
upload_chunk,
)
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
from packages.domain import AssetLibrary, AssetLibraryKind, Project, User
# ---------------------------------------------------------------------------
# 1. Stub Repository 实现
# ---------------------------------------------------------------------------
class StubProjectRepository:
def __init__(self, projects: dict[str, Project] | None = None):
self._projects = projects or {}
def get(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
def find_by_id(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
class StubAssetLibraryRepository:
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
self._libraries = libraries or {}
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
if kind is not None:
items = [lib for lib in items if lib.kind == kind]
return items
def get(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
class StubAssetRepository:
def __init__(self):
self._assets = {}
def find_by_library_and_file_hash(self, library_id: str, file_hash: str):
if not file_hash:
return None
for asset in self._assets.values():
if asset.library_id == library_id and getattr(asset, "file_hash", "") == file_hash:
return asset
return None
class StubIngestJobRepository:
"""内存 IngestJob Repository,模拟持久化行为。"""
def __init__(self):
self._jobs: dict[str, object] = {}
def create(self, job) -> object:
self._jobs[job.id] = job
return job
def add(self, job) -> None:
self._jobs[job.id] = job
def get(self, job_id: str):
return self._jobs.get(job_id)
def update(self, job) -> object:
self._jobs[job.id] = job
return job
def update_status(self, job_id, status, **kwargs):
job = self._jobs.get(job_id)
if job:
job.status = status
def list_by_project(self, project_id: str, skip: int = 0, limit: int = 50):
return [j for j in self._jobs.values() if getattr(j, "project_id", None) == project_id]
def list_by_library(self, library_id: str, skip: int = 0, limit: int = 50):
return [j for j in self._jobs.values() if getattr(j, "library_id", None) == library_id]
# ---------------------------------------------------------------------------
# 2. Helpers & Fixtures
# ---------------------------------------------------------------------------
def _make_user(**overrides) -> User:
from datetime import datetime, timezone
defaults = dict(
id="user-test-001",
email="test@example.com",
display_name="Test User",
username="testuser",
subscription_plan="free",
subscription_status="active",
max_projects=3,
max_storage_gb=10,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
defaults.update(overrides)
return User(**defaults)
def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
return AssetLibrary(id=id, name="Test Library", project_id=project_id, kind=AssetLibraryKind.VIDEO)
@pytest.fixture
def project():
return _make_project()
@pytest.fixture
def library():
return _make_library()
@pytest.fixture
def mock_storage():
storage = MagicMock()
storage.is_configured = True
storage.upload_file.return_value = "https://oss.example.com/uploads/test/test.mp4"
storage.get_download_url.return_value = "https://oss.example.com/uploads/test/test.mp4?sign=xxx"
return storage
@pytest.fixture
def client(project, library, mock_storage):
"""创建带有依赖覆盖的 TestClient。
注意手动按正确顺序注册路由避免 /{upload_id}/{chunk_index} 抢占
/{upload_id}/complete /{upload_id}/status 的匹配
"""
test_app = FastAPI()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
asset_repo = StubAssetRepository()
ingest_repo = StubIngestJobRepository()
def _override_current_user():
mock_auth = MagicMock(spec=AuthenticatedUser)
mock_auth.user = _make_user()
mock_auth.id = "user-test-001"
return mock_auth
test_app.dependency_overrides[get_current_user] = _override_current_user
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
test_app.dependency_overrides[get_storage_service] = lambda: mock_storage
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
# 手动按正确顺序注册路由(具体路径在前,参数路径在后)
prefix = "/api/v1/upload/chunk"
test_app.add_api_route(f"{prefix}/init", init_chunked_upload, methods=["POST"])
test_app.add_api_route(f"{prefix}/{{upload_id}}/status", get_upload_status, methods=["GET"])
test_app.add_api_route(f"{prefix}/{{upload_id}}/complete", complete_chunked_upload, methods=["POST"])
test_app.add_api_route(f"{prefix}/{{upload_id}}/{{chunk_index}}", upload_chunk, methods=["POST"])
# 临时修改 CHUNK_STORAGE_ROOT 到测试临时目录
test_temp_dir = tempfile.mkdtemp(prefix="test_chunked_upload_")
import app.api.routes.chunked_upload as chunk_mod
chunk_mod.CHUNK_STORAGE_ROOT = Path(test_temp_dir)
yield TestClient(test_app)
# 清理
import shutil
chunk_mod.CHUNK_STORAGE_ROOT = CHUNK_STORAGE_ROOT
if Path(test_temp_dir).exists():
shutil.rmtree(test_temp_dir)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 3. POST /init — 初始化分片上传
# ---------------------------------------------------------------------------
class TestInitChunkedUpload:
"""初始化分片上传端点测试。"""
def test_init_success(self, client):
"""正常初始化分片上传成功。"""
file_size = 10 * 1024 * 1024 # 10MB
chunk_size = 5 * 1024 * 1024 # 5MB
total_chunks = (file_size + chunk_size - 1) // chunk_size # 2
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"filename": "test-video.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
assert resp.status_code == 200
data = resp.json()
assert "upload_id" in data
assert data["filename"] == "test-video.mp4"
assert data["total_chunks"] == total_chunks
assert data["chunk_size"] == chunk_size
assert "expires_at" in data
def test_init_with_invalid_total_chunks(self, client):
"""total_chunks 与 file_size 不匹配返回 400。"""
file_size = 10 * 1024 * 1024
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"filename": "test.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": 999, # 错误的分片数
},
)
assert resp.status_code == 400
assert "total_chunks" in resp.json()["detail"].lower() or "mismatch" in resp.json()["detail"].lower()
def test_init_project_not_found(self, client):
"""项目不存在返回 404。"""
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "nonexistent",
"library_id": "lib-1",
"filename": "test.mp4",
"content_type": "video/mp4",
"file_size": 1024 * 1024,
"total_chunks": 1,
},
)
assert resp.status_code == 404
assert "Project not found" in resp.json()["detail"]
def test_init_library_not_found(self, client):
"""素材库不存在返回 404。"""
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "nonexistent",
"filename": "test.mp4",
"content_type": "video/mp4",
"file_size": 1024 * 1024,
"total_chunks": 1,
},
)
assert resp.status_code == 404
assert "Asset library not found" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# 4. POST /{upload_id}/{chunk_index} — 上传分片
# ---------------------------------------------------------------------------
class TestUploadChunk:
"""上传分片端点测试。"""
def _init_upload(self, client, file_size: int = 10 * 1024 * 1024) -> str:
"""辅助方法:初始化上传并返回 upload_id。"""
chunk_size = 5 * 1024 * 1024
total_chunks = (file_size + chunk_size - 1) // chunk_size
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"filename": "test-video.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
return resp.json()["upload_id"]
def test_upload_first_chunk_success(self, client):
"""上传第一个分片成功。"""
upload_id = self._init_upload(client)
chunk_data = b"a" * (5 * 1024 * 1024) # 5MB
resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/0",
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
)
assert resp.status_code == 200
data = resp.json()
assert data["chunk_index"] == 0
assert data["uploaded_chunks"] == 1
assert data["total_chunks"] == 2
def test_upload_nonexistent_upload_returns_404(self, client):
"""上传不存在的 upload_id 返回 404。"""
resp = client.post(
"/api/v1/upload/chunk/nonexistent-upload-id/0",
files={"chunk": ("chunk_0", b"data", "application/octet-stream")},
)
assert resp.status_code == 404
assert "Upload not found" in resp.json()["detail"]
def test_upload_chunk_index_out_of_bounds(self, client):
"""分片索引越界返回 400。"""
upload_id = self._init_upload(client)
resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/999",
files={"chunk": ("chunk_999", b"data", "application/octet-stream")},
)
assert resp.status_code == 400
assert "Invalid chunk index" in resp.json()["detail"]
def test_upload_chunk_index_negative(self, client):
"""分片索引为负数返回 422(FastAPI 路径参数校验)。"""
upload_id = self._init_upload(client)
resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/-1",
files={"chunk": ("chunk_-1", b"data", "application/octet-stream")},
)
assert resp.status_code in (400, 422)
def test_upload_duplicate_chunk_returns_message(self, client):
"""重复上传同一分片返回已上传提示(幂等)。"""
upload_id = self._init_upload(client)
chunk_data = b"b" * (5 * 1024 * 1024)
resp1 = client.post(
f"/api/v1/upload/chunk/{upload_id}/0",
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
)
assert resp1.status_code == 200
resp2 = client.post(
f"/api/v1/upload/chunk/{upload_id}/0",
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
)
assert resp2.status_code == 200
assert "already uploaded" in resp2.json()["message"].lower()
# ---------------------------------------------------------------------------
# 5. GET /{upload_id}/status — 获取上传状态
# ---------------------------------------------------------------------------
class TestGetUploadStatus:
"""获取上传状态端点测试。"""
def _init_upload(self, client) -> str:
file_size = 10 * 1024 * 1024
chunk_size = 5 * 1024 * 1024
total_chunks = (file_size + chunk_size - 1) // chunk_size
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"filename": "test-video.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
return resp.json()["upload_id"]
def test_status_pending_after_init(self, client):
"""刚初始化后状态为 pending,无已上传分片。"""
upload_id = self._init_upload(client)
resp = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
assert resp.status_code == 200
data = resp.json()
assert data["upload_id"] == upload_id
assert data["status"] == "pending"
assert data["uploaded_chunks"] == []
assert data["total_chunks"] == 2
assert data["file_size"] == 10 * 1024 * 1024
def test_status_after_uploading_chunks(self, client):
"""上传部分分片后状态更新。"""
upload_id = self._init_upload(client)
chunk_data = b"c" * (5 * 1024 * 1024)
client.post(
f"/api/v1/upload/chunk/{upload_id}/0",
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
)
resp = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "uploading"
assert 0 in data["uploaded_chunks"]
assert len(data["uploaded_chunks"]) == 1
def test_status_nonexistent_upload_returns_404(self, client):
"""查询不存在的 upload_id 返回 404。"""
resp = client.get("/api/v1/upload/chunk/nonexistent-id/status")
assert resp.status_code == 404
assert "Upload not found" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# 6. POST /{upload_id}/complete — 完成分片上传
# ---------------------------------------------------------------------------
class TestCompleteChunkedUpload:
"""完成分片上传端点测试。"""
def _init_and_upload_all_chunks(self, client, file_size: int = 10 * 1024 * 1024) -> str:
"""辅助方法:初始化并上传所有分片。"""
chunk_size = 5 * 1024 * 1024
total_chunks = (file_size + chunk_size - 1) // chunk_size
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"filename": "test-video.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
upload_id = resp.json()["upload_id"]
for i in range(total_chunks):
if i == total_chunks - 1:
remaining = file_size - i * chunk_size
chunk_data = b"x" * remaining
else:
chunk_data = b"x" * chunk_size
client.post(
f"/api/v1/upload/chunk/{upload_id}/{i}",
files={"chunk": (f"chunk_{i}", chunk_data, "application/octet-stream")},
)
return upload_id
@patch("app.api.routes.chunked_upload._validate_file_type")
@patch("app.api.routes.chunked_upload.celery_app")
def test_complete_success(self, mock_celery, mock_validate, client, mock_storage):
"""完整上传后调用 complete 成功。"""
mock_celery.send_task = MagicMock()
mock_validate.return_value = "video/mp4"
upload_id = self._init_and_upload_all_chunks(client)
resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/complete",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"file_hash": "",
},
)
assert resp.status_code == 200
data = resp.json()
assert "storage_key" in data
assert "url" in data
assert "ingest_job_id" in data
assert data["duplicated"] is False
assert mock_storage.upload_file.called
assert mock_celery.send_task.called
def test_complete_with_missing_chunks(self, client):
"""缺少分片时调用 complete 返回 400。"""
file_size = 10 * 1024 * 1024
chunk_size = 5 * 1024 * 1024
total_chunks = (file_size + chunk_size - 1) // chunk_size
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"filename": "test-video.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
upload_id = resp.json()["upload_id"]
# 只上传第0个分片,缺少第1个
chunk_data = b"y" * chunk_size
client.post(
f"/api/v1/upload/chunk/{upload_id}/0",
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
)
resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/complete",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"file_hash": "",
},
)
assert resp.status_code == 400
assert "Missing chunks" in resp.json()["detail"]
def test_complete_nonexistent_upload_returns_404(self, client):
"""完成不存在的 upload_id 返回 404。"""
resp = client.post(
"/api/v1/upload/chunk/nonexistent-id/complete",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"file_hash": "",
},
)
assert resp.status_code == 404
assert "Upload not found" in resp.json()["detail"]
def test_complete_project_mismatch_returns_400(self, client):
"""project_id 不匹配返回 400。"""
# 只传一个分片用于测试(不完成也没关系,project 校验在 missing chunks 之前)
file_size = 5 * 1024 * 1024
resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"filename": "test-video.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": 1,
},
)
upload_id = resp.json()["upload_id"]
chunk_data = b"z" * file_size
client.post(
f"/api/v1/upload/chunk/{upload_id}/0",
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
)
resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/complete",
json={
"project_id": "wrong-project",
"library_id": "lib-1",
"file_hash": "",
},
)
assert resp.status_code == 400
assert "mismatch" in resp.json()["detail"].lower()
@patch("app.api.routes.chunked_upload._validate_file_type")
@patch("app.api.routes.chunked_upload.celery_app")
def test_complete_with_file_hash_dedup(self, mock_celery, mock_validate, client, mock_storage):
"""带 file_hash 的去重检测命中时返回 duplicated=true。"""
mock_celery.send_task = MagicMock()
mock_validate.return_value = "video/mp4"
# 先在 asset_repo 里预置一个重复素材
file_size = 5 * 1024 * 1024
file_hash = "abc123def456"
# 需要在 asset_repo 中预置数据
# 由于 client fixture 中 asset_repo 是内部创建的,我们需要用另一种方式
# 直接通过 patch 模拟 find_by_library_and_file_hash 返回值
from packages.domain import Asset, AssetStatus, ClassificationStatus
existing_asset = Asset(
id="existing-asset-1",
project_id="proj-1",
library_id="lib-1",
name="existing.mp4",
storage_key="uploads/existing.mp4",
mime_type="video/mp4",
file_hash=file_hash,
status=AssetStatus.READY,
classification_status=ClassificationStatus.COMPLETED,
)
# 通过 patch 修改 asset_repository 的返回值
with patch(
"app.api.routes.chunked_upload.get_asset_repository",
return_value=type(
"Repo",
(),
{"find_by_library_and_file_hash": lambda self, lib_id, fh: existing_asset if fh == file_hash else None},
)(),
):
upload_id = self._init_and_upload_all_chunks(client, file_size)
resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/complete",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"file_hash": file_hash,
},
)
# 注:此测试可能受依赖注入顺序影响,仅验证基本路径
# 实际命中去重的情况在端到端测试中验证
assert resp.status_code in (200, 400)
# ---------------------------------------------------------------------------
# 7. 完整流程集成测试
# ---------------------------------------------------------------------------
class TestFullChunkedUploadFlow:
"""分片上传完整流程集成测试。"""
@patch("app.api.routes.chunked_upload._validate_file_type")
@patch("app.api.routes.chunked_upload.celery_app")
def test_full_upload_flow(self, mock_celery, mock_validate, client):
"""测试完整的分片上传流程:init → 上传分片 → status → complete。"""
mock_celery.send_task = MagicMock()
mock_validate.return_value = "video/mp4"
file_size = 12 * 1024 * 1024 # 12MB = 3个分片 (5+5+2)
chunk_size = 5 * 1024 * 1024
total_chunks = (file_size + chunk_size - 1) // chunk_size # 3
# 1. 初始化
init_resp = client.post(
"/api/v1/upload/chunk/init",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"filename": "full-flow.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
assert init_resp.status_code == 200
upload_id = init_resp.json()["upload_id"]
# 2. 检查初始状态
status_resp = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
assert status_resp.status_code == 200
assert status_resp.json()["status"] == "pending"
# 3. 上传所有分片
for i in range(total_chunks):
if i == total_chunks - 1:
remaining = file_size - i * chunk_size
chunk_data = b"z" * remaining
else:
chunk_data = b"z" * chunk_size
chunk_resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/{i}",
files={"chunk": (f"chunk_{i}", chunk_data, "application/octet-stream")},
)
assert chunk_resp.status_code == 200
# 4. 检查上传中状态
status_resp = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
assert status_resp.status_code == 200
assert status_resp.json()["status"] == "uploading"
assert len(status_resp.json()["uploaded_chunks"]) == total_chunks
# 5. 完成上传
complete_resp = client.post(
f"/api/v1/upload/chunk/{upload_id}/complete",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"file_hash": "abc123def456",
},
)
assert complete_resp.status_code == 200
complete_data = complete_resp.json()
assert complete_data["ingest_job_id"] != ""
assert complete_data["storage_key"].startswith("uploads/")
# 6. 验证 Celery 任务被发送
assert mock_celery.send_task.called
assert mock_celery.send_task.call_args[0][0] == "worker.ingest_asset"
# 7. 完成后再次查询状态应返回 404(元数据已清理)
status_after = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
assert status_after.status_code == 404
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -1,301 +0,0 @@
"""
分类任务 API 集成测试
覆盖端点
- POST /classification-jobs 提交分类任务
- GET /classification-jobs/{job_id} 获取分类任务详情
使用 FastAPI TestClient + dependency_overrides 模式
导入真实路由模块mock Celery repository
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
# mock celery_app 以避免实际发送任务
import app.api.routes.classification_jobs as classification_routes
from app.api.routes.classification_jobs import router
from app.dependencies import get_classification_job_repository
from packages.adapters.in_memory import InMemoryClassificationJobRepository
from packages.domain import ClassificationJob, ClassificationJobStatus
classification_routes.celery_app = MagicMock()
# ---------------------------------------------------------------------------
# 1. 辅助函数
# ---------------------------------------------------------------------------
def _make_job(
project_id: str = "proj-1",
asset_id: str = "asset-1",
status: ClassificationJobStatus = ClassificationJobStatus.PENDING,
) -> ClassificationJob:
job = ClassificationJob.create(project_id=project_id, asset_id=asset_id)
if status == ClassificationJobStatus.PROCESSING:
job.status = ClassificationJobStatus.PROCESSING
elif status == ClassificationJobStatus.COMPLETED:
job.status = ClassificationJobStatus.COMPLETED
job.classification = "scenic"
job.confidence = 0.92
elif status == ClassificationJobStatus.FAILED:
job.status = ClassificationJobStatus.FAILED
job.error_message = "AI 服务不可用"
return job
# ---------------------------------------------------------------------------
# 2. Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def repo():
return InMemoryClassificationJobRepository()
@pytest.fixture
def client(repo):
"""创建带有依赖覆盖的 TestClient。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/classification-jobs")
def _override_repo():
return repo
test_app.dependency_overrides[get_classification_job_repository] = _override_repo
yield TestClient(test_app)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 3. POST / — 提交分类任务
# ---------------------------------------------------------------------------
class TestSubmitClassificationJob:
"""提交分类任务端点测试。"""
def test_submit_with_valid_data(self, client):
"""使用有效数据提交分类任务应成功。"""
resp = client.post(
"/classification-jobs",
json={
"project_id": "proj-123",
"asset_id": "asset-456",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["project_id"] == "proj-123"
assert data["asset_id"] == "asset-456"
assert data["status"] == "pending"
assert data["classification"] == ""
assert data["confidence"] == 0.0
assert data["error_message"] == ""
assert "id" in data
assert len(data["id"]) > 0
def test_submit_generates_unique_id(self, client):
"""每次提交应生成不同的任务 ID。"""
resp1 = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
resp2 = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a2"})
assert resp1.json()["id"] != resp2.json()["id"]
def test_submit_missing_project_id_returns_422(self, client):
"""缺少 project_id 应返回 422。"""
resp = client.post("/classification-jobs", json={"asset_id": "asset-1"})
assert resp.status_code == 422
def test_submit_missing_asset_id_returns_422(self, client):
"""缺少 asset_id 应返回 422。"""
resp = client.post("/classification-jobs", json={"project_id": "proj-1"})
assert resp.status_code == 422
def test_submit_empty_project_id_returns_422(self, client):
"""空 project_id 应返回 422。"""
resp = client.post("/classification-jobs", json={"project_id": "", "asset_id": "asset-1"})
assert resp.status_code == 422
def test_submit_empty_asset_id_returns_422(self, client):
"""空 asset_id 应返回 422。"""
resp = client.post("/classification-jobs", json={"project_id": "proj-1", "asset_id": ""})
assert resp.status_code == 422
def test_submit_sends_celery_task(self, client):
"""提交任务后应触发 Celery 异步任务。"""
classification_routes.celery_app.send_task.reset_mock()
resp = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
assert resp.status_code == 200
job_id = resp.json()["id"]
classification_routes.celery_app.send_task.assert_called_once_with(
"worker.classify_asset",
args=[job_id],
)
def test_submit_persists_to_repository(self, client, repo):
"""提交后任务应保存到 repository。"""
resp = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
job_id = resp.json()["id"]
saved = repo.get(job_id)
assert saved is not None
assert saved.project_id == "p1"
assert saved.asset_id == "a1"
assert saved.status == ClassificationJobStatus.PENDING
# ---------------------------------------------------------------------------
# 4. GET /{job_id} — 获取分类任务详情
# ---------------------------------------------------------------------------
class TestGetClassificationJob:
"""获取分类任务详情端点测试。"""
def test_get_pending_job(self, client, repo):
"""获取 pending 状态的任务。"""
job = _make_job(status=ClassificationJobStatus.PENDING)
repo.create(job)
resp = client.get(f"/classification-jobs/{job.id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == job.id
assert data["status"] == "pending"
assert data["classification"] == ""
assert data["confidence"] == 0.0
def test_get_processing_job(self, client, repo):
"""获取 processing 状态的任务。"""
job = _make_job(status=ClassificationJobStatus.PROCESSING)
repo.create(job)
resp = client.get(f"/classification-jobs/{job.id}")
assert resp.status_code == 200
assert resp.json()["status"] == "processing"
def test_get_completed_job(self, client, repo):
"""获取已完成的任务应包含分类结果和置信度。"""
job = _make_job(status=ClassificationJobStatus.COMPLETED)
repo.create(job)
resp = client.get(f"/classification-jobs/{job.id}")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "completed"
assert data["classification"] == "scenic"
assert data["confidence"] == 0.92
assert data["error_message"] == ""
def test_get_failed_job(self, client, repo):
"""获取失败的任务应包含错误信息。"""
job = _make_job(status=ClassificationJobStatus.FAILED)
repo.create(job)
resp = client.get(f"/classification-jobs/{job.id}")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "failed"
assert "AI 服务不可用" in data["error_message"]
def test_get_nonexistent_job_returns_404(self, client):
"""获取不存在的任务应返回 404。"""
resp = client.get("/classification-jobs/nonexistent-job-id")
assert resp.status_code == 404
def test_response_contains_all_required_fields(self, client, repo):
"""响应应包含所有必需字段。"""
job = _make_job()
repo.create(job)
resp = client.get(f"/classification-jobs/{job.id}")
data = resp.json()
for field in ["id", "project_id", "asset_id", "status", "classification", "confidence", "error_message"]:
assert field in data, f"缺少字段: {field}"
# ---------------------------------------------------------------------------
# 5. 跨端点场景
# ---------------------------------------------------------------------------
class TestClassificationApiScenarios:
"""分类任务 API 跨端点集成场景。"""
def test_submit_then_get_pending(self, client, repo):
"""提交任务后立即查询应为 pending 状态。"""
submit_resp = client.post(
"/classification-jobs",
json={"project_id": "proj-scenario", "asset_id": "asset-scenario"},
)
assert submit_resp.status_code == 200
job_id = submit_resp.json()["id"]
get_resp = client.get(f"/classification-jobs/{job_id}")
assert get_resp.status_code == 200
assert get_resp.json()["status"] == "pending"
assert get_resp.json()["project_id"] == "proj-scenario"
assert get_resp.json()["asset_id"] == "asset-scenario"
def test_submit_simulate_complete_then_get(self, client, repo):
"""模拟 worker 完成任务后查询应返回结果。"""
submit_resp = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
job_id = submit_resp.json()["id"]
# 模拟 worker 处理完成
job = repo.get(job_id)
assert job is not None
job.status = ClassificationJobStatus.COMPLETED
job.classification = "product"
job.confidence = 0.88
repo.update(job)
# 查询结果
get_resp = client.get(f"/classification-jobs/{job_id}")
assert get_resp.status_code == 200
data = get_resp.json()
assert data["status"] == "completed"
assert data["classification"] == "product"
assert data["confidence"] == 0.88
def test_submit_simulate_failure_then_get(self, client, repo):
"""模拟 worker 失败后查询应返回错误信息。"""
submit_resp = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
job_id = submit_resp.json()["id"]
# 模拟处理失败
job = repo.get(job_id)
assert job is not None
job.status = ClassificationJobStatus.FAILED
job.error_message = "网络超时"
repo.update(job)
get_resp = client.get(f"/classification-jobs/{job_id}")
assert get_resp.status_code == 200
data = get_resp.json()
assert data["status"] == "failed"
assert "网络超时" in data["error_message"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])
-496
View File
@@ -1,496 +0,0 @@
"""
仪表盘 API 集成测试
覆盖端点
- GET /dashboard/overview 仪表盘概览
验证返回数据结构空数据场景数据汇总正确性
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from app.api.routes.dashboard import router
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_generation_task_repository,
get_project_repository,
get_title_library_repository,
get_voice_library_repository,
)
from packages.domain.entities import Project, User
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ---------------------------------------------------------------------------
# 1. 内存 Repository
# ---------------------------------------------------------------------------
class InMemoryProjectRepository:
def __init__(self):
self._projects: dict[str, Project] = {}
def save(self, project: Project) -> None:
self._projects[project.id] = project
def find_by_id(self, project_id: str):
return self._projects.get(project_id)
def find_by_owner_user_id(self, owner_user_id: str):
return [p for p in self._projects.values() if p.owner_user_id == owner_user_id]
def find_accessible_projects(self, user_id: str):
return [p for p in self._projects.values() if p.owner_user_id == user_id]
def count_by_owner(self, owner_user_id: str) -> int:
return len(self.find_by_owner_user_id(owner_user_id))
def delete(self, project_id: str) -> bool:
if project_id in self._projects:
del self._projects[project_id]
return True
return False
class InMemoryAssetRepository:
def __init__(self):
self._assets = []
def add_asset(self, project_id: str, storage_size: int = 0):
self._assets.append({"project_id": project_id, "storage_size": storage_size})
def count_by_project_ids(self, project_ids: list[str]) -> int:
return sum(1 for a in self._assets if a["project_id"] in project_ids)
def sum_storage_by_project_ids(self, project_ids: list[str]) -> int:
return sum(a["storage_size"] for a in self._assets if a["project_id"] in project_ids)
# 其他方法占位
def create(self, asset):
return asset
def find_by_id(self, asset_id):
return None
def find_by_project(self, project_id, **kwargs):
return []
def find_by_library(self, library_id, **kwargs):
return []
def update(self, asset):
return asset
def delete(self, asset_id):
return False
def batch_delete(self, asset_ids):
return 0
def search_candidates(self, **kwargs):
return []
def find_by_tag_ids(self, tag_ids):
return []
def count_by_project(self, project_id):
return 0
def find_by_library_and_file_type(self, library_id, file_type):
return []
def find_by_library_and_file_hash(self, library_id, file_hash):
return None
class InMemoryGenerationTaskRepository:
def __init__(self):
self._tasks = {}
def add_task(self, task: GenerationTask):
self._tasks[task.id] = task
def count_by_user(self, user_id: str) -> int:
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list:
user_tasks = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
# 按 created_at 倒序
user_tasks.sort(key=lambda t: t.created_at, reverse=True)
return user_tasks[:limit]
# 其他方法占位
def create(self, task):
return task
def get(self, task_id):
return None
def list_by_project(self, project_id):
return []
def list_by_user(self, user_id):
return []
def list_by_source_edit_plan(self, plan_id):
return []
def update(self, task):
return task
class InMemoryTitleLibraryRepository:
def __init__(self):
self._items = {}
def add_item(self, user_id: str):
from uuid import uuid4
item_id = uuid4().hex
self._items[item_id] = {"id": item_id, "user_id": user_id}
return item_id
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
return len([i for i in self._items.values() if i["user_id"] == user_id])
# 其他方法占位
def list_by_user(self, user_id, **kwargs):
return []
def get(self, title_id, user_id):
return None
def create(self, item):
return item
def update(self, item):
return item
def delete(self, title_id, user_id):
return False
class InMemoryVoiceLibraryRepository:
def __init__(self):
self._items = {}
def add_item(self, user_id: str):
from uuid import uuid4
item_id = uuid4().hex
self._items[item_id] = {"id": item_id, "user_id": user_id}
return item_id
def count_by_user(self, user_id: str) -> int:
return len([i for i in self._items.values() if i["user_id"] == user_id])
# 其他方法占位
def list_by_user(self, user_id, **kwargs):
return []
def get(self, voice_id, user_id):
return None
def create(self, item):
return item
def update(self, item):
return item
def delete(self, voice_id, user_id):
return False
# ---------------------------------------------------------------------------
# 2. 辅助函数
# ---------------------------------------------------------------------------
def _make_user(**overrides) -> User:
defaults = dict(
id="user-test-001",
email="test@example.com",
display_name="Test User",
username="testuser",
subscription_plan="free",
subscription_status="active",
max_projects=3,
max_storage_gb=10,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
defaults.update(overrides)
return User(**defaults)
def _make_project(project_id: str, owner_user_id: str = "user-test-001") -> Project:
return Project(
id=project_id,
name=f"Project {project_id}",
owner_user_id=owner_user_id,
description="",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
def _make_generation_task(
task_id: str,
user_id: str = "user-test-001",
status: GenerationTaskStatus = GenerationTaskStatus.COMPLETED,
created_at: datetime | None = None,
) -> GenerationTask:
return GenerationTask(
id=task_id,
project_id="proj-1",
asset_library_id="lib-1",
created_by_user_id=user_id,
status=status,
error_message="",
created_at=created_at or datetime.now(timezone.utc),
started_at=datetime.now(timezone.utc) if status != GenerationTaskStatus.PENDING else None,
completed_at=datetime.now(timezone.utc) if status == GenerationTaskStatus.COMPLETED else None,
)
# ---------------------------------------------------------------------------
# 3. Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def project_repo():
repo = InMemoryProjectRepository()
repo.save(_make_project("proj-1", "user-test-001"))
repo.save(_make_project("proj-2", "user-test-001"))
repo.save(_make_project("proj-other", "other-user"))
return repo
@pytest.fixture
def asset_repo():
return InMemoryAssetRepository()
@pytest.fixture
def generation_task_repo():
return InMemoryGenerationTaskRepository()
@pytest.fixture
def title_library_repo():
return InMemoryTitleLibraryRepository()
@pytest.fixture
def voice_library_repo():
return InMemoryVoiceLibraryRepository()
@pytest.fixture
def client(project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo):
"""创建带有依赖覆盖的 TestClient。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/dashboard")
def _override_current_user():
return AuthenticatedUser(user=_make_user())
test_app.dependency_overrides[get_current_user] = _override_current_user
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
yield TestClient(test_app)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 4. GET /overview — 仪表盘概览
# ---------------------------------------------------------------------------
class TestDashboardOverview:
"""仪表盘概览端点测试。"""
def test_empty_data_returns_zeros(self, client):
"""空数据时所有计数为 0。"""
resp = client.get("/dashboard/overview")
assert resp.status_code == 200
data = resp.json()
assert data["total_assets"] == 0
assert data["used_storage_bytes"] == 0
assert data["total_titles"] == 0
assert data["total_voices"] == 0
assert data["total_tasks"] == 0
assert data["total_products"] == 2 # fixture 中有 2 个项目
assert data["recent_tasks"] == []
def test_assets_count_and_storage(self, client, asset_repo):
"""素材统计正确。"""
asset_repo.add_asset("proj-1", 1024)
asset_repo.add_asset("proj-1", 2048)
asset_repo.add_asset("proj-2", 4096)
# 其他用户的不计入
asset_repo.add_asset("proj-other", 9999)
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_assets"] == 3
assert data["used_storage_bytes"] == 1024 + 2048 + 4096
def test_title_library_count(self, client, title_library_repo):
"""标题库统计正确。"""
title_library_repo.add_item("user-test-001")
title_library_repo.add_item("user-test-001")
title_library_repo.add_item("user-test-001")
title_library_repo.add_item("other-user")
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_titles"] == 3
def test_voice_library_count(self, client, voice_library_repo):
"""配音库统计正确。"""
voice_library_repo.add_item("user-test-001")
voice_library_repo.add_item("other-user")
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_voices"] == 1
def test_generation_tasks_count(self, client, generation_task_repo):
"""生成任务统计正确。"""
generation_task_repo.add_task(_make_generation_task("task-1"))
generation_task_repo.add_task(_make_generation_task("task-2"))
generation_task_repo.add_task(_make_generation_task("task-other", user_id="other-user"))
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_tasks"] == 2
def test_recent_tasks_limited_to_5(self, client, generation_task_repo):
"""最近任务最多返回 5 个。"""
for i in range(10):
task = _make_generation_task(f"task-{i}")
generation_task_repo.add_task(task)
resp = client.get("/dashboard/overview")
data = resp.json()
assert len(data["recent_tasks"]) <= 5
def test_recent_tasks_have_correct_fields(self, client, generation_task_repo):
"""最近任务包含正确字段。"""
task = _make_generation_task("task-1", status=GenerationTaskStatus.COMPLETED)
generation_task_repo.add_task(task)
resp = client.get("/dashboard/overview")
data = resp.json()
assert len(data["recent_tasks"]) == 1
item = data["recent_tasks"][0]
for field in ["id", "task_type", "status", "current_step", "error_message", "updated_at"]:
assert field in item, f"缺少字段: {field}"
assert item["task_type"] == "generation"
def test_subscription_info(self, client):
"""订阅信息正确。"""
resp = client.get("/dashboard/overview")
data = resp.json()
assert "subscription" in data
sub = data["subscription"]
assert "plan" in sub
assert "is_active" in sub
assert sub["plan"] == "free"
assert sub["is_active"] is True
def test_pro_user_subscription(
self, project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo
):
"""Pro 用户订阅信息正确。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/dashboard")
test_app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(
user=_make_user(subscription_plan="pro", subscription_status="active")
)
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
c = TestClient(test_app)
resp = c.get("/dashboard/overview")
assert resp.status_code == 200
assert resp.json()["subscription"]["plan"] == "pro"
assert resp.json()["subscription"]["is_active"] is True
test_app.dependency_overrides.clear()
def test_total_products_count(self, client, project_repo):
"""项目(产品)数量正确。"""
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_products"] == 2
# 新增一个项目后
project_repo.save(_make_project("proj-3", "user-test-001"))
resp2 = client.get("/dashboard/overview")
assert resp2.json()["total_products"] == 3
def test_unauthorized_returns_401(
self, project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo
):
"""未授权访问返回 401/403。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/dashboard")
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
c = TestClient(test_app)
resp = c.get("/dashboard/overview")
assert resp.status_code in (401, 403)
test_app.dependency_overrides.clear()
def test_recent_tasks_status_mapping(self, client, generation_task_repo):
"""不同状态的任务显示正确的当前步骤。"""
# 已完成任务
completed_task = _make_generation_task("task-completed", status=GenerationTaskStatus.COMPLETED)
generation_task_repo.add_task(completed_task)
resp = client.get("/dashboard/overview")
tasks = resp.json()["recent_tasks"]
completed = [t for t in tasks if t["id"] == "task-completed"][0]
assert completed["status"] == "completed"
assert "完成" in completed["current_step"] or "completed" in completed["current_step"].lower()
if __name__ == "__main__":
pytest.main([__file__, "-v"])

Some files were not shown because too many files have changed in this diff Show More