Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b034764ad | |||
| 8748b43070 | |||
| d213a055a1 | |||
| 2371860f82 | |||
| dbd956fc6e | |||
| 1d59ee5336 |
@@ -7,59 +7,15 @@ on:
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: saas
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
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']}/{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:
|
||||
top_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == top_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(top_prefix):
|
||||
member.name = name[len(top_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- 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
|
||||
|
||||
+324
-367
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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,28 +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.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,
|
||||
@@ -205,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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -252,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:
|
||||
@@ -305,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,校验用户是否有权访问
|
||||
@@ -431,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:
|
||||
@@ -487,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:
|
||||
"""触发剪辑计划渲染生成
|
||||
|
||||
@@ -508,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)
|
||||
@@ -637,64 +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)
|
||||
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(
|
||||
@@ -841,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 异步)
|
||||
@@ -892,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(
|
||||
@@ -1118,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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Executable → Regular
+25
-99
@@ -1,11 +1,9 @@
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
@@ -32,48 +30,9 @@ from packages.application import (
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_enqueue_generation_task(
|
||||
task: Any,
|
||||
generation_task_repository: Any,
|
||||
) -> bool:
|
||||
"""安全入队:send_task 失败时自动把任务标记为 failed,避免留下 pending 僵尸任务。
|
||||
|
||||
Returns:
|
||||
True 表示入队成功,False 表示入队失败(已标记为 failed)
|
||||
"""
|
||||
try:
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
logger.info(
|
||||
"[生成任务] 入队成功: task_id=%s, status=%s",
|
||||
task.id,
|
||||
task.status,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[生成任务] 入队失败,标记为失败: task_id=%s error=%s",
|
||||
task.id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
task.mark_failed(f"任务入队失败: {e}")
|
||||
generation_task_repository.update(task)
|
||||
except Exception as update_err:
|
||||
logger.error(
|
||||
"[生成任务] 入队失败后更新状态也失败: task_id=%s error=%s",
|
||||
task.id,
|
||||
update_err,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
@@ -97,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,
|
||||
@@ -105,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,
|
||||
@@ -118,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -222,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:
|
||||
@@ -265,37 +204,30 @@ def create_generation_task(
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
count = request.count
|
||||
created_tasks = []
|
||||
failed_tasks = []
|
||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||
batch_id = uuid.uuid4().hex if count > 1 else ""
|
||||
|
||||
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=authenticated_user.user.id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
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 _safe_enqueue_generation_task(task, generation_task_repository):
|
||||
created_tasks.append(task)
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except Exception as e:
|
||||
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
created_tasks.append(task)
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@@ -333,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:
|
||||
@@ -342,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)
|
||||
@@ -381,6 +308,5 @@ def retry_generation_task(
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
)
|
||||
)
|
||||
if not _safe_enqueue_generation_task(retried, generation_task_repository):
|
||||
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
Executable → Regular
+2
-33
@@ -25,35 +25,6 @@ from packages.application import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_enqueue_generation_task(
|
||||
task: Any,
|
||||
generation_task_repository: Any,
|
||||
) -> bool:
|
||||
"""安全入队:send_task 失败时自动把任务标记为 failed,避免留下 pending 僵尸任务。"""
|
||||
try:
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
logger.info("[任务中心] 生成任务入队成功: task_id=%s", task.id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[任务中心] 生成任务入队失败,标记为失败: task_id=%s error=%s",
|
||||
task.id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
task.mark_failed(f"任务入队失败: {e}")
|
||||
generation_task_repository.update(task)
|
||||
except Exception as update_err:
|
||||
logger.error(
|
||||
"[任务中心] 入队失败后更新状态也失败: task_id=%s error=%s",
|
||||
task.id,
|
||||
update_err,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _humanize_task_error(error_message: str) -> str:
|
||||
raw = (error_message or "").strip()
|
||||
if not raw:
|
||||
@@ -182,8 +153,7 @@ def retry_task_by_id(
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
if not _safe_enqueue_generation_task(retried, generation_task_repository):
|
||||
logger.warning("[任务中心] 用户级重试入队失败: task_id=%s", retried.id)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
return UserTaskResponse(
|
||||
id=f"generation:{retried.id}",
|
||||
task_type="generation",
|
||||
@@ -265,8 +235,7 @@ def retry_project_task(
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
if not _safe_enqueue_generation_task(retried, generation_task_repository):
|
||||
logger.warning("[任务中心] 项目级重试用队失败: task_id=%s", retried.id)
|
||||
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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -201,18 +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)
|
||||
return CosyVoiceService()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 clips(N = asset_count,至少1个)
|
||||
- PIP: 1 个 main + (N-1) 个 overlay(N = 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 clips(B-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])
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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_url,video 元素应能渲染)
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+415
-2
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/** 创建标题 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* 标题库页面 — V21 设计系统
|
||||
* 两栏布局:左侧分类列表(220px)+ 右侧标题卡片网格(3列)
|
||||
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
||||
* 对接后端真实 API(GET/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 生成的标题 */
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 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">
|
||||
|
||||
@@ -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
|
||||
@@ -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 转场滤镜构建
|
||||
等底层能力,供 EditingModeProcessor、VideoComposeService、UnifiedRenderService
|
||||
共同复用。
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -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: 存储键或完整 URL(URL 会自动提取 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,489 +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
Executable → Regular
+1
-4
@@ -16,7 +16,6 @@ from packages.application.cosyvoice_service import (
|
||||
CosyVoiceTimeoutError,
|
||||
)
|
||||
from packages.application.voice_clone.workflow import VoiceCloneWorkflowService
|
||||
from video_processing.oss_helpers import get_signed_download_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` |
|
||||
@@ -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
@@ -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,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 \
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# ============================================
|
||||
# Production 部署脚本 - Registry 方式
|
||||
# 用法:IMAGE_TAG=<version> REGISTRY_TOKEN=<token> sh deploy-production-registry.sh
|
||||
# ============================================
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-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 "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 三镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# ---- Re-tag 成本地名 ----
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "All images pulled and tagged."
|
||||
|
||||
# ---- 备份旧版 assets(部署期间缓存用户不 404) ----
|
||||
echo "Backing up legacy assets from current web container..."
|
||||
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
|
||||
_tmpdir="/tmp/legacy-assets-$$"
|
||||
rm -rf "$_tmpdir"
|
||||
mkdir -p "$_tmpdir"
|
||||
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
|
||||
# 合并到 LEGACY_ASSETS_DIR(保留所有历史版本的 assets)
|
||||
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
|
||||
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
|
||||
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
|
||||
fi
|
||||
rm -rf "$_tmpdir"
|
||||
else
|
||||
echo "No existing web container, skipping legacy assets backup"
|
||||
fi
|
||||
|
||||
# 清理超过 7 天的旧 assets 文件(避免无限增长)
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ]; then
|
||||
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
echo "Legacy assets cleanup done (retain 7 days)"
|
||||
fi
|
||||
|
||||
# ---- 确保基础设施容器在运行 ----
|
||||
echo "Checking infrastructure containers..."
|
||||
for c in xiaoxia-postgres-production xiaoxia-redis-production; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 确保生产网络存在 ----
|
||||
docker network create xiaoxia-net-production 2>/dev/null || true
|
||||
|
||||
# ---- 执行数据库 Migration ----
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
|
||||
# ---- 日志配置(所有容器共用) ----
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# ---- 启动 API ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://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"
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://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 挂载到 /usr/share/nginx/html/assets-legacy/assets/
|
||||
# nginx 配置中 assets location 有 fallback 逻辑
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
echo "Web container: legacy assets mounted (fallback)"
|
||||
else
|
||||
echo "Web container: no legacy assets to mount"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Production deployment complete ==="
|
||||
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,178 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# ============================================
|
||||
# Staging 部署脚本 - Registry 方式
|
||||
# 用法:IMAGE_TAG=<sha|version> REGISTRY_TOKEN=<token> sh deploy-staging.sh
|
||||
# ============================================
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/var/lib/xiaoxia-saas-staging/repo/infra/docker}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$(echo $REGISTRY | cut -d/ -f1)" -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="${REGISTRY}/xiaoxia-saas-api:staging"
|
||||
LOCAL_WORKER="${REGISTRY}/xiaoxia-saas-worker:staging"
|
||||
LOCAL_WEB="${REGISTRY}/xiaoxia-saas-web:staging"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# ---- Re-tag 成本地名 ----
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "All images pulled and tagged."
|
||||
|
||||
# ---- 确保基础设施容器在运行 ----
|
||||
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f {{.State.Status}} "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 确保 staging 网络存在 ----
|
||||
docker network create xiaoxia-net-staging 2>/dev/null || true
|
||||
|
||||
# ---- 执行数据库 Migration ----
|
||||
echo "Running database migrations..."
|
||||
docker run --rm --env-file "$ENV_FILE" --network xiaoxia-net-staging "$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
# ---- 启动 API ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--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 \
|
||||
"$LOCAL_API"
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--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 \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web ----
|
||||
# Web 镜像默认打包 production nginx.conf,staging 需要挂载 staging 配置
|
||||
NGINX_CONF="${NGINX_CONF:-${COMPOSE_DIR}/nginx-staging.conf}"
|
||||
if [ ! -f "$NGINX_CONF" ]; then
|
||||
echo "WARN: nginx config not found at $NGINX_CONF, using image default"
|
||||
NGINX_VOLUME=""
|
||||
else
|
||||
NGINX_VOLUME="-v ${NGINX_CONF}:/etc/nginx/conf.d/default.conf:ro"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
$NGINX_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo "ERROR: API did not become healthy within 60s"
|
||||
docker logs --tail 30 xiaoxia-api-staging
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
docker image prune -af --filter "until=72h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Staging deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}" | grep staging
|
||||
|
||||
# Watchtower auto-update: 容器加com.centurylinklabs.watchtower.enable=true标签,用:staging tag启动
|
||||
@@ -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}"
|
||||
@@ -39,15 +39,6 @@ server {
|
||||
alias /app/generated/;
|
||||
}
|
||||
|
||||
# Assets with legacy fallback (higher priority than generic static regex)
|
||||
# 部署期间,缓存了旧版 index.html 的用户会请求旧版带 hash 的 assets 文件
|
||||
# 先在当前镜像中找,找不到去 legacy-assets 目录找(从旧版本容器中备份的)
|
||||
location ^~ /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri /assets-legacy$uri =404;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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
|
||||
@@ -11,7 +11,7 @@ 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 ./
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -131,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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Executable → Regular
+307
-282
@@ -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,24 +60,23 @@ 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.5-plus)
|
||||
- 非流式: 同步返回音频 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.5-plus",
|
||||
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")
|
||||
|
||||
@@ -87,9 +84,9 @@ class CosyVoiceService:
|
||||
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,34 +97,24 @@ 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
|
||||
|
||||
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
|
||||
|
||||
@@ -145,7 +132,7 @@ class CosyVoiceService:
|
||||
# ── 预置音色 ─────────────────────────────────────────
|
||||
|
||||
def list_preset_voices(self) -> list[PresetVoice]:
|
||||
"""获取预置音色列表.
|
||||
"""获取预置音色列表。
|
||||
|
||||
Returns:
|
||||
预置音色列表
|
||||
@@ -159,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 调用失败
|
||||
@@ -186,68 +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 调用失败
|
||||
@@ -256,98 +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,
|
||||
@@ -355,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}")
|
||||
|
||||
# ── 语音合成 ─────────────────────────────────────────
|
||||
|
||||
@@ -404,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: 要合成的文本
|
||||
@@ -417,11 +400,10 @@ class CosyVoiceService:
|
||||
sample_rate: 采样率(Hz),0 表示使用配置默认值
|
||||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||||
speed: 语速(0.5-2.0),1.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 调用失败
|
||||
@@ -441,54 +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:
|
||||
"""轮询合成任务(同步接口无需轮询,保留兼容).
|
||||
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,
|
||||
@@ -497,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: 要合成的文本
|
||||
@@ -511,52 +492,128 @@ class CosyVoiceService:
|
||||
sample_rate: 采样率(Hz),0 表示使用配置默认值
|
||||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||||
speed: 语速(0.5-2.0),1.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,
|
||||
@@ -565,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: 超时时间(秒)
|
||||
|
||||
@@ -605,57 +662,25 @@ class CosyVoiceService:
|
||||
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}"
|
||||
)
|
||||
raise CosyVoiceAuthError(f"CosyVoice API 认证失败: HTTP {response.status_code}")
|
||||
elif response.status_code >= 500:
|
||||
# 服务端错误,可重试
|
||||
last_error = CosyVoiceError(
|
||||
f"CosyVoice API 服务端错误: HTTP {response.status_code}"
|
||||
)
|
||||
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}"
|
||||
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:
|
||||
|
||||
Executable → Regular
+60
-155
@@ -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,42 +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,
|
||||
@@ -279,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:
|
||||
"""处理合成失败结果。
|
||||
|
||||
@@ -485,108 +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:
|
||||
|
||||
Executable → Regular
+7
-10
@@ -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,7 +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
|
||||
@@ -250,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_at、progress=100.0、result_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_message、completed_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_id、duration 等)
|
||||
"""
|
||||
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_message、started_at、completed_at、progress。
|
||||
|
||||
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
|
||||
|
||||
@@ -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.5-plus"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +68,7 @@ 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) ==="
|
||||
@@ -97,14 +86,14 @@ if [ "$USE_CACHE" -eq 1 ]; then
|
||||
--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" \
|
||||
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
|
||||
-t "$WEB_IMAGE" \
|
||||
--load \
|
||||
.
|
||||
else
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
|
||||
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
|
||||
-t "$WEB_IMAGE" \
|
||||
.
|
||||
fi
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -1,236 +0,0 @@
|
||||
"""四模式渲染集成测试.
|
||||
|
||||
验证 4 种剪辑模式(ONE_TAKE / PIP / VOICE_OVER / VOICE_PIP)通过
|
||||
_build_plan_and_clips_from_task + UnifiedRenderService 的完整渲染流程。
|
||||
|
||||
需要 ffmpeg 可用;CI 无 ffmpeg 时自动跳过。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
RenderResult,
|
||||
UnifiedRenderService,
|
||||
_resolve_layer_role,
|
||||
)
|
||||
from worker_app.tasks.generation import _build_plan_and_clips_from_task
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("ffmpeg"),
|
||||
reason="ffmpeg not available",
|
||||
)
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _generate_test_video(path: Path, duration: float = 3.0, color: str = "red") -> None:
|
||||
"""生成一个纯色测试视频。"""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c={color}:s=640x360:d={duration}:r=25",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(path),
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
|
||||
def _render_with_mode(
|
||||
mode: str,
|
||||
num_clips: int = 3,
|
||||
duration: float = 2.0,
|
||||
) -> tuple[RenderResult, Path]:
|
||||
"""用指定模式生成测试视频并渲染,返回 (result, work_dir)。
|
||||
|
||||
调用方负责清理 work_dir。
|
||||
"""
|
||||
work_dir = Path(tempfile.mkdtemp(prefix="test_4mode_"))
|
||||
|
||||
# 生成测试视频素材
|
||||
colors = ["red", "green", "blue", "yellow", "purple"]
|
||||
downloaded_paths: list[Path] = []
|
||||
for i in range(num_clips):
|
||||
p = work_dir / f"test_{i:03d}.mp4"
|
||||
_generate_test_video(p, duration=duration, color=colors[i % len(colors)])
|
||||
downloaded_paths.append(p)
|
||||
|
||||
# 构建虚拟 plan + clips
|
||||
task_id = f"test_task_{mode}"
|
||||
plan, clips, asset_path_map = _build_plan_and_clips_from_task(
|
||||
task_id=task_id,
|
||||
downloaded_paths=downloaded_paths,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
# 渲染
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=work_dir,
|
||||
output_width=640,
|
||||
output_height=360,
|
||||
output_fps=25,
|
||||
)
|
||||
result = service.render()
|
||||
return result, work_dir
|
||||
|
||||
|
||||
# ── 测试 _build_plan_and_clips_from_task ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildPlanAndClips:
|
||||
"""测试 4 种模式的虚拟 plan 构建。"""
|
||||
|
||||
def _make_paths(self, n: int) -> list[Path]:
|
||||
return [Path(f"/tmp/test_{i}.mp4") for i in range(n)]
|
||||
|
||||
def test_one_take_mode(self):
|
||||
paths = self._make_paths(3)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t1", paths, "one_take")
|
||||
|
||||
assert plan.id == "t1"
|
||||
assert len(clips) == 3
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
assert len(asset_map) == 3
|
||||
|
||||
def test_pip_mode(self):
|
||||
paths = self._make_paths(3)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t2", paths, "pip")
|
||||
|
||||
assert len(clips) == 3
|
||||
assert clips[0].clip_type == "main"
|
||||
assert clips[1].clip_type == "overlay"
|
||||
assert clips[2].clip_type == "overlay"
|
||||
|
||||
def test_voice_over_mode(self):
|
||||
paths = self._make_paths(3)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t3", paths, "voice_over")
|
||||
|
||||
assert len(clips) == 3
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
assert all(c.config.get("role") == "b_roll" for c in clips)
|
||||
|
||||
def test_voice_pip_mode(self):
|
||||
paths = self._make_paths(4)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t4", paths, "voice_pip")
|
||||
|
||||
assert len(clips) == 4
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[3].clip_type == "b_roll"
|
||||
|
||||
def test_unknown_mode_defaults_to_one_take(self):
|
||||
paths = self._make_paths(2)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t5", paths, "unknown_mode")
|
||||
|
||||
assert len(clips) == 2
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
|
||||
def test_asset_path_map_keys_match_clip_asset_ids(self):
|
||||
paths = self._make_paths(3)
|
||||
_, clips, asset_map = _build_plan_and_clips_from_task("t6", paths, "one_take")
|
||||
|
||||
clip_asset_ids = {c.asset_id for c in clips}
|
||||
map_keys = set(asset_map.keys())
|
||||
assert clip_asset_ids == map_keys
|
||||
|
||||
|
||||
# ── 测试图层分组(4 模式) ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFourModeLayerGrouping:
|
||||
"""验证 4 种模式的 clip_type 分布经 _resolve_layer_role 后产生正确的图层。"""
|
||||
|
||||
def test_one_take_layers(self):
|
||||
"""ONE_TAKE: 3 main → 1 main layer。"""
|
||||
paths = [Path(f"/tmp/ot_{i}.mp4") for i in range(3)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("ot", paths, "one_take")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"main"}
|
||||
|
||||
def test_pip_layers(self):
|
||||
"""PIP: 1 main + 2 overlay → main + overlay。"""
|
||||
paths = [Path(f"/tmp/pip_{i}.mp4") for i in range(3)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("pip", paths, "pip")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"main", "overlay"}
|
||||
|
||||
def test_voice_over_layers(self):
|
||||
"""VOICE_OVER: 3 main(b_roll) → broll。"""
|
||||
paths = [Path(f"/tmp/vo_{i}.mp4") for i in range(3)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("vo", paths, "voice_over")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"broll"}
|
||||
|
||||
def test_voice_pip_layers(self):
|
||||
"""VOICE_PIP: 1 bg + 1 corner_voice + 2 b_roll → 3 个图层。"""
|
||||
paths = [Path(f"/tmp/vpip_{i}.mp4") for i in range(4)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("vpip", paths, "voice_pip")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"background", "corner_voice", "broll"}
|
||||
|
||||
|
||||
# ── 端到端渲染测试(需要 ffmpeg) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEndToEndRendering:
|
||||
"""4 种模式的完整渲染测试,验证输出文件存在且时长合理。"""
|
||||
|
||||
def test_one_take_render(self):
|
||||
result, work_dir = _render_with_mode("one_take", num_clips=2, duration=2.0)
|
||||
try:
|
||||
assert result.output_path.exists()
|
||||
assert result.file_size > 0
|
||||
assert result.duration > 0
|
||||
assert result.width == 640
|
||||
assert result.height == 360
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
def test_pip_render(self):
|
||||
result, work_dir = _render_with_mode("pip", num_clips=2, duration=2.0)
|
||||
try:
|
||||
assert result.output_path.exists()
|
||||
assert result.file_size > 0
|
||||
assert result.duration > 0
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
def test_voice_over_render(self):
|
||||
result, work_dir = _render_with_mode("voice_over", num_clips=2, duration=2.0)
|
||||
try:
|
||||
assert result.output_path.exists()
|
||||
assert result.file_size > 0
|
||||
assert result.duration > 0
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
def test_voice_pip_render(self):
|
||||
result, work_dir = _render_with_mode("voice_pip", num_clips=3, duration=2.0)
|
||||
try:
|
||||
assert result.output_path.exists()
|
||||
assert result.file_size > 0
|
||||
assert result.duration > 0
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
@@ -1,238 +0,0 @@
|
||||
"""全链路集成测试.
|
||||
|
||||
验证 PlanGeneratorService → UnifiedRenderService → 查重 的端到端流程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
RenderResult,
|
||||
UnifiedRenderService,
|
||||
)
|
||||
from worker_app.tasks.generation import (
|
||||
OUTPUT_HEIGHT,
|
||||
OUTPUT_WIDTH,
|
||||
_build_plan_and_clips_from_task,
|
||||
_create_fallback_clip,
|
||||
_mux_audio_track,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("ffmpeg"),
|
||||
reason="ffmpeg not available",
|
||||
)
|
||||
|
||||
|
||||
def _generate_test_video(path: Path, duration: float = 3.0) -> None:
|
||||
"""生成一个测试视频。"""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c=blue:s=640x360:d={duration}:r=25",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(path),
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
|
||||
def _generate_test_audio(path: Path, duration: float = 5.0) -> None:
|
||||
"""生成一个测试音频文件。"""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"sine=frequency=440:duration={duration}",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(path),
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
|
||||
# ── 测试 _create_fallback_clip ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFallbackClip:
|
||||
"""测试 fallback 视频生成。"""
|
||||
|
||||
def test_fallback_clip_creates_video(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "fallback.mp4"
|
||||
_create_fallback_clip(output, "Test Fallback")
|
||||
|
||||
assert output.exists()
|
||||
assert output.stat().st_size > 0
|
||||
|
||||
|
||||
# ── 测试 _mux_audio_track ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMuxAudioTrack:
|
||||
"""测试视频+音频混合。"""
|
||||
|
||||
def test_mux_audio_into_video(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
video_path = Path(tmpdir) / "video.mp4"
|
||||
audio_path = Path(tmpdir) / "audio.aac"
|
||||
output_path = Path(tmpdir) / "output.mp4"
|
||||
|
||||
_generate_test_video(video_path, duration=3.0)
|
||||
_generate_test_audio(audio_path, duration=5.0)
|
||||
|
||||
_mux_audio_track(video_path, str(audio_path), output_path)
|
||||
|
||||
assert output_path.exists()
|
||||
assert output_path.stat().st_size > 0
|
||||
|
||||
# 验证输出文件包含音频轨
|
||||
probe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-show_streams",
|
||||
"-select_streams",
|
||||
"a",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
|
||||
# 如果有音频流,输出非空
|
||||
assert result.stdout.strip() != "" or result.returncode == 0
|
||||
|
||||
|
||||
# ── 测试 PlanGenerator → UnifiedRenderService 全链路 ─────────────────────────
|
||||
|
||||
|
||||
class TestFullPipeline:
|
||||
"""验证从虚拟 plan 构建到渲染输出的完整流程。"""
|
||||
|
||||
def test_one_take_pipeline(self):
|
||||
"""ONE_TAKE 模式完整流程。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
work_dir = Path(tmpdir)
|
||||
|
||||
# 生成测试素材
|
||||
paths = []
|
||||
for i in range(3):
|
||||
p = work_dir / f"clip_{i}.mp4"
|
||||
_generate_test_video(p, duration=2.0)
|
||||
paths.append(p)
|
||||
|
||||
# 构建虚拟 plan
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("pipeline_test", paths, "one_take")
|
||||
|
||||
# 渲染
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_map,
|
||||
work_dir=work_dir,
|
||||
output_width=640,
|
||||
output_height=360,
|
||||
)
|
||||
result = service.render()
|
||||
|
||||
assert result.output_path.exists()
|
||||
assert result.duration > 0
|
||||
assert result.file_size > 0
|
||||
assert result.width == 640
|
||||
assert result.height == 360
|
||||
|
||||
def test_pipeline_with_audio_mux(self):
|
||||
"""渲染 + 混音后处理。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
work_dir = Path(tmpdir)
|
||||
|
||||
# 生成测试素材
|
||||
video_path = work_dir / "clip_0.mp4"
|
||||
_generate_test_video(video_path, duration=3.0)
|
||||
|
||||
# 构建虚拟 plan
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("audio_test", [video_path], "one_take")
|
||||
|
||||
# 渲染
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_map,
|
||||
work_dir=work_dir,
|
||||
output_width=640,
|
||||
output_height=360,
|
||||
)
|
||||
render_result = service.render()
|
||||
|
||||
# 混音
|
||||
audio_path = work_dir / "voice.aac"
|
||||
_generate_test_audio(audio_path, duration=5.0)
|
||||
|
||||
final_path = work_dir / "final.mp4"
|
||||
_mux_audio_track(render_result.output_path, str(audio_path), final_path)
|
||||
|
||||
assert final_path.exists()
|
||||
assert final_path.stat().st_size > 0
|
||||
|
||||
def test_single_clip_pipeline(self):
|
||||
"""单 clip 渲染(无转场)。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
work_dir = Path(tmpdir)
|
||||
|
||||
video_path = work_dir / "single.mp4"
|
||||
_generate_test_video(video_path, duration=5.0)
|
||||
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("single_test", [video_path], "one_take")
|
||||
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_map,
|
||||
work_dir=work_dir,
|
||||
output_width=640,
|
||||
output_height=360,
|
||||
)
|
||||
result = service.render()
|
||||
|
||||
assert result.output_path.exists()
|
||||
assert result.duration > 0
|
||||
|
||||
def test_dedup_helper_integration(self):
|
||||
"""验证 dedup_helpers.create_video_record_and_dedup 的导入和签名。"""
|
||||
# 只验证函数存在且签名正确(不实际调用,需要数据库)
|
||||
import inspect
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
sig = inspect.signature(create_video_record_and_dedup)
|
||||
params = set(sig.parameters.keys())
|
||||
expected = {
|
||||
"generation_task_id",
|
||||
"project_id",
|
||||
"batch_id",
|
||||
"file_url",
|
||||
"file_size",
|
||||
"duration",
|
||||
"video_path",
|
||||
"mode",
|
||||
"session",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
}
|
||||
assert expected.issubset(params), f"Missing params: {expected - params}"
|
||||
@@ -1,554 +0,0 @@
|
||||
"""
|
||||
生成视频管理 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- GET /generated-videos — 列出生成视频
|
||||
- GET /generated-videos/{video_id} — 获取生成视频详情
|
||||
- PATCH /generated-videos/{video_id}/review — 更新审核状态
|
||||
- GET /generated-videos/{video_id}/download-url — 获取下载地址
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实路由模块,mock 所有外部依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
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"))
|
||||
|
||||
from app.api.routes.generated_videos import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_generated_video_repository, get_project_repository
|
||||
|
||||
from packages.domain.entities import Project, User
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository + 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryGeneratedVideoRepository:
|
||||
"""内存中的生成视频 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: dict[str, GeneratedVideo] = {}
|
||||
|
||||
def create(self, video: GeneratedVideo) -> GeneratedVideo:
|
||||
self._items[video.id] = video
|
||||
return video
|
||||
|
||||
def get(self, video_id: str) -> GeneratedVideo | None:
|
||||
return self._items.get(video_id)
|
||||
|
||||
def update(self, video: GeneratedVideo) -> GeneratedVideo:
|
||||
self._items[video.id] = video
|
||||
return video
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
|
||||
return [v for v in self._items.values() if v.project_id == project_id]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
return [v for v in self._items.values() if v.generation_task_id == generation_task_id]
|
||||
|
||||
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
|
||||
return []
|
||||
|
||||
|
||||
class InMemoryProjectRepository:
|
||||
"""内存中的项目 Repository。"""
|
||||
|
||||
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) -> Project | None:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]:
|
||||
return [p for p in self._projects.values() if p.owner_user_id == owner_user_id]
|
||||
|
||||
def find_accessible_projects(self, user_id: str) -> list[Project]:
|
||||
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 MockStorageService:
|
||||
"""Mock OSS 存储服务。"""
|
||||
|
||||
def get_download_url(self, file_url: str) -> str:
|
||||
return f"https://cdn.example.com/download/{file_url}?token=abc123"
|
||||
|
||||
|
||||
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 = "proj-1", 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_video(
|
||||
project_id: str = "proj-1",
|
||||
name: str = "output.mp4",
|
||||
status: str = "completed",
|
||||
review_status: str = "pending_review",
|
||||
**kwargs,
|
||||
) -> GeneratedVideo:
|
||||
return GeneratedVideo.create(
|
||||
project_id=project_id,
|
||||
generation_task_id=kwargs.pop("generation_task_id", "task-1"),
|
||||
name=name,
|
||||
file_url=kwargs.pop("file_url", f"generated/{name}"),
|
||||
file_size=kwargs.pop("file_size", 1024000),
|
||||
duration=kwargs.pop("duration", 30.5),
|
||||
width=kwargs.pop("width", 1920),
|
||||
height=kwargs.pop("height", 1080),
|
||||
fps=kwargs.pop("fps", 30.0),
|
||||
thumbnail_url=kwargs.pop("thumbnail_url", None),
|
||||
generation_params=kwargs.pop("generation_params", {"resolution": "1080p"}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video_repo():
|
||||
return InMemoryGeneratedVideoRepository()
|
||||
|
||||
|
||||
@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 storage_service():
|
||||
return MockStorageService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(video_repo, project_repo, storage_service):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/generated-videos")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
def _override_video_repo():
|
||||
return video_repo
|
||||
|
||||
def _override_project_repo():
|
||||
return project_repo
|
||||
|
||||
def _override_storage():
|
||||
return storage_service
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_generated_video_repository] = _override_video_repo
|
||||
test_app.dependency_overrides[get_project_repository] = _override_project_repo
|
||||
test_app.dependency_overrides[get_storage_service] = _override_storage
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. GET / — 列出生成视频
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListGeneratedVideos:
|
||||
"""列出生成视频端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无视频时返回空列表。"""
|
||||
resp = client.get("/generated-videos")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
|
||||
def test_list_all_user_videos(self, client, video_repo, project_repo):
|
||||
"""列出当前用户所有项目的视频。"""
|
||||
v1 = _make_video(project_id="proj-1", name="video1.mp4")
|
||||
v2 = _make_video(project_id="proj-2", name="video2.mp4")
|
||||
v3 = _make_video(project_id="proj-other", name="other.mp4") # 其他用户
|
||||
video_repo.create(v1)
|
||||
video_repo.create(v2)
|
||||
video_repo.create(v3)
|
||||
|
||||
resp = client.get("/generated-videos")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
names = {item["name"] for item in data["items"]}
|
||||
assert names == {"video1.mp4", "video2.mp4"}
|
||||
|
||||
def test_filter_by_project_id(self, client, video_repo):
|
||||
"""按 project_id 筛选视频。"""
|
||||
v1 = _make_video(project_id="proj-1", name="a.mp4")
|
||||
v2 = _make_video(project_id="proj-2", name="b.mp4")
|
||||
video_repo.create(v1)
|
||||
video_repo.create(v2)
|
||||
|
||||
resp = client.get("/generated-videos?project_id=proj-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["name"] == "a.mp4"
|
||||
|
||||
def test_filter_by_nonexistent_project_returns_404(self, client):
|
||||
"""筛选不存在的项目返回 404。"""
|
||||
resp = client.get("/generated-videos?project_id=nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_list_includes_download_url(self, client, video_repo):
|
||||
"""列表响应应包含下载地址。"""
|
||||
v = _make_video(file_url="generated/test.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get("/generated-videos")
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert "download_url" in item
|
||||
assert item["download_url"] is not None
|
||||
assert "cdn.example.com" in item["download_url"]
|
||||
|
||||
def test_list_response_fields(self, client, video_repo):
|
||||
"""列表响应包含所有必需字段。"""
|
||||
v = _make_video()
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get("/generated-videos")
|
||||
item = resp.json()["items"][0]
|
||||
for field in [
|
||||
"id",
|
||||
"project_id",
|
||||
"generation_task_id",
|
||||
"name",
|
||||
"file_url",
|
||||
"file_size",
|
||||
"duration",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"status",
|
||||
"review_status",
|
||||
"generation_params",
|
||||
"download_url",
|
||||
]:
|
||||
assert field in item, f"缺少字段: {field}"
|
||||
|
||||
def test_unauthorized_returns_401(self, video_repo, project_repo, storage_service):
|
||||
"""未授权访问返回 401/403。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/generated-videos")
|
||||
|
||||
# 不覆盖 get_current_user,使用默认(会拒绝无 token 请求)
|
||||
test_app.dependency_overrides[get_generated_video_repository] = lambda: video_repo
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_storage_service] = lambda: storage_service
|
||||
|
||||
c = TestClient(test_app)
|
||||
resp = c.get("/generated-videos")
|
||||
# 无 token 时 fastapi HTTPBearer auto_error=False 会返回 None,
|
||||
# get_current_user 会抛 401
|
||||
assert resp.status_code in (401, 403)
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /{video_id} — 获取生成视频详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetGeneratedVideo:
|
||||
"""获取生成视频详情端点测试。"""
|
||||
|
||||
def test_get_existing_video(self, client, video_repo):
|
||||
"""获取存在的视频返回详情。"""
|
||||
v = _make_video(name="detail.mp4", duration=45.0)
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == v.id
|
||||
assert data["name"] == "detail.mp4"
|
||||
assert data["duration"] == 45.0
|
||||
assert data["status"] == "completed"
|
||||
|
||||
def test_get_includes_download_url(self, client, video_repo):
|
||||
"""详情响应包含下载地址。"""
|
||||
v = _make_video(file_url="generated/detail.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}")
|
||||
data = resp.json()
|
||||
assert "download_url" in data
|
||||
assert "cdn.example.com" in data["download_url"]
|
||||
|
||||
def test_get_nonexistent_returns_404(self, client):
|
||||
"""获取不存在的视频返回 404。"""
|
||||
resp = client.get("/generated-videos/nonexistent-video-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
def test_get_thumbnail_url(self, client, video_repo):
|
||||
"""有缩略图时返回缩略图 URL。"""
|
||||
v = _make_video(thumbnail_url="thumbs/test.jpg")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}")
|
||||
data = resp.json()
|
||||
assert data["thumbnail_url"] == "thumbs/test.jpg"
|
||||
|
||||
def test_get_generation_params(self, client, video_repo):
|
||||
"""返回生成参数。"""
|
||||
params = {"resolution": "4k", "style": "cinematic"}
|
||||
v = _make_video(generation_params=params)
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}")
|
||||
data = resp.json()
|
||||
assert data["generation_params"]["resolution"] == "4k"
|
||||
assert data["generation_params"]["style"] == "cinematic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. PATCH /{video_id}/review — 更新审核状态
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateReviewStatus:
|
||||
"""更新审核状态端点测试。"""
|
||||
|
||||
def test_approve_video(self, client, video_repo):
|
||||
"""审核通过。"""
|
||||
v = _make_video(review_status="pending_review")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "approved"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["review_status"] == "approved"
|
||||
|
||||
# 验证 repository 已更新
|
||||
updated = video_repo.get(v.id)
|
||||
assert updated.review_status == "approved"
|
||||
|
||||
def test_reject_video(self, client, video_repo):
|
||||
"""审核拒绝。"""
|
||||
v = _make_video(review_status="pending_review")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "rejected"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["review_status"] == "rejected"
|
||||
|
||||
def test_set_pending_review(self, client, video_repo):
|
||||
"""设置为待审核。"""
|
||||
v = _make_video(review_status="approved")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "pending_review"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["review_status"] == "pending_review"
|
||||
|
||||
def test_nonexistent_video_returns_404(self, client):
|
||||
"""更新不存在的视频返回 404。"""
|
||||
resp = client.patch(
|
||||
"/nonexistent-id/review",
|
||||
json={"review_status": "approved"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_invalid_status_returns_422(self, client, video_repo):
|
||||
"""无效审核状态返回 422。"""
|
||||
v = _make_video()
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "invalid_status"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_missing_status_returns_422(self, client, video_repo):
|
||||
"""缺少 review_status 字段返回 422。"""
|
||||
v = _make_video()
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(f"/generated-videos/{v.id}/review", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_returns_updated_fields(self, client, video_repo):
|
||||
"""更新后返回完整的视频信息。"""
|
||||
v = _make_video(name="review_test.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "approved"},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["name"] == "review_test.mp4"
|
||||
assert "id" in data
|
||||
assert "download_url" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. GET /{video_id}/download-url — 获取下载地址
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetDownloadUrl:
|
||||
"""获取下载地址端点测试。"""
|
||||
|
||||
def test_get_download_url_success(self, client, video_repo):
|
||||
"""获取下载地址成功。"""
|
||||
v = _make_video(file_url="generated/video.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}/download-url")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["video_id"] == v.id
|
||||
assert "download_url" in data
|
||||
assert "cdn.example.com" in data["download_url"]
|
||||
|
||||
def test_nonexistent_video_returns_404(self, client):
|
||||
"""获取不存在视频的下载地址返回 404。"""
|
||||
resp = client.get("/generated-videos/nonexistent-id/download-url")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_download_url_format(self, client, video_repo):
|
||||
"""下载地址格式正确。"""
|
||||
v = _make_video(file_url="my-video.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}/download-url")
|
||||
url = resp.json()["download_url"]
|
||||
assert url.startswith("https://")
|
||||
assert "token=" in url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCrossEndpointScenarios:
|
||||
"""跨端点集成场景。"""
|
||||
|
||||
def test_create_list_detail_review_flow(self, client, video_repo):
|
||||
"""列表 → 详情 → 审核 完整流程。"""
|
||||
# 准备数据
|
||||
v = _make_video(name="flow.mp4", review_status="pending_review")
|
||||
video_repo.create(v)
|
||||
|
||||
# 1. 列表
|
||||
list_resp = client.get("/generated-videos")
|
||||
assert list_resp.status_code == 200
|
||||
assert len(list_resp.json()["items"]) == 1
|
||||
|
||||
# 2. 详情
|
||||
detail_resp = client.get(f"/generated-videos/{v.id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["name"] == "flow.mp4"
|
||||
assert detail_resp.json()["review_status"] == "pending_review"
|
||||
|
||||
# 3. 审核通过
|
||||
review_resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "approved"},
|
||||
)
|
||||
assert review_resp.status_code == 200
|
||||
assert review_resp.json()["review_status"] == "approved"
|
||||
|
||||
# 4. 再次查看详情确认
|
||||
detail_resp2 = client.get(f"/generated-videos/{v.id}")
|
||||
assert detail_resp2.json()["review_status"] == "approved"
|
||||
|
||||
# 5. 获取下载地址
|
||||
dl_resp = client.get(f"/generated-videos/{v.id}/download-url")
|
||||
assert dl_resp.status_code == 200
|
||||
assert dl_resp.json()["video_id"] == v.id
|
||||
|
||||
def test_multiple_videos_pagination_simulation(self, client, video_repo):
|
||||
"""多个视频时列表正确返回所有视频。"""
|
||||
for i in range(5):
|
||||
v = _make_video(project_id="proj-1", name=f"video_{i}.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get("/generated-videos")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
assert len(items) == 5
|
||||
names = {item["name"] for item in items}
|
||||
assert len(names) == 5 # 全部不同
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,614 +0,0 @@
|
||||
"""
|
||||
生成任务 API 集成测试
|
||||
|
||||
覆盖端点:
|
||||
- POST /generation/tasks — 创建生成任务
|
||||
- GET /generation/tasks — 列出生成任务
|
||||
- GET /generation/tasks/{task_id} — 获取生成任务详情
|
||||
- GET /generation/tasks/{task_id}/results — 列出生成结果
|
||||
- POST /generation/tasks/{task_id}/retry — 重试生成任务
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实模块,mock 外部依赖(Celery任务)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
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.generation_tasks import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
from packages.domain import (
|
||||
Asset,
|
||||
AssetLibrary,
|
||||
AssetLibraryKind,
|
||||
AssetStatus,
|
||||
ClassificationStatus,
|
||||
GeneratedVideo,
|
||||
GenerationTask,
|
||||
GenerationTaskStatus,
|
||||
Project,
|
||||
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)]
|
||||
|
||||
|
||||
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_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 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]:
|
||||
return [a for a in self._assets.values() if a.library_id == library_id][skip : skip + limit]
|
||||
|
||||
|
||||
class StubGenerationTaskRepository:
|
||||
def __init__(self, tasks: dict[str, GenerationTask] | None = None):
|
||||
self._tasks = tasks or {}
|
||||
|
||||
def create(self, task: GenerationTask) -> GenerationTask:
|
||||
self._tasks[task.id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> GenerationTask | None:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if t.project_id == project_id]
|
||||
|
||||
def list_by_user(self, user_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
|
||||
def update(self, task: GenerationTask) -> GenerationTask:
|
||||
self._tasks[task.id] = task
|
||||
return 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[GenerationTask]:
|
||||
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if getattr(t, "source_edit_plan_id", "") == plan_id]
|
||||
|
||||
|
||||
class StubGeneratedVideoRepository:
|
||||
def __init__(self, videos: dict[str, GeneratedVideo] | None = None):
|
||||
self._videos = videos or {}
|
||||
|
||||
def create(self, video: GeneratedVideo) -> GeneratedVideo:
|
||||
self._videos[video.id] = video
|
||||
return video
|
||||
|
||||
def get(self, video_id: str) -> GeneratedVideo | None:
|
||||
return self._videos.get(video_id)
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
|
||||
return [v for v in self._videos.values() if v.project_id == project_id]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
return [v for v in self._videos.values() if v.generation_task_id == generation_task_id]
|
||||
|
||||
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Helpers & Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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(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="Generation Library",
|
||||
project_id=project_id,
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
)
|
||||
|
||||
|
||||
def _make_ready_asset(asset_id: str, library_id: str = "lib-1", project_id: str = "proj-1") -> Asset:
|
||||
return Asset(
|
||||
id=asset_id,
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
name=f"{asset_id}.mp4",
|
||||
storage_key=f"uploads/{asset_id}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
classification_status=ClassificationStatus.COMPLETED,
|
||||
duration=30.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
quality_score=80.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/api/v1/generation")
|
||||
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
# 预置一个 ready 状态的视频素材,用于创建生成任务
|
||||
asset = _make_ready_asset("asset-ready-1")
|
||||
|
||||
project_repo = StubProjectRepository({project.id: project})
|
||||
library_repo = StubAssetLibraryRepository({library.id: library})
|
||||
asset_repo = StubAssetRepository({asset.id: asset})
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
video_repo = StubGeneratedVideoRepository()
|
||||
|
||||
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_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_generated_video_repository] = lambda: video_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST /tasks — 创建生成任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateGenerationTask:
|
||||
"""创建生成任务端点测试。"""
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_create_task_success(self, mock_celery, client):
|
||||
"""正常创建生成任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "strategy-default",
|
||||
"voice_library_id": "voice-lib-1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
task = data["items"][0]
|
||||
assert task["project_id"] == "proj-1"
|
||||
assert task["status"] == "pending"
|
||||
assert task["progress"] == 0.0
|
||||
assert task["result_count"] == 0
|
||||
assert "id" in task
|
||||
# 验证 Celery 任务被发送
|
||||
assert mock_celery.send_task.called
|
||||
assert mock_celery.send_task.call_args[0][0] == "worker.generate_video"
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_create_batch_tasks(self, mock_celery, client):
|
||||
"""批量创建多个生成任务。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "strategy-default",
|
||||
"voice_library_id": "voice-lib-1",
|
||||
"count": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["total"] == 3
|
||||
# 验证所有任务都有不同的 ID
|
||||
task_ids = [t["id"] for t in data["items"]]
|
||||
assert len(set(task_ids)) == 3
|
||||
# 同一批次应有相同的 batch_id
|
||||
batch_ids = [t["batch_id"] for t in data["items"] if t["batch_id"]]
|
||||
assert len(batch_ids) == 3
|
||||
assert len(set(batch_ids)) == 1
|
||||
|
||||
def test_create_task_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "nonexistent",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "Project" in resp.json()["detail"]
|
||||
|
||||
def test_create_task_library_not_found(self, client):
|
||||
"""素材库不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "nonexistent",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "AssetLibrary" in resp.json()["detail"]
|
||||
|
||||
def test_create_task_missing_project_and_template(self, client):
|
||||
"""缺少 project_id 和 template_id 返回 422。"""
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /tasks — 列出生成任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListGenerationTasks:
|
||||
"""列出生成任务端点测试。"""
|
||||
|
||||
def _create_task(self, client, task_suffix: str = "1"):
|
||||
"""辅助方法:创建一个生成任务。"""
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": f"strategy-{task_suffix}",
|
||||
"voice_library_id": "voice-lib-1",
|
||||
},
|
||||
)
|
||||
return resp.json()["items"][0]["id"]
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无任务时返回空列表。"""
|
||||
resp = client.get("/api/v1/generation/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_list_returns_user_tasks(self, mock_celery, client):
|
||||
"""返回当前用户的生成任务列表。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
# 创建 2 个任务
|
||||
for i in range(2):
|
||||
client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": f"strat-{i}",
|
||||
"voice_library_id": "voice-1",
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get("/api/v1/generation/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
# 验证响应字段
|
||||
for item in data["items"]:
|
||||
assert "id" in item
|
||||
assert "status" in item
|
||||
assert "progress" in item
|
||||
assert "project_id" in item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. GET /tasks/{task_id} — 获取生成任务详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetGenerationTask:
|
||||
"""获取生成任务详情端点测试。"""
|
||||
|
||||
def _create_task(self, client) -> str:
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
return resp.json()["items"][0]["id"]
|
||||
|
||||
def test_get_task_success(self, client):
|
||||
"""获取存在的任务详情成功。"""
|
||||
task_id = self._create_task(client)
|
||||
|
||||
resp = client.get(f"/api/v1/generation/tasks/{task_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == task_id
|
||||
assert data["status"] == "pending"
|
||||
assert data["progress"] == 0.0
|
||||
assert data["result_count"] == 0
|
||||
assert "asset_ids" in data
|
||||
assert "strategy_id" in data
|
||||
|
||||
def test_get_nonexistent_task_returns_404(self, client):
|
||||
"""获取不存在的任务返回 404。"""
|
||||
resp = client.get("/api/v1/generation/tasks/nonexistent-task-id")
|
||||
assert resp.status_code == 404
|
||||
assert "GenerationTask" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. GET /tasks/{task_id}/results — 列出生成结果
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListGenerationResults:
|
||||
"""列出生成结果端点测试。"""
|
||||
|
||||
def _create_task(self, client) -> str:
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
return resp.json()["items"][0]["id"]
|
||||
|
||||
def test_empty_results(self, client):
|
||||
"""无生成结果时返回空列表。"""
|
||||
task_id = self._create_task(client)
|
||||
|
||||
resp = client.get(f"/api/v1/generation/tasks/{task_id}/results")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
def test_results_nonexistent_task_returns_404(self, client):
|
||||
"""查询不存在任务的结果返回 404。"""
|
||||
resp = client.get("/api/v1/generation/tasks/nonexistent-task/results")
|
||||
assert resp.status_code == 404
|
||||
assert "GenerationTask" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. POST /tasks/{task_id}/retry — 重试生成任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryGenerationTask:
|
||||
"""重试生成任务端点测试。"""
|
||||
|
||||
def _create_failed_task(self, client) -> str:
|
||||
"""创建一个失败状态的任务。"""
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
task_id = resp.json()["items"][0]["id"]
|
||||
|
||||
# 直接修改 repository 中的任务状态为 failed
|
||||
from app.dependencies import get_generation_task_repository
|
||||
|
||||
# 由于是 stub,我们需要通过另一种方式设置状态
|
||||
# 让我们直接通过 retry 测试来验证
|
||||
return task_id
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_retry_failed_task(self, mock_celery, client):
|
||||
"""重试失败的任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
# 先创建一个任务
|
||||
create_resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
task_id = create_resp.json()["items"][0]["id"]
|
||||
|
||||
# 手动将任务状态设为 failed(通过直接访问 repository)
|
||||
# 由于 repository 在 fixture 中创建,我们需要另一种方式
|
||||
# 这里我们测试:pending 状态的任务重试应返回 409
|
||||
resp = client.post(f"/api/v1/generation/tasks/{task_id}/retry")
|
||||
assert resp.status_code == 409
|
||||
assert "Only failed" in resp.json()["detail"]
|
||||
|
||||
def test_retry_nonexistent_task_returns_404(self, client):
|
||||
"""重试不存在的任务返回 404。"""
|
||||
resp = client.post("/api/v1/generation/tasks/nonexistent-task/retry")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_retry_completed_task_returns_409(self, mock_celery, client):
|
||||
"""重试已完成的任务返回 409。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
create_resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
task_id = create_resp.json()["items"][0]["id"]
|
||||
|
||||
# pending 状态不是 failed,重试应返回 409
|
||||
resp = client.post(f"/api/v1/generation/tasks/{task_id}/retry")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. 完整流程集成测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerationTaskFlow:
|
||||
"""生成任务完整流程集成测试。"""
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_create_list_detail_results_flow(self, mock_celery, client):
|
||||
"""测试创建 → 列表 → 详情 → 结果 完整流程。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
# 1. 创建任务
|
||||
create_resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "strategy-main",
|
||||
"voice_library_id": "voice-main",
|
||||
"count": 1,
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
task_id = create_resp.json()["items"][0]["id"]
|
||||
|
||||
# 2. 列表应包含新任务
|
||||
list_resp = client.get("/api/v1/generation/tasks")
|
||||
assert list_resp.status_code == 200
|
||||
assert any(t["id"] == task_id for t in list_resp.json()["items"])
|
||||
|
||||
# 3. 获取详情
|
||||
detail_resp = client.get(f"/api/v1/generation/tasks/{task_id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["id"] == task_id
|
||||
assert detail_resp.json()["status"] == "pending"
|
||||
|
||||
# 4. 获取结果(初始为空)
|
||||
results_resp = client.get(f"/api/v1/generation/tasks/{task_id}/results")
|
||||
assert results_resp.status_code == 200
|
||||
assert results_resp.json()["items"] == []
|
||||
|
||||
# 5. 验证 Celery worker 被调用
|
||||
assert mock_celery.send_task.called
|
||||
call_args = mock_celery.send_task.call_args
|
||||
assert call_args[0][0] == "worker.generate_video"
|
||||
assert call_args[1]["args"][0] == task_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,369 +0,0 @@
|
||||
"""
|
||||
摄入任务 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- POST /ingest-jobs — 提交摄入任务
|
||||
- GET /ingest-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.ingest_jobs as ingest_routes
|
||||
from app.api.routes.ingest_jobs import router
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
|
||||
from packages.adapters.in_memory import InMemoryIngestJobRepository
|
||||
from packages.domain import IngestJob, IngestJobStatus
|
||||
|
||||
ingest_routes.celery_app = MagicMock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_job(
|
||||
project_id: str = "proj-1",
|
||||
library_id: str = "lib-1",
|
||||
storage_key: str = "uploads/test.mp4",
|
||||
status: IngestJobStatus = IngestJobStatus.PENDING,
|
||||
) -> IngestJob:
|
||||
job = IngestJob.create(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
)
|
||||
if status == IngestJobStatus.PROCESSING:
|
||||
job.status = IngestJobStatus.PROCESSING
|
||||
elif status == IngestJobStatus.COMPLETED:
|
||||
job.status = IngestJobStatus.COMPLETED
|
||||
job.result_asset_id = "asset-completed-001"
|
||||
elif status == IngestJobStatus.FAILED:
|
||||
job.status = IngestJobStatus.FAILED
|
||||
job.error_message = "文件解析失败"
|
||||
return job
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemoryIngestJobRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(repo):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/ingest-jobs")
|
||||
|
||||
def _override_repo():
|
||||
return repo
|
||||
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = _override_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST / — 提交摄入任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubmitIngestJob:
|
||||
"""提交摄入任务端点测试。"""
|
||||
|
||||
def test_submit_with_valid_data(self, client):
|
||||
"""使用有效数据提交摄入任务应成功。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={
|
||||
"project_id": "proj-123",
|
||||
"library_id": "lib-456",
|
||||
"storage_key": "uploads/video.mp4",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["project_id"] == "proj-123"
|
||||
assert data["library_id"] == "lib-456"
|
||||
assert data["storage_key"] == "uploads/video.mp4"
|
||||
assert data["status"] == "pending"
|
||||
assert data["error_message"] == ""
|
||||
assert data["result_asset_id"] == "" or data["result_asset_id"] is None
|
||||
assert "id" in data
|
||||
assert len(data["id"]) > 0
|
||||
|
||||
def test_submit_generates_unique_id(self, client):
|
||||
"""每次提交应生成不同的任务 ID。"""
|
||||
resp1 = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "a.mp4"},
|
||||
)
|
||||
resp2 = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "b.mp4"},
|
||||
)
|
||||
assert resp1.json()["id"] != resp2.json()["id"]
|
||||
|
||||
def test_submit_missing_project_id_returns_422(self, client):
|
||||
"""缺少 project_id 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"library_id": "lib-1", "storage_key": "uploads/test.mp4"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_missing_library_id_returns_422(self, client):
|
||||
"""缺少 library_id 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "proj-1", "storage_key": "uploads/test.mp4"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_missing_storage_key_returns_422(self, client):
|
||||
"""缺少 storage_key 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "proj-1", "library_id": "lib-1"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_empty_project_id_returns_422(self, client):
|
||||
"""空 project_id 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "", "library_id": "lib-1", "storage_key": "x.mp4"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_empty_library_id_returns_422(self, client):
|
||||
"""空 library_id 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "", "storage_key": "x.mp4"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_empty_storage_key_returns_422(self, client):
|
||||
"""空 storage_key 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_sends_celery_task(self, client):
|
||||
"""提交任务后应触发 Celery 异步任务。"""
|
||||
ingest_routes.celery_app.send_task.reset_mock()
|
||||
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "x.mp4"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
job_id = resp.json()["id"]
|
||||
ingest_routes.celery_app.send_task.assert_called_once_with(
|
||||
"worker.ingest_asset",
|
||||
args=[job_id],
|
||||
)
|
||||
|
||||
def test_submit_persists_to_repository(self, client, repo):
|
||||
"""提交后任务应保存到 repository。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "test.mp4"},
|
||||
)
|
||||
job_id = resp.json()["id"]
|
||||
|
||||
saved = repo.get(job_id)
|
||||
assert saved is not None
|
||||
assert saved.project_id == "p1"
|
||||
assert saved.library_id == "l1"
|
||||
assert saved.storage_key == "test.mp4"
|
||||
assert saved.status == IngestJobStatus.PENDING
|
||||
|
||||
def test_submit_with_different_file_types(self, client):
|
||||
"""支持不同文件类型的 storage_key。"""
|
||||
for key in ["uploads/image.jpg", "videos/clip.mov", "audio/sound.mp3"]:
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": key},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["storage_key"] == key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /{job_id} — 获取摄入任务详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetIngestJob:
|
||||
"""获取摄入任务详情端点测试。"""
|
||||
|
||||
def test_get_pending_job(self, client, repo):
|
||||
"""获取 pending 状态的任务。"""
|
||||
job = _make_job(status=IngestJobStatus.PENDING)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == job.id
|
||||
assert data["status"] == "pending"
|
||||
assert data["result_asset_id"] == "" or data["result_asset_id"] is None
|
||||
|
||||
def test_get_processing_job(self, client, repo):
|
||||
"""获取 processing 状态的任务。"""
|
||||
job = _make_job(status=IngestJobStatus.PROCESSING)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "processing"
|
||||
|
||||
def test_get_completed_job(self, client, repo):
|
||||
"""获取已完成的任务应包含 result_asset_id。"""
|
||||
job = _make_job(status=IngestJobStatus.COMPLETED)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["result_asset_id"] == "asset-completed-001"
|
||||
assert data["error_message"] == ""
|
||||
|
||||
def test_get_failed_job(self, client, repo):
|
||||
"""获取失败的任务应包含错误信息。"""
|
||||
job = _make_job(status=IngestJobStatus.FAILED)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "文件解析失败" in data["error_message"]
|
||||
|
||||
def test_get_nonexistent_job_raises_error(self, client):
|
||||
"""获取不存在的任务会抛出 ValueError(当前实现未使用 HTTPException)。"""
|
||||
# 注:路由中使用 raise ValueError 而非 HTTPException,
|
||||
# 在 TestClient 中会以异常形式抛出。生产环境会返回 500。
|
||||
# 此处验证当前行为:当 job 不存在时会报错。
|
||||
try:
|
||||
resp = client.get("/ingest-jobs/nonexistent-job-id")
|
||||
# 如果 FastAPI 捕获了异常,会返回 500
|
||||
assert resp.status_code == 500
|
||||
except (ValueError, Exception):
|
||||
# TestClient 中 ValueError 可能直接抛出
|
||||
pass # 符合预期:不存在的任务会报错
|
||||
|
||||
def test_response_contains_all_required_fields(self, client, repo):
|
||||
"""响应应包含所有必需字段。"""
|
||||
job = _make_job()
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
data = resp.json()
|
||||
for field in ["id", "project_id", "library_id", "storage_key", "status", "error_message"]:
|
||||
assert field in data, f"缺少字段: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIngestApiScenarios:
|
||||
"""摄入任务 API 跨端点集成场景。"""
|
||||
|
||||
def test_submit_then_get_pending(self, client, repo):
|
||||
"""提交任务后立即查询应为 pending 状态。"""
|
||||
submit_resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={
|
||||
"project_id": "proj-scenario",
|
||||
"library_id": "lib-scenario",
|
||||
"storage_key": "uploads/scenario.mp4",
|
||||
},
|
||||
)
|
||||
assert submit_resp.status_code == 200
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
get_resp = client.get(f"/ingest-jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["status"] == "pending"
|
||||
assert get_resp.json()["storage_key"] == "uploads/scenario.mp4"
|
||||
|
||||
def test_submit_simulate_complete_then_get(self, client, repo):
|
||||
"""模拟 worker 完成任务后查询应返回 asset_id。"""
|
||||
submit_resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "video.mp4"},
|
||||
)
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
# 模拟 worker 处理完成
|
||||
job = repo.get(job_id)
|
||||
assert job is not None
|
||||
job.status = IngestJobStatus.COMPLETED
|
||||
job.result_asset_id = "asset-new-001"
|
||||
repo.update(job)
|
||||
|
||||
get_resp = client.get(f"/ingest-jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["result_asset_id"] == "asset-new-001"
|
||||
|
||||
def test_submit_simulate_failure_then_get(self, client, repo):
|
||||
"""模拟 worker 失败后查询应返回错误信息。"""
|
||||
submit_resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "bad.mp4"},
|
||||
)
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
# 模拟处理失败
|
||||
job = repo.get(job_id)
|
||||
assert job is not None
|
||||
job.status = IngestJobStatus.FAILED
|
||||
job.error_message = "文件格式不支持"
|
||||
repo.update(job)
|
||||
|
||||
get_resp = client.get(f"/ingest-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"])
|
||||
@@ -1,632 +0,0 @@
|
||||
"""
|
||||
任务中心 API 集成测试
|
||||
|
||||
覆盖端点:
|
||||
- GET /tasks — 列出用户任务
|
||||
- POST /tasks/{task_id}/retry — 重试用户任务
|
||||
- GET /projects/{project_id}/tasks — 列出项目任务
|
||||
- POST /tasks/{task_type}/{source_id}/retry — 重试项目任务
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实模块,mock 外部依赖(Celery任务)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
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.task_center import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_generation_task_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
from packages.domain import (
|
||||
GenerationTask,
|
||||
GenerationTaskStatus,
|
||||
IngestJob,
|
||||
IngestJobStatus,
|
||||
Project,
|
||||
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)
|
||||
|
||||
|
||||
class StubGenerationTaskRepository:
|
||||
def __init__(self, tasks: dict[str, GenerationTask] | None = None):
|
||||
self._tasks = tasks or {}
|
||||
|
||||
def create(self, task: GenerationTask) -> GenerationTask:
|
||||
self._tasks[task.id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> GenerationTask | None:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if t.project_id == project_id]
|
||||
|
||||
def list_by_user(self, user_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
|
||||
def update(self, task: GenerationTask) -> GenerationTask:
|
||||
self._tasks[task.id] = task
|
||||
return 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[GenerationTask]:
|
||||
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if getattr(t, "source_edit_plan_id", "") == plan_id]
|
||||
|
||||
|
||||
class StubIngestJobRepository:
|
||||
def __init__(self, jobs: dict[str, IngestJob] | None = None):
|
||||
self._jobs = jobs or {}
|
||||
|
||||
def create(self, job: IngestJob) -> IngestJob:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> IngestJob | None:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def update(self, job: IngestJob) -> IngestJob:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def update_status(self, job_id: str, 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) -> list[IngestJob]:
|
||||
return [j for j in self._jobs.values() if j.project_id == project_id][skip : skip + limit]
|
||||
|
||||
def list_by_library(self, library_id: str, skip: int = 0, limit: int = 50) -> list[IngestJob]:
|
||||
return [j for j in self._jobs.values() if j.library_id == library_id][skip : skip + limit]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Helpers & Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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(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_generation_task(
|
||||
task_id: str = "gen-task-1",
|
||||
project_id: str = "proj-1",
|
||||
user_id: str = "user-test-001",
|
||||
status: GenerationTaskStatus = GenerationTaskStatus.PENDING,
|
||||
) -> GenerationTask:
|
||||
task = GenerationTask(
|
||||
id=task_id,
|
||||
project_id=project_id,
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="s1",
|
||||
voice_library_id="v1",
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
task.status = status
|
||||
return task
|
||||
|
||||
|
||||
def _make_ingest_job(
|
||||
job_id: str = "ingest-job-1",
|
||||
project_id: str = "proj-1",
|
||||
library_id: str = "lib-1",
|
||||
status: IngestJobStatus = IngestJobStatus.PENDING,
|
||||
) -> IngestJob:
|
||||
job = IngestJob(
|
||||
id=job_id,
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key="uploads/test.mp4",
|
||||
)
|
||||
job.status = status
|
||||
return job
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
|
||||
project = _make_project()
|
||||
project_repo = StubProjectRepository({project.id: project})
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
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_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. GET /tasks — 列出用户任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListUserTasks:
|
||||
"""列出用户任务端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无任务时返回空列表。"""
|
||||
resp = client.get("/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
def test_list_returns_generation_tasks(self, client):
|
||||
"""返回当前用户的 generation 任务。"""
|
||||
# 直接在 repository 中注入任务
|
||||
from app.dependencies import get_generation_task_repository
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task1 = _make_generation_task("gen-1", status=GenerationTaskStatus.PENDING)
|
||||
task2 = _make_generation_task("gen-2", status=GenerationTaskStatus.COMPLETED)
|
||||
task_repo.create(task1)
|
||||
task_repo.create(task2)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
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_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: StubIngestJobRepository()
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.get("/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
# 验证响应字段
|
||||
for item in data["items"]:
|
||||
assert "id" in item
|
||||
assert "task_type" in item
|
||||
assert item["task_type"] == "generation"
|
||||
assert "status" in item
|
||||
assert "current_step" in item
|
||||
assert "retryable" in item
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_tasks_sorted_by_updated_time(self, client):
|
||||
"""任务按更新时间倒序排列。"""
|
||||
# 由于两个任务同时创建,验证它们都出现在列表中
|
||||
resp = client.get("/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data["items"], list)
|
||||
|
||||
def test_task_response_fields(self, client):
|
||||
"""任务响应包含所有必需字段。"""
|
||||
resp = client.get("/tasks")
|
||||
assert resp.status_code == 200
|
||||
# 空列表也应该返回正确的结构
|
||||
assert resp.json()["items"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. POST /tasks/{task_id}/retry — 重试用户任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryUserTask:
|
||||
"""重试用户任务端点测试。"""
|
||||
|
||||
def test_retry_nonexistent_task_returns_404(self, client):
|
||||
"""重试不存在的任务返回 404。"""
|
||||
resp = client.post("/tasks/nonexistent-task-id/retry")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_retry_pending_task_returns_409(self, mock_celery, client):
|
||||
"""重试 pending 状态的任务返回 409(只有 failed 任务才能重试)。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
# 在 repository 中创建一个 pending 任务
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task = _make_generation_task("gen-pending", status=GenerationTaskStatus.PENDING)
|
||||
task_repo.create(task)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: StubIngestJobRepository()
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/gen-pending/retry")
|
||||
assert resp.status_code == 409
|
||||
assert "Only failed" in resp.json()["detail"]
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_retry_completed_task_returns_409(self, mock_celery, client):
|
||||
"""重试 completed 状态的任务返回 409。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task = _make_generation_task("gen-completed", status=GenerationTaskStatus.COMPLETED)
|
||||
task_repo.create(task)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: StubIngestJobRepository()
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/gen-completed/retry")
|
||||
assert resp.status_code == 409
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. GET /projects/{project_id}/tasks — 列出项目任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListProjectTasks:
|
||||
"""列出项目任务端点测试。"""
|
||||
|
||||
def test_empty_project_tasks(self, client):
|
||||
"""项目无任务时返回空列表。"""
|
||||
resp = client.get("/projects/proj-1/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
def test_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
resp = client.get("/projects/nonexistent-project/tasks")
|
||||
assert resp.status_code == 404
|
||||
assert "Project not found" in resp.json()["detail"]
|
||||
|
||||
def test_returns_ingest_and_generation_tasks(self, client):
|
||||
"""返回项目中 ingest 和 generation 两种任务。"""
|
||||
# 在 repository 中注入任务
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
gen_task = _make_generation_task("gen-proj-1", status=GenerationTaskStatus.PENDING)
|
||||
task_repo.create(gen_task)
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
ingest_job = _make_ingest_job("ingest-proj-1", status=IngestJobStatus.PENDING)
|
||||
ingest_repo.create(ingest_job)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.get("/projects/proj-1/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
task_types = {item["task_type"] for item in data["items"]}
|
||||
assert "generation" in task_types
|
||||
assert "ingest" in task_types
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_project_task_response_fields(self, client):
|
||||
"""项目任务响应包含所有必需字段。"""
|
||||
resp = client.get("/projects/proj-1/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data["items"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. POST /tasks/{task_type}/{source_id}/retry — 重试项目任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryProjectTask:
|
||||
"""重试项目任务端点测试。"""
|
||||
|
||||
def test_retry_unsupported_task_type_returns_400(self, client):
|
||||
"""不支持的任务类型返回 400。"""
|
||||
resp = client.post("/tasks/unknown/some-source-id/retry")
|
||||
assert resp.status_code == 400
|
||||
assert "Unsupported" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_retry_failed_generation_task(self, mock_celery, client):
|
||||
"""重试失败的 generation 任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task = _make_generation_task("gen-failed-1", status=GenerationTaskStatus.FAILED)
|
||||
task_repo.create(task)
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/generation/gen-failed-1/retry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["task_type"] == "generation"
|
||||
assert data["status"] == "pending"
|
||||
assert "current_step" in data
|
||||
# 验证新任务的 ID 不同于原任务
|
||||
assert data["source_id"] != "gen-failed-1"
|
||||
# 验证 Celery 任务被发送
|
||||
assert mock_celery.send_task.called
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_retry_failed_ingest_task(self, mock_celery, client):
|
||||
"""重试失败的 ingest 任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
job = _make_ingest_job("ingest-failed-1", status=IngestJobStatus.FAILED)
|
||||
ingest_repo.create(job)
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/ingest/ingest-failed-1/retry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["task_type"] == "ingest"
|
||||
assert data["status"] == "pending"
|
||||
assert mock_celery.send_task.called
|
||||
assert mock_celery.send_task.call_args[0][0] == "worker.ingest_asset"
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_retry_pending_generation_task_returns_409(self, client):
|
||||
"""重试 pending 状态的 generation 任务返回 409。"""
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task = _make_generation_task("gen-pending-proj", status=GenerationTaskStatus.PENDING)
|
||||
task_repo.create(task)
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/generation/gen-pending-proj/retry")
|
||||
assert resp.status_code == 409
|
||||
assert "Only failed" in resp.json()["detail"]
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_retry_nonexistent_generation_task_returns_404(self, client):
|
||||
"""重试不存在的 generation 任务返回 404。"""
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/generation/nonexistent-id/retry")
|
||||
assert resp.status_code == 404
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_retry_nonexistent_ingest_task_returns_404(self, client):
|
||||
"""重试不存在的 ingest 任务返回 404。"""
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/ingest/nonexistent-id/retry")
|
||||
assert resp.status_code == 404
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 跨端点集成场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTaskCenterCrossEndpoint:
|
||||
"""任务中心跨端点集成测试。"""
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_list_then_retry_then_list(self, mock_celery, client):
|
||||
"""列出任务 → 重试失败任务 → 再列出验证新任务。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
failed_task = _make_generation_task("gen-fail-cross", status=GenerationTaskStatus.FAILED)
|
||||
failed_task.error_message = "ffmpeg error"
|
||||
task_repo.create(failed_task)
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
|
||||
# 1. 列出任务
|
||||
list_resp = tc.get("/tasks")
|
||||
assert list_resp.status_code == 200
|
||||
items = list_resp.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["retryable"] is True # failed 任务应可重试
|
||||
|
||||
# 2. 重试失败任务
|
||||
retry_resp = tc.post("/tasks/gen-fail-cross/retry")
|
||||
assert retry_resp.status_code == 200
|
||||
new_task_id = retry_resp.json()["source_id"]
|
||||
|
||||
# 3. 再次列出,应有2个任务(旧的failed + 新的pending)
|
||||
list_resp2 = tc.get("/tasks")
|
||||
assert list_resp2.status_code == 200
|
||||
items2 = list_resp2.json()["items"]
|
||||
assert len(items2) == 2
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,349 +0,0 @@
|
||||
"""
|
||||
模板分类 CRUD API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- GET /templates/categories/list — 列出分类
|
||||
- POST /templates/categories — 创建分类
|
||||
- DELETE /templates/categories/{category_id} — 删除分类
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
mock template repository,验证分类 CRUD 行为。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
# ── 环境变量 & 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 import templates as templates_module
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.template import TemplateCategory
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryTemplateRepository:
|
||||
"""内存中的模板 Repository,仅实现分类相关方法。"""
|
||||
|
||||
def __init__(self):
|
||||
self._categories: dict[str, TemplateCategory] = {}
|
||||
self._templates = {}
|
||||
self._segments = {}
|
||||
|
||||
# ── 分类相关 ──
|
||||
|
||||
def list_categories(self, user_id: str) -> list[TemplateCategory]:
|
||||
return [c for c in self._categories.values() if c.user_id == user_id]
|
||||
|
||||
def create_category(self, category: TemplateCategory) -> TemplateCategory:
|
||||
# 检查重复名称
|
||||
existing = [c for c in self._categories.values() if c.user_id == category.user_id and c.name == category.name]
|
||||
if existing:
|
||||
raise ValueError(f"分类名称已存在: {category.name}")
|
||||
self._categories[category.id] = category
|
||||
return category
|
||||
|
||||
def get_category(self, category_id: str, user_id: str) -> TemplateCategory | None:
|
||||
cat = self._categories.get(category_id)
|
||||
if cat and cat.user_id == user_id:
|
||||
return cat
|
||||
return None
|
||||
|
||||
def delete_category(self, category_id: str, user_id: str) -> bool:
|
||||
cat = self.get_category(category_id, user_id)
|
||||
if cat:
|
||||
del self._categories[category_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── 模板相关(路由可能调用,提供占位实现) ──
|
||||
|
||||
def list_by_user(self, user_id: str, *, skip: int = 0, limit: int = 50):
|
||||
return []
|
||||
|
||||
def get(self, template_id: str, user_id: str):
|
||||
return None
|
||||
|
||||
def create(self, template):
|
||||
return template
|
||||
|
||||
def update(self, template):
|
||||
return template
|
||||
|
||||
def delete(self, template_id: str, user_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return 0
|
||||
|
||||
def list_segments(self, template_id: str):
|
||||
return []
|
||||
|
||||
def create_segments(self, segments):
|
||||
return segments
|
||||
|
||||
def delete_segments_by_template(self, template_id: str) -> int:
|
||||
return 0
|
||||
|
||||
def validate_template(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_category(
|
||||
name: str,
|
||||
user_id: str = "user-test-001",
|
||||
) -> TemplateCategory:
|
||||
return TemplateCategory(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def template_repo():
|
||||
return InMemoryTemplateRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(template_repo):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(templates_module.router, prefix="/templates")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
def _override_template_repo():
|
||||
return template_repo
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
# 覆盖路由模块内的 _get_template_repository 依赖
|
||||
test_app.dependency_overrides[templates_module._get_template_repository] = _override_template_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /categories/list — 列出分类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListCategories:
|
||||
"""列出分类端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无分类时返回空列表。"""
|
||||
resp = client.get("/templates/categories/list")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
|
||||
def test_returns_user_categories(self, client, template_repo):
|
||||
"""只返回当前用户的分类。"""
|
||||
c1 = _make_category("美食", "user-test-001")
|
||||
c2 = _make_category("旅行", "user-test-001")
|
||||
c3 = _make_category("科技", "other-user")
|
||||
template_repo.create_category(c1)
|
||||
template_repo.create_category(c2)
|
||||
template_repo.create_category(c3)
|
||||
|
||||
resp = client.get("/templates/categories/list")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
names = {item["name"] for item in data["items"]}
|
||||
assert names == {"美食", "旅行"}
|
||||
|
||||
def test_response_fields(self, client, template_repo):
|
||||
"""响应包含所有必需字段。"""
|
||||
c = _make_category("测试分类")
|
||||
template_repo.create_category(c)
|
||||
|
||||
resp = client.get("/templates/categories/list")
|
||||
item = resp.json()["items"][0]
|
||||
assert "id" in item
|
||||
assert "user_id" in item
|
||||
assert "name" in item
|
||||
assert "created_at" in item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST /categories — 创建分类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateCategory:
|
||||
"""创建分类端点测试。"""
|
||||
|
||||
def test_create_valid_category(self, client):
|
||||
"""使用有效名称创建分类应成功。"""
|
||||
resp = client.post("/templates/categories", json={"name": "vlog"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "vlog"
|
||||
assert "id" in data
|
||||
assert data["user_id"] == "user-test-001"
|
||||
assert "created_at" in data
|
||||
|
||||
def test_create_with_chinese_name(self, client):
|
||||
"""支持中文分类名称。"""
|
||||
resp = client.post("/templates/categories", json={"name": "美食探店"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["name"] == "美食探店"
|
||||
|
||||
def test_create_persists_to_repo(self, client, template_repo):
|
||||
"""创建后分类保存到 repository。"""
|
||||
resp = client.post("/templates/categories", json={"name": "新知识"})
|
||||
cat_id = resp.json()["id"]
|
||||
|
||||
saved = template_repo.get_category(cat_id, "user-test-001")
|
||||
assert saved is not None
|
||||
assert saved.name == "新知识"
|
||||
|
||||
def test_create_missing_name_returns_422(self, client):
|
||||
"""缺少 name 字段返回 422。"""
|
||||
resp = client.post("/templates/categories", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_empty_name_returns_422(self, client):
|
||||
"""空名称返回 422(Pydantic min_length 校验)。"""
|
||||
resp = client.post("/templates/categories", json={"name": ""})
|
||||
# CreateCategoryRequest 没有 min_length 限制,此处验证实际行为
|
||||
assert resp.status_code in (201, 422)
|
||||
|
||||
def test_create_multiple_categories(self, client, template_repo):
|
||||
"""可创建多个不同名称的分类。"""
|
||||
names = ["美食", "旅行", "科技", "教育", "娱乐"]
|
||||
for name in names:
|
||||
resp = client.post("/templates/categories", json={"name": name})
|
||||
assert resp.status_code == 201
|
||||
|
||||
all_cats = template_repo.list_categories("user-test-001")
|
||||
assert len(all_cats) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. DELETE /categories/{category_id} — 删除分类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteCategory:
|
||||
"""删除分类端点测试。"""
|
||||
|
||||
def test_delete_existing_category(self, client, template_repo):
|
||||
"""删除存在的分类返回 204。"""
|
||||
c = _make_category("待删除")
|
||||
template_repo.create_category(c)
|
||||
|
||||
resp = client.delete(f"/templates/categories/{c.id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
assert template_repo.get_category(c.id, "user-test-001") is None
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, client):
|
||||
"""删除不存在的分类返回 404。"""
|
||||
resp = client.delete("/templates/categories/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower() or "Category" in resp.json()["detail"]
|
||||
|
||||
def test_delete_other_user_category_returns_404(self, client, template_repo):
|
||||
"""删除其他用户的分类返回 404(安全隔离)。"""
|
||||
c = _make_category("他人分类", user_id="other-user")
|
||||
template_repo.create_category(c)
|
||||
|
||||
resp = client.delete(f"/templates/categories/{c.id}")
|
||||
assert resp.status_code == 404
|
||||
# 验证未被删除
|
||||
assert template_repo.get_category(c.id, "other-user") is not None
|
||||
|
||||
def test_delete_idempotent(self, client, template_repo):
|
||||
"""删除后再次删除返回 404。"""
|
||||
c = _make_category("幂等测试")
|
||||
template_repo.create_category(c)
|
||||
|
||||
resp1 = client.delete(f"/templates/categories/{c.id}")
|
||||
assert resp1.status_code == 204
|
||||
|
||||
resp2 = client.delete(f"/templates/categories/{c.id}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCategoryCrudFlow:
|
||||
"""分类 CRUD 完整流程。"""
|
||||
|
||||
def test_create_list_delete_flow(self, client, template_repo):
|
||||
"""创建 → 列表 → 删除 完整流程。"""
|
||||
# 1. 创建
|
||||
create_resp = client.post("/templates/categories", json={"name": "流程测试"})
|
||||
assert create_resp.status_code == 201
|
||||
cat_id = create_resp.json()["id"]
|
||||
|
||||
# 2. 列表验证
|
||||
list_resp = client.get("/templates/categories/list")
|
||||
assert list_resp.status_code == 200
|
||||
assert len(list_resp.json()["items"]) == 1
|
||||
assert list_resp.json()["items"][0]["name"] == "流程测试"
|
||||
|
||||
# 3. 删除
|
||||
del_resp = client.delete(f"/templates/categories/{cat_id}")
|
||||
assert del_resp.status_code == 204
|
||||
|
||||
# 4. 再次列表验证已删除
|
||||
list_resp2 = client.get("/templates/categories/list")
|
||||
assert list_resp2.json()["items"] == []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,918 +0,0 @@
|
||||
"""
|
||||
TTS 合成 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- POST /tts/synthesize — 创建 TTS 合成任务
|
||||
- GET /tts/jobs — 列出 TTS 任务
|
||||
- GET /tts/jobs/{job_id} — 获取 TTS 任务详情
|
||||
- GET /tts/jobs/{job_id}/status — 获取 TTS 任务状态
|
||||
- DELETE /tts/jobs/{job_id} — 删除 TTS 任务
|
||||
- POST /tts/jobs/{job_id}/save-to-library — 保存到音色库
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
mock repository 和 CosyVoice 服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
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"))
|
||||
|
||||
from app.api.routes.tts import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_cosyvoice_service,
|
||||
get_user_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
get_voice_library_repository,
|
||||
)
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryTTSJobRepository:
|
||||
"""内存中的 TTS 任务 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: dict[str, TTSJob] = {}
|
||||
|
||||
def create(self, job: TTSJob) -> TTSJob:
|
||||
self._items[job.id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> TTSJob | None:
|
||||
return self._items.get(job_id)
|
||||
|
||||
def update(self, job: TTSJob) -> TTSJob:
|
||||
self._items[job.id] = job
|
||||
return job
|
||||
|
||||
def delete(self, job_id: str) -> bool:
|
||||
if job_id in self._items:
|
||||
del self._items[job_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status=None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[TTSJob]:
|
||||
items = [j for j in self._items.values() if j.user_id == user_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [j for j in items if j.status.value == status_str]
|
||||
items.sort(key=lambda j: j.created_at, reverse=True)
|
||||
return items[offset : offset + limit]
|
||||
|
||||
def count_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status=None,
|
||||
) -> int:
|
||||
items = [j for j in self._items.values() if j.user_id == user_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [j for j in items if j.status.value == status_str]
|
||||
return len(items)
|
||||
|
||||
def list_by_profile(
|
||||
self,
|
||||
voice_clone_profile_id: str,
|
||||
*,
|
||||
status=None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[TTSJob]:
|
||||
items = [j for j in self._items.values() if j.voice_clone_profile_id == voice_clone_profile_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [j for j in items if j.status.value == status_str]
|
||||
return items[offset : offset + limit]
|
||||
|
||||
|
||||
class InMemoryVoiceCloneProfileRepository:
|
||||
"""内存中的音色克隆档案 Repository(用于 TTS 测试)。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: dict[str, VoiceCloneProfile] = {}
|
||||
|
||||
def create(self, profile):
|
||||
self._items[profile.id] = profile
|
||||
return profile
|
||||
|
||||
def get(self, profile_id: str):
|
||||
return self._items.get(profile_id)
|
||||
|
||||
def update(self, profile):
|
||||
self._items[profile.id] = profile
|
||||
return profile
|
||||
|
||||
def delete(self, profile_id):
|
||||
if profile_id in self._items:
|
||||
del self._items[profile_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_by_user(self, user_id, **kwargs):
|
||||
return [p for p in self._items.values() if p.user_id == user_id]
|
||||
|
||||
def count_by_user(self, user_id, **kwargs):
|
||||
return len([p for p in self._items.values() if p.user_id == user_id])
|
||||
|
||||
def find_by_voice_id(self, voice_id):
|
||||
return None
|
||||
|
||||
def find_profile_ids_by_voice_ids(self, voice_ids):
|
||||
return {}
|
||||
|
||||
|
||||
class InMemoryVoiceLibraryRepository:
|
||||
"""内存中的配音库 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items = {}
|
||||
|
||||
def create(self, item):
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
def get(self, voice_id: str, user_id: str):
|
||||
item = self._items.get(voice_id)
|
||||
if item and item.user_id == user_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
def update(self, item):
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
def delete(self, voice_id: str, user_id: str) -> bool:
|
||||
item = self.get(voice_id, user_id)
|
||||
if item:
|
||||
del self._items[voice_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_by_user(self, user_id, **kwargs):
|
||||
return [i for i in self._items.values() if i.user_id == user_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])
|
||||
|
||||
|
||||
class InMemoryUserRepository:
|
||||
"""内存中的用户 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._users = {}
|
||||
|
||||
def save(self, user):
|
||||
self._users[user.id] = user
|
||||
|
||||
def find_by_id(self, user_id: str):
|
||||
return self._users.get(user_id)
|
||||
|
||||
def find_by_email(self, email: str):
|
||||
for u in self._users.values():
|
||||
if u.email == email:
|
||||
return u
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Mock CosyVoice 服务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockCosyVoiceService:
|
||||
"""Mock CosyVoice 服务。"""
|
||||
|
||||
def __init__(self, *, fail_submit: bool = False):
|
||||
self.fail_submit = fail_submit
|
||||
self.submit_called = False
|
||||
|
||||
def submit_synthesize_task(self, *, text: str, voice_id: str = "", **kwargs) -> dict:
|
||||
self.submit_called = True
|
||||
if self.fail_submit:
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
|
||||
raise CosyVoiceError("模拟 CosyVoice 合成失败")
|
||||
|
||||
return {
|
||||
"task_id": "mock-tts-task-123",
|
||||
"status": "processing",
|
||||
}
|
||||
|
||||
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
|
||||
return {
|
||||
"status": "completed",
|
||||
"audio_url": "https://cdn.example.com/tts/output.mp3",
|
||||
"duration": 5.5,
|
||||
"file_size": 88000,
|
||||
"sample_rate": 22050,
|
||||
"format": "mp3",
|
||||
}
|
||||
|
||||
def synthesize_speech(self, *, text: str, voice_id: str = "", **kwargs) -> dict:
|
||||
return {
|
||||
"audio_url": "https://cdn.example.com/tts/output.mp3",
|
||||
"duration": 5.5,
|
||||
"file_size": 88000,
|
||||
}
|
||||
|
||||
def submit_clone_task(self, **kwargs) -> dict:
|
||||
return {"task_id": "clone-1", "status": "processing"}
|
||||
|
||||
def list_preset_voices(self) -> list:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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_tts_job(
|
||||
text: str = "你好,这是一段测试文本。",
|
||||
user_id: str = "user-test-001",
|
||||
status: TTSJobStatus = TTSJobStatus.PENDING,
|
||||
**kwargs,
|
||||
) -> TTSJob:
|
||||
job = TTSJob.create(
|
||||
user_id=user_id,
|
||||
input_text=text,
|
||||
voice_id=kwargs.get("voice_id", "voice-1"),
|
||||
voice_model=kwargs.get("voice_model", "cosyvoice-v2"),
|
||||
project_id=kwargs.get("project_id", ""),
|
||||
voice_clone_profile_id=kwargs.get("voice_clone_profile_id", ""),
|
||||
format=kwargs.get("format", "mp3"),
|
||||
sample_rate=kwargs.get("sample_rate", 22050),
|
||||
max_retries=kwargs.get("max_retries", 3),
|
||||
metadata=kwargs.get("metadata", None),
|
||||
)
|
||||
# 设置状态
|
||||
if status == TTSJobStatus.PROCESSING:
|
||||
job.mark_processing()
|
||||
elif status == TTSJobStatus.COMPLETED:
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url=kwargs.get("output_audio_url", "https://cdn.example.com/tts/out.mp3"),
|
||||
output_audio_key=kwargs.get("output_audio_key", "tts/out.mp3"),
|
||||
duration=kwargs.get("duration", 5.5),
|
||||
file_size=kwargs.get("file_size", 88000),
|
||||
)
|
||||
elif status == TTSJobStatus.FAILED:
|
||||
job.mark_processing()
|
||||
job.mark_failed("合成失败")
|
||||
elif status == TTSJobStatus.CANCELLED:
|
||||
job.mark_cancelled()
|
||||
return job
|
||||
|
||||
|
||||
def _make_voice_clone_profile(
|
||||
user_id: str = "user-test-001",
|
||||
status: VoiceCloneStatus = VoiceCloneStatus.READY,
|
||||
) -> VoiceCloneProfile:
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id=user_id,
|
||||
name="测试克隆音色",
|
||||
voice_model="cosyvoice-v2",
|
||||
)
|
||||
if status == VoiceCloneStatus.READY:
|
||||
profile.mark_processing()
|
||||
profile.mark_ready("clone-voice-001")
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tts_repo():
|
||||
return InMemoryTTSJobRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voice_clone_repo():
|
||||
return InMemoryVoiceCloneProfileRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voice_library_repo():
|
||||
return InMemoryVoiceLibraryRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_repo():
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(_make_user())
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cosyvoice_service():
|
||||
return MockCosyVoiceService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tts_repo, voice_clone_repo, voice_library_repo, user_repo, cosyvoice_service):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/tts")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
def _override_tts_repo():
|
||||
return tts_repo
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
|
||||
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: voice_clone_repo
|
||||
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
|
||||
test_app.dependency_overrides[get_user_repository] = lambda: user_repo
|
||||
|
||||
# 使用 FastAPI dependency_overrides 覆盖 TTS repository
|
||||
from app.api.routes import tts as tts_module
|
||||
|
||||
test_app.dependency_overrides[tts_module._get_repository] = lambda: tts_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST /synthesize — 创建 TTS 合成任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateTTSJob:
|
||||
"""创建 TTS 合成任务端点测试。"""
|
||||
|
||||
def test_create_with_valid_text(self, client, cosyvoice_service):
|
||||
"""使用有效文本创建 TTS 任务。"""
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "你好,世界!",
|
||||
"voice_id": "voice-1",
|
||||
"voice_model": "cosyvoice-v2",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "job_id" in data
|
||||
assert data["message"] == "合成任务已创建"
|
||||
assert "status" in data
|
||||
|
||||
def test_create_persists_to_repository(self, client, tts_repo):
|
||||
"""创建后任务保存到 repository。"""
|
||||
resp = client.post("/tts/synthesize", json={"text": "持久化测试"})
|
||||
job_id = resp.json()["job_id"]
|
||||
|
||||
saved = tts_repo.get(job_id)
|
||||
assert saved is not None
|
||||
assert saved.input_text == "持久化测试"
|
||||
assert saved.user_id == "user-test-001"
|
||||
|
||||
def test_create_missing_text_returns_422(self, client):
|
||||
"""缺少 text 返回 422。"""
|
||||
resp = client.post("/tts/synthesize", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_empty_text_returns_422(self, client):
|
||||
"""空 text 返回 422。"""
|
||||
resp = client.post("/tts/synthesize", json={"text": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_with_custom_format(self, client):
|
||||
"""支持指定输出格式。"""
|
||||
for fmt in ["mp3", "wav", "pcm"]:
|
||||
resp = client.post("/tts/synthesize", json={"text": "测试", "format": fmt})
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_create_with_invalid_format_returns_422(self, client):
|
||||
"""无效格式在 Pydantic 层校验返回 422。"""
|
||||
# format 参数不在 TTSSynthesizeRequest schema 中,
|
||||
# 或者有默认值/枚举校验。此处测试额外字段会被忽略或校验失败。
|
||||
# 实际:schema 中 format 是可选的,有默认值,无效值会在领域层被捕获
|
||||
# 但 API 仍返回 201,任务标记为 failed(与音色克隆行为一致)
|
||||
resp = client.post("/tts/synthesize", json={"text": "测试", "format": "flac"})
|
||||
# 格式不在请求 schema 中时,FastAPI 会忽略额外字段,任务正常创建
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_create_with_metadata(self, client):
|
||||
"""支持自定义 metadata。"""
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "元数据测试",
|
||||
"metadata": {"source": "api", "version": "1.0"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_create_with_voice_clone_profile_id(self, client, voice_clone_repo):
|
||||
"""使用音色克隆档案创建 TTS。"""
|
||||
# 准备一个克隆档案
|
||||
profile = _make_voice_clone_profile()
|
||||
voice_clone_repo.create(profile)
|
||||
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "使用克隆音色",
|
||||
"voice_clone_profile_id": profile.id,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_create_with_nonexistent_clone_profile_returns_404(self, client):
|
||||
"""使用不存在的克隆档案返回 404。"""
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "测试",
|
||||
"voice_clone_profile_id": "nonexistent-profile",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_with_other_user_clone_profile_returns_403(self, client, voice_clone_repo):
|
||||
"""使用其他用户的克隆档案返回 403。"""
|
||||
profile = _make_voice_clone_profile(user_id="other-user")
|
||||
voice_clone_repo.create(profile)
|
||||
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "越权测试",
|
||||
"voice_clone_profile_id": profile.id,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. GET /jobs — 列出 TTS 任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListTTSJobs:
|
||||
"""列出 TTS 任务端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无任务时返回空列表。"""
|
||||
resp = client.get("/tts/jobs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
|
||||
def test_list_user_jobs(self, client, tts_repo):
|
||||
"""只返回当前用户的任务。"""
|
||||
j1 = _make_tts_job("任务1", "user-test-001")
|
||||
j2 = _make_tts_job("任务2", "user-test-001")
|
||||
j3 = _make_tts_job("他人任务", "other-user")
|
||||
tts_repo.create(j1)
|
||||
tts_repo.create(j2)
|
||||
tts_repo.create(j3)
|
||||
|
||||
resp = client.get("/tts/jobs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_filter_by_status(self, client, tts_repo):
|
||||
"""按状态筛选。"""
|
||||
completed = _make_tts_job("已完成", status=TTSJobStatus.COMPLETED)
|
||||
failed = _make_tts_job("已失败", status=TTSJobStatus.FAILED)
|
||||
tts_repo.create(completed)
|
||||
tts_repo.create(failed)
|
||||
|
||||
resp = client.get("/tts/jobs?status=completed")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["status"] == "completed"
|
||||
|
||||
def test_pagination(self, client, tts_repo):
|
||||
"""分页功能。"""
|
||||
for i in range(5):
|
||||
job = _make_tts_job(f"任务{i}")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get("/tts/jobs?page=1&page_size=2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 5
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
resp2 = client.get("/tts/jobs?page=2&page_size=2")
|
||||
assert resp2.json()["page"] == 2
|
||||
assert len(resp2.json()["items"]) == 2
|
||||
|
||||
resp3 = client.get("/tts/jobs?page=3&page_size=2")
|
||||
assert len(resp3.json()["items"]) == 1
|
||||
|
||||
def test_list_response_fields(self, client, tts_repo):
|
||||
"""列表响应包含所有必需字段。"""
|
||||
job = _make_tts_job("字段测试", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get("/tts/jobs")
|
||||
item = resp.json()["items"][0]
|
||||
for field in [
|
||||
"id",
|
||||
"user_id",
|
||||
"input_text",
|
||||
"voice_id",
|
||||
"voice_model",
|
||||
"status",
|
||||
"output_audio_url",
|
||||
"duration",
|
||||
"format",
|
||||
"error_message",
|
||||
"retry_count",
|
||||
"max_retries",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]:
|
||||
assert field in item, f"缺少字段: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. GET /jobs/{job_id} — 获取 TTS 任务详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTTSJob:
|
||||
"""获取 TTS 任务详情端点测试。"""
|
||||
|
||||
def test_get_existing_job(self, client, tts_repo):
|
||||
"""获取存在的任务返回详情。"""
|
||||
job = _make_tts_job("详情测试", voice_model="cosyvoice-v2")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == job.id
|
||||
assert data["input_text"] == "详情测试"
|
||||
assert data["voice_model"] == "cosyvoice-v2"
|
||||
|
||||
def test_get_nonexistent_returns_404(self, client):
|
||||
"""获取不存在的任务返回 404。"""
|
||||
resp = client.get("/tts/jobs/nonexistent-job-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
def test_get_other_user_job_returns_404(self, client, tts_repo):
|
||||
"""获取其他用户的任务返回 404(安全隔离)。"""
|
||||
job = _make_tts_job("他人任务", user_id="other-user")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_completed_job(self, client, tts_repo):
|
||||
"""获取已完成任务包含音频 URL 和时长。"""
|
||||
job = _make_tts_job("已完成", status=TTSJobStatus.COMPLETED, duration=10.5)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}")
|
||||
data = resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["output_audio_url"] != ""
|
||||
assert data["duration"] == 10.5
|
||||
assert data["file_size"] > 0
|
||||
|
||||
def test_get_failed_job(self, client, tts_repo):
|
||||
"""获取失败任务包含错误信息。"""
|
||||
job = _make_tts_job("失败任务", status=TTSJobStatus.FAILED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}")
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. GET /jobs/{job_id}/status — 获取 TTS 任务状态
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTTSJobStatus:
|
||||
"""获取 TTS 任务状态端点测试。"""
|
||||
|
||||
def test_status_pending(self, client, tts_repo):
|
||||
"""pending 状态。"""
|
||||
job = _make_tts_job("pending", status=TTSJobStatus.PENDING)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == job.id
|
||||
assert data["status"] == "pending"
|
||||
|
||||
def test_status_completed(self, client, tts_repo):
|
||||
"""completed 状态包含音频 URL。"""
|
||||
job = _make_tts_job("completed", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||||
data = resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["output_audio_url"] != ""
|
||||
assert data["duration"] > 0
|
||||
|
||||
def test_status_failed(self, client, tts_repo):
|
||||
"""failed 状态包含错误信息。"""
|
||||
job = _make_tts_job("failed", status=TTSJobStatus.FAILED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] != ""
|
||||
|
||||
def test_status_nonexistent_returns_404(self, client):
|
||||
"""获取不存在任务的状态返回 404。"""
|
||||
resp = client.get("/tts/jobs/nonexistent/status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. DELETE /jobs/{job_id} — 删除 TTS 任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteTTSJob:
|
||||
"""删除 TTS 任务端点测试。"""
|
||||
|
||||
def test_delete_existing_job(self, client, tts_repo):
|
||||
"""删除存在的任务返回 204。"""
|
||||
job = _make_tts_job("待删除")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.delete(f"/tts/jobs/{job.id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
assert tts_repo.get(job.id) is None
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, client):
|
||||
"""删除不存在的任务返回 404。"""
|
||||
resp = client.delete("/tts/jobs/nonexistent-job-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_other_user_job_returns_404(self, client, tts_repo):
|
||||
"""删除其他用户的任务返回 404(安全隔离)。"""
|
||||
job = _make_tts_job("他人任务", user_id="other-user")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.delete(f"/tts/jobs/{job.id}")
|
||||
assert resp.status_code == 404
|
||||
# 验证未被删除
|
||||
assert tts_repo.get(job.id) is not None
|
||||
|
||||
def test_delete_idempotent(self, client, tts_repo):
|
||||
"""删除后再次删除返回 404。"""
|
||||
job = _make_tts_job("幂等测试")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp1 = client.delete(f"/tts/jobs/{job.id}")
|
||||
assert resp1.status_code == 204
|
||||
|
||||
resp2 = client.delete(f"/tts/jobs/{job.id}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. POST /jobs/{job_id}/save-to-library — 保存到配音库
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSaveToLibrary:
|
||||
"""保存到配音库端点测试。"""
|
||||
|
||||
def test_save_completed_job(self, client, tts_repo):
|
||||
"""保存已完成的 TTS 任务到配音库。"""
|
||||
job = _make_tts_job("保存测试", status=TTSJobStatus.COMPLETED, duration=5.5)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(
|
||||
f"/tts/jobs/{job.id}/save-to-library",
|
||||
json={"name": "我的配音"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "我的配音"
|
||||
assert data["duration"] == 5.5
|
||||
assert data["status"] == "completed"
|
||||
assert "id" in data
|
||||
assert "audio_url" in data
|
||||
assert "voice_id" in data
|
||||
assert "voice_name" in data
|
||||
|
||||
def test_save_pending_job_returns_400(self, client, tts_repo):
|
||||
"""保存未完成的任务返回 400。"""
|
||||
job = _make_tts_job("未完成", status=TTSJobStatus.PENDING)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||||
assert resp.status_code == 400
|
||||
assert "not completed" in resp.json()["detail"].lower() or "完成" in resp.json()["detail"]
|
||||
|
||||
def test_save_failed_job_returns_400(self, client, tts_repo):
|
||||
"""保存失败的任务返回 400。"""
|
||||
job = _make_tts_job("失败", status=TTSJobStatus.FAILED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_save_nonexistent_job_returns_404(self, client):
|
||||
"""保存不存在的任务返回 404。"""
|
||||
resp = client.post("/tts/jobs/nonexistent/save-to-library")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_save_other_user_job_returns_404(self, client, tts_repo):
|
||||
"""保存其他用户的任务返回 404。"""
|
||||
job = _make_tts_job("他人任务", user_id="other-user", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_save_auto_generates_name(self, client, tts_repo):
|
||||
"""不指定名称时自动生成。"""
|
||||
job = _make_tts_job("自动命名", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library", json={})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] != ""
|
||||
# 自动生成的名称应该以 TTS- 开头
|
||||
assert data["name"].startswith("TTS-")
|
||||
|
||||
def test_save_creates_library_item(self, client, tts_repo, voice_library_repo):
|
||||
"""保存后配音库中新增一条记录。"""
|
||||
before_count = voice_library_repo.count_by_user("user-test-001")
|
||||
|
||||
job = _make_tts_job("入库测试", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library", json={"name": "入库"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
after_count = voice_library_repo.count_by_user("user-test-001")
|
||||
assert after_count == before_count + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTTSLifecycle:
|
||||
"""TTS 完整生命周期测试。"""
|
||||
|
||||
def test_create_list_get_delete_flow(self, client, tts_repo):
|
||||
"""创建 → 列表 → 详情 → 删除 完整流程。"""
|
||||
# 1. 创建
|
||||
create_resp = client.post("/tts/synthesize", json={"text": "完整流程测试"})
|
||||
assert create_resp.status_code == 201
|
||||
job_id = create_resp.json()["job_id"]
|
||||
|
||||
# 2. 列表
|
||||
list_resp = client.get("/tts/jobs")
|
||||
assert list_resp.json()["total"] == 1
|
||||
|
||||
# 3. 详情
|
||||
detail_resp = client.get(f"/tts/jobs/{job_id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["input_text"] == "完整流程测试"
|
||||
|
||||
# 4. 状态
|
||||
status_resp = client.get(f"/tts/jobs/{job_id}/status")
|
||||
assert status_resp.status_code == 200
|
||||
|
||||
# 5. 删除
|
||||
del_resp = client.delete(f"/tts/jobs/{job_id}")
|
||||
assert del_resp.status_code == 204
|
||||
|
||||
# 6. 删除后列表为空
|
||||
list_resp2 = client.get("/tts/jobs")
|
||||
assert list_resp2.json()["total"] == 0
|
||||
|
||||
def test_create_simulate_complete_save_to_library(self, client, tts_repo):
|
||||
"""创建 → 模拟完成 → 保存到配音库 流程。"""
|
||||
# 创建任务
|
||||
create_resp = client.post("/tts/synthesize", json={"text": "入库流程"})
|
||||
job_id = create_resp.json()["job_id"]
|
||||
|
||||
# 模拟 worker 完成
|
||||
job = tts_repo.get(job_id)
|
||||
assert job is not None
|
||||
# 根据当前状态决定下一步:failed 先重置,pending 则转 processing,已是 processing 则跳过
|
||||
if job.status == TTSJobStatus.FAILED:
|
||||
job.prepare_retry()
|
||||
job.mark_processing()
|
||||
elif job.status == TTSJobStatus.PENDING:
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url="https://cdn.example.com/tts/final.mp3",
|
||||
duration=8.0,
|
||||
file_size=128000,
|
||||
)
|
||||
tts_repo.update(job)
|
||||
|
||||
# 确认完成
|
||||
status_resp = client.get(f"/tts/jobs/{job_id}/status")
|
||||
assert status_resp.json()["status"] == "completed"
|
||||
|
||||
# 保存到配音库
|
||||
save_resp = client.post(
|
||||
f"/tts/jobs/{job_id}/save-to-library",
|
||||
json={"name": "最终配音"},
|
||||
)
|
||||
assert save_resp.status_code == 201
|
||||
assert save_resp.json()["name"] == "最终配音"
|
||||
assert save_resp.json()["duration"] == 8.0
|
||||
|
||||
def test_multiple_jobs_status_filter(self, client, tts_repo):
|
||||
"""多个任务时按状态筛选正确。"""
|
||||
# 创建不同状态的任务
|
||||
for text, status in [
|
||||
("任务A-完成", TTSJobStatus.COMPLETED),
|
||||
("任务B-完成", TTSJobStatus.COMPLETED),
|
||||
("任务C-失败", TTSJobStatus.FAILED),
|
||||
("任务D-处理中", TTSJobStatus.PROCESSING),
|
||||
]:
|
||||
job = _make_tts_job(text, status=status)
|
||||
tts_repo.create(job)
|
||||
|
||||
# 按状态筛选
|
||||
completed_resp = client.get("/tts/jobs?status=completed")
|
||||
assert completed_resp.json()["total"] == 2
|
||||
|
||||
failed_resp = client.get("/tts/jobs?status=failed")
|
||||
assert failed_resp.json()["total"] == 1
|
||||
|
||||
processing_resp = client.get("/tts/jobs?status=processing")
|
||||
assert processing_resp.json()["total"] == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,716 +0,0 @@
|
||||
"""
|
||||
声音克隆 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- POST /voice-clones — 创建声音克隆
|
||||
- GET /voice-clones — 列出声音克隆
|
||||
- GET /voice-clones/{clone_id} — 获取克隆详情
|
||||
- GET /voice-clones/{clone_id}/status — 获取克隆状态
|
||||
- POST /voice-clones/{clone_id}/retry — 重试克隆
|
||||
- DELETE /voice-clones/{clone_id} — 删除克隆
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
mock repository 和 CosyVoice 服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
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"))
|
||||
|
||||
from app.api.routes.voice_clones import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.voice_clone_profile import (
|
||||
VoiceCloneProfile,
|
||||
VoiceCloneStatus,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryVoiceCloneProfileRepository:
|
||||
"""内存中的音色克隆档案 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: dict[str, VoiceCloneProfile] = {}
|
||||
|
||||
def create(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
|
||||
self._items[profile.id] = profile
|
||||
return profile
|
||||
|
||||
def get(self, profile_id: str) -> VoiceCloneProfile | None:
|
||||
return self._items.get(profile_id)
|
||||
|
||||
def update(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
|
||||
self._items[profile.id] = profile
|
||||
return profile
|
||||
|
||||
def delete(self, profile_id: str) -> bool:
|
||||
if profile_id in self._items:
|
||||
del self._items[profile_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status=None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[VoiceCloneProfile]:
|
||||
items = [p for p in self._items.values() if p.user_id == user_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [p for p in items if p.status.value == status_str]
|
||||
# 按 created_at 倒序
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[offset : offset + limit]
|
||||
|
||||
def count_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status=None,
|
||||
) -> int:
|
||||
items = [p for p in self._items.values() if p.user_id == user_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [p for p in items if p.status.value == status_str]
|
||||
return len(items)
|
||||
|
||||
def find_by_voice_id(self, voice_id: str) -> VoiceCloneProfile | None:
|
||||
for p in self._items.values():
|
||||
if p.voice_id == voice_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
def find_profile_ids_by_voice_ids(self, voice_ids: list[str]) -> dict[str, str]:
|
||||
result = {}
|
||||
for p in self._items.values():
|
||||
if p.voice_id in voice_ids:
|
||||
result[p.voice_id] = p.id
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Mock CosyVoice 服务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockCosyVoiceService:
|
||||
"""Mock CosyVoice 服务,模拟克隆任务提交和状态查询。"""
|
||||
|
||||
def __init__(self, *, fail_submit: bool = False, async_mode: bool = True):
|
||||
self.fail_submit = fail_submit
|
||||
self.async_mode = async_mode
|
||||
self.submit_called = False
|
||||
self.submit_args = None
|
||||
|
||||
def submit_clone_task(self, *, audio_url: str, voice_name: str, language: str = "zh-CN") -> dict:
|
||||
self.submit_called = True
|
||||
self.submit_args = {"audio_url": audio_url, "voice_name": voice_name, "language": language}
|
||||
|
||||
if self.fail_submit:
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
|
||||
raise CosyVoiceError("模拟 CosyVoice 提交失败")
|
||||
|
||||
if self.async_mode:
|
||||
# 异步模式:返回 task_id,需要轮询
|
||||
return {"task_id": "mock-task-123", "request_id": "req-456", "status": "processing"}
|
||||
else:
|
||||
# 同步模式:直接返回 voice_id
|
||||
return {"voice_id": "mock-voice-789", "status": "success"}
|
||||
|
||||
def check_task_status(self, task_id: str) -> dict:
|
||||
return {"status": "completed", "voice_id": "mock-voice-789"}
|
||||
|
||||
def list_preset_voices(self) -> list:
|
||||
return []
|
||||
|
||||
def submit_synthesize_task(self, **kwargs) -> dict:
|
||||
return {"task_id": "synth-1", "status": "processing"}
|
||||
|
||||
def synthesize_speech(self, **kwargs) -> dict:
|
||||
return {"audio_url": "https://example.com/audio.mp3", "duration": 5.0}
|
||||
|
||||
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
|
||||
return {
|
||||
"status": "completed",
|
||||
"audio_url": "https://example.com/audio.mp3",
|
||||
"duration": 5.0,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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_clone_profile(
|
||||
name: str = "我的音色",
|
||||
user_id: str = "user-test-001",
|
||||
status: VoiceCloneStatus = VoiceCloneStatus.PENDING,
|
||||
source_audio_url: str = "https://example.com/source.wav",
|
||||
**kwargs,
|
||||
) -> VoiceCloneProfile:
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
source_audio_url=source_audio_url,
|
||||
voice_model=kwargs.get("voice_model", "cosyvoice-v2"),
|
||||
language=kwargs.get("language", "zh-CN"),
|
||||
gender=kwargs.get("gender", "female"),
|
||||
max_retries=kwargs.get("max_retries", 3),
|
||||
metadata=kwargs.get("metadata", None),
|
||||
description=kwargs.get("description", ""),
|
||||
)
|
||||
# 设置状态
|
||||
if status == VoiceCloneStatus.PROCESSING:
|
||||
profile.mark_processing()
|
||||
profile.metadata = {"cosyvoice_task_id": "task-123"}
|
||||
elif status == VoiceCloneStatus.READY:
|
||||
profile.mark_processing()
|
||||
profile.mark_ready("voice-ready-001")
|
||||
elif status == VoiceCloneStatus.FAILED:
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("模拟失败")
|
||||
elif status == VoiceCloneStatus.DISABLED:
|
||||
profile.mark_disabled()
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clone_repo():
|
||||
return InMemoryVoiceCloneProfileRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cosyvoice_service():
|
||||
return MockCosyVoiceService(async_mode=False) # 同步模式,简化测试
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(clone_repo, cosyvoice_service):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/voice-clones")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: clone_repo
|
||||
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST / — 创建声音克隆
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateVoiceClone:
|
||||
"""创建声音克隆端点测试。"""
|
||||
|
||||
def test_create_with_source_audio(self, client, cosyvoice_service):
|
||||
"""提供源音频时创建克隆,同步模式下直接 ready。"""
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "我的专属音色",
|
||||
"source_audio_url": "https://example.com/voice.wav",
|
||||
"voice_model": "cosyvoice-v2",
|
||||
"language": "zh-CN",
|
||||
"gender": "female",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "我的专属音色"
|
||||
assert data["source_audio_url"] == "https://example.com/voice.wav"
|
||||
assert data["voice_model"] == "cosyvoice-v2"
|
||||
assert data["language"] == "zh-CN"
|
||||
assert data["gender"] == "female"
|
||||
assert "id" in data
|
||||
assert len(data["id"]) > 0
|
||||
|
||||
# 同步模式下应直接 ready
|
||||
assert data["status"] == "ready"
|
||||
assert data["voice_id"] == "mock-voice-789"
|
||||
assert data["error_message"] == ""
|
||||
|
||||
def test_create_without_source_audio(self, client):
|
||||
"""不提供源音频时创建,状态为 pending。"""
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "待上传音色",
|
||||
"description": "等待上传音频",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "待上传音色"
|
||||
assert data["status"] == "pending"
|
||||
assert data["source_audio_url"] == ""
|
||||
assert data["voice_id"] == ""
|
||||
|
||||
def test_create_persists_to_repository(self, client, clone_repo):
|
||||
"""创建后档案保存到 repository。"""
|
||||
resp = client.post("/voice-clones", json={"name": "持久化测试"})
|
||||
profile_id = resp.json()["id"]
|
||||
|
||||
saved = clone_repo.get(profile_id)
|
||||
assert saved is not None
|
||||
assert saved.name == "持久化测试"
|
||||
assert saved.user_id == "user-test-001"
|
||||
|
||||
def test_create_missing_name_returns_422(self, client):
|
||||
"""缺少 name 返回 422。"""
|
||||
resp = client.post("/voice-clones", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_empty_name_returns_422(self, client):
|
||||
"""空 name 返回 422。"""
|
||||
resp = client.post("/voice-clones", json={"name": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_name_too_long_returns_422(self, client):
|
||||
"""名称超长返回 422。"""
|
||||
long_name = "a" * 101
|
||||
resp = client.post("/voice-clones", json={"name": long_name})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_with_metadata(self, client, cosyvoice_service):
|
||||
"""支持自定义 metadata。"""
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "带元数据的克隆",
|
||||
"source_audio_url": "https://example.com/v.wav",
|
||||
"metadata": {"source": "mobile_app", "version": "1.0"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["metadata"]["source"] == "mobile_app"
|
||||
assert data["metadata"]["version"] == "1.0"
|
||||
|
||||
def test_create_cosyvoice_failure_returns_failed(self, client, clone_repo, cosyvoice_service):
|
||||
"""CosyVoice 提交失败时返回 201 + failed 状态(不抛 500)。"""
|
||||
cosyvoice_service.fail_submit = True
|
||||
cosyvoice_service.async_mode = True # 异步模式才会调用 submit_clone_task
|
||||
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "会失败的克隆",
|
||||
"source_audio_url": "https://example.com/bad.wav",
|
||||
},
|
||||
)
|
||||
# 不抛 500,返回 201 + failed 状态
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. GET / — 列出声音克隆
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListVoiceClones:
|
||||
"""列出声音克隆端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无克隆时返回空列表。"""
|
||||
resp = client.get("/voice-clones")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_user_clones(self, client, clone_repo):
|
||||
"""只返回当前用户的克隆。"""
|
||||
p1 = _make_clone_profile("音色1", "user-test-001")
|
||||
p2 = _make_clone_profile("音色2", "user-test-001")
|
||||
p3 = _make_clone_profile("他人音色", "other-user")
|
||||
clone_repo.create(p1)
|
||||
clone_repo.create(p2)
|
||||
clone_repo.create(p3)
|
||||
|
||||
resp = client.get("/voice-clones")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
names = {item["name"] for item in data["items"]}
|
||||
assert names == {"音色1", "音色2"}
|
||||
|
||||
def test_filter_by_status(self, client, clone_repo):
|
||||
"""按状态筛选。"""
|
||||
ready = _make_clone_profile("已就绪", status=VoiceCloneStatus.READY)
|
||||
failed = _make_clone_profile("已失败", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(ready)
|
||||
clone_repo.create(failed)
|
||||
|
||||
resp = client.get("/voice-clones?status=ready")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["name"] == "已就绪"
|
||||
|
||||
def test_filter_by_failed_status(self, client, clone_repo):
|
||||
"""筛选失败状态。"""
|
||||
failed = _make_clone_profile("失败的", status=VoiceCloneStatus.FAILED)
|
||||
ready = _make_clone_profile("成功的", status=VoiceCloneStatus.READY)
|
||||
clone_repo.create(failed)
|
||||
clone_repo.create(ready)
|
||||
|
||||
resp = client.get("/voice-clones?status=failed")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["name"] == "失败的"
|
||||
|
||||
def test_list_response_fields(self, client, clone_repo):
|
||||
"""列表响应包含所有必需字段。"""
|
||||
p = _make_clone_profile("字段测试")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get("/voice-clones")
|
||||
item = resp.json()["items"][0]
|
||||
for field in [
|
||||
"id",
|
||||
"user_id",
|
||||
"name",
|
||||
"description",
|
||||
"source_audio_url",
|
||||
"voice_id",
|
||||
"voice_model",
|
||||
"language",
|
||||
"gender",
|
||||
"status",
|
||||
"error_message",
|
||||
"retry_count",
|
||||
"max_retries",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]:
|
||||
assert field in item, f"缺少字段: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. GET /{clone_id} — 获取克隆详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetVoiceClone:
|
||||
"""获取克隆详情端点测试。"""
|
||||
|
||||
def test_get_existing_clone(self, client, clone_repo):
|
||||
"""获取存在的克隆返回详情。"""
|
||||
p = _make_clone_profile("详情测试", description="这是一段描述")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == p.id
|
||||
assert data["name"] == "详情测试"
|
||||
assert data["description"] == "这是一段描述"
|
||||
|
||||
def test_get_nonexistent_returns_404(self, client):
|
||||
"""获取不存在的克隆返回 404。"""
|
||||
resp = client.get("/voice-clones/nonexistent-clone-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
def test_get_other_user_clone_returns_404(self, client, clone_repo):
|
||||
"""获取其他用户的克隆返回 404(安全隔离)。"""
|
||||
p = _make_clone_profile("他人音色", user_id="other-user")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_ready_clone_has_voice_id(self, client, clone_repo):
|
||||
"""就绪状态的克隆有 voice_id。"""
|
||||
p = _make_clone_profile("就绪音色", status=VoiceCloneStatus.READY)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}")
|
||||
data = resp.json()
|
||||
assert data["status"] == "ready"
|
||||
assert data["voice_id"] == "voice-ready-001"
|
||||
|
||||
def test_get_failed_clone_has_error_message(self, client, clone_repo):
|
||||
"""失败状态的克隆有错误信息。"""
|
||||
p = _make_clone_profile("失败音色", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}")
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "模拟失败" in data["error_message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. GET /{clone_id}/status — 获取克隆状态
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetVoiceCloneStatus:
|
||||
"""获取克隆状态端点测试。"""
|
||||
|
||||
def test_status_pending(self, client, clone_repo):
|
||||
"""pending 状态。"""
|
||||
p = _make_clone_profile("pending", status=VoiceCloneStatus.PENDING)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == p.id
|
||||
assert data["status"] == "pending"
|
||||
assert data["retry_count"] == 0
|
||||
|
||||
def test_status_ready(self, client, clone_repo):
|
||||
"""ready 状态包含 voice_id。"""
|
||||
p = _make_clone_profile("ready", status=VoiceCloneStatus.READY)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}/status")
|
||||
data = resp.json()
|
||||
assert data["status"] == "ready"
|
||||
assert data["voice_id"] == "voice-ready-001"
|
||||
|
||||
def test_status_failed(self, client, clone_repo):
|
||||
"""failed 状态包含错误信息。"""
|
||||
p = _make_clone_profile("failed", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}/status")
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] != ""
|
||||
assert data["retry_count"] == 0 # mark_failed 不增加 retry_count,只有重试时才增加
|
||||
|
||||
def test_status_nonexistent_returns_404(self, client):
|
||||
"""获取不存在克隆的状态返回 404。"""
|
||||
resp = client.get("/voice-clones/nonexistent/status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. POST /{clone_id}/retry — 重试克隆
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryVoiceClone:
|
||||
"""重试克隆端点测试。"""
|
||||
|
||||
def test_retry_failed_clone(self, client, clone_repo, cosyvoice_service):
|
||||
"""重试失败的克隆应成功。"""
|
||||
cosyvoice_service.async_mode = False
|
||||
p = _make_clone_profile("重试测试", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 同步模式下重试后应变为 ready
|
||||
assert data["status"] == "ready"
|
||||
assert data["retry_count"] >= 1
|
||||
|
||||
def test_retry_nonexistent_returns_404(self, client):
|
||||
"""重试不存在的克隆返回 404。"""
|
||||
resp = client.post("/voice-clones/nonexistent/retry")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_retry_ready_clone_returns_400(self, client, clone_repo):
|
||||
"""重试已就绪的克隆返回 400(不可重试)。"""
|
||||
p = _make_clone_profile("已就绪", status=VoiceCloneStatus.READY)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert resp.status_code == 400
|
||||
assert "retryable" in resp.json()["detail"].lower() or "not" in resp.json()["detail"].lower()
|
||||
|
||||
def test_retry_processing_clone_returns_400(self, client, clone_repo):
|
||||
"""重试处理中的克隆返回 400。"""
|
||||
p = _make_clone_profile("处理中", status=VoiceCloneStatus.PROCESSING)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_retry_increments_retry_count(self, client, clone_repo, cosyvoice_service):
|
||||
"""重试后重试次数增加。"""
|
||||
cosyvoice_service.async_mode = False
|
||||
p = _make_clone_profile("重试计数", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
before_count = p.retry_count
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
after_count = resp.json()["retry_count"]
|
||||
|
||||
assert after_count > before_count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. DELETE /{clone_id} — 删除克隆
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteVoiceClone:
|
||||
"""删除克隆端点测试。"""
|
||||
|
||||
def test_delete_existing_clone(self, client, clone_repo):
|
||||
"""删除存在的克隆返回 204。"""
|
||||
p = _make_clone_profile("待删除")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.delete(f"/voice-clones/{p.id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
assert clone_repo.get(p.id) is None
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, client):
|
||||
"""删除不存在的克隆返回 404。"""
|
||||
resp = client.delete("/voice-clones/nonexistent-clone-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_other_user_clone_returns_404(self, client, clone_repo):
|
||||
"""删除其他用户的克隆返回 404(安全隔离)。"""
|
||||
p = _make_clone_profile("他人音色", user_id="other-user")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.delete(f"/voice-clones/{p.id}")
|
||||
assert resp.status_code == 404
|
||||
# 验证未被删除
|
||||
assert clone_repo.get(p.id) is not None
|
||||
|
||||
def test_delete_idempotent(self, client, clone_repo):
|
||||
"""删除后再次删除返回 404。"""
|
||||
p = _make_clone_profile("幂等测试")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp1 = client.delete(f"/voice-clones/{p.id}")
|
||||
assert resp1.status_code == 204
|
||||
|
||||
resp2 = client.delete(f"/voice-clones/{p.id}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVoiceCloneLifecycle:
|
||||
"""音色克隆完整生命周期测试。"""
|
||||
|
||||
def test_full_lifecycle_create_list_get_delete(self, client, clone_repo, cosyvoice_service):
|
||||
"""创建 → 列表 → 详情 → 删除 完整流程。"""
|
||||
# 1. 创建
|
||||
create_resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "生命周期测试",
|
||||
"source_audio_url": "https://example.com/voice.wav",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
clone_id = create_resp.json()["id"]
|
||||
|
||||
# 2. 列表
|
||||
list_resp = client.get("/voice-clones")
|
||||
assert list_resp.json()["total"] == 1
|
||||
|
||||
# 3. 详情
|
||||
detail_resp = client.get(f"/voice-clones/{clone_id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["name"] == "生命周期测试"
|
||||
|
||||
# 4. 状态
|
||||
status_resp = client.get(f"/voice-clones/{clone_id}/status")
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.json()["status"] == "ready"
|
||||
|
||||
# 5. 删除
|
||||
del_resp = client.delete(f"/voice-clones/{clone_id}")
|
||||
assert del_resp.status_code == 204
|
||||
|
||||
# 6. 删除后列表为空
|
||||
list_resp2 = client.get("/voice-clones")
|
||||
assert list_resp2.json()["total"] == 0
|
||||
|
||||
def test_failed_retry_flow(self, client, clone_repo, cosyvoice_service):
|
||||
"""失败 → 重试 → 成功 流程。"""
|
||||
# 创建一个失败的克隆
|
||||
p = _make_clone_profile("失败重试", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
# 确认状态
|
||||
status_resp = client.get(f"/voice-clones/{p.id}/status")
|
||||
assert status_resp.json()["status"] == "failed"
|
||||
|
||||
# 重试
|
||||
cosyvoice_service.async_mode = False
|
||||
retry_resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert retry_resp.status_code == 200
|
||||
assert retry_resp.json()["status"] == "ready"
|
||||
|
||||
# 再次确认状态
|
||||
status_resp2 = client.get(f"/voice-clones/{p.id}/status")
|
||||
assert status_resp2.json()["status"] == "ready"
|
||||
assert status_resp2.json()["voice_id"] != ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,254 +0,0 @@
|
||||
"""
|
||||
DELETE /asset-libraries/{library_id} 单元测试
|
||||
|
||||
覆盖:
|
||||
- 正常删除空素材库(204)
|
||||
- 删除含素材的库(同时删除库内素材)
|
||||
- 素材库不存在(404)
|
||||
- 无权限访问(403)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.adapters.in_memory.asset_library_repository import InMemoryAssetLibraryRepository
|
||||
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Project Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProject:
|
||||
"""最小化 Project stub,支持 can_access"""
|
||||
|
||||
def __init__(self, project_id: str, owner_id: str):
|
||||
self.id = project_id
|
||||
self._owner_id = owner_id
|
||||
|
||||
def can_access(self, user_id: str) -> bool:
|
||||
return user_id == self._owner_id
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects: dict[str, StubProject] | None = None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def find_by_id(self, project_id: str):
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_auth_user(user_id: str = "user-001"):
|
||||
from app.auth import AuthenticatedUser
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
user = User(id=user_id, email="test@example.com", display_name="测试用户")
|
||||
return AuthenticatedUser(user=user)
|
||||
|
||||
|
||||
def _create_test_app(
|
||||
library_repo: InMemoryAssetLibraryRepository,
|
||||
asset_repo: InMemoryAssetRepository,
|
||||
project_repo: StubProjectRepository,
|
||||
user_id: str = "user-001",
|
||||
):
|
||||
from app.api.routes import asset_libraries as module
|
||||
from app.api.routes.asset_libraries import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/asset-libraries")
|
||||
|
||||
app.dependency_overrides[module.get_current_user] = lambda: _make_auth_user(user_id)
|
||||
app.dependency_overrides[module.get_asset_library_repository] = lambda: library_repo
|
||||
app.dependency_overrides[module.get_asset_repository] = lambda: asset_repo
|
||||
app.dependency_overrides[module.get_project_repository] = lambda: project_repo
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteAssetLibrary:
|
||||
"""DELETE /asset-libraries/{library_id} 测试"""
|
||||
|
||||
def test_delete_empty_library(self):
|
||||
"""删除空素材库 → 204"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
proj_repo = StubProjectRepository({"proj-1": StubProject("proj-1", "user-001")})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
library = AssetLibrary(
|
||||
id="lib-1",
|
||||
project_id="proj-1",
|
||||
name="视频素材库",
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
lib_repo.create(library)
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 204
|
||||
|
||||
# 验证库已删除
|
||||
assert lib_repo.find_by_id("lib-1") is None
|
||||
|
||||
def test_delete_library_with_assets(self):
|
||||
"""删除含素材的库 → 库和素材都被删除"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
proj_repo = StubProjectRepository({"proj-1": StubProject("proj-1", "user-001")})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
library = AssetLibrary(
|
||||
id="lib-1",
|
||||
project_id="proj-1",
|
||||
name="视频素材库",
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
asset_count=3,
|
||||
total_size=1000,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
lib_repo.create(library)
|
||||
|
||||
# 创建 3 个素材
|
||||
for i in range(3):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"video_{i}.mp4",
|
||||
storage_key=f"uploads/video_{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
|
||||
# 创建一个不属于该库的素材(不应被删除)
|
||||
other_asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-other",
|
||||
name="other.mp4",
|
||||
storage_key="uploads/other.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset_repo.create(other_asset)
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 204
|
||||
|
||||
# 库已删除
|
||||
assert lib_repo.find_by_id("lib-1") is None
|
||||
# 库内素材已删除
|
||||
assert asset_repo.find_by_library("lib-1") == []
|
||||
# 其他素材未受影响
|
||||
assert asset_repo.get(other_asset.id) is not None
|
||||
|
||||
def test_delete_nonexistent_library(self):
|
||||
"""删除不存在的素材库 → 404"""
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
proj_repo = StubProjectRepository({"proj-1": StubProject("proj-1", "user-001")})
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "素材库不存在" in response.json()["detail"]
|
||||
|
||||
def test_delete_library_access_denied(self):
|
||||
"""无权限用户删除素材库 → 403"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
# 项目属于 user-002,当前用户是 user-001
|
||||
proj_repo = StubProjectRepository({"proj-1": StubProject("proj-1", "user-002")})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
library = AssetLibrary(
|
||||
id="lib-1",
|
||||
project_id="proj-1",
|
||||
name="视频素材库",
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
lib_repo.create(library)
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo, user_id="user-001")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.json()["detail"]
|
||||
|
||||
# 库未被删除
|
||||
assert lib_repo.find_by_id("lib-1") is not None
|
||||
|
||||
def test_delete_library_project_not_found(self):
|
||||
"""素材库所属项目不存在 → 404"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
# 空的项目仓库,找不到项目
|
||||
proj_repo = StubProjectRepository({})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
library = AssetLibrary(
|
||||
id="lib-1",
|
||||
project_id="proj-missing",
|
||||
name="视频素材库",
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
lib_repo.create(library)
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 404
|
||||
@@ -105,19 +105,11 @@ class TestNormalizePlanConfig:
|
||||
|
||||
|
||||
class TestNormalizeTemplateConfig:
|
||||
def test_same_as_plan_config_plus_template_fields(self):
|
||||
"""template config 包含 plan config 的所有字段,外加 transition_enabled"""
|
||||
def test_same_as_plan_config(self):
|
||||
from packages.domain.config_schemas import normalize_plan_config, normalize_template_config
|
||||
|
||||
raw = {"title": {"text": "模板标题"}}
|
||||
plan_cfg = normalize_plan_config(raw)
|
||||
tpl_cfg = normalize_template_config(raw)
|
||||
# plan config 的字段在 template config 中应一致
|
||||
for key in plan_cfg:
|
||||
assert tpl_cfg[key] == plan_cfg[key]
|
||||
# template config 额外包含 transition_enabled
|
||||
assert "transition_enabled" in tpl_cfg
|
||||
assert tpl_cfg["transition_enabled"] is True
|
||||
assert normalize_template_config(raw) == normalize_plan_config(raw)
|
||||
|
||||
def test_none_returns_defaults(self):
|
||||
from packages.domain.config_schemas import DEFAULT_EDIT_TEMPLATE_CONFIG, normalize_template_config
|
||||
@@ -412,7 +404,7 @@ class TestAIRecommendEndpoint:
|
||||
json={"asset_ids": ["asset-1"]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "当前计划状态" in resp.json()["detail"] or "编辑计划" in resp.json()["detail"]
|
||||
assert "draft/editing" in resp.json()["detail"]
|
||||
|
||||
def test_ai_recommend_with_custom_params(self, ai_client):
|
||||
c, repo = ai_client
|
||||
|
||||
Executable → Regular
+491
-391
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user