Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia-bot 34336b2907 feat(ci): 新增发布与灰度部署脚本
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m12s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m51s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m25s
- release.sh: 一键发布脚本(打tag + 触发CI构建 + 灰度提示)
- gray_deploy.sh: 灰度发布脚本(canary容器 + nginx权重切流)
- rollback_gray.sh: 灰度回滚脚本(nginx回滚 + canary清理)
- 更新 rollback.sh 占位符为生产环境适配版

灰度范围:API + Web(Nginx upstream 权重)
Worker 暂不支持灰度(队列消费无法按比例切流)
2026-07-14 13:09:27 +08:00
525 changed files with 18411 additions and 79139 deletions
-1
View File
@@ -1 +0,0 @@
re-trigger
+1 -2
View File
@@ -1,2 +1 @@
CI trigger file - safe to delete # CI trigger Fri Jun 26 09:53:28 PM CST 2026
updated!
+14
View File
@@ -0,0 +1,14 @@
[flake8]
max-line-length = 120
exclude =
.git,
.cache,
__pycache__,
.venv,
venv,
node_modules,
alembic
per-file-ignores =
tests/integration/*:F821
tests/unit/*:F821
File diff suppressed because one or more lines are too long
-81
View File
@@ -1,81 +0,0 @@
name: CI Health Daily Report
on:
schedule:
- cron: '0 1 * * *' # UTC 01:00 = 北京时间 09:00
workflow_dispatch:
permissions:
contents: read
jobs:
ci-health-report:
name: CI健康度每日巡检
runs-on: saas
timeout-minutes: 10
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 CI health check and report
shell: sh
env:
GITEA_TOKEN: ${{ github.token }}
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
echo "=== CI健康度每日巡检 ==="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
python3 scripts/ci/ci_health_report.py --limit 30
EXIT_CODE=$?
echo ""
echo "巡检完成 (exit code: $EXIT_CODE)"
# 永远成功,不影响CI状态(通知失败不应该标红)
exit 0
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
name: CI Trigger Monitor
on:
schedule:
- cron: '*/5 * * * *' # 每5分钟检查一次
workflow_dispatch:
inputs:
stale_threshold:
description: 'CI未触发告警阈值(分钟)'
required: false
default: '5'
permissions:
contents: read
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v3
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Check CI trigger status for all open PRs
env:
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
STALE_THRESHOLD_MIN: ${{ inputs.stale_threshold || 5 }}
run: |
set +e
python3 scripts/ci_trigger_monitor.py
# 监控脚本永远不fail,避免告警风暴
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-79
View File
@@ -1,79 +0,0 @@
name: AI Code Review
on:
pull_request:
types:
- opened
- synchronize
- reopened
# 同一个 PR 只跑一个 review,新的取消旧的
concurrency:
group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
code-review:
name: AI Code Review
runs-on: ubuntu-latest
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Install dependencies
run: |
# 确保 python3-pip 可用(兼容不同基础镜像)
if ! python3 -m pip --version >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq python3-pip python3-venv >/dev/null 2>&1
fi
# 部分镜像 ensurepip 方式兜底
if ! python3 -m pip --version >/dev/null 2>&1; then
python3 -m ensurepip --upgrade 2>/dev/null || curl -sS https://bootstrap.pypa.io/get-pip.py | python3
fi
python3 -m pip install --upgrade pip
python3 -m pip install requests
- name: Run AI Code Review
env:
# Gitea 配置(自动从运行环境获取)
GITEA_API_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REPO_NAME: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
LLM_PROVIDER: "coze"
# 扣子模式配置(默认国内站 api.coze.cn
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
# 可选参数
MAX_DIFF_CHARS: "30000"
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 审查脚本异常不影响 CI 通过
continue-on-error: true
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-60
View File
@@ -106,18 +106,6 @@ jobs:
echo "======================================" echo "======================================"
exit $SMOKE_EXIT exit $SMOKE_EXIT
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 2. Staging API 集成测试 ───────────────────────────────────────── # ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests: staging-api-tests:
name: Staging API Integration Tests name: Staging API Integration Tests
@@ -255,18 +243,6 @@ jobs:
echo "report=FAIL" >> "${GITHUB_OUTPUT}" echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi fi
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 3. Staging 浏览器 E2E ────────────────────────────────────────── # ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e: staging-e2e:
name: Staging Browser E2E name: Staging Browser E2E
@@ -356,18 +332,6 @@ jobs:
echo "==========================================" echo "=========================================="
exit $EXIT_CODE exit $EXIT_CODE
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 4. 性能基线巡检 ──────────────────────────────────────────────── # ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check: performance-check:
name: Performance Baseline Check name: Performance Baseline Check
@@ -616,18 +580,6 @@ jobs:
exit 0 exit 0
fi fi
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 5. 每日巡检汇总报告 ──────────────────────────────────────────── # ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report: daily-report:
name: Daily Check Report name: Daily Check Report
@@ -704,15 +656,3 @@ jobs:
# 不 exit 1,因为我们用了 always(),保持 report job 成功, # 不 exit 1,因为我们用了 always(),保持 report job 成功,
# 但其他失败的 job 已经让整体流水线标记为失败 # 但其他失败的 job 已经让整体流水线标记为失败
fi fi
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-368
View File
@@ -1,368 +0,0 @@
name: PR Automation
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
permissions:
contents: read
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 20
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING review
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑 → 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
auto-merge:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 30
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / PR Build API Image (pull_request)"
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
"CI/CD Pipeline / PR Build Web Image (pull_request)"
)
echo "检查required门禁(与分支保护一致)"
fi
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 405连续计数器
MERGE_405_COUNT=0
MAX_405_RETRIES=10
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 180); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# 检查审批状态
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
echo " 审批: $APPROVAL_RESULT"
HAS_APPROVAL=false
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
HAS_APPROVAL=true
fi
# 全部满足 → 合并
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
echo
echo "CI全绿 + 审批通过,执行自动合并"
echo "等待60秒冷却,给Gitea内部状态同步时间..."
sleep 60
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
sleep 30
continue
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "等待超时(30分钟)"
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-207
View File
@@ -1,207 +0,0 @@
name: Preview Cleanup
on:
pull_request:
types:
- closed
branches:
- main
- develop
permissions:
contents: read
pull-requests: write
jobs:
cleanup-preview:
name: Cleanup Preview Environment
runs-on: runtime-builder
timeout-minutes: 10
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: Extract PR number
shell: sh
run: |
set -eu
# 优先从event payload中读取(兼容所有PR事件类型)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
fi
# fallback: 从GITHUB_REF中提取
if [ -z "${PR_NUMBER:-}" ]; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
# 再fallback: 兼容纯数字ref
if [ -z "${PR_NUMBER:-}" ] || ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
echo "WARNING: Could not extract PR number cleanly, using raw ref suffix"
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
fi
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "Preview dir: /var/www/preview/pr-${PR_NUMBER}"
- name: Install SSH client
shell: sh
run: |
set -eu
# 先检查是否已存在ssh
if command -v ssh >/dev/null 2>&1 && command -v ssh-keyscan >/dev/null 2>&1; then
echo "SSH client already available: $(ssh -V 2>&1)"
exit 0
fi
# 尝试多种包管理器安装
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client >/dev/null 2>&1
echo "openssh-client installed via apk"
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed via apt-get"
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via yum"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via dnf"
else
echo "ERROR: No package manager found and ssh not pre-installed"
which ssh 2>/dev/null || echo " ssh: not found"
which ssh-keyscan 2>/dev/null || echo " ssh-keyscan: not found"
exit 1
fi
- name: Remove preview directory from server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (builder key)"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (home key)"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
ls -la /root/.ssh/ 2>/dev/null || true
exit 1
fi
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 检查目录是否存在
DIR_EXISTS=$(ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi")
if [ "$DIR_EXISTS" = "yes" ]; then
echo "Removing preview directory: ${preview_dir}"
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"rm -rf ${preview_dir} && echo 'Preview directory removed successfully'"
echo "Cleanup completed: ${preview_dir}"
else
echo "Preview directory does not exist: ${preview_dir}, nothing to clean up"
fi
- name: Comment cleanup notice on PR
if: success()
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
# 从event payload读取PR号(最可靠)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
else
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
export PR_NUMBER
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py cleanup)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Cleanup comment posted"
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-296
View File
@@ -1,296 +0,0 @@
name: Preview Deploy
on:
pull_request:
types:
- opened
- synchronize
- reopened
branches:
- main
- develop
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - 预览环境补跑"
permissions:
contents: read
pull-requests: write
concurrency:
group: preview-deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
name: Deploy Preview Environment
runs-on: runtime-builder
timeout-minutes: 20
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: Record job start time
shell: sh
run: |
set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
- name: Extract PR number
shell: sh
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Build frontend
shell: sh
run: |
set -eu
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm ci"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci --include=dev; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci --include=dev
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
- name: Install SSH client and rsync
shell: sh
run: |
set -eu
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client rsync >/dev/null 2>&1
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients rsync >/dev/null 2>&1
else
echo "ERROR: No package manager found"
exit 1
fi
echo "openssh-client and rsync installed"
- name: Deploy preview to server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
preview_user="${PREVIEW_SSH_USER:-root}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (builder key)"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (home key)"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
ls -la /root/.ssh/ 2>/dev/null || true
exit 1
fi
# SSH密钥完整性自检
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
echo "ERROR: SSH密钥损坏(private key contents do not match public"
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
head -2 "$key_path"
exit 1
fi
echo "SSH key integrity check passed"
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 创建预览目录并上传文件
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'"
# 使用rsync上传dist目录内容
rsync -avz --delete -e "ssh -p ${preview_port} -i ${key_path} -o StrictHostKeyChecking=no" \
apps/web/dist/ \
"${preview_user}@${preview_host}:${preview_dir}/"
echo "Preview deployed to: ${preview_dir}"
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Comment preview link on PR
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
export PR_NUMBER PREVIEW_URL
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py deploy)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
EXISTING_COMMENT_ID=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
import sys, json
try:
for c in json.load(sys.stdin):
if '预览环境已部署' in c.get('body', ''):
print(c['id'])
break
except Exception:
pass
")
if [ -n "$EXISTING_COMMENT_ID" ]; then
curl -s -X PATCH \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \
> /dev/null
echo "Comment updated"
else
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Comment posted"
fi
- name: Job duration summary
if: always()
shell: sh
run: |
set +eu
if [ -n "$JOB_START_TIME" ]; then
END_TIME=$(date +%s)
DURATION=$((END_TIME - JOB_START_TIME))
MINS=$((DURATION / 60))
SECS=$((DURATION % 60))
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
echo "=== Job Duration: ${MINS}m${SECS}s ==="
else
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
echo "=== Job Duration: unknown ==="
fi
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-5
View File
@@ -49,8 +49,3 @@ build/
tracker_tasks.json tracker_tasks.json
frontend-v21-ui-prototype-final.html frontend-v21-ui-prototype-final.html
!.vscode/
!.vscode/settings.json
.vscode/extensions.json
.coverage
-29
View File
@@ -1,29 +0,0 @@
repos:
- repo: https://github.com/psf/black
rev: 26.5.1
hooks:
- id: black
language_version: python3.12
- repo: https://github.com/pycqa/isort
rev: 8.0.1
hooks:
- id: isort
args: ["--profile", "black"]
language_version: python3.12
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff
args: [--fix]
language_version: python3.12
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.11
hooks:
- id: prettier
name: prettier (frontend)
files: ^apps/web/.*\.(ts|tsx|js|jsx|css|scss|less|json|html|md|yaml|yml)$
additional_dependencies:
- prettier@3.4.2
-49
View File
@@ -1,49 +0,0 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"prettier.requireConfig": true,
"isort.args": ["--profile", "black"],
"python.linting.ruffEnabled": true,
"python.analysis.typeCheckingMode": "basic"
}
@@ -1,29 +0,0 @@
"""add result_count to edit_plans
Revision ID: 041_result_count
Revises: 040_playback_speed
Create Date: 2026-07-15 14:05:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "041_result_count"
down_revision = "040_playback_speed"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plans",
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("edit_plans", "result_count")
@@ -1,29 +0,0 @@
"""add storage_key to assets
Revision ID: 042_storage_key
Revises: 041_result_count
Create Date: 2026-07-17 18:10:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "042_storage_key"
down_revision = "041_result_count"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"assets",
sa.Column("storage_key", sa.String(500), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("assets", "storage_key")
@@ -1,33 +0,0 @@
"""add updated_at to generation_tasks
Revision ID: 043_updated_at_generation_tasks
Revises: 042_storage_key
Create Date: 2026-07-18 19:30:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "043_updated_at_generation_tasks"
down_revision = "042_storage_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"updated_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "updated_at")
@@ -1,34 +0,0 @@
"""add user_id to generated_videos
Revision ID: 044_user_id_generated_videos
Revises: 043_updated_at_generation_tasks
Create Date: 2026-07-19 08:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "044_user_id_generated_videos"
down_revision = "043_updated_at_generation_tasks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generated_videos",
sa.Column(
"user_id",
sa.String(36),
nullable=False,
server_default="",
index=True,
),
)
def downgrade() -> None:
op.drop_column("generated_videos", "user_id")
-36
View File
@@ -1,36 +0,0 @@
"""backfill user_id for generated_videos from generation_tasks
Revision ID: 045_backfill_user_id_generated_videos
Revises: 044_user_id_generated_videos
Create Date: 2026-07-19 10:50:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "045_backfill_user_id"
down_revision = "044_user_id_generated_videos"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 回填 generated_videos.user_id:通过 generation_task_id 关联 generation_tasks 表
# 取 generation_tasks.created_by_user_id 作为 user_id
# 回填不到的(无关联task的兜底记录)保持空字符串
op.execute("""
UPDATE generated_videos gv
SET user_id = gt.created_by_user_id
FROM generation_tasks gt
WHERE gv.generation_task_id = gt.id
AND gv.user_id = ''
AND gt.created_by_user_id != ''
""")
def downgrade() -> None:
# 降级不做处理(无法精确区分哪些是回填的)
pass
-33
View File
@@ -1,33 +0,0 @@
"""add video_title to generation_tasks
Revision ID: 046_add_video_title_to_generation_tasks
Revises: 045_backfill_user_id_generated_videos
Create Date: 2026-07-19 11:20:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "046_task_title"
down_revision = "045_backfill_user_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"video_title",
sa.String(255),
nullable=False,
server_default="",
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "video_title")
@@ -1,64 +0,0 @@
"""Phase 2 - 模板发布版本化:version字段 + 发布历史表
Revision ID: 047
Revises: 046
Create Date: 2026-07-20
Changes:
1. edit_templates 加 version 字段(INT,默认1,每次发布+1)
2. 新建 edit_template_versions 表存发布历史快照,支持回滚
"""
import sqlalchemy as sa
from alembic import op
revision = "047_template_versioning"
down_revision = "046_task_title"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# 1. edit_templates 加 version 字段
op.add_column(
"edit_templates",
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
)
# 2. 新建 edit_template_versions 发布历史表
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS edit_template_versions (
id VARCHAR(36) PRIMARY KEY,
template_id VARCHAR(32) NOT NULL,
version INTEGER NOT NULL,
name VARCHAR(200) NOT NULL DEFAULT '',
editing_mode VARCHAR(30) NOT NULL DEFAULT 'one_take',
config JSONB NOT NULL DEFAULT '{}',
clip_configs JSONB NOT NULL DEFAULT '[]',
change_note VARCHAR(500) NOT NULL DEFAULT '',
published_by VARCHAR(36) NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_edit_template_versions_template_id " "ON edit_template_versions(template_id)"
)
)
conn.execute(
sa.text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_edit_template_versions_template_version "
"ON edit_template_versions(template_id, version)"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS edit_template_versions"))
op.drop_column("edit_templates", "version")
@@ -1,37 +0,0 @@
"""Phase 3 - 清理 EditPlan 表冗余字段
Revision ID: 048
Revises: 047
Create Date: 2026-07-21
Changes:
1. 删除 edit_plans.result_count 字段(剪辑计划独立功能遗留,模板草稿不用,
生成结果数由 generation_tasks.result_count 承载)
"""
import sqlalchemy as sa
from alembic import op
revision = "048_cleanup_result_count"
down_revision = "047_template_versioning"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 删除 result_count 字段(剪辑计划独立功能遗留字段)
op.drop_column("edit_plans", "result_count")
def downgrade() -> None:
# 回滚:恢复 result_count 字段,默认值 0
op.add_column(
"edit_plans",
sa.Column(
"result_count",
sa.Integer,
nullable=False,
server_default="0",
),
)
@@ -1,97 +0,0 @@
"""#558 - 微信登录:手机号绑定字段 + 验证码表
Revision ID: 049
Revises: 048
Create Date: 2026-07-21
Changes:
1. users 表新增 phone_verified / binding_completed_at 字段(phone 字段已在 029 中添加)
2. users 表 phone 字段添加唯一索引(幂等)
3. 新建 verification_codes 表(统一管理邮箱+手机验证码)
"""
import sqlalchemy as sa
from alembic import context, op
revision = "049_wechat_login_phone"
down_revision = "048_cleanup_result_count"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
"""检查列是否已存在。离线模式下返回 False。"""
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"),
{"table": table, "column": column},
)
return result.first() is not None
def _index_exists(index_name: str) -> bool:
"""检查索引是否已存在。离线模式下返回 False。"""
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
{"index_name": index_name},
)
return result.first() is not None
def upgrade() -> None:
# 1. users 表新增手机号验证状态字段(幂等)
if not _column_exists("users", "phone_verified"):
op.add_column(
"users",
sa.Column(
"phone_verified",
sa.Boolean,
nullable=False,
server_default=sa.text("false"),
),
)
if not _column_exists("users", "binding_completed_at"):
op.add_column(
"users",
sa.Column("binding_completed_at", sa.DateTime, nullable=True),
)
# 2. phone 字段唯一索引(幂等 - 029 加了字段但没加索引)
if not _index_exists("ix_users_phone"):
op.create_index("ix_users_phone", "users", ["phone"], unique=True)
# 3. verification_codes 表
op.create_table(
"verification_codes",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("recipient", sa.String(255), nullable=False, index=True),
sa.Column("code", sa.String(10), nullable=False),
sa.Column("code_type", sa.String(32), nullable=False, index=True),
sa.Column("expires_at", sa.DateTime, nullable=False),
sa.Column("used_at", sa.DateTime, nullable=True),
sa.Column("attempts", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime, nullable=False),
sa.Index(
"ix_verification_recipient_type",
"recipient",
"code_type",
"created_at",
),
)
def downgrade() -> None:
op.drop_table("verification_codes")
if _index_exists("ix_users_phone"):
op.drop_index("ix_users_phone", table_name="users")
if _column_exists("users", "binding_completed_at"):
op.drop_column("users", "binding_completed_at")
if _column_exists("users", "phone_verified"):
op.drop_column("users", "phone_verified")
View File
+4 -4
View File
@@ -5,6 +5,7 @@ from app.api.routes.auth import router as auth_router
from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.duplication import router as duplication_router from app.api.routes.duplication import router as duplication_router
from app.api.routes.edit_plans import router as edit_plans_router
from app.api.routes.feature_flags import router as feature_flags_router from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router from app.api.routes.health import router as health_check_router
@@ -15,7 +16,6 @@ from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router from app.api.routes.task_center import router as task_center_router
from app.api.routes.templates import router as templates_router from app.api.routes.templates import router as templates_router
from app.api.routes.templates_editor import router as templates_editor_router
from app.api.routes.titles import router as titles_router from app.api.routes.titles import router as titles_router
from app.api.routes.tts import router as tts_router from app.api.routes.tts import router as tts_router
from app.api.routes.upload import router as upload_router from app.api.routes.upload import router as upload_router
@@ -120,9 +120,9 @@ api_router.include_router(
tags=["Template"], tags=["Template"],
) )
api_router.include_router( api_router.include_router(
templates_editor_router, edit_plans_router,
prefix="/templates/{template_id}/editor", prefix="/edit-plans",
tags=["TemplateEditor"], tags=["EditPlan"],
) )
api_router.include_router( api_router.include_router(
tts_router, tts_router,
-93
View File
@@ -1,6 +1,5 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。""" """路由层共享辅助函数 — 消除跨文件重复定义。"""
from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -47,95 +46,3 @@ def require_project_and_library(
libraries = asset_library_repository.find_by_project(project_id) libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries): if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
def auto_select_video_assets(
*,
project_id: str,
asset_library_repo: Any,
asset_repo: Any,
logger=None,
) -> list[str]:
"""从项目视频素材库自动选取 ready 状态的视频素材。
Args:
project_id: 项目 ID
asset_library_repo: 素材库仓储
asset_repo: 素材仓储
logger: 可选的 logger 实例,用于记录警告
Returns:
选中的素材 ID 列表,无可用素材时返回空列表
"""
if not project_id:
return []
# 找到项目的视频素材库
libs = asset_library_repo.find_by_project(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 not video_lib:
if logger:
logger.warning("自动选素材: 项目 %s 无视频素材库", project_id)
return []
# 从素材库中选取 ready 状态的视频素材
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
# 过滤横屏素材(只保留竖屏/正方形)
# 移动端短视频场景默认竖屏,横屏素材裁剪后画面不可用
# 注意:这只是选素材优化,渲染引擎本身支持任何分辨率的素材
filtered_videos = []
skipped_landscape = 0
for a in ready_videos:
width = a.width if hasattr(a, "width") and a.width else 0
height = a.height if hasattr(a, "height") and a.height else 0
if not width or not height:
# 从 metadata 兜底
if a.metadata and isinstance(a.metadata, dict):
width = int(a.metadata.get("width", 0) or 0)
height = int(a.metadata.get("height", 0) or 0)
if width and height and width > height:
skipped_landscape += 1
continue
filtered_videos.append(a)
if skipped_landscape and logger:
logger.warning("自动选素材: 跳过 %d 个横屏素材", skipped_landscape)
if not filtered_videos:
if logger:
logger.warning("自动选素材: 素材库 %s 无可用视频素材", video_lib.name)
return []
# 按创建时间降序(新素材在前)
filtered_videos.sort(key=lambda a: a.created_at, reverse=True)
return [a.id for a in filtered_videos]
def format_utc_datetime(dt: datetime | None) -> str:
"""将数据库读出的 UTC naive datetime 格式化为带时区的 ISO 8601 字符串。
数据库 DateTime 列不带时区信息,但存的是 UTC 时间。
直接 .isoformat() 输出无时区标识,前端会按本地时间解析,导致差 8 小时。
输出带 Z 后缀,前端 new Date() 自动转本地时间。
"""
if dt is None:
return ""
if isinstance(dt, str):
return dt
if dt.tzinfo is None:
return dt.isoformat() + "Z"
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+33 -98
View File
@@ -1,7 +1,7 @@
import logging import logging
from typing import Any, Optional from typing import Any, Optional
from app.api.routes._helpers import check_project_access, format_utc_datetime from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service from app.core.storage import get_storage_service
from app.dependencies import ( from app.dependencies import (
@@ -71,7 +71,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
status=item.status.value, status=item.status.value,
classification_status=item.classification_status.value, classification_status=item.classification_status.value,
quality_score=item.quality_score, quality_score=item.quality_score,
created_at=format_utc_datetime(item.created_at),
uploaded_by_user_id=item.uploaded_by_user_id, uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []), tag_ids=getattr(item, "tag_ids", []),
) )
@@ -95,12 +94,6 @@ def list_assets(
None, None,
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他", description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
), ),
status: Optional[str] = Query(
"default",
description="按状态筛选,逗号分隔多值;默认返回除deleted外的所有状态;传deleted查看回收站;传all返回所有状态",
),
page: Optional[int] = Query(None, ge=1, description="页码,从1开始;与 page_size 配对使用,优先于 skip/limit"),
page_size: Optional[int] = Query(None, ge=1, le=500, description="每页数量;与 page 配对使用"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -110,26 +103,6 @@ def list_assets(
) -> ListAssetsResponse: ) -> ListAssetsResponse:
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
# ── 分页:page/page_size 优先于 skip/limit
if page is not None and page_size is not None:
skip = (page - 1) * page_size
limit = page_size
# ── 解析 status 过滤
status_list: list[str] | None
if status and status.lower() == "all":
status_list = None # None = 不过滤,返回所有状态
elif status and status.lower() == "deleted":
status_list = ["deleted"] # 仅查回收站
elif status and status.lower() == "default":
status_list = ["ready", "uploading", "processing", "error"] # 默认排除deleted
elif status:
status_list = [s.strip() for s in status.split(",") if s.strip()]
if not status_list:
status_list = ["ready", "uploading", "processing", "error"]
else:
status_list = ["ready", "uploading", "processing", "error"]
# kind → file_type 映射(voice 对应 audio # kind → file_type 映射(voice 对应 audio
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"} kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
@@ -198,13 +171,11 @@ def list_assets(
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
check_project_access(library.project_id, user_id, project_repository) check_project_access(library.project_id, user_id, project_repository)
if ft: if ft:
items = asset_repository.find_by_library_and_file_type( items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
library_id, ft, skip=skip, limit=limit, status=status_list total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
)
total = asset_repository.count_by_library_and_file_type(library_id, ft, status=status_list)
else: else:
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit, status=status_list) items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id, status=status_list) total = asset_repository.count_by_project(library.project_id)
return ListAssetsResponse( return ListAssetsResponse(
items=[_to_asset_response(item) for item in items], items=[_to_asset_response(item) for item in items],
total=total, total=total,
@@ -216,14 +187,14 @@ def list_assets(
if project_id: if project_id:
check_project_access(project_id, user_id, project_repository) check_project_access(project_id, user_id, project_repository)
if ft: if ft:
items = asset_repository.find_by_project_and_file_type( # 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
project_id, ft, skip=skip, limit=limit, status=status_list all_items = asset_repository.find_by_project(project_id)
) items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
total = asset_repository.count_by_project_and_file_type(project_id, ft, status=status_list) total = len(items)
paged = items paged = items[skip : skip + limit]
else: else:
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit, status=status_list) items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(project_id, status=status_list) total = asset_repository.count_by_project(project_id)
paged = items paged = items
return ListAssetsResponse( return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged], items=[_to_asset_response(item) for item in paged],
@@ -243,43 +214,22 @@ def list_assets(
if not project_ids: if not project_ids:
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
if ft: total = asset_repository.count_by_project_ids(project_ids)
# 有 kind 过滤:逐项目查 file_type凑够一页 # 跨项目分页:逐项目累积直到凑够一页
total = 0 paged_items: list = []
paged_items: list = [] offset = skip
offset = skip remaining = limit
remaining = limit for pid in project_ids:
for pid in project_ids: proj_total = asset_repository.count_by_project(pid)
proj_total = asset_repository.count_by_project_and_file_type(pid, ft, status=status_list) if offset >= proj_total:
total += proj_total offset -= proj_total
if offset >= proj_total: continue
offset -= proj_total proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
continue paged_items.extend(proj_items)
proj_items = asset_repository.find_by_project_and_file_type( remaining -= len(proj_items)
pid, ft, skip=offset, limit=remaining, status=status_list offset = 0
) if remaining <= 0:
paged_items.extend(proj_items) break
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
else:
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
# 跨项目分页:逐项目累积直到凑够一页
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project(pid, status=status_list)
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
paged_items.extend(proj_items)
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
return ListAssetsResponse( return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged_items], items=[_to_asset_response(item) for item in paged_items],
@@ -295,21 +245,12 @@ def list_assets(
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
check_project_access(library.project_id, user_id, project_repository) check_project_access(library.project_id, user_id, project_repository)
if kind: if kind:
all_items = asset_repository.find_by_library_and_file_type( all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
library_id, kind_to_file_type[kind], status=status_list
)
else: else:
all_items = asset_repository.find_by_library(library_id, status=status_list) all_items = asset_repository.find_by_library(library_id)
elif project_id: elif project_id:
check_project_access(project_id, user_id, project_repository) check_project_access(project_id, user_id, project_repository)
if kind: all_items = asset_repository.find_by_project(project_id)
ft = kind_to_file_type.get(kind)
if ft:
all_items = asset_repository.find_by_project_and_file_type(project_id, ft, status=status_list)
else:
all_items = asset_repository.find_by_project(project_id, status=status_list)
else:
all_items = asset_repository.find_by_project(project_id, status=status_list)
else: else:
try: try:
projects = project_repository.find_accessible_projects(user_id) projects = project_repository.find_accessible_projects(user_id)
@@ -318,18 +259,12 @@ def list_assets(
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
all_items = [] all_items = []
for proj in projects: for proj in projects:
if kind and kind_to_file_type.get(kind): all_items.extend(asset_repository.find_by_project(proj.id))
all_items.extend(
asset_repository.find_by_project_and_file_type(proj.id, kind_to_file_type[kind], status=status_list)
)
else:
all_items.extend(asset_repository.find_by_project(proj.id, status=status_list))
# 应用 kind 过滤(如果有)+ keyword/gender/style # 应用 kind 过滤(如果有)+ keyword/gender/style
if kind: if kind:
ft = kind_to_file_type.get(kind) ft = kind_to_file_type.get(kind)
if ft: all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
all_items = [i for i in all_items if i.file_type == ft]
filtered = _apply_memory_filters(all_items) filtered = _apply_memory_filters(all_items)
total = len(filtered) total = len(filtered)
paged = filtered[skip : skip + limit] paged = filtered[skip : skip + limit]
-206
View File
@@ -81,9 +81,6 @@ class CurrentUserResponse(BaseModel):
username: str username: str
display_name: str display_name: str
email_verified: bool email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
class PasswordResetRequestModel(BaseModel): class PasswordResetRequestModel(BaseModel):
@@ -262,16 +259,12 @@ async def get_current_user_info(
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> CurrentUserResponse: ) -> CurrentUserResponse:
user = authenticated_user.user user = authenticated_user.user
binding_complete = user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
return CurrentUserResponse( return CurrentUserResponse(
user_id=user.id, user_id=user.id,
email=user.email, email=user.email,
username=user.username, username=user.username,
display_name=user.display_name, display_name=user.display_name,
email_verified=user.email_verified, email_verified=user.email_verified,
phone=user.phone or "",
phone_verified=user.phone_verified,
binding_complete=binding_complete,
) )
@@ -387,202 +380,3 @@ async def wechat_sync(
raise HTTPException(status_code=400, detail=error) raise HTTPException(status_code=400, detail=error)
return WechatSyncResponse(**response.to_dict()) return WechatSyncResponse(**response.to_dict())
# ==================== 微信网页登录(OAuth ====================
class WechatAuthUrlResponse(BaseModel):
auth_url: str
state: str
class WechatCallbackRequest(BaseModel):
code: str
state: str = ""
class WechatLoginResponse(BaseModel):
access_token: str
refresh_token: str
user_id: str
display_name: str
avatar_url: str = ""
is_new_user: bool
binding_complete: bool
expires_in: int
@router.get("/wechat/url", response_model=WechatAuthUrlResponse)
async def get_wechat_auth_url() -> WechatAuthUrlResponse:
"""获取微信扫码登录授权链接"""
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
oauth_service = get_wechat_oauth_service()
auth_url, state = oauth_service.generate_auth_url()
return WechatAuthUrlResponse(auth_url=auth_url, state=state)
@router.post("/wechat/callback", response_model=WechatLoginResponse)
async def wechat_callback(
request: WechatCallbackRequest,
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatLoginResponse:
"""微信登录回调处理"""
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
# 1. 用 code 换微信用户信息
oauth_service = get_wechat_oauth_service()
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
if err:
raise HTTPException(status_code=400, detail=err)
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
use_case = WechatSyncUseCase(user_repository=user_repository)
sync_request = SyncRequest(
openid=wechat_user.openid,
unionid=wechat_user.unionid,
nickname=wechat_user.nickname,
avatar_url=wechat_user.avatar_url,
source="web",
)
response, err = use_case.execute(sync_request)
if err:
raise HTTPException(status_code=400, detail=err)
# 3. 判断绑定状态
user = user_repository.find_by_id(response.user_id)
binding_complete = False
if user:
binding_complete = (
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
return WechatLoginResponse(
access_token=response.access_token,
refresh_token=response.refresh_token,
user_id=response.user_id,
display_name=response.nickname,
avatar_url=response.avatar_url or wechat_user.avatar_url,
is_new_user=response.is_new_user,
binding_complete=binding_complete,
expires_in=response.expires_in,
)
# ==================== 验证码 & 绑定 ====================
class SendVerificationCodeRequest(BaseModel):
target: str # phone / email
value: str
purpose: str # bind / login / reset_password
class SendVerificationCodeResponse(BaseModel):
expires_in: int
resend_after: int
class BindContactRequest(BaseModel):
phone: str = ""
phone_code: str = ""
email: str = ""
email_code: str = ""
class BindContactResponse(BaseModel):
success: bool
user: dict
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
async def send_verification_code(
request: SendVerificationCodeRequest,
) -> SendVerificationCodeResponse:
"""发送验证码(手机或邮箱)"""
from app.dependencies import get_db_session
from packages.adapters.sms.sms_service import get_sms_service
from packages.adapters.smtp import get_email_service
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
SQLAlchemyVerificationCodeRepository,
)
from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest
from packages.application.auth.bind_contact_use_case import (
SendVerificationCodeUseCase,
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db_session())
repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=repo)
sms_service = get_sms_service()
email_service = get_email_service()
use_case = SendVerificationCodeUseCase(
verification_code_service=vc_service,
sms_service=sms_service,
email_service=email_service,
)
uc_request = UseCaseRequest(
target=request.target,
value=request.value,
purpose=request.purpose,
)
response, err = use_case.execute(uc_request)
if err:
raise HTTPException(status_code=400, detail=err)
return SendVerificationCodeResponse(
expires_in=response.expires_in,
resend_after=response.resend_after,
)
@router.post("/bind-contact", response_model=BindContactResponse)
async def bind_contact(
request: BindContactRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> BindContactResponse:
"""绑定手机号和/或邮箱(需登录态)"""
from app.dependencies import get_db_session
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
SQLAlchemyVerificationCodeRepository,
)
from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest
from packages.application.auth.bind_contact_use_case import (
BindContactUseCase,
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db_session())
vc_repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=vc_repo)
use_case = BindContactUseCase(
user_repository=user_repository,
verification_code_service=vc_service,
)
uc_request = UseCaseRequest(
user_id=current_user.user.id,
phone=request.phone,
phone_code=request.phone_code,
email=request.email,
email_code=request.email_code,
)
response, err = use_case.execute(uc_request)
if err:
raise HTTPException(status_code=400, detail=err)
return BindContactResponse(success=True, user=response.to_dict()["user"])
# ==================== 当前用户信息扩展 ====================
# 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充)
# 通过给 get_current_user_info 返回值补充字段实现
+2 -4
View File
@@ -239,9 +239,7 @@ def get_duplication_detail(
return _to_detail_response(record) return _to_detail_response(record)
@router.delete( @router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_duplication_record( def delete_duplication_record(
record_id: str, record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -289,7 +287,7 @@ def retry_duplication(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e), detail=str(e),
) from e )
if updated is None: if updated is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+462
View File
@@ -0,0 +1,462 @@
"""剪辑计划管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditPlan:
- GET /api/v1/edit-plans 列表(分页 + 状态/模板筛选)
- GET /api/v1/edit-plans/{id} 详情
- POST /api/v1/edit-plans 创建
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
- DELETE /api/v1/edit-plans/{id} 删除
拆分模块(各自独立 router,由本文件 include_router 聚合):
- edit_plans_generation.py 生成相关(generate / generation-status / generations
- edit_plans_ai.py AI 推荐 & 封面(ai-recommend / generate-cover
- edit_plans_timeline.py 时间线 & 模板生成(timeline / generate-from-template
业务逻辑委托给 EditPlanService 服务层。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditPlanCreateRequest(BaseModel):
"""创建剪辑计划请求体"""
template_id: str = Field(..., min_length=1, max_length=32, description="关联模板 ID")
name: str = Field(..., min_length=1, max_length=200, description="计划名称")
config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)")
total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)")
project_id: str = Field(default="", description="所属项目 ID")
class EditPlanUpdateRequest(BaseModel):
"""更新剪辑计划请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="计划名称")
config: Optional[dict[str, Any]] = Field(default=None, description="计划配置 (JSON)")
total_duration: Optional[float] = Field(default=None, ge=0.0, description="总时长 (秒)")
status: Optional[str] = Field(
default=None,
description="目标状态 (通过状态机流转): editing / rendering / completed / failed / draft",
)
class EditPlanResponse(BaseModel):
"""剪辑计划响应体"""
id: str
template_id: str
name: str
status: str
total_duration: float
project_id: str = ""
created_by_user_id: str = ""
config: dict[str, Any]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditPlanListResponse(BaseModel):
"""剪辑计划列表响应体"""
items: List[EditPlanResponse]
total: int
page: int
page_size: int
class ClipStatusItem(BaseModel):
"""片段生成状态"""
clip_id: str
clip_type: str
order: int
status: str
asset_id: str
text_content: str
duration: float
class EditPlanGenerationStatusResponse(BaseModel):
"""剪辑计划生成进度响应体"""
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
clips: List[ClipStatusItem]
class EditPlanGenerateResponse(BaseModel):
"""剪辑计划触发生成响应体"""
plan_id: str
plan_status: str
generation_task_id: str
clip_count: int
class EditPlanGenerationsResponse(BaseModel):
"""剪辑计划关联的生成记录列表响应体"""
items: List[GenerationTaskResponse]
total: int
# ── AI 推荐片段方案 Schemas(任务 3.09) ──────────────────────────────────────
class AIRecommendRequest(BaseModel):
"""AI 推荐片段方案请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
class AIRecommendClipItem(BaseModel):
"""AI 推荐的单个片段"""
clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro")
order: int = Field(..., ge=0, description="片段顺序")
text_content: str = Field(default="", description="文字内容")
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
transition_effect: str = Field(default="cut", description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
asset_id: str = Field(default="", description="关联素材 ID")
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
class AIRecommendResponse(BaseModel):
"""AI 推荐片段方案响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
config: dict[str, Any] = Field(..., description="推荐的 plan configcover/title/subtitle/bgm")
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
# ── AI 封面生成 Schemas(任务 3.09) ─────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── 基于模板生成剪辑计划 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
transition_duration: float
playback_speed: float = 1.0
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 ───────────────────────────────────────────────────────────────────
def _to_response(p: EditPlan) -> EditPlanResponse:
return EditPlanResponse(
id=p.id,
template_id=p.template_id,
name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration,
project_id=p.project_id or "",
created_by_user_id=p.created_by_user_id or "",
config=p.config,
created_at=p.created_at,
updated_at=p.updated_at,
)
# ── CRUD Routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanListResponse)
def list_plans(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"),
project_id: Optional[str] = Query(default=None, description="按项目 ID 筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: draft / editing / rendering / completed / failed",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanListResponse:
"""获取剪辑计划列表(支持分页、按模板/状态/项目筛选)"""
svc = EditPlanService(db)
# 空串 project_id 视为未传(避免 DB 中匹配到空串记录)
if project_id is not None and not project_id.strip():
project_id = None
# 解析状态筛选
status_enum: Optional[EditPlanStatus] = None
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
)
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id:
check_project_access(project_id, current_user.user.id, project_repository)
skip = (page - 1) * page_size
plans = svc.list_plans(
template_id=template_id,
project_id=project_id,
status=status_enum,
skip=skip,
limit=page_size,
)
total = svc.count_plans(
template_id=template_id,
project_id=project_id,
status=status_enum,
)
return EditPlanListResponse(
items=[_to_response(p) for p in plans],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{plan_id}", response_model=EditPlanResponse)
def get_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""获取单个剪辑计划详情"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
return _to_response(plan)
@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
def create_plan(
body: EditPlanCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""创建剪辑计划"""
# 空串 project_id 统一为 ""
project_id = (body.project_id or "").strip()
# 项目鉴权
if project_id:
check_project_access(project_id, current_user.user.id, project_repository)
svc = EditPlanService(db)
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_plan_config(body.config)
try:
created = svc.create_plan(
template_id=body.template_id,
name=body.name,
config=normalized_config,
total_duration=body.total_duration,
project_id=project_id,
created_by_user_id=current_user.user.id,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
created.name,
current_user.user.id,
)
return _to_response(created)
@router.put("/{plan_id}", response_model=EditPlanResponse)
def update_plan(
plan_id: str,
body: EditPlanUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""更新剪辑计划(支持状态机流转)"""
svc = EditPlanService(db)
# 项目鉴权
existing = svc.get_plan(plan_id)
if existing is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if existing.project_id:
check_project_access(existing.project_id, current_user.user.id, project_repository)
# 基础字段更新
try:
if body.name is not None or body.config is not None or body.total_duration is not None:
# 标准化 config(如果提供了)
config_to_update = normalize_plan_config(body.config) if body.config is not None else None
svc.update_plan(
plan_id,
name=body.name,
config=config_to_update,
total_duration=body.total_duration,
)
# 状态机流转
if body.status is not None:
try:
target_status = EditPlanStatus(body.status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态",
)
svc.transition_status(plan_id, target_status)
except ValueError as exc:
err_msg = str(exc)
if "不存在" in err_msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
)
# 返回最新状态
result = svc.get_plan_or_raise(plan_id)
logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id)
return _to_response(result)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除剪辑计划"""
svc = EditPlanService(db)
# 项目鉴权
existing = svc.get_plan(plan_id)
if existing and existing.project_id:
check_project_access(existing.project_id, current_user.user.id, project_repository)
deleted = svc.delete_plan(plan_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
logger.info(
"删除剪辑计划: id=%s by user=%s",
plan_id,
current_user.user.id,
)
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
from .edit_plans_ai import router as ai_router
from .edit_plans_generation import router as generation_router
from .edit_plans_timeline import router as timeline_router
router.include_router(generation_router)
router.include_router(ai_router)
router.include_router(timeline_router)
+199
View File
@@ -0,0 +1,199 @@
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/ai-recommend AI 推荐片段方案
- POST /{plan_id}/generate-cover AI 生成封面
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
AIRecommendClipItem,
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/{plan_id}/ai-recommend",
response_model=AIRecommendResponse,
)
def ai_recommend_clips(
plan_id: str,
body: AIRecommendRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> AIRecommendResponse:
"""AI 推荐片段方案
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
流程:
1. 验证计划存在且状态为 draft/editing
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
3. 清除计划现有片段,按推荐方案重新创建
4. 更新计划 configcover/title/subtitle/bgm)和 total_duration
5. 返回推荐方案详情
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
)
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id=plan_id,
template_id=plan.template_id,
asset_ids=body.asset_ids,
editing_mode=body.editing_mode,
target_duration=body.target_duration,
)
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
try:
svc.delete_all_clips(plan_id)
for clip_data in result["clips"]:
svc.create_clip(
plan_id=plan_id,
clip_type=clip_data["clip_type"],
order=clip_data["order"],
text_content=clip_data.get("text_content", ""),
duration=clip_data["duration"],
transition_effect=clip_data.get("transition_effect", "cut"),
asset_id=clip_data.get("asset_id", ""),
start_time=clip_data.get("start_time", 0.0),
config=clip_data.get("config", {}),
)
normalized_config = normalize_plan_config(result.get("config", {}))
svc.update_plan(
plan_id,
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
except Exception as rollback_err:
logger.error(
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
plan_id,
rollback_err,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
)
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
plan_id,
len(result["clips"]),
result["total_duration"],
current_user.user.id,
)
return AIRecommendResponse(
plan_id=plan_id,
clips=[
AIRecommendClipItem(
clip_type=c["clip_type"],
order=c["order"],
text_content=c.get("text_content", ""),
duration=c["duration"],
transition_effect=c.get("transition_effect", "cut"),
asset_id=c.get("asset_id", ""),
start_time=c.get("start_time", 0.0),
config=c.get("config", {}),
)
for c in result["clips"]
],
config=normalized_config,
total_duration=result["total_duration"],
confidence=result["confidence"],
)
@router.post(
"/{plan_id}/generate-cover",
response_model=GenerateCoverResponse,
)
def generate_cover(
plan_id: str,
body: GenerateCoverRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateCoverResponse:
"""AI 生成封面
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
current_config = dict(plan.config)
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"AI 封面生成: plan_id=%s type=%s by user=%s",
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(
plan_id=plan_id,
cover=cover_data,
)
@@ -0,0 +1,383 @@
"""剪辑计划生成相关 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/generate 触发剪辑渲染生成
- GET /{plan_id}/generation-status 查询生成进度
- GET /{plan_id}/generations 查询关联的生成记录
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
router = APIRouter()
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
def _auto_fallback_assign_assets(
svc: EditPlanService,
plan_id: str,
plan_check,
) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = []
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
) -> None:
"""自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取"""
if not clips_without_asset:
return
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if material_mode != "auto" or not plan_check.project_id:
return
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
def generate_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
前置条件:计划状态必须为 editing,且至少有一个片段。
流程:
1. 验证计划状态为 editing
2. 将 pending 片段标记为 ready
3. 创建 GenerationTask
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
svc = EditPlanService(db)
plan_check = svc.get_plan(plan_id)
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# 自动兜底流程
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
# 核心生成流程
try:
clip_count = svc.mark_clips_ready(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
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,
)
)
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
@router.get(
"/{plan_id}/generation-status",
response_model=EditPlanGenerationStatusResponse,
)
def get_generation_status(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度"""
svc = EditPlanService(db)
try:
gen_status = svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
plan = gen_status["plan"]
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"],
clips=clip_items,
)
@router.get(
"/{plan_id}/generations",
response_model=EditPlanGenerationsResponse,
)
def list_plan_generations(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationsResponse:
"""查询剪辑计划关联的所有生成记录"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from app.schemas.generation_task import GenerationTaskResponse
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
+221
View File
@@ -0,0 +1,221 @@
"""剪辑计划时间线 & 模板生成 API 端点。
从 edit_plans.py 拆分,包含:
- GET /{plan_id}/timeline 时间线场景数据
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
"""
from __future__ import annotations
import logging
from typing import Any, List
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
_PlanClipItem,
_to_response,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Timeline Schemas ──────────────────────────────────────────────────────────
class TimelineSceneResponse(BaseModel):
"""时间线场景"""
scene: str = Field(..., description="场景描述")
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
duration: float = Field(..., ge=0, description="时长(秒)")
color: str = Field(..., description="展示颜色")
clip_id: str = Field(default="", description="关联的片段 ID")
clip_type: str = Field(default="", description="片段类型")
class TimelineResponse(BaseModel):
"""时间线响应"""
plan_id: str
total_duration: float
scenes: List[TimelineSceneResponse]
# clip_type → 颜色映射
_CLIP_TYPE_COLORS = {
"intro": "#6366f1",
"title": "#6366f1",
"product": "#818cf8",
"showcase": "#10b981",
"scene": "#10b981",
"subtitle": "#f59e0b",
"text": "#f59e0b",
"cta": "#ef4444",
"outro": "#ef4444",
"voiceover": "#8b5cf6",
"transition": "#64748b",
}
_DEFAULT_COLOR = "#6366f1"
def _format_time(seconds: float) -> str:
"""将秒数格式化为 M:SS"""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m}:{s:02d}"
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
"""根据 clip_type 和 text_content 生成场景描述"""
type_labels = {
"intro": "开场",
"title": "标题",
"product": "产品展示",
"showcase": "场景展示",
"scene": "场景",
"subtitle": "字幕",
"text": "文字",
"cta": "结尾 CTA",
"outro": "结尾",
"voiceover": "配音",
"transition": "转场",
}
label = type_labels.get(clip_type, clip_type or "片段")
if text_content:
short = text_content[:20].strip()
if short:
return f"{label} - {short}"
return label
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get(
"/{plan_id}/timeline",
response_model=TimelineResponse,
)
def get_plan_timeline(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> TimelineResponse:
"""获取剪辑计划的时间线场景数据"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
clips.sort(key=lambda c: c.order)
scenes: List[TimelineSceneResponse] = []
current_time = 0.0
for clip in clips:
start = current_time
end = start + clip.duration
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
scenes.append(
TimelineSceneResponse(
scene=scene_label,
time=f"{_format_time(start)} - {_format_time(end)}",
duration=clip.duration,
color=color,
clip_id=clip.id,
clip_type=clip.clip_type,
)
)
current_time = end
total_duration = sum(s.duration for s in scenes) or plan.total_duration
return TimelineResponse(
plan_id=plan_id,
total_duration=total_duration,
scenes=scenes,
)
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
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:
"""基于模板 + 素材自动生成剪辑计划"""
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)
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,
transition_duration=c.transition_duration,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
+9 -7
View File
@@ -32,7 +32,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"]) router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
# 允许管理的 flag 白名单(防止误操作其他系统 flag) # 允许管理的 flag 白名单(防止误操作其他系统 flag)
ALLOWED_FLAGS: set[str] = set() ALLOWED_FLAGS = {
"render_engine",
}
def _get_feature_flag_store() -> RedisFeatureFlagStore: def _get_feature_flag_store() -> RedisFeatureFlagStore:
@@ -103,7 +105,7 @@ async def list_feature_flags(
return sorted(result, key=lambda x: x.name) return sorted(result, key=lambda x: x.name)
except Exception as exc: except Exception as exc:
logger.error("Failed to list feature flags: %s", exc) logger.error("Failed to list feature flags: %s", exc)
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
@router.get("/{name}", response_model=FeatureFlagResponse) @router.get("/{name}", response_model=FeatureFlagResponse)
@@ -118,7 +120,7 @@ async def get_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to get feature flag %s: %s", name, exc) logger.error("Failed to get feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse) @router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
@@ -134,7 +136,7 @@ async def check_feature_flag(
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier) return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as exc: except Exception as exc:
logger.error("Failed to check feature flag %s: %s", name, exc) logger.error("Failed to check feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
@router.put("/{name}", response_model=FeatureFlagResponse) @router.put("/{name}", response_model=FeatureFlagResponse)
@@ -168,7 +170,7 @@ async def update_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to update feature flag %s: %s", name, exc) logger.error("Failed to update feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) @router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@@ -176,7 +178,7 @@ async def delete_feature_flag(
name: str, name: str,
_: bool = Depends(_verify_internal_api_key), _: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store), store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
): ) :
"""删除 Feature Flag。 """删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。 只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -189,4 +191,4 @@ async def delete_feature_flag(
pass pass
except Exception as exc: except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc) logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
+5 -59
View File
@@ -43,7 +43,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse( return GenerationTaskResponse(
id=task.id, id=task.id,
@@ -58,7 +57,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
source_edit_plan_id=task.source_edit_plan_id or "", source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""), asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""), batch_id=getattr(task, "batch_id", ""),
video_title=getattr(task, "video_title", ""),
logs=getattr(task, "logs", "[]"), logs=getattr(task, "logs", "[]"),
status=task.status, status=task.status,
progress=task.progress, progress=task.progress,
@@ -269,7 +267,6 @@ def create_generation_task(
source_edit_plan_id=request.source_edit_plan_id, source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode, asset_select_mode=request.asset_select_mode,
batch_id=batch_id, batch_id=batch_id,
video_title=request.video_title,
auto_retry_enabled=request.auto_retry_enabled, auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max, auto_retry_max=request.auto_retry_max,
) )
@@ -285,28 +282,28 @@ def create_generation_task(
created_tasks.append(task) created_tasks.append(task)
else: else:
failed_tasks.append(task) failed_tasks.append(task)
except UserPendingLimitExceeded as _e: except UserPendingLimitExceeded:
# 兜底:如果预检查后又并发提交了,在这里也拦住 # 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail="您的待处理任务过多,请等待完成后再提交", detail="您的待处理任务过多,请等待完成后再提交",
) from _e )
break break
except GlobalQueueFull as _e: except GlobalQueueFull:
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) from _e )
break break
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True) logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks] items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items)) return BatchGenerationTaskResponse(items=items, total=len(items))
@@ -407,7 +404,6 @@ def retry_generation_task(
created_by_user_id=user_id, created_by_user_id=user_id,
source_edit_plan_id=task.source_edit_plan_id or "", source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""), asset_select_mode=getattr(task, "asset_select_mode", ""),
video_title=getattr(task, "video_title", ""),
) )
) )
try: try:
@@ -430,53 +426,3 @@ def retry_generation_task(
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) from None ) from None
return _to_generation_task_response(retried) return _to_generation_task_response(retried)
@router.post("/tasks/{task_id}/cancel", response_model=GenerationTaskResponse)
def cancel_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
"""取消生成任务。
仅 pending / running 状态的任务可取消;取消后状态变为 cancelled。
对于已在运行的 Celery 任务,标记为 cancelled 后,worker 在下次检查点会中止执行。
"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
# 权限校验
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
# 终态不可取消
if status_val in ("completed", "failed", "cancelled"):
raise HTTPException(
status_code=409,
detail=f"Cannot cancel task in {status_val} status",
)
# 执行取消
try:
task.mark_cancelled()
task.append_log(
stage="cancelled",
message="用户主动取消任务",
level="INFO",
cancelled_by=authenticated_user.user.id,
)
generation_task_repository.update(task)
logger.info(
"生成任务已取消: task_id=%s user_id=%s previous_status=%s",
task_id,
authenticated_user.user.id,
status_val,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return _to_generation_task_response(task)
+3 -3
View File
@@ -81,11 +81,11 @@ def delete_project(
use_case = DeleteProjectUseCase(project_repository) use_case = DeleteProjectUseCase(project_repository)
try: try:
deleted = use_case.execute(project_id, authenticated_user.user.id) deleted = use_case.execute(project_id, authenticated_user.user.id)
except PermissionError as _e: except PermissionError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only the project owner can delete this project", detail="Only the project owner can delete this project",
) from _e )
if not deleted: if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return # type: ignore[return-value] return
+1 -6
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List from typing import List
@@ -21,8 +20,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
from packages.ports.user_repository import UserRepository from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -257,9 +254,7 @@ async def payment_callback(
return {"success": True, "message": "支付成功", "record_id": record_id} return {"success": True, "message": "支付成功", "record_id": record_id}
except Exception as e: except Exception as e:
session.rollback() session.rollback()
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}") raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
# 不返回原始异常信息,避免泄漏内部实现细节
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
finally: finally:
session.close() session.close()
+12 -11
View File
@@ -1,7 +1,6 @@
import logging import logging
from typing import Any from typing import Any
from app.api.routes._helpers import format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.task_enqueue import ( from app.core.task_enqueue import (
@@ -25,6 +24,8 @@ from app.schemas.task_center import (
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import ( from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
RetryGenerationTaskUseCase, RetryGenerationTaskUseCase,
SubmitIngestJobCommand, SubmitIngestJobCommand,
SubmitIngestJobUseCase, SubmitIngestJobUseCase,
@@ -100,8 +101,8 @@ def _generation_task_to_user_response(task) -> UserTaskResponse:
retryable=_status_value(task.status) == "failed", retryable=_status_value(task.status) == "failed",
retry_count=task.retry_count or 0, retry_count=task.retry_count or 0,
source_id=task.id, source_id=task.id,
created_at=format_utc_datetime(task.created_at), created_at=task.created_at,
updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at), updated_at=task.completed_at or task.started_at or task.created_at,
) )
@@ -120,8 +121,8 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
retry_count=task.retry_count or 0, retry_count=task.retry_count or 0,
source_id=task.id, source_id=task.id,
template_id=task.template_id, template_id=task.template_id,
created_at=format_utc_datetime(task.created_at), created_at=task.created_at,
updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at), updated_at=task.completed_at or task.started_at or task.created_at,
) )
@@ -285,8 +286,8 @@ def list_project_tasks(
user_message=_humanize_task_error(job.error_message), user_message=_humanize_task_error(job.error_message),
retryable=_status_value(job.status) == "failed", retryable=_status_value(job.status) == "failed",
source_id=job.id, source_id=job.id,
created_at=format_utc_datetime(job.created_at), created_at=job.created_at,
updated_at=format_utc_datetime(job.updated_at), updated_at=job.updated_at,
) )
) )
@@ -367,9 +368,9 @@ def retry_project_task(
raise HTTPException(status_code=404, detail="Ingest job not found") raise HTTPException(status_code=404, detail="Ingest job not found")
if _status_value(job.status) != "failed": if _status_value(job.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried") raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = SubmitIngestJobUseCase(ingest_job_repository) # type: ignore[assignment] use_case = SubmitIngestJobUseCase(ingest_job_repository)
retried = use_case.execute( retried = use_case.execute(
SubmitIngestJobCommand( # type: ignore[arg-type] SubmitIngestJobCommand(
project_id=job.project_id, project_id=job.project_id,
library_id=job.library_id, library_id=job.library_id,
storage_key=job.storage_key, storage_key=job.storage_key,
@@ -384,7 +385,7 @@ def retry_project_task(
progress=0, progress=0,
current_step=_ingest_step(retried), current_step=_ingest_step(retried),
source_id=retried.id, source_id=retried.id,
created_at=format_utc_datetime(retried.created_at), created_at=retried.created_at,
updated_at=format_utc_datetime(retried.updated_at), # type: ignore[attr-defined] updated_at=retried.updated_at,
) )
raise HTTPException(status_code=400, detail="Unsupported task type") raise HTTPException(status_code=400, detail="Unsupported task type")
+16 -17
View File
@@ -45,6 +45,7 @@ from packages.application.template.use_cases import (
CreateTemplateUseCase, CreateTemplateUseCase,
DeleteCategoryUseCase, DeleteCategoryUseCase,
DeleteTemplateUseCase, DeleteTemplateUseCase,
GetTemplateUsageUseCase,
GetTemplateUseCase, GetTemplateUseCase,
ListCategoriesUseCase, ListCategoriesUseCase,
ListTagsUseCase, ListTagsUseCase,
@@ -147,9 +148,9 @@ def get_template(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id) usage = template_repository.get_usage_count(template_id)
except Exception as _e: except Exception:
logger.exception("get_template 查询失败: template_id=%s", template_id) logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage) return _to_response(template, usage_count=usage)
@@ -186,7 +187,7 @@ def create_template(
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -226,10 +227,10 @@ def update_template(
use_case = UpdateTemplateUseCase(template_repository) use_case = UpdateTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -264,10 +265,10 @@ def copy_template(
use_case = CopyTemplateUseCase(template_repository) use_case = CopyTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -299,9 +300,9 @@ def toggle_favorite(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
try: try:
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
except Exception as _e: except Exception:
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id) logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False) return ToggleFavoriteResponse(id=template_id, is_favorite=False)
@@ -326,10 +327,10 @@ def validate_template(
use_case = ValidateTemplateUseCase(template_repository) use_case = ValidateTemplateUseCase(template_repository)
try: try:
result = use_case.execute(command) result = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return ValidateTemplateResponse( return ValidateTemplateResponse(
template=_to_response(result.template), template=_to_response(result.template),
@@ -375,9 +376,7 @@ def create_category(
) )
@router.delete( @router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_category( def delete_category(
category_id: str, category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -148,7 +148,7 @@ def create_title(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return _to_response(item) return _to_response(item)
@@ -172,8 +172,8 @@ def update_title(
use_case = UpdateTitleLibraryUseCase(title_repository) use_case = UpdateTitleLibraryUseCase(title_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return _to_response(item) return _to_response(item)
+7 -7
View File
@@ -236,8 +236,8 @@ def get_tts_job(
use_case = GetTTSJobUseCase(repository) use_case = GetTTSJobUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return _to_response(job, sign_url) return _to_response(job, sign_url)
@@ -253,8 +253,8 @@ def get_tts_job_status(
use_case = GetTTSJobStatusUseCase(repository) use_case = GetTTSJobStatusUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
output_url = job.output_audio_url output_url = job.output_audio_url
if output_url: if output_url:
output_url = sign_url(output_url) output_url = sign_url(output_url)
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
get_use_case = GetTTSJobUseCase(tts_repository) get_use_case = GetTTSJobUseCase(tts_repository)
try: try:
job = get_use_case.execute(job_id, user_id) job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
# 校验已完成 # 校验已完成
if not job.is_completed: if not job.is_completed:
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return SaveToLibraryResponse( return SaveToLibraryResponse(
id=item.id, id=item.id,
+10 -69
View File
@@ -1,20 +1,19 @@
import logging import logging
import uuid
from app.api.routes._helpers import format_utc_datetime from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository from app.dependencies import get_generated_video_repository
from app.schemas.video_center import ( from app.schemas.video_center import (
BatchDeleteRequest,
BatchDownloadRequest, BatchDownloadRequest,
BatchDownloadResponse, BatchDownloadResponse,
BatchOperationResponse,
ListVideosResponse, ListVideosResponse,
UpdateVideoReviewRequest, UpdateVideoReviewRequest,
VideoItemResponse, VideoItemResponse,
) )
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import ( from packages.application import (
GetGeneratedVideoUseCase, GetGeneratedVideoUseCase,
@@ -51,13 +50,13 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
review_status=item.review_status, review_status=item.review_status,
generation_params=item.generation_params, generation_params=item.generation_params,
download_url=download_url, download_url=download_url,
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "", generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "",
) )
@router.get("/videos", response_model=ListVideosResponse) @router.get("/videos", response_model=ListVideosResponse)
def list_videos( def list_videos(
project_id: str | None = Query(None, description="项目ID可选过滤"), project_id: str | None = Query(None, description="项目ID不传则返回所有项目"),
status: str | None = Query(None, description="按状态筛选"), status: str | None = Query(None, description="按状态筛选"),
review_status: str | None = Query(None, description="按复核状态筛选"), review_status: str | None = Query(None, description="按复核状态筛选"),
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
@@ -66,10 +65,9 @@ def list_videos(
storage: OSSStorageService = Depends(get_storage_service), storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
): ):
"""成片列表,默认返回当前用户的所有成片,支持按项目/状态/复核状态筛选。""" """成片列表,支持分页、按项目/状态/复核状态筛选。"""
use_case = ListGeneratedVideosPaginatedUseCase(repo) use_case = ListGeneratedVideosPaginatedUseCase(repo)
items, total = use_case.execute( items, total = use_case.execute(
user_id=current_user.user.id,
project_id=project_id, project_id=project_id,
status=status, status=status,
review_status=review_status, review_status=review_status,
@@ -112,67 +110,10 @@ def update_video_review_status(
item = use_case.execute(video_id, request.review_status) item = use_case.execute(video_id, request.review_status)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="Video not found") raise HTTPException(status_code=404, detail="Video not found")
logger.info( logger.info("Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id)
"Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user.id
)
return _to_video_response(item, storage) return _to_video_response(item, storage)
@router.delete("/videos/{video_id}", status_code=204, response_class=Response)
def delete_video(
video_id: str,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""删除单个成片(硬删除)。"""
use_case = GetGeneratedVideoUseCase(repo)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
# 尝试删除 OSS 文件,失败不影响数据库删除
if item.file_url:
try:
key = storage._normalize_storage_key(item.file_url)
storage.delete_file(key)
except Exception:
logger.warning("删除 OSS 视频文件失败,跳过: video_id=%s", video_id)
repo.delete(video_id)
return Response(status_code=204)
@router.post("/videos/batch-delete", response_model=BatchOperationResponse)
def batch_delete_videos(
request: BatchDeleteRequest,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""批量删除成片。"""
videos = repo.get_by_ids(request.video_ids)
existing_ids = {v.id for v in videos}
failed_ids = [vid for vid in request.video_ids if vid not in existing_ids]
failed_details = {vid: "Video not found" for vid in failed_ids}
# 尝试删除 OSS 文件
for video in videos:
if video.file_url:
try:
key = storage._normalize_storage_key(video.file_url)
storage.delete_file(key)
except Exception:
logger.warning("批量删除 OSS 视频文件失败,跳过: video_id=%s", video.id)
success_count = repo.batch_delete(list(existing_ids))
return BatchOperationResponse(
success_count=success_count,
failed_ids=failed_ids,
failed_details=failed_details,
)
@router.post("/videos/batch-download", response_model=BatchDownloadResponse) @router.post("/videos/batch-download", response_model=BatchDownloadResponse)
def batch_download_videos( def batch_download_videos(
request: BatchDownloadRequest, request: BatchDownloadRequest,
@@ -180,7 +121,7 @@ def batch_download_videos(
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
): ):
"""批量下载成片,异步打包 zip。 """批量下载成片,异步打包 zip。
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。 传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
""" """
if not request.video_ids: if not request.video_ids:
@@ -197,7 +138,7 @@ def batch_download_videos(
# 发送 celery 任务 # 发送 celery 任务
task = celery_app.send_task( task = celery_app.send_task(
"worker.batch_download_videos", "worker.batch_download_videos",
args=[request.video_ids, current_user.user.id], args=[request.video_ids, current_user.user_id],
) )
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids)) logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
@@ -213,7 +154,7 @@ def get_batch_download_status(
from celery.result import AsyncResult from celery.result import AsyncResult
task = AsyncResult(job_id, app=celery_app) task = AsyncResult(job_id, app=celery_app)
status_map = { status_map = {
"PENDING": "pending", "PENDING": "pending",
"STARTED": "running", "STARTED": "running",
+8 -8
View File
@@ -141,8 +141,8 @@ def get_voice_clone(
use_case = GetVoiceCloneUseCase(repository) use_case = GetVoiceCloneUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return _to_response(profile) return _to_response(profile)
@@ -157,8 +157,8 @@ def get_voice_clone_status(
use_case = GetVoiceCloneStatusUseCase(repository) use_case = GetVoiceCloneStatusUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return VoiceCloneStatusResponse( return VoiceCloneStatusResponse(
id=profile.id, id=profile.id,
status=profile.status, status=profile.status,
@@ -201,13 +201,13 @@ def retry_voice_clone(
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
try: try:
profile = workflow.retry_clone(clone_id, user_id) profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotRetryableError as _e: except VoiceCloneNotRetryableError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)", detail="Voice clone is not retryable (only failed clones can be retried)",
) from _e )
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
+5 -65
View File
@@ -9,7 +9,7 @@ from typing import Literal, Optional
from app.api.routes._helpers import get_user_plan from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
from app.schemas.voice import ( from app.schemas.voice import (
PresetVoiceItemResponse, PresetVoiceItemResponse,
PresetVoiceListResponse, PresetVoiceListResponse,
@@ -27,7 +27,6 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
from packages.application.voice_library.use_cases import ( from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase, CreateVoiceLibraryUseCase,
@@ -38,18 +37,11 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError, QuotaExceededError,
UpdateVoiceLibraryUseCase, UpdateVoiceLibraryUseCase,
) )
from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository from packages.ports.user_repository import UserRepository
router = APIRouter() router = APIRouter()
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
# key: voice_id, value: (audio_url, timestamp)
_preset_preview_cache: dict[str, tuple[str, float]] = {}
PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
# 每个预置音色的默认试听文本
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository: def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
return SQLAlchemyVoiceLibraryRepository(session) return SQLAlchemyVoiceLibraryRepository(session)
@@ -224,58 +216,6 @@ def list_preset_voices() -> PresetVoiceListResponse:
return PresetVoiceListResponse(items=items, total=len(items)) return PresetVoiceListResponse(items=items, total=len(items))
@router.get("/presets/{voice_id}/preview")
def get_preset_voice_preview(
voice_id: str,
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> dict:
"""获取预置音色试听音频(实时 TTS 合成)。
- 首次调用会合成并缓存7天
- 相同 voice_id 重复调用直接返回缓存的音频URL
- 可传入自定义 text 参数试听不同文本
"""
import time
preset = get_preset_voice_by_id(voice_id)
if preset is None:
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
# 有自定义文本时不缓存
use_cache = not text.strip()
if use_cache and voice_id in _preset_preview_cache:
audio_url, cached_at = _preset_preview_cache[voice_id]
if time.time() - cached_at < PREVIEW_CACHE_TTL:
return {"voice_id": voice_id, "audio_url": audio_url, "cached": True}
# 合成试听音频
preview_text = text.strip() or PREVIEW_TEMPLATE.format(name=preset.name)
try:
result = cosyvoice.synthesize_speech(
text=preview_text,
voice_id=preset.voice_id,
format="mp3",
speed=1.0,
)
except CosyVoiceError as e:
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
audio_url = result.audio_url
# 缓存(仅默认试听文本)
if use_cache:
_preset_preview_cache[voice_id] = (audio_url, time.time())
return {
"voice_id": voice_id,
"audio_url": audio_url,
"text": preview_text,
"cached": False,
}
# ==================== 原有 CRUD 端点(保持向后兼容)==================== # ==================== 原有 CRUD 端点(保持向后兼容)====================
@@ -347,7 +287,7 @@ def create_voice(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return _to_response(item, sign_url) return _to_response(item, sign_url)
@@ -377,8 +317,8 @@ def update_voice(
use_case = UpdateVoiceLibraryUseCase(voice_repository) use_case = UpdateVoiceLibraryUseCase(voice_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url) return _to_response(item, sign_url)
+2 -2
View File
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
global_pending_limit, global_pending_limit,
user_id or "unknown", user_id or "unknown",
) )
exc: Exception = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit) exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc)) _mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc raise exc
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
if global_over or user_over: if global_over or user_over:
if global_over: if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}" reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit) exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
else: else:
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}" reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit) exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
+1 -1
View File
@@ -132,7 +132,7 @@ def get_tag_repository(
session: Session = Depends(get_db_session), session: Session = Depends(get_db_session),
) -> TagRepository: ) -> TagRepository:
"""Provide the SQLAlchemy tag repository implementation.""" """Provide the SQLAlchemy tag repository implementation."""
return SQLAlchemyTagRepository(session) # type: ignore[return-value] return SQLAlchemyTagRepository(session)
def get_user_repository( def get_user_repository(
+1 -1
View File
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.max_requests = max_requests self.max_requests = max_requests
self.window_seconds = window_seconds self.window_seconds = window_seconds
self.paths = set(paths) if paths else None self.paths = set(paths) if paths else None
self.requests: dict[str, list[float]] = {} self.requests = {} # {ip: [timestamps]}
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# 如果配置了路径过滤,只对指定路径限流 # 如果配置了路径过滤,只对指定路径限流
+1 -1
View File
@@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
class VersionNotFoundMiddleware(BaseHTTPMiddleware): class VersionNotFoundMiddleware(BaseHTTPMiddleware):
"""处理已下线的 API 版本""" """处理已下线的 API 版本"""
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表 SUNSET_VERSIONS = [] # 已下线的版本列表
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
version = self._extract_version(request.url.path) version = self._extract_version(request.url.path)
-1
View File
@@ -50,7 +50,6 @@ class AssetResponse(BaseModel):
status: str status: str
classification_status: str classification_status: str
quality_score: float | None = None quality_score: float | None = None
created_at: str
uploaded_by_user_id: str uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list) tag_ids: list[str] = Field(default_factory=list)
-3
View File
@@ -23,8 +23,6 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list) voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ── # ── 来源剪辑计划 ──
source_edit_plan_id: str = "" source_edit_plan_id: str = ""
# ── 视频标题 ──
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
# ── 批量生成 ── # ── 批量生成 ──
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50") count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
# ── 素材库自动匹配 ── # ── 素材库自动匹配 ──
@@ -73,7 +71,6 @@ class GenerationTaskResponse(BaseModel):
source_edit_plan_id: str = "" source_edit_plan_id: str = ""
asset_select_mode: str = "" asset_select_mode: str = ""
batch_id: str = "" batch_id: str = ""
video_title: str = ""
status: str status: str
progress: float progress: float
result_count: int result_count: int
+6 -4
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,8 +17,8 @@ class ProjectTaskResponse(BaseModel):
retry_count: int = 0 retry_count: int = 0
source_id: str = "" source_id: str = ""
template_id: str = "" template_id: str = ""
created_at: str = "" created_at: datetime | None = None
updated_at: str = "" updated_at: datetime | None = None
class ListProjectTasksResponse(BaseModel): class ListProjectTasksResponse(BaseModel):
@@ -40,8 +42,8 @@ class UserTaskResponse(BaseModel):
retryable: bool = False retryable: bool = False
retry_count: int = 0 retry_count: int = 0
source_id: str = "" source_id: str = ""
created_at: str = "" created_at: datetime | None = None
updated_at: str = "" updated_at: datetime | None = None
class ListTasksResponse(BaseModel): class ListTasksResponse(BaseModel):
-13
View File
@@ -35,19 +35,6 @@ class UpdateVideoReviewRequest(BaseModel):
review_status: VideoReviewStatus review_status: VideoReviewStatus
MAX_BATCH_DELETE_SIZE = 200
class BatchDeleteRequest(BaseModel):
video_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_DELETE_SIZE, description="要删除的成片ID列表")
class BatchOperationResponse(BaseModel):
success_count: int = Field(..., ge=0, description="成功删除数量")
failed_ids: list[str] = Field(default_factory=list, description="失败的ID列表")
failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情")
class BatchDownloadRequest(BaseModel): class BatchDownloadRequest(BaseModel):
video_ids: list[str] video_ids: list[str]
+1 -2
View File
@@ -12,7 +12,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -156,7 +155,7 @@ class AutoClipService:
self, self,
clip: EditPlanClip, clip: EditPlanClip,
project_id: str, project_id: str,
config_map: Mapping[str, object], config_map: dict[str, object],
) -> ClipAssignDetail: ) -> ClipAssignDetail:
"""为单个片段分配素材。""" """为单个片段分配素材。"""
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
-276
View File
@@ -1,276 +0,0 @@
"""封面管理服务.
提供封面配置管理和从视频抽帧生成封面的能力。
抽帧使用 FFmpeg,上传使用共享存储服务。
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
COVER_STORAGE_PREFIX = "covers"
class CoverService:
"""封面管理服务."""
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
self._storage = storage_service
self._asset_repo = asset_repository
# ── 配置读写 ──────────────────────────────────────────────────────────
@staticmethod
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
"""从 plan.config 中提取封面配置.
Args:
plan_config: 剪辑计划的 config 字段
Returns:
封面配置 dict
"""
cover = plan_config.get("cover", {})
if not isinstance(cover, dict):
cover = {}
# 确保默认字段存在
return {
"type": cover.get("type", "ai_frame"),
"image_url": cover.get("image_url", ""),
"frame_time": cover.get("frame_time"),
}
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
def extract_cover_from_clip(
self,
plan_id: str,
asset_id: str,
frame_time: float = 1.0,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""从指定素材的指定时间点抽取一帧作为封面.
Args:
plan_id: 剪辑计划 ID(用于生成存储路径)
asset_id: 素材 ID
frame_time: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict,包含 type / image_url / frame_time
Raises:
ValueError: 素材不存在或不是视频
RuntimeError: 抽帧或上传失败
"""
# 1. 获取素材
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
if not asset:
raise ValueError(f"素材不存在: {asset_id}")
storage_key = getattr(asset, "storage_key", "")
if not storage_key:
raise ValueError(f"素材没有文件: {asset_id}")
mime_type = getattr(asset, "mime_type", "")
if mime_type and not mime_type.startswith("video"):
raise ValueError(f"素材不是视频类型: {mime_type}")
# 2. 下载视频到临时目录
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
tmp_path = Path(tmp_dir)
video_path = tmp_path / f"source_{asset_id[:8]}"
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
try:
self._storage.download_file(storage_key, str(video_path))
except Exception as e:
raise RuntimeError(f"下载素材失败: {e}") from e
if not video_path.exists() or video_path.stat().st_size == 0:
raise RuntimeError("下载的素材文件为空")
# 3. FFmpeg 抽帧
output_path = tmp_path / "cover.jpg"
self._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=frame_time,
width=width,
height=height,
quality=quality,
)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError("封面抽帧失败")
# 4. 上传到 OSS
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
logger.info("上传封面到存储: key=%s", cover_key)
try:
self._storage.upload_file(
file_or_path=str(output_path),
storage_key=cover_key,
content_type="image/jpeg",
)
except Exception as e:
raise RuntimeError(f"上传封面失败: {e}") from e
# 5. 获取访问 URL
try:
image_url = self._storage.get_url(cover_key)
except Exception:
image_url = cover_key # 降级为 storage_key
logger.info(
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
plan_id,
asset_id,
frame_time,
output_path.stat().st_size if output_path.exists() else 0,
)
return {
"type": "manual",
"image_url": image_url,
"frame_time": frame_time,
}
def generate_smart_cover(
self,
plan_id: str,
asset_id: str,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
Args:
plan_id: 剪辑计划 ID
asset_id: 素材 ID
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict
"""
# 简单实现:取视频 1/3 处的帧作为智能封面
# 更复杂的多帧选清晰帧可以后续优化
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
result = self.extract_cover_from_clip(
plan_id=plan_id,
asset_id=asset_id,
frame_time=frame_time,
width=width,
height=height,
quality=quality,
)
result["type"] = "ai_frame"
return result
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _extract_frame(
video_path: Path,
output_path: Path,
*,
time_sec: float,
width: int,
height: int,
quality: int,
) -> None:
"""使用 FFmpeg 从视频中抽取一帧.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
"""
import subprocess
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
# 尝试不使用 scale+crop 的简化命令
simple_command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
result2 = subprocess.run(
simple_command,
capture_output=True,
text=True,
timeout=60,
)
if result2.returncode != 0:
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
except subprocess.TimeoutExpired as e:
raise RuntimeError("FFmpeg 抽帧超时") from e
except FileNotFoundError as e:
raise RuntimeError("FFmpeg 不可用") from e
+96 -223
View File
@@ -39,6 +39,70 @@ class EditPlanService:
# ── 剪辑计划 CRUD ────────────────────────────────────────────────────── # ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
def list_plans(
self,
*,
template_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditPlan]:
"""列出剪辑计划
Args:
template_id: 按模板 ID 筛选
project_id: 按项目 ID 筛选
status: 按状态筛选
skip: 分页偏移
limit: 每页数量
"""
if project_id:
return self._plan_repo.list_by_project(
project_id,
status=status,
skip=skip,
limit=limit,
)
if template_id:
return self._plan_repo.list_by_template(
template_id,
status=status,
skip=skip,
limit=limit,
)
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
def count_plans(
self,
*,
template_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
) -> int:
"""统计计划数量
Note:
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
"""
if project_id:
all_matching = self._plan_repo.list_by_project(
project_id,
status=status,
skip=0,
limit=10000,
)
return len(all_matching)
if template_id:
all_matching = self._plan_repo.list_by_template(
template_id,
status=status,
skip=0,
limit=10000,
)
return len(all_matching)
return self._plan_repo.count(status=status)
def get_plan(self, plan_id: str) -> Optional[EditPlan]: def get_plan(self, plan_id: str) -> Optional[EditPlan]:
"""获取计划详情""" """获取计划详情"""
return self._plan_repo.get(plan_id) return self._plan_repo.get(plan_id)
@@ -60,7 +124,7 @@ class EditPlanService:
project_id: str = "", project_id: str = "",
created_by_user_id: str = "", created_by_user_id: str = "",
) -> EditPlan: ) -> EditPlan:
"""创建剪辑计划(基础 CRUD,供内部测试与脚本使用) """创建剪辑计划
Raises: Raises:
ValueError: 参数校验失败 ValueError: 参数校验失败
@@ -77,19 +141,6 @@ class EditPlanService:
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name) logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
return created return created
def _auto_resume_editing(self, plan_id: str) -> None:
"""如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)"""
plan = self._plan_repo.get(plan_id)
if plan is None:
return
if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
try:
plan.resume_editing()
self._plan_repo.update(plan)
logger.info("自动重新编辑: plan_id=%s", plan_id)
except ValueError:
pass
def update_plan( def update_plan(
self, self,
plan_id: str, plan_id: str,
@@ -105,10 +156,6 @@ class EditPlanService:
""" """
existing = self.get_plan_or_raise(plan_id) existing = self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
existing = self.get_plan_or_raise(plan_id)
updated = EditPlan( updated = EditPlan(
id=existing.id, id=existing.id,
template_id=existing.template_id, template_id=existing.template_id,
@@ -126,6 +173,23 @@ class EditPlanService:
logger.info("更新剪辑计划: id=%s", plan_id) logger.info("更新剪辑计划: id=%s", plan_id)
return result return result
def delete_plan(self, plan_id: str) -> bool:
"""删除剪辑计划及其所有片段
Returns:
bool: 是否删除成功
"""
existing = self._plan_repo.get(plan_id)
if existing is None:
return False
# 先删除所有片段
self._clip_repo.delete_by_plan(plan_id)
# 再删除计划
self._plan_repo.delete(plan_id)
logger.info("删除剪辑计划: id=%s", plan_id)
return True
# ── 状态机流转 ────────────────────────────────────────────────────────── # ── 状态机流转 ──────────────────────────────────────────────────────────
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan: def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
@@ -148,24 +212,8 @@ class EditPlanService:
return plan return plan
# 根据目标状态调用对应的状态机方法 # 根据目标状态调用对应的状态机方法
# EDITING 支持从 draft / completed / failed 进入
if target_status == EditPlanStatus.EDITING:
if plan.status == EditPlanStatus.DRAFT:
plan.start_editing()
elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
plan.resume_editing()
else:
raise ValueError(f"无法从 {plan.status} 切换到 {target_status}")
result = self._plan_repo.update(plan)
logger.info(
"状态流转: plan_id=%s %s%s",
plan_id,
plan.status,
target_status,
)
return result
transition_map = { transition_map = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering, EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed, EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed, EditPlanStatus.FAILED: plan.mark_failed,
@@ -244,8 +292,6 @@ class EditPlanService:
""" """
# 确保计划存在 # 确保计划存在
self.get_plan_or_raise(plan_id) self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
clip = EditPlanClip.create( clip = EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -293,9 +339,6 @@ class EditPlanService:
""" """
existing = self.get_clip_or_raise(clip_id) existing = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(existing.plan_id)
# 速度边界钳制 # 速度边界钳制
if playback_speed is not None: if playback_speed is not None:
if playback_speed <= 0: if playback_speed <= 0:
@@ -338,8 +381,6 @@ class EditPlanService:
ValueError: 片段不存在或 asset_id 为空 ValueError: 片段不存在或 asset_id 为空
""" """
clip = self.get_clip_or_raise(clip_id) clip = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(clip.plan_id)
clip.assign_asset(asset_id) clip.assign_asset(asset_id)
result = self._clip_repo.update(clip) result = self._clip_repo.update(clip)
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id) logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
@@ -366,178 +407,21 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count return count
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
"""将一个片段从指定位置分割为两个片段
Args:
clip_id: 要分割的片段 ID
split_time: 分割点(相对于片段起始的秒数),必须在 (0, duration) 范围内
Returns:
dict: {"left_clip": EditPlanClip, "right_clip": EditPlanClip}
Raises:
ValueError: 片段不存在、分割时间越界
"""
clip = self.get_clip_or_raise(clip_id)
plan_id = clip.plan_id
if split_time <= 0 or split_time >= clip.duration:
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
self._auto_resume_editing(plan_id)
original_duration = clip.duration
left_duration = round(split_time, 3)
right_duration = round(original_duration - split_time, 3)
original_order = clip.order
# 更新左半部分(原片段)
clip.duration = left_duration
left_clip = self._clip_repo.update(clip)
# 后面片段的 order 全部 +1(给右半部分腾位置)
all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips:
if c.order > original_order and c.id != clip_id:
c.order += 1
self._clip_repo.update(c)
# 创建右半部分新片段(继承原片段的大部分属性)
right_config = dict(clip.config) if clip.config else {}
# 素材裁剪信息
if clip.asset_id:
# 右半部分从 split_time 开始播放
right_config["trim_start"] = left_duration
# 左半部分在 split_time 处结束
left_config = dict(left_clip.config) if left_clip.config else {}
left_config["trim_end"] = right_duration
left_clip.config = left_config
left_clip = self._clip_repo.update(left_clip)
right_clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip.clip_type,
order=original_order + 1,
template_clip_config_id=clip.template_clip_config_id,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=clip.start_time + left_duration,
duration=right_duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
config=right_config,
)
created_right = self._clip_repo.create(right_clip)
logger.info(
"分割片段: clip_id=%s plan_id=%s split_time=%.3fs left_dur=%.3fs right_dur=%.3fs",
clip_id,
plan_id,
split_time,
left_duration,
right_duration,
)
return {
"left_clip": left_clip,
"right_clip": created_right,
}
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
"""合并多个连续片段为一个片段
Args:
clip_ids: 要合并的片段 ID 列表(至少2个),必须属于同一个计划且 order 连续
Returns:
EditPlanClip: 合并后的新片段
Raises:
ValueError: 数量不足、不属于同一计划、不连续、类型不一致
"""
if len(clip_ids) < 2:
raise ValueError("至少需要 2 个片段才能合并")
# 读取所有片段
clips = []
for cid in clip_ids:
clip = self.get_clip_or_raise(cid)
clips.append(clip)
# 校验:同一计划
plan_id = clips[0].plan_id
for c in clips[1:]:
if c.plan_id != plan_id:
raise ValueError("只能合并同一计划下的片段")
# 按 order 排序
clips.sort(key=lambda c: c.order)
# 校验:order 连续
for i in range(1, len(clips)):
if clips[i].order != clips[i - 1].order + 1:
raise ValueError(f"片段不连续:order {clips[i-1].order}{clips[i].order}")
# 校验:类型一致
clip_type = clips[0].clip_type
for c in clips[1:]:
if c.clip_type != clip_type:
raise ValueError("只能合并相同类型的片段")
self._auto_resume_editing(plan_id)
# 计算合并后的属性
first_clip = clips[0]
total_duration = round(sum(c.duration for c in clips), 3)
first_order = first_clip.order
# 合并文案(用换行连接)
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
# 合并 config(后面的覆盖前面的)
merged_config: Dict[str, Any] = {}
for c in clips:
if c.config:
merged_config.update(c.config)
# 清理 trim 相关字段(合并后就是完整片段了)
merged_config.pop("trim_start", None)
merged_config.pop("trim_end", None)
# 更新第一个片段(保留它作为合并结果)
first_clip.duration = total_duration
first_clip.text_content = merged_text
first_clip.config = merged_config
# 转场保留第一个的(合并后的入点转场)
# playback_speed 取第一个的
merged_clip = self._clip_repo.update(first_clip)
# 删除其余片段
for c in clips[1:]:
self._clip_repo.delete(c.id)
# 后面的片段 order 前移 (len - 1) 位
shift = len(clips) - 1
all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips:
if c.order > first_order and c.id != merged_clip.id:
c.order -= shift
self._clip_repo.update(c)
logger.info(
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
plan_id,
len(clips),
total_duration,
)
return merged_clip
# ── 渲染生成流程 ──────────────────────────────────────────────────────── # ── 渲染生成流程 ────────────────────────────────────────────────────────
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
"""获取计划及其所有片段
Returns:
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
"""
plan = self.get_plan_or_raise(plan_id)
clips = self._clip_repo.list_by_plan(plan_id)
return {
"plan": plan,
"clips": clips,
}
def get_generation_status(self, plan_id: str) -> Dict[str, Any]: def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
"""获取渲染进度状态 """获取渲染进度状态
@@ -547,8 +431,6 @@ class EditPlanService:
"clips": List[EditPlanClip], "clips": List[EditPlanClip],
"generation_task_id": Optional[str], "generation_task_id": Optional[str],
"generation_task_status": Optional[str], "generation_task_status": Optional[str],
"progress": float,
"error_message": str,
} }
Raises: Raises:
@@ -560,23 +442,17 @@ class EditPlanService:
# 从 plan.config 中获取 generation_task_id # 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id") generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None generation_task_status = None
progress = 0.0
error_message = ""
if generation_task_id: if generation_task_id:
task = self._generation_task_repo.get(generation_task_id) task = self._generation_task_repo.get(generation_task_id)
if task: if task:
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
progress = getattr(task, "progress", 0.0) or 0.0
error_message = getattr(task, "error_message", "") or ""
return { return {
"plan": plan, "plan": plan,
"clips": clips, "clips": clips,
"generation_task_id": generation_task_id, "generation_task_id": generation_task_id,
"generation_task_status": generation_task_status, "generation_task_status": generation_task_status,
"progress": progress,
"error_message": error_message,
} }
def can_generate(self, plan_id: str) -> tuple[bool, str]: def can_generate(self, plan_id: str) -> tuple[bool, str]:
@@ -627,9 +503,6 @@ class EditPlanService:
更新后的计划 更新后的计划
""" """
plan = self.get_plan_or_raise(plan_id) plan = self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
plan = self.get_plan_or_raise(plan_id)
new_config = {**plan.config, **config_updates} new_config = {**plan.config, **config_updates}
updated = EditPlan( updated = EditPlan(
-563
View File
@@ -12,8 +12,6 @@ from typing import Any, List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import ( from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
SQLAlchemyEditTemplateRepository, SQLAlchemyEditTemplateRepository,
SQLAlchemyTemplateClipConfigRepository, SQLAlchemyTemplateClipConfigRepository,
) )
@@ -39,14 +37,6 @@ class EditTemplateService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self._template_repo = SQLAlchemyEditTemplateRepository(db) self._template_repo = SQLAlchemyEditTemplateRepository(db)
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db) self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
from packages.adapters.sqlalchemy_impl.template_version_repository import (
SQLAlchemyTemplateVersionRepository,
)
self._version_repo = SQLAlchemyTemplateVersionRepository(db)
self._db = db
# ── 模板 CRUD ────────────────────────────────────────────────────────── # ── 模板 CRUD ──────────────────────────────────────────────────────────
@@ -404,556 +394,3 @@ class EditTemplateService:
"template": template, "template": template,
"clip_configs": clip_configs, "clip_configs": clip_configs,
} }
# ── 从剪辑计划保存为模板 ──────────────────────────────────────────────
def save_plan_as_template(
self,
plan_id: str,
name: str,
*,
description: str = "",
template_type: str = "custom",
preview_url: str = "",
) -> dict[str, Any]:
"""将剪辑计划保存为模板
将指定剪辑计划的配置和片段结构另存为一个新模板,
方便后续基于该模板快速创建新的剪辑计划。
转换规则:
- 计划名称 → 模板名称(调用方传入,支持自定义)
- 计划 config → 模板 config(整体迁移)
- 计划 editing_mode 从 config 中提取,默认 one_take
- 每个片段转换为模板片段配置:
- clip_type 直接映射
- order 保持不变
- duration → min_duration = max_duration = duration(固定时长)
- text_content → text_template
- transition_effect 直接映射
- playback_speed 等播放参数存入 config
- 不保留 asset_id(模板不绑定具体素材)
Args:
plan_id: 源剪辑计划 ID
name: 新模板名称
description: 模板描述
template_type: 模板类型,默认 custom(用户自定义)
preview_url: 预览图 URL
Returns:
dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
Raises:
ValueError: 计划不存在或名称为空/重复
"""
# 1. 读取源计划
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
# 2. 读取所有片段(按 order 排序)
clips = self._plan_clip_repo.list_by_plan(plan_id)
clips.sort(key=lambda c: c.order)
# 3. 提取 editing_mode
editing_mode = plan.config.get("editing_mode", "one_take") if plan.config else "one_take"
# 4. 创建模板(复用 create_template 的校验逻辑,但手动构建避免重复查询)
clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
# 名称重复检查
existing = self._template_repo.list_all(skip=0, limit=1000)
for t in existing:
if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE:
raise ValueError(f"模板名称已存在: {clean_name}")
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
plan_config = plan.config or {}
template_config: dict[str, Any] = {}
for key, value in plan_config.items():
# 跳过明显的运行时/实例字段,保留风格/模式类配置
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
template_config[key] = value
template = EditTemplate.create(
name=clean_name,
description=description,
template_type=template_type,
editing_mode=editing_mode,
config=template_config,
preview_url=preview_url,
)
created_template = self._template_repo.create(template)
logger.info(
"从剪辑计划创建模板: plan_id=%s template_id=%s name=%s clip_count=%d",
plan_id,
created_template.id,
clean_name,
len(clips),
)
# 5. 转换每个片段为模板片段配置
created_configs: List[TemplateClipConfig] = []
for clip in clips:
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
transition = TransitionEffect(clip.transition_effect)
except ValueError:
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
clip_type = ClipType(clip.clip_type)
except ValueError:
clip_type = ClipType.MAIN
clip_config_obj = TemplateClipConfig.create(
template_id=created_template.id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(clip_config_obj)
created_configs.append(created)
return {
"template": created_template,
"clip_configs": created_configs,
}
# ── 模板草稿(编辑器)相关 ──────────────────────────────────────────────────
def get_template_draft(self, template_id: str) -> Optional[Any]:
"""获取模板的草稿剪辑计划
通过 template_id + config.is_template_draft=True 标记查找。
每个模板有且仅有一个草稿计划。
Args:
template_id: 模板 ID
Returns:
EditPlan | None: 草稿剪辑计划,不存在则返回 None
"""
from packages.domain.edit_plan import EditPlan # noqa: F401
plans = self._plan_repo.list_by_template(template_id, limit=50)
for plan in plans:
config = plan.config or {}
if config.get("is_template_draft") is True:
return plan
return None
def create_template_draft(
self,
template_id: str,
user_id: str,
*,
project_id: str = "",
) -> Any:
"""基于模板创建草稿剪辑计划
草稿与普通剪辑计划的区别:
- config.is_template_draft = True
- 不绑定具体素材(空素材列表)
- 用于模板编辑器的编辑上下文
Args:
template_id: 模板 ID
user_id: 创建者用户 ID
project_id: 所属项目 ID(可选)
Returns:
EditPlan: 创建的草稿剪辑计划
Raises:
ValueError: 模板不存在,或草稿已存在
"""
from .plan_generator_service import PlanGeneratorService
# 检查模板是否存在
template = self.get_template_or_raise(template_id)
# 检查草稿是否已存在
existing = self.get_template_draft(template_id)
if existing is not None:
raise ValueError(f"模板草稿已存在: {template_id}")
# 读取模板片段配置
clip_configs = self.list_clip_configs(template_id)
# 基于模板生成计划(空素材)
generator = PlanGeneratorService(self._db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=[],
project_id=project_id,
created_by_user_id=user_id,
name=f"{template.name} - 草稿",
)
plan = result["plan"]
# 标记为模板草稿
plan_config = plan.config or {}
plan_config["is_template_draft"] = True
plan.config = plan_config
plan = self._plan_repo.update(plan)
logger.info(
"创建模板草稿: template_id=%s draft_plan_id=%s user_id=%s",
template_id,
plan.id,
user_id,
)
return plan
def get_or_create_draft(
self,
template_id: str,
user_id: str,
*,
project_id: str = "",
) -> Any:
"""获取或创建模板草稿
首次访问模板编辑器时自动创建草稿。
Args:
template_id: 模板 ID
user_id: 操作用户 ID
project_id: 所属项目 ID(可选)
Returns:
EditPlan: 草稿剪辑计划
"""
draft = self.get_template_draft(template_id)
if draft is not None:
return draft
return self.create_template_draft(template_id, user_id, project_id=project_id)
def publish_template_from_draft(
self,
template_id: str,
draft_plan_id: str,
*,
change_note: str = "",
published_by: str = "",
) -> Any:
"""将草稿剪辑计划的内容发布(同步)到模板
将草稿的配置和片段结构同步到模板,相当于"保存"编辑结果。
使用事务保证一致性,失败则回滚。
同步规则:
- 草稿 plan.config → template.config(过滤掉草稿特有字段)
- 草稿 clips → template_clip_configs(先删后插)
- 草稿 editing_mode → template.editing_mode
- 不更新模板名称、描述等元信息(由专门的接口处理)
Args:
template_id: 模板 ID
draft_plan_id: 草稿剪辑计划 ID
Returns:
EditTemplate: 更新后的模板
Raises:
ValueError: 模板/草稿不存在,或草稿不属于该模板
"""
from packages.domain.template_clip_config import TemplateClipConfig
# 1. 校验模板和草稿
template = self.get_template_or_raise(template_id)
draft = self._plan_repo.get(draft_plan_id)
if draft is None:
raise ValueError(f"草稿计划不存在: {draft_plan_id}")
if draft.template_id != template_id:
raise ValueError(f"草稿不属于该模板: plan_template_id={draft.template_id}")
config = draft.config or {}
if config.get("is_template_draft") is not True:
raise ValueError("指定的计划不是模板草稿")
# 2. 读取草稿片段
draft_clips = self._plan_clip_repo.list_by_plan(draft_plan_id)
draft_clips.sort(key=lambda c: c.order)
# 3. 提取 editing_mode
editing_mode = config.get("editing_mode", "one_take")
# 4. 提取模板配置(去掉草稿/运行时字段)
draft_config = draft.config or {}
template_config: dict[str, Any] = {}
skip_keys = {
"is_template_draft",
"asset_ids",
"source_edit_plan_id",
"generation_task_id",
}
for key, value in draft_config.items():
if key not in skip_keys:
template_config[key] = value
# 5. 事务更新
try:
# 5.0 先保存旧版快照(发布前的状态),用于回滚
old_version = template.version or 1
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion
old_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=old_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{old_version} 快照(发布前)",
published_by=published_by,
)
self._version_repo.create(old_snapshot)
# 更新模板元信息
template.config = template_config
template.editing_mode = editing_mode
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 批量删除旧的片段配置(外层事务统一提交)
self._clip_config_repo.delete_by_template(template_id, commit=False)
# 创建新的片段配置
created_configs: list[TemplateClipConfig] = []
for clip in draft_clips:
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import (
TransitionEffect,
)
transition = TransitionEffect(clip.transition_effect)
except (ValueError, ImportError):
transition = TransitionEffect.CUT # type: ignore
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip.clip_type)
except (ValueError, ImportError):
clip_type = ClipType.MAIN # type: ignore
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(config_obj)
created_configs.append(created)
self._db.commit()
logger.info(
"发布模板草稿: template_id=%s draft_plan_id=%s clip_count=%d",
template_id,
draft_plan_id,
len(created_configs),
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"发布模板草稿失败: template_id=%s draft_plan_id=%s error=%s",
template_id,
draft_plan_id,
exc,
)
raise
# ── 版本历史与回滚 ────────────────────────────────────────────────────
def list_template_versions(self, template_id: str, limit: int = 50) -> list[Any]:
"""列出模板的发布版本历史(按版本号倒序)"""
self.get_template_or_raise(template_id) # 校验存在性
return self._version_repo.list_by_template(template_id, limit=limit)
def rollback_to_version(self, template_id: str, version: int) -> Any:
"""回滚模板到指定历史版本
流程:
1. 校验目标版本存在
2. 保存当前状态为新版本快照(当前版本号)
3. 用目标版本的快照覆盖模板 config + clip_configs
4. 版本号 +1(回滚本身也是一次发布)
Returns:
EditTemplate: 回滚后的模板
Raises:
ValueError: 模板/版本不存在
"""
from packages.domain.template_clip_config import TemplateClipConfig
template = self.get_template_or_raise(template_id)
# 1. 读取目标版本快照
target_version = self._version_repo.get_by_version(template_id, version)
if target_version is None:
raise ValueError(f"模板 {template_id} 不存在版本 {version}")
current_version = template.version or 1
try:
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion
current_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=current_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{current_version} 快照(回滚到 v{version} 前)",
published_by="rollback",
)
self._version_repo.create(current_snapshot)
# 3. 覆盖模板配置 + editing_mode + name + preview_url
template.config = dict(target_version.config)
template.editing_mode = target_version.editing_mode
if target_version.name:
template.name = target_version.name
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 4. 先删后插 clip_configs(批量删除避免N+1
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
)
self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete(
synchronize_session=False
)
for clip_snap in target_version.clip_configs:
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip_snap.get("clip_type", "main"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import ClipType
clip_type = ClipType.MAIN
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip_snap.get("order", 0),
min_duration=clip_snap.get("min_duration", 0.0),
max_duration=clip_snap.get("max_duration", 0.0),
text_template=clip_snap.get("text_template", ""),
transition_effect=transition,
config=clip_snap.get("config", {}) or {},
)
self._clip_config_repo.create(config_obj)
self._db.commit()
logger.info(
"模板回滚成功: template_id=%s from_v=%d to_v=%d new_v=%d",
template_id,
current_version,
version,
updated_template.version,
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"模板回滚失败: template_id=%s target_version=%d error=%s",
template_id,
version,
exc,
)
raise
+6 -52
View File
@@ -99,11 +99,6 @@ class PlanGeneratorService:
# 3. 生成片段列表 # 3. 生成片段列表
if clip_configs: if clip_configs:
clips = self._create_clips_from_configs(plan.id, clip_configs) clips = self._create_clips_from_configs(plan.id, clip_configs)
# 模板 clip_config 的 clip_type 是 ClipType 枚举(main/intro/outro 等),
# 但 PIP / VOICE_PIP 模式需要特定的 clip_typeoverlay/background/corner_voice/b_roll
# 才能让素材分配和渲染分层正确工作。
# 这里将 MAIN 类型的片段按顺序映射为对应模式的角色类型。
self._map_clip_types_for_mode(clips, editing_mode)
else: else:
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids)) clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
@@ -151,8 +146,8 @@ class PlanGeneratorService:
plan_config: dict[str, Any] = { plan_config: dict[str, Any] = {
"editing_mode": editing_mode, "editing_mode": editing_mode,
} }
# 继承模板的 cover/title/subtitle/bgm/export/filter 配置 # 继承模板的 cover/title/subtitle/bgm 配置
for key in ("cover", "title", "subtitle", "bgm", "export", "filter"): for key in ("cover", "title", "subtitle", "bgm"):
if key in template_config: if key in template_config:
plan_config[key] = template_config[key] plan_config[key] = template_config[key]
@@ -187,10 +182,6 @@ class PlanGeneratorService:
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
) )
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
clip_cfg = cfg.config or {}
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
clip = EditPlanClip.create( clip = EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
clip_type=clip_type, clip_type=clip_type,
@@ -199,48 +190,11 @@ class PlanGeneratorService:
text_content=getattr(cfg, "text_template", "") or "", text_content=getattr(cfg, "text_template", "") or "",
duration=duration, duration=duration,
transition_effect=transition or "cut", transition_effect=transition or "cut",
playback_speed=playback_speed,
config=clip_cfg,
) )
clips.append(clip) clips.append(clip)
return clips return clips
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
"""将模板 clip_config 生成的 MAIN 类型片段,按 editing_mode 映射为对应角色类型。
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
overlay / background / corner_voice / b_roll)。
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
- ONE_TAKE / VOICE_OVER: 保持 main 不变
"""
from packages.domain.template_clip_config import ClipType
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if not main_clips:
return
if editing_mode == EditingMode.PIP.value:
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
for i, clip in enumerate(main_clips):
if i > 0:
clip.clip_type = "overlay"
elif editing_mode == EditingMode.VOICE_PIP.value:
for i, clip in enumerate(main_clips):
if i == 0:
clip.clip_type = "background"
elif i == 1:
clip.clip_type = "corner_voice"
else:
clip.clip_type = "b_roll"
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
def _generate_default_clips( def _generate_default_clips(
self, self,
plan_id: str, plan_id: str,
@@ -270,7 +224,7 @@ class PlanGeneratorService:
) )
order += 1 order += 1
# 剩余为 overlay # 剩余为 overlay
for _ in range(1, n): for i in range(1, n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -283,7 +237,7 @@ class PlanGeneratorService:
elif editing_mode == EditingMode.VOICE_OVER.value: elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll # N 个 main clipsB-roll
for _ in range(n): for i in range(n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -317,7 +271,7 @@ class PlanGeneratorService:
) )
order += 1 order += 1
# 剩余为 b_roll # 剩余为 b_roll
for _ in range(2, n): for i in range(2, n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -330,7 +284,7 @@ class PlanGeneratorService:
else: else:
# ONE_TAKE: N 个 main clips # ONE_TAKE: N 个 main clips
for _ in range(n): for i in range(n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
+18 -43
View File
@@ -219,7 +219,7 @@ class VideoComposeService:
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。 根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
滤镜链逻辑: 滤镜链逻辑:
- 每个片段:scale → crop → fps → setpts → trim → atrim - 每个片段:scale → crop → setpts → trim → atrim
- 多片段之间:concat 滤镜 或 xfade 转场 - 多片段之间:concat 滤镜 或 xfade 转场
- 最终输出:-map '[outv]' -map '[outa]'(如有音频) - 最终输出:-map '[outv]' -map '[outa]'(如有音频)
""" """
@@ -406,30 +406,20 @@ class VideoComposeService:
滤镜顺序: 滤镜顺序:
1. scale — 等比缩放到目标分辨率(保证覆盖) 1. scale — 等比缩放到目标分辨率(保证覆盖)
2. crop — 居中裁剪到目标分辨率 2. crop — 居中裁剪到目标分辨率
3. fps — 统一输出帧率(concat 要求所有输入帧率一致) 3. setpts — 重置时间戳 + 偏移
4. setpts — 重置时间戳 + 偏移 4. trim — 视频时长裁剪
5. trim — 频时长裁剪 5. atrim — 频时长裁剪(如有音频流)
6. atrim — 音频时长裁剪(如有音频流)
""" """
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒 duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time start = clip.start_time
filters: list[str] = [] filters: list[str] = []
# 1. scale: 等比缩放(保持比例,不裁剪 # 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease") filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase")
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容) # 2. crop: 居中裁剪
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black") filters.append(f"crop={output_width}:{output_height}")
# 3. format: 统一像素格式为 yuv420pH.264 标准格式,concat 要求所有输入像素格式一致)
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
filters.append("format=yuv420p")
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
if fps and fps > 0:
filters.append(f"fps={fps}")
# 3. setpts: 重置时间戳 # 3. setpts: 重置时间戳
if start > 0: if start > 0:
@@ -542,17 +532,11 @@ def _build_concat_filter(
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]" concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
parts.append(concat_filter) parts.append(concat_filter)
# 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败 # 音频 concat(如果有)
audio_parts: list[str] = [] audio_parts: list[str] = []
for idx, chain in enumerate(clip_chains): for idx, chain in enumerate(clip_chains):
if chain.audio_label: if chain.audio_label:
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式) audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]")
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]")
if audio_parts: if audio_parts:
parts.extend(audio_parts) parts.extend(audio_parts)
@@ -627,22 +611,13 @@ def _build_xfade_filter(
# 总时长需要减去转场重叠部分 # 总时长需要减去转场重叠部分
total_duration -= transition_duration * (n - 1) total_duration -= transition_duration * (n - 1)
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败 # 音频 crossfade(简化处理:使用 adelay + amix
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label] audio_labels = [c.audio_label for c in clip_chains if c.audio_label]
if len(audio_chains_with_label) >= 2: if len(audio_labels) >= 2:
normalized_audio_labels: list[str] = [] # 简单拼接音频(不做 crossfade)
for chain, _ in audio_chains_with_label: audio_inputs = "".join(f"[{label}]" for label in audio_labels)
norm_label = f"anorm_{chain.video_label}" parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]")
audio_filters = [ elif len(audio_labels) == 1:
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", parts.append(f"[{audio_labels[0]}]acopy[outa]")
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
normalized_audio_labels.append(norm_label)
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
elif len(audio_chains_with_label) == 1:
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration) return ";".join(parts), max(0.0, total_duration)
Executable → Regular
+6 -4
View File
@@ -8,14 +8,16 @@ module.exports = {
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"prettier", // 关掉与 Prettier 冲突的 ESLint 规则
], ],
ignorePatterns: ["dist", ".eslintrc.cjs"], ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
plugins: ["react-refresh"], plugins: ["react-refresh"],
rules: { rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
}, },
} };
-12
View File
@@ -1,12 +0,0 @@
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/extensions.json
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-12
View File
@@ -1,12 +0,0 @@
{
"semi": false,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false
}
+4 -4
View File
@@ -91,17 +91,17 @@ VITE_API_URL=http://localhost:8000
使用 Zustand 创建 Store 使用 Zustand 创建 Store
```typescript ```typescript
import { create } from "zustand" import { create } from "zustand";
interface MyStore { interface MyStore {
data: any data: any;
setData: (data: any) => void setData: (data: any) => void;
} }
export const useMyStore = create<MyStore>((set) => ({ export const useMyStore = create<MyStore>((set) => ({
data: null, data: null,
setData: (data) => set({ data }), setData: (data) => set({ data }),
})) }));
``` ```
## 部署 ## 部署
+301 -296
View File
@@ -5,29 +5,33 @@
* 删除素材、批量删除、空状态 * 删除素材、批量删除、空状态
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互 * 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!" const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { const routeBrowserApiToTestApi = async (
if (!apiOrigin) return page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -40,30 +44,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -71,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 创建项目 */ /** 创建项目 */
@@ -82,14 +86,11 @@ async function createProject(
): Promise<string> { ): Promise<string> {
const resp = await request.post(`${apiBase}/projects`, { const resp = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
name: `Assets Test Proj ${suffix}`, });
description: "E2E assets test", expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
}, const data = await resp.json();
}) return data.id;
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy()
const data = await resp.json()
return data.id
} }
/** 创建素材库 */ /** 创建素材库 */
@@ -103,10 +104,10 @@ async function createLibrary(
const resp = await request.post(`${apiBase}/asset-libraries`, { const resp = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
data: { project_id: projectId, name, kind }, data: { project_id: projectId, name, kind },
}) });
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy() expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json() const data = await resp.json();
return data.id return data.id;
} }
/** 创建素材记录 */ /** 创建素材记录 */
@@ -132,10 +133,10 @@ async function createAsset(
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
metadata: { duration: 15.5, resolution: "1080p" }, metadata: { duration: 15.5, resolution: "1080p" },
}, },
}) });
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy() expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json() const data = await resp.json();
return data.id return data.id;
} }
/** 在浏览器中设置登录态 */ /** 在浏览器中设置登录态 */
@@ -146,14 +147,14 @@ async function setupAuthInBrowser(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -167,400 +168,410 @@ async function setupAuthInBrowser(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
test.describe("素材库页面 - 完整交互测试", () => { test.describe("素材库页面 - 完整交互测试", () => {
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
// ─── 页面加载 ────────────────────────────────────── // ─── 页面加载 ──────────────────────────────────────
test("素材库列表页面加载", async ({ page, request }) => { test("素材库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-load", "assets-load",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "默认视频库", "video") await createLibrary(request, headers, projectId, "默认视频库", "video");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
// 页面布局容器 // 页面布局容器
await expect(page.locator(".xx-assets-page")).toBeVisible({ await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
timeout: 20_000, await expect(page.locator(".xx-assets-layout")).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-asset-library-list")).toBeVisible();
// 右侧内容区(上传区 + 筛选 + 素材网格) // 右侧内容区(上传区 + 筛选 + 素材网格)
await expect(page.locator(".xx-assets-content")).toBeVisible() await expect(page.locator(".xx-assets-content")).toBeVisible();
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible() await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
await expect(page.locator(".xx-assets-filters")).toBeVisible() await expect(page.locator(".xx-assets-filters")).toBeVisible();
// 无错误提示 // 无错误提示
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, { await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}) });
}) });
// ─── 创建素材库 ──────────────────────────────────── // ─── 创建素材库 ────────────────────────────────────
test("创建新素材库 - 通过 UI", async ({ page, request }) => { test("创建新素材库 - 通过 UI", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-create", "assets-create",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "初始库", "video") await createLibrary(request, headers, projectId, "初始库", "video");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
timeout: 20_000,
})
// 点击新建素材库 // 点击新建素材库
await page.locator(".xx-asset-library-add").click() await page.locator(".xx-asset-library-add").click();
// 弹窗出现 // 弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" }) const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
await expect(modal).toBeVisible() await expect(modal).toBeVisible();
// 填写表单 // 填写表单
const newLibName = `E2E 新建库 ${Date.now()}` const newLibName = `E2E 新建库 ${Date.now()}`;
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName) await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
// 类型选择默认是 video,保持即可 // 类型选择默认是 video,保持即可
// 监听创建请求 // 监听创建请求
const createPromise = page.waitForResponse( const createPromise = page.waitForResponse(
(resp) => resp.url().includes("/asset-libraries") && resp.request().method() === "POST", (resp) =>
resp.url().includes("/asset-libraries") &&
resp.request().method() === "POST",
{ timeout: 10_000 }, { timeout: 10_000 },
) );
// 点击创建 // 点击创建
await modal.getByRole("button", { name: "创建" }).click() await modal.getByRole("button", { name: "创建" }).click();
const resp = await createPromise const resp = await createPromise;
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy() expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
// 新素材库应出现在列表中 // 新素材库应出现在列表中
await expect( await expect(
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }), page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
).toBeVisible({ timeout: 10_000 }) ).toBeVisible({ timeout: 10_000 });
}) });
// ─── 切换素材库 ──────────────────────────────────── // ─── 切换素材库 ────────────────────────────────────
test("切换不同素材库", async ({ page, request }) => { test("切换不同素材库", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-switch", "assets-switch",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
const videoLibName = "视频素材库 A" const videoLibName = "视频素材库 A";
const imageLibName = "图片素材库 B" const imageLibName = "图片素材库 B";
const videoLibId = await createLibrary(request, headers, projectId, videoLibName, "video") 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 createAsset(
request,
headers,
projectId,
videoLibId,
userId,
"demo_video.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
timeout: 20_000,
})
// 点击视频库,应显示素材 // 点击视频库,应显示素材
const videoLibItem = page.locator(".xx-asset-library-item").filter({ hasText: videoLibName }) const videoLibItem = page
await videoLibItem.click({ force: true }) .locator(".xx-asset-library-item")
await expect(videoLibItem).toHaveClass(/active/) .filter({ hasText: videoLibName });
await videoLibItem.click({ force: true });
await expect(videoLibItem).toHaveClass(/active/);
// 验证视频素材出现 // 验证视频素材出现
await expect(page.getByText("demo_video.mp4")).toBeVisible({ await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
timeout: 10_000,
})
// 点击图片库,应切换且不显示视频 // 点击图片库,应切换且不显示视频
const imageLibItem = page.locator(".xx-asset-library-item").filter({ hasText: imageLibName }) const imageLibItem = page
await imageLibItem.click({ force: true }) .locator(".xx-asset-library-item")
await expect(imageLibItem).toHaveClass(/active/) .filter({ hasText: imageLibName });
await imageLibItem.click({ force: true });
await expect(imageLibItem).toHaveClass(/active/);
// 空状态或图片库内容 // 空状态或图片库内容
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
timeout: 5_000, });
})
})
// ─── 素材搜索 ────────────────────────────────────── // ─── 素材搜索 ──────────────────────────────────────
test("素材搜索功能", async ({ page, request }) => { test("素材搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-search", "assets-search",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(request, headers, projectId, "搜索测试库", "video") 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, "apple_clip.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4") await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
timeout: 20_000,
})
// 确保在测试库中 // 确保在测试库中
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "搜索测试库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "搜索测试库" });
await libItem.click({ force: true });
// 两个素材都应可见 // 两个素材都应可见
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
timeout: 10_000, await expect(page.getByText("banana_clip.mp4")).toBeVisible();
})
await expect(page.getByText("banana_clip.mp4")).toBeVisible()
// 搜索 apple,只显示 apple // 搜索 apple,只显示 apple
await page.getByPlaceholder("搜索素材名称...").fill("apple") await page.getByPlaceholder("搜索素材名称...").fill("apple");
await expect(page.getByText("apple_clip.mp4")).toBeVisible() await expect(page.getByText("apple_clip.mp4")).toBeVisible();
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0) await expect(page.getByText("banana_clip.mp4")).toHaveCount(0);
// 清空搜索,两个都显示 // 清空搜索,两个都显示
await page.getByPlaceholder("搜索素材名称...").fill("") await page.getByPlaceholder("搜索素材名称...").fill("");
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
timeout: 5_000, await expect(page.getByText("banana_clip.mp4")).toBeVisible();
}) });
await expect(page.getByText("banana_clip.mp4")).toBeVisible()
})
// ─── 筛选类型 ────────────────────────────────────── // ─── 筛选类型 ──────────────────────────────────────
test("素材类型筛选", async ({ page, request }) => { test("素材类型筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-filter", "assets-filter",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(request, headers, projectId, "筛选测试库", "video") const libraryId = await createLibrary(
request,
headers,
projectId,
"筛选测试库",
"video",
);
// 创建视频素材 // 创建视频素材
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4") await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
timeout: 20_000,
})
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "筛选测试库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "筛选测试库" });
await libItem.click({ force: true });
// 素材应可见 // 素材应可见
await expect(page.getByText("video_clip.mp4")).toBeVisible({ await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
timeout: 10_000,
})
// 筛选类型下拉存在 // 筛选类型下拉存在
const filterSelect = page.locator(".xx-assets-filters-left select").first() const filterSelect = page.locator(".xx-assets-filters-left select").first();
await expect(filterSelect).toBeVisible() await expect(filterSelect).toBeVisible();
}) });
// ─── 素材详情/播放 ──────────────────────────────── // ─── 素材详情/播放 ────────────────────────────────
test("素材详情查看 - 播放弹窗", async ({ page, request }) => { test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-detail", "assets-detail",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(request, headers, projectId, "详情测试库", "video") const libraryId = await createLibrary(
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4") request,
headers,
projectId,
"详情测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
timeout: 20_000,
})
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "详情测试库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "详情测试库" });
await libItem.click({ force: true });
// 找到素材卡片并点击播放按钮 // 找到素材卡片并点击播放按钮
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "play_test.mp4" }) const assetCard = page
await expect(assetCard).toBeVisible({ timeout: 10_000 }) .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 }) await assetCard.locator(".xx-asset-play").click({ force: true });
// 播放弹窗出现 // 播放弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" }) const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
await expect(modal).toBeVisible() await expect(modal).toBeVisible();
// 关闭弹窗 // 关闭弹窗
await modal.locator(".ant-modal-close").click() await modal.locator(".ant-modal-close").click();
await expect(modal).not.toBeVisible({ timeout: 5_000 }) await expect(modal).not.toBeVisible({ timeout: 5_000 });
}) });
// ─── 删除素材 ────────────────────────────────────── // ─── 删除素材 ──────────────────────────────────────
test("删除素材 - 带确认对话框", async ({ page, request }) => { test("删除素材 - 带确认对话框", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-delete", "assets-delete",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(request, headers, projectId, "删除测试库", "video") const libraryId = await createLibrary(
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4") request,
headers,
projectId,
"删除测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
timeout: 20_000,
})
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "删除测试库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "删除测试库" });
await libItem.click({ force: true });
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "to_delete.mp4" }) const assetCard = page
await expect(assetCard).toBeVisible({ timeout: 10_000 }) .locator(".xx-asset-card")
.filter({ hasText: "to_delete.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 悬停显示删除按钮 // 悬停显示删除按钮
await assetCard.hover() await assetCard.hover();
// 点击删除 // 点击删除
const deleteBtn = assetCard.locator(".xx-asset-delete") const deleteBtn = assetCard.locator(".xx-asset-delete");
await expect(deleteBtn).toBeVisible() await expect(deleteBtn).toBeVisible();
await deleteBtn.click({ force: true }) await deleteBtn.click({ force: true });
// 确认对话框出现 // 确认对话框出现
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" }) const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
await expect(confirmModal).toBeVisible() await expect(confirmModal).toBeVisible();
// 监听删除请求 // 监听删除请求
const deletePromise = page.waitForResponse( const deletePromise = page.waitForResponse(
(resp) => resp.url().includes("/assets/") && resp.request().method() === "DELETE", (resp) =>
resp.url().includes("/assets/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 }, { timeout: 10_000 },
) );
// 点击确认删除 // 点击确认删除
await confirmModal.getByRole("button", { name: "删除" }).click() await confirmModal.getByRole("button", { name: "删除" }).click();
const resp = await deletePromise const resp = await deletePromise;
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy() expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
// 素材应从列表中消失 // 素材应从列表中消失
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, { await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
timeout: 10_000, timeout: 10_000,
}) });
}) });
// ─── 批量删除素材 ────────────────────────────────── // ─── 批量删除素材 ──────────────────────────────────
test("批量删除素材", async ({ page, request }) => { test("批量删除素材", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-batch", "assets-batch",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(request, headers, projectId, "批量删除库", "video") 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_1.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4") await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4") await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
timeout: 20_000,
})
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "批量删除库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "批量删除库" });
await libItem.click({ force: true });
// 所有素材应可见 // 所有素材应可见
await expect(page.getByText("batch_1.mp4")).toBeVisible({ await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
timeout: 10_000, await expect(page.getByText("batch_2.mp4")).toBeVisible();
}) await expect(page.getByText("batch_3.mp4")).toBeVisible();
await expect(page.getByText("batch_2.mp4")).toBeVisible()
await expect(page.getByText("batch_3.mp4")).toBeVisible()
// 点击全选 // 点击全选
const selectAllBtn = page.getByRole("button", { name: "全选" }) const selectAllBtn = page.getByRole("button", { name: "全选" });
await expect(selectAllBtn).toBeVisible() await expect(selectAllBtn).toBeVisible();
await selectAllBtn.click() await selectAllBtn.click();
// 批量操作栏出现 // 批量操作栏出现
const batchBar = page.locator(".xx-assets-batch-bar") const batchBar = page.locator(".xx-assets-batch-bar");
await expect(batchBar).toBeVisible() await expect(batchBar).toBeVisible();
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible() await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
// 点击批量删除 // 点击批量删除
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" }) const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
await expect(batchDeleteBtn).toBeVisible() await expect(batchDeleteBtn).toBeVisible();
await batchDeleteBtn.click() await batchDeleteBtn.click();
// 确认对话框 // 确认对话框
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" }) const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
await expect(confirmPop).toBeVisible() await expect(confirmPop).toBeVisible();
// 确认删除 // 确认删除
await confirmPop.getByRole("button", { name: "删除" }).click() await confirmPop.getByRole("button", { name: "删除" }).click();
// 验证素材已删除(通过 API 确认) // 验证素材已删除(通过 API 确认)
await expect await expect
@@ -569,53 +580,47 @@ test.describe("素材库页面 - 完整交互测试", () => {
const resp = await request.get(`${apiBase}/assets`, { const resp = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libraryId }, params: { library_id: libraryId },
}) });
if (!resp.ok()) return "error" if (!resp.ok()) return "error";
const data = await resp.json() const data = await resp.json();
const items = data.items || [] const items = data.items || [];
return items.length return items.length;
}, },
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] }, { timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
) )
.toBe(0) .toBe(0);
}) });
// ─── 空状态 ──────────────────────────────────────── // ─── 空状态 ────────────────────────────────────────
test("空素材库展示空状态", async ({ page, request }) => { test("空素材库展示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"assets-empty", "assets-empty",
) );
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "空素材库", "video") await createLibrary(request, headers, projectId, "空素材库", "video");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
timeout: 20_000,
})
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "空素材库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "空素材库" });
await libItem.click({ force: true });
// 空状态应显示 // 空状态应显示
await expect(page.locator(".xx-assets-empty")).toBeVisible({ await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
timeout: 10_000, await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
}) });
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible()
})
// ─── 未登录访问 ──────────────────────────────────── // ─── 未登录访问 ────────────────────────────────────
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => { test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
+5 -5
View File
@@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test";
test.describe("App route guard", () => { test.describe("App route guard", () => {
test("redirects anonymous users to login", async ({ page }) => { test("redirects anonymous users to login", async ({ page }) => {
await page.goto("/app/dashboard") await page.goto("/app/dashboard");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
+7 -7
View File
@@ -1,10 +1,10 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test";
test.describe("Authentication page", () => { test.describe("Authentication page", () => {
test("renders login form", async ({ page }) => { test("renders login form", async ({ page }) => {
await page.goto("/login") await page.goto("/login");
await expect(page.getByLabel("邮箱")).toBeVisible() await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.getByLabel("密码")).toBeVisible() await expect(page.getByLabel("密码")).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible() await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
}) });
}) });
+134 -147
View File
@@ -1,23 +1,23 @@
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const PASSWORD = "SmokePass123!";
const PASSWORD = "SmokePass123!" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiOrigin = apiBase.endsWith("/api/v1")
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" ? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { const routeBrowserApiToTestApi = async (
if (!apiOrigin) return page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
async function loginWithRetry( async function loginWithRetry(
request: APIRequestContext, request: APIRequestContext,
@@ -28,72 +28,73 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
type ProjectResponse = { id: string } type ProjectResponse = { id: string };
type LibraryResponse = { id: string } type LibraryResponse = { id: string };
type TemplateResponse = { id: string } type TemplateResponse = { id: string };
type AssetListResponse = { type AssetListResponse = {
items: Array<{ items: Array<{
id: string id: string;
name: string name: string;
status: string status: string;
}> }>;
} };
test.describe("Core generation flow", () => { test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
test("walks through 7-step wizard and starts generation", async ({ page, request }) => { test("walks through 5-step wizard and starts generation", async ({
test.setTimeout(180_000) page,
request,
}) => {
test.setTimeout(180_000);
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const email = `e2e-gen-${suffix}@example.com` const email = `e2e-gen-${suffix}@example.com`;
const username = `e2e_gen_${suffix}` const username = `e2e_gen_${suffix}`;
const libraryName = `E2E Gen Lib ${suffix}` const libraryName = `E2E Gen Lib ${suffix}`;
// Register // Register
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}) });
expect(register.status()).toBe(201) expect(register.status()).toBe(201);
const registerData = (await register.json()) as { user_id: string } const registerData = (await register.json()) as { user_id: string };
// Login // Login
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.status()).toBe(200) expect(login.status()).toBe(200);
const loginData = (await login.json()) as { access_token: string } const loginData = (await login.json()) as { access_token: string };
const headers = { Authorization: `Bearer ${loginData.access_token}` } const headers = { Authorization: `Bearer ${loginData.access_token}` };
// Create project // Create project
const project = await request.post(`${apiBase}/projects`, { const project = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `E2E Gen Proj ${suffix}` }, data: { name: `E2E Gen Proj ${suffix}` },
}) });
expect(project.status()).toBe(200) expect(project.status()).toBe(200);
const projectData = (await project.json()) as ProjectResponse const projectData = (await project.json()) as ProjectResponse;
// Create asset library // Create asset library
const library = await request.post(`${apiBase}/asset-libraries`, { const library = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
data: { project_id: projectData.id, name: libraryName, kind: "video" }, data: { project_id: projectData.id, name: libraryName, kind: "video" },
}) });
expect(library.status()).toBe(200) expect(library.status()).toBe(200);
const libraryData = (await library.json()) as LibraryResponse const libraryData = (await library.json()) as LibraryResponse;
// Upload source video // Upload source video
const sourceFileName = "e2e-gen-source.mp4" const sourceFileName = "e2e-gen-source.mp4";
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, { const upload = await request.post(`${apiBase}/upload`, {
headers, headers,
multipart: { multipart: {
@@ -102,11 +103,11 @@ test.describe("Core generation flow", () => {
file: { file: {
name: sourceFileName, name: sourceFileName,
mimeType: "video/mp4", mimeType: "video/mp4",
buffer: sampleVideoBuffer, buffer: Buffer.from("e2e source data"),
}, },
}, },
}) });
expect(upload.status()).toBe(200) expect(upload.status()).toBe(200);
// Wait for asset to be ready // Wait for asset to be ready
await expect await expect
@@ -115,16 +116,16 @@ test.describe("Core generation flow", () => {
const assets = await request.get(`${apiBase}/assets`, { const assets = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libraryData.id }, params: { library_id: libraryData.id },
}) });
if (!assets.ok()) return `http_${assets.status()}` if (!assets.ok()) return `http_${assets.status()}`;
const data = (await assets.json()) as AssetListResponse const data = (await assets.json()) as AssetListResponse;
const asset = data.items.find((a) => a.name === sourceFileName) const asset = data.items.find((a) => a.name === sourceFileName);
if (!asset) return "missing" if (!asset) return "missing";
return asset.status return asset.status;
}, },
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
) )
.toBe("ready") .toBe("ready");
// Create an editing template so the generate page has at least one template // Create an editing template so the generate page has at least one template
// (templates are now loaded from API; new users have none by default) // (templates are now loaded from API; new users have none by default)
@@ -144,22 +145,22 @@ test.describe("Core generation flow", () => {
], ],
tags: ["e2e"], tags: ["e2e"],
}, },
}) });
expect(template.status(), await template.text()).toBe(201) expect(template.status(), await template.text()).toBe(201);
const templateData = (await template.json()) as TemplateResponse const templateData = (await template.json()) as TemplateResponse;
expect(templateData.id).toBeTruthy() expect(templateData.id).toBeTruthy();
// Set auth in localStorage // Set auth in localStorage
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -173,127 +174,113 @@ test.describe("Core generation flow", () => {
email_verified: true, email_verified: true,
}, },
}, },
) );
// Navigate to generate page // Navigate to generate page
await page.goto("/app/generate") await page.goto("/app/generate");
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({ await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// Step 1: template - default selected, click next // Step 1: template - default selected, click next
await expect(page.locator(".xx-choice-item.selected")).toBeVisible() await expect(page.locator(".xx-choice-item.selected")).toBeVisible();
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click();
// Step 2: select material // Step 2: select material
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible() await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible();
const librarySelect = page.locator("select").first() const librarySelect = page.locator("select").first();
await librarySelect.selectOption({ label: libraryName }) await librarySelect.selectOption({ label: libraryName });
const materialLabel = page.getByText(sourceFileName).locator("..") const materialLabel = page.getByText(sourceFileName).locator("..");
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({ await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
await materialLabel.locator("input[type='checkbox']").check() await materialLabel.locator("input[type='checkbox']").check();
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click();
// Step 3: preview (纯展示页,AI 智能匹配预览) // Step 3: title
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible() await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible();
await page.getByRole("button", { name: "下一步" }).click() const titleText = `E2E Test ${suffix}`;
await page.getByPlaceholder("输入自定义标题…").fill(titleText);
await page.getByRole("button", { name: "下一步" }).click();
// Step 4: title // Step 4: voice
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible() await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible();
const titleText = `E2E Test ${suffix}` const firstVoiceCard = page.locator(".xx-voice-choice-item").first();
await page.getByPlaceholder("输入自定义标题…").fill(titleText) await firstVoiceCard.click();
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click();
// Step 5: voice // Step 5: confirm and generate
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible() await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible();
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
await firstVoiceCard.click()
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: cover (默认 AI 智能选帧模式,直接下一步) // Wait for plan creation API to be called
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible() const createPlanPromise = page.waitForResponse(
await page.getByRole("button", { name: "下一步" }).click() (response) =>
response.url().includes("/edit-plans") &&
// Step 7: confirm and generate response.request().method() === "POST" &&
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible() !response.url().includes("/generate"),
// Wait for generation API to be called
// 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
// 等 generate 接口返回,确认生成流程启动
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/editor/generate")
},
{ timeout: 30_000 }, { timeout: 30_000 },
) );
// Click generate button // Click generate button
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click() await page
.locator(".xx-btn-primary")
.filter({ hasText: "确认生成" })
.first()
.click();
// Verify generation was triggered successfully // Verify plan was created successfully
const genResp = await generatePromise const planResp = await createPlanPromise;
if (!genResp.ok()) { expect(planResp.ok()).toBeTruthy();
const body = await genResp.text() const planData = (await planResp.json()) as { id: string };
console.error( expect(planData.id).toBeTruthy();
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
)
}
expect(genResp.ok()).toBeTruthy()
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
expect(genData.plan_id).toBeTruthy()
expect(genData.generation_task_id).toBeTruthy()
// Generation may fail in test env (no worker), that's OK // Generation may fail in test env (no worker), that's OK
// Just verify the flow started - check page shows generation-related UI // Just verify the flow started - check page shows generation-related UI
await page await page
.getByText(/生成中|生成完成|生成失败/) .getByText(/生成中|生成完成|生成失败/)
.isVisible({ timeout: 15_000 }) .isVisible({ timeout: 15_000 })
.catch(() => false) .catch(() => false);
// If we see progress or result, great; if not, flow still reached the end // If we see progress or result, great; if not, flow still reached the end
// which is sufficient for an E2E smoke test // which is sufficient for an E2E smoke test
// Verify product library page loads (smoke: just verify page renders) // Verify product library page loads (smoke: just verify page renders)
await page.goto("/app/products") await page.goto("/app/products");
await expect(page).toHaveURL(/\/app\/products/) await expect(page).toHaveURL(/\/app\/products/);
// Verify page container exists = page rendered correctly // Verify page container exists = page rendered correctly
// (works in all states: loading/error/success - more reliable than checking search input) // (works in all states: loading/error/success - more reliable than checking search input)
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 15_000, timeout: 15_000,
}) });
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错 // 清理所有路由,避免页面关闭时飞地API请求导致测试报错
await page.unrouteAll({ behavior: "ignoreErrors" }) await page.unrouteAll({ behavior: "ignoreErrors" });
}) });
test("generation task API creates and lists tasks", async ({ request }) => { test("generation task API creates and lists tasks", async ({ request }) => {
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const email = `e2e-gen-api-${suffix}@example.com` const email = `e2e-gen-api-${suffix}@example.com`;
const username = `e2e_gen_api_${suffix}` const username = `e2e_gen_api_${suffix}`;
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}) });
expect(register.status()).toBe(201) expect(register.status()).toBe(201);
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.status()).toBe(200) expect(login.status()).toBe(200);
const loginData = (await login.json()) as { access_token: string } const loginData = (await login.json()) as { access_token: string };
const headers = { Authorization: `Bearer ${loginData.access_token}` } const headers = { Authorization: `Bearer ${loginData.access_token}` };
const project = await request.post(`${apiBase}/projects`, { const project = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `E2E API Proj ${suffix}` }, data: { name: `E2E API Proj ${suffix}` },
}) });
expect(project.status()).toBe(200) expect(project.status()).toBe(200);
// List generation tasks via task center API // List generation tasks via task center API
const tasks = await request.get(`${apiBase}/tasks`, { headers }) const tasks = await request.get(`${apiBase}/tasks`, { headers });
expect(tasks.status()).toBe(200) expect(tasks.status()).toBe(200);
const tasksData = await tasks.json() const tasksData = await tasks.json();
expect(Array.isArray(tasksData.items)).toBe(true) expect(Array.isArray(tasksData.items)).toBe(true);
}) });
}) });
+76 -66
View File
@@ -1,19 +1,23 @@
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!" const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { const routeBrowserApiToTestApi = async (
if (!apiOrigin) return page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry( async function loginWithRetry(
@@ -25,35 +29,38 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
test.describe("Title library flow", () => { test.describe("Title library flow", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("loads title library page and displays titles", async ({ page, request }) => { test("loads title library page and displays titles", async ({
await routeBrowserApiToTestApi(page) page,
const suffix = Date.now().toString(36) request,
const email = `e2e-title-${suffix}@example.com` }) => {
const username = `e2e_title_${suffix}` await routeBrowserApiToTestApi(page);
const suffix = Date.now().toString(36);
const email = `e2e-title-${suffix}@example.com`;
const username = `e2e_title_${suffix}`;
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}) });
expect(register.status(), await register.text()).toBe(201) expect(register.status(), await register.text()).toBe(201);
const registerData = (await register.json()) as { user_id: string } const registerData = (await register.json()) as { user_id: string };
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.status(), await login.text()).toBe(200) expect(login.status(), await login.text()).toBe(200);
const loginData = (await login.json()) as { access_token: string } const loginData = (await login.json()) as { access_token: string };
const headers = { Authorization: `Bearer ${loginData.access_token}` } const headers = { Authorization: `Bearer ${loginData.access_token}` };
// Create a title so the titles page has at least one title card to display // Create a title so the titles page has at least one title card to display
// (titles are loaded from API; new users have none by default) // (titles are loaded from API; new users have none by default)
@@ -64,19 +71,19 @@ test.describe("Title library flow", () => {
text: `E2E 测试标题内容 ${suffix}`, text: `E2E 测试标题内容 ${suffix}`,
category: "default", category: "default",
}, },
}) });
expect(createTitle.status(), await createTitle.text()).toBe(201) expect(createTitle.status(), await createTitle.text()).toBe(201);
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -90,42 +97,45 @@ test.describe("Title library flow", () => {
email_verified: true, email_verified: true,
}, },
}, },
) );
await page.goto("/app/titles") await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
await expect(page.locator(".xx-title-card").first()).toBeVisible({ await expect(page.locator(".xx-title-card").first()).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
const firstTitleText = await page.locator(".xx-title-card-text").first().textContent() const firstTitleText = await page
.locator(".xx-title-card-text")
.first()
.textContent();
if (firstTitleText) { if (firstTitleText) {
await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText) await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText);
await expect(page.getByText(firstTitleText)).toBeVisible() await expect(page.getByText(firstTitleText)).toBeVisible();
} }
await expect(page.locator(".xx-title-card-stat").first()).toBeVisible() await expect(page.locator(".xx-title-card-stat").first()).toBeVisible();
}) });
test("titles API creates and lists titles", async ({ request }) => { test("titles API creates and lists titles", async ({ request }) => {
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const email = `e2e-title-api-${suffix}@example.com` const email = `e2e-title-api-${suffix}@example.com`;
const username = `e2e_title_api_${suffix}` const username = `e2e_title_api_${suffix}`;
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}) });
expect(register.status()).toBe(201) expect(register.status()).toBe(201);
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.status()).toBe(200) expect(login.status()).toBe(200);
const loginData = (await login.json()) as { access_token: string } const loginData = (await login.json()) as { access_token: string };
const headers = { Authorization: `Bearer ${loginData.access_token}` } const headers = { Authorization: `Bearer ${loginData.access_token}` };
const titleText = `E2E Test Title ${suffix}` const titleText = `E2E Test Title ${suffix}`;
const createResp = await request.post(`${apiBase}/titles`, { const createResp = await request.post(`${apiBase}/titles`, {
headers, headers,
data: { data: {
@@ -133,20 +143,20 @@ test.describe("Title library flow", () => {
text: titleText, text: titleText,
category: "default", category: "default",
}, },
}) });
expect(createResp.status(), await createResp.text()).toBe(201) expect(createResp.status(), await createResp.text()).toBe(201);
const created = (await createResp.json()) as { const created = (await createResp.json()) as {
id: string id: string;
text: string text: string;
} };
expect(created.id).toBeTruthy() expect(created.id).toBeTruthy();
const listResp = await request.get(`${apiBase}/titles`, { headers }) const listResp = await request.get(`${apiBase}/titles`, { headers });
expect(listResp.status()).toBe(200) expect(listResp.status()).toBe(200);
const listData = (await listResp.json()) as { const listData = (await listResp.json()) as {
items: Array<{ id: string; text: string }> items: Array<{ id: string; text: string }>;
} };
const found = listData.items.find((t) => t.id === created.id) const found = listData.items.find((t) => t.id === created.id);
expect(found).toBeTruthy() expect(found).toBeTruthy();
}) });
}) });
+86 -79
View File
@@ -1,23 +1,23 @@
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const PASSWORD = "SmokePass123!";
const PASSWORD = "SmokePass123!" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiOrigin = apiBase.endsWith("/api/v1")
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" ? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { const routeBrowserApiToTestApi = async (
if (!apiOrigin) return page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry( async function loginWithRetry(
@@ -29,28 +29,31 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
type ProjectResponse = { id: string } type ProjectResponse = { id: string };
type LibraryResponse = { id: string } type LibraryResponse = { id: string };
test.describe("Core media upload flow", () => { test.describe("Core media upload flow", () => {
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
test("uploads a video asset and shows it in the asset library", async ({ page, request }) => { test("uploads a video asset and shows it in the asset library", async ({
test.setTimeout(120_000) page,
request,
}) => {
test.setTimeout(120_000);
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const email = `e2e-mov-${suffix}@example.com` const email = `e2e-mov-${suffix}@example.com`;
const username = `e2e_mov_${suffix}` const username = `e2e_mov_${suffix}`;
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { data: {
@@ -59,15 +62,15 @@ test.describe("Core media upload flow", () => {
password: PASSWORD, password: PASSWORD,
display_name: username, display_name: username,
}, },
}) });
expect(register.status(), await register.text()).toBe(201) expect(register.status(), await register.text()).toBe(201);
const registerData = (await register.json()) as { user_id: string } const registerData = (await register.json()) as { user_id: string };
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.status(), await login.text()).toBe(200) expect(login.status(), await login.text()).toBe(200);
const loginData = (await login.json()) as { access_token: string } const loginData = (await login.json()) as { access_token: string };
const headers = { Authorization: `Bearer ${loginData.access_token}` } const headers = { Authorization: `Bearer ${loginData.access_token}` };
const project = await request.post(`${apiBase}/projects`, { const project = await request.post(`${apiBase}/projects`, {
headers, headers,
@@ -75,9 +78,9 @@ test.describe("Core media upload flow", () => {
name: `E2E Project ${suffix}`, name: `E2E Project ${suffix}`,
description: "Playwright upload smoke", description: "Playwright upload smoke",
}, },
}) });
expect(project.status(), await project.text()).toBe(200) expect(project.status(), await project.text()).toBe(200);
const projectData = (await project.json()) as ProjectResponse const projectData = (await project.json()) as ProjectResponse;
const library = await request.post(`${apiBase}/asset-libraries`, { const library = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
@@ -86,20 +89,20 @@ test.describe("Core media upload flow", () => {
name: `E2E Video Library ${suffix}`, name: `E2E Video Library ${suffix}`,
kind: "video", kind: "video",
}, },
}) });
expect(library.status(), await library.text()).toBe(200) expect(library.status(), await library.text()).toBe(200);
const libraryData = (await library.json()) as LibraryResponse const libraryData = (await library.json()) as LibraryResponse;
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -113,32 +116,30 @@ test.describe("Core media upload flow", () => {
email_verified: true, email_verified: true,
}, },
}, },
) );
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, { const upload = await request.post(`${apiBase}/upload`, {
headers, headers,
multipart: { multipart: {
project_id: projectData.id, project_id: projectData.id,
library_id: libraryData.id, library_id: libraryData.id,
file: { file: {
name: "e2e-sample.mp4", name: "e2e-sample.MOV",
mimeType: "video/mp4", mimeType: "video/quicktime",
buffer: sampleVideoBuffer, buffer: Buffer.from("playwright mov upload smoke"),
}, },
}, },
}) });
expect(upload.status(), await upload.text()).toBe(200) expect(upload.status(), await upload.text()).toBe(200);
await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, { await expect(
timeout: 5_000, page.getByText(/上传失败|素材列表加载失败|素材库加载失败/),
}) ).toHaveCount(0, { timeout: 5_000 });
await expect await expect
.poll( .poll(
@@ -146,44 +147,50 @@ test.describe("Core media upload flow", () => {
const assets = await request.get(`${apiBase}/assets`, { const assets = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libraryData.id }, params: { library_id: libraryData.id },
}) });
if (!assets.ok()) { if (!assets.ok()) {
return `http_${assets.status()}` return `http_${assets.status()}`;
} }
const data = (await assets.json()) as { const data = (await assets.json()) as {
items: Array<{ items: Array<{
name: string name: string;
status: string status: string;
file_type?: string file_type?: string;
mime_type?: string mime_type?: string;
}> }>;
} };
const asset = data.items.find((item) => item.name === "e2e-sample.mp4") const asset = data.items.find(
return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing" (item) => item.name === "e2e-sample.MOV",
);
return asset
? `${asset.mime_type || asset.file_type || ""}:${asset.status}`
: "missing";
}, },
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
) )
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/) .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
// Select the test library from sidebar // Select the test library from sidebar
await page await page
.locator(".xx-asset-library-item") .locator(".xx-asset-library-item")
.filter({ hasText: `E2E Video Library ${suffix}` }) .filter({ hasText: `E2E Video Library ${suffix}` })
.click({ force: true }) .click({ force: true });
await page.reload() await page.reload();
await expect(page.locator(".xx-assets-content")).toBeVisible({ await expect(page.locator(".xx-assets-content")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
await expect(page.getByText("e2e-sample.mp4", { exact: true })).toBeVisible({ await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// Verify asset card shows status // Verify asset card shows status
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.mp4" }) const assetCard = page
await expect(assetCard).toBeVisible() .locator(".xx-asset-card")
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible() .filter({ hasText: "e2e-sample.MOV" });
await expect(assetCard).toBeVisible();
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible();
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0) await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0);
}) });
}) });
+237 -234
View File
@@ -4,29 +4,33 @@
* 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、 * 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、
* 删除记录、重试去重 * 删除记录、重试去重
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!" const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { const routeBrowserApiToTestApi = async (
if (!apiOrigin) return page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -39,30 +43,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -70,7 +74,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 在浏览器中设置登录态 */ /** 在浏览器中设置登录态 */
@@ -81,14 +85,14 @@ async function setupAuthInBrowser(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -102,216 +106,210 @@ async function setupAuthInBrowser(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
test.describe("去重流程", () => { test.describe("去重流程", () => {
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
// ─── 上传页面加载 ────────────────────────────────── // ─── 上传页面加载 ──────────────────────────────────
test("去重上传页面加载", async ({ page, request }) => { test("去重上传页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-load") const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-load",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication") await page.goto("/app/duplication");
// 页面容器 // 页面容器
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 页面标题 // 页面标题
await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible() await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible();
// 描述 // 描述
await expect(page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段")).toBeVisible() await expect(
}) page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"),
).toBeVisible();
});
// ─── 上传区域展示 ────────────────────────────────── // ─── 上传区域展示 ──────────────────────────────────
test("上传区域展示", async ({ page, request }) => { test("上传区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"dup-upload-zone", "dup-upload-zone",
) );
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication") await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 拖拽上传区域 // 拖拽上传区域
const uploadZone = page.locator(".dup-upload-zone") const uploadZone = page.locator(".dup-upload-zone");
await expect(uploadZone).toBeVisible() await expect(uploadZone).toBeVisible();
// 上传图标和文字 // 上传图标和文字
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible() await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
// 格式提示 // 格式提示
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible() await expect(
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
).toBeVisible();
// 格式标签 // 格式标签
await expect(page.locator(".dup-upload-formats")).toBeVisible() await expect(page.locator(".dup-upload-formats")).toBeVisible();
// 选择文件按钮 // 选择文件按钮
const selectBtn = page.getByRole("button", { name: "选择文件" }) const selectBtn = page.getByRole("button", { name: "选择文件" });
await expect(selectBtn).toBeVisible() await expect(selectBtn).toBeVisible();
// 隐藏的文件 input // 隐藏的文件 input
const fileInput = page.locator('input[type="file"]') const fileInput = page.locator('input[type="file"]');
await expect(fileInput).toHaveCount(1) await expect(fileInput).toHaveCount(1);
}) });
// ─── 格式说明区 ──────────────────────────────────── // ─── 格式说明区 ────────────────────────────────────
test("格式说明和提示区域展示", async ({ page, request }) => { test("格式说明和提示区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-info") const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-info",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication") await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 右侧说明区 // 右侧说明区
const infoCard = page.locator(".dup-info-card") const infoCard = page.locator(".dup-info-card");
await expect(infoCard).toBeVisible() await expect(infoCard).toBeVisible();
// 查重说明 // 查重说明
await expect(infoCard.getByText("查重说明")).toBeVisible() await expect(infoCard.getByText("查重说明")).toBeVisible();
// 支持格式 // 支持格式
await expect(infoCard.getByText("支持格式")).toBeVisible() await expect(infoCard.getByText("支持格式")).toBeVisible();
// 温馨提示 // 温馨提示
await expect(infoCard.getByText("温馨提示")).toBeVisible() await expect(infoCard.getByText("温馨提示")).toBeVisible();
// 格式标签 // 格式标签
await expect(page.locator(".dup-format-tags")).toBeVisible() await expect(page.locator(".dup-format-tags")).toBeVisible();
}) });
// ─── 去重记录列表页面 ────────────────────────────── // ─── 去重记录列表页面 ──────────────────────────────
test("去重记录列表页面加载", async ({ page, request }) => { test("去重记录列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-list") const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-list",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication/results") await page.goto("/app/duplication/results");
// 页面容器 // 页面容器
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 页面标题 // 页面标题
await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible() await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible();
// 筛选按钮 // 筛选按钮
await expect(page.locator(".dup-filter")).toBeVisible() await expect(page.locator(".dup-filter")).toBeVisible();
// 上传查重按钮 // 上传查重按钮
await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible() await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible();
}) });
test("去重记录列表 - 空状态", async ({ page, request }) => { test("去重记录列表 - 空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"dup-list-empty", "dup-list-empty",
) );
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication/results") await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 空状态(新用户没有记录) // 空状态(新用户没有记录)
const emptyState = page.locator(".dup-results-empty") const emptyState = page.locator(".dup-results-empty");
await expect(emptyState).toBeVisible({ timeout: 10_000 }) await expect(emptyState).toBeVisible({ timeout: 10_000 });
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible() await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible();
}) });
test("去重记录列表 - 风险等级筛选", async ({ page, request }) => { test("去重记录列表 - 风险等级筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-filter") const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-filter",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication/results") await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 筛选按钮存在 // 筛选按钮存在
const filterBtns = page.locator(".dup-filter-btn") const filterBtns = page.locator(".dup-filter-btn");
await expect(filterBtns).toHaveCount(4) // 全部、低风险、中风险、高风险 await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险
// 验证按钮文本 // 验证按钮文本
await expect(filterBtns.nth(0)).toHaveText("全部") await expect(filterBtns.nth(0)).toHaveText("全部");
await expect(filterBtns.nth(1)).toHaveText("低风险") await expect(filterBtns.nth(1)).toHaveText("低风险");
await expect(filterBtns.nth(2)).toHaveText("中风险") await expect(filterBtns.nth(2)).toHaveText("中风险");
await expect(filterBtns.nth(3)).toHaveText("高风险") await expect(filterBtns.nth(3)).toHaveText("高风险");
// 默认选中"全部" // 默认选中"全部"
await expect(filterBtns.nth(0)).toHaveClass(/active/) await expect(filterBtns.nth(0)).toHaveClass(/active/);
// 点击低风险 // 点击低风险
await filterBtns.nth(1).click() await filterBtns.nth(1).click();
await expect(filterBtns.nth(1)).toHaveClass(/active/) await expect(filterBtns.nth(1)).toHaveClass(/active/);
}) });
test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => { test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-nav") const { userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-nav",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication/results") await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 点击上传查重按钮 // 点击上传查重按钮
await page.getByRole("button", { name: "上传查重" }).click() await page.getByRole("button", { name: "上传查重" }).click();
await expect(page).toHaveURL(/\/app\/duplication$/) await expect(page).toHaveURL(/\/app\/duplication$/);
await expect(page.locator(".dup-upload-zone")).toBeVisible() await expect(page.locator(".dup-upload-zone")).toBeVisible();
}) });
// ─── 去重详情页 ──────────────────────────────────── // ─── 去重详情页 ────────────────────────────────────
test("去重详情页 - 通过 API 创建测试数据后访问", async ({ page, request }) => { test("去重详情页 - 通过 API 创建测试数据后访问", async ({
await routeBrowserApiToTestApi(page) page,
request,
}) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"dup-detail", "dup-detail",
) );
// 先上传一个文件进行查重,获取 record id // 先上传一个文件进行查重,获取 record id
const uploadResp = await request.post(`${apiBase}/duplication/upload`, { const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -323,41 +321,42 @@ test.describe("去重流程", () => {
buffer: Buffer.from("e2e duplication test data"), buffer: Buffer.from("e2e duplication test data"),
}, },
}, },
}) });
// 如果查重 API 不可用,跳过详情页测试 // 如果查重 API 不可用,跳过详情页测试
if (!uploadResp.ok()) { if (!uploadResp.ok()) {
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`) console.log(
return `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`,
);
return;
} }
const uploadData = await uploadResp.json() const uploadData = await uploadResp.json();
const recordId = uploadData.id const recordId = uploadData.id;
expect(recordId, "应返回查重记录 ID").toBeTruthy() expect(recordId, "应返回查重记录 ID").toBeTruthy();
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
// 访问详情页 // 访问详情页
await page.goto(`/app/duplication/${recordId}`) await page.goto(`/app/duplication/${recordId}`);
// 页面应正常渲染 // 页面应正常渲染
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 验证无错误 // 验证无错误
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, { await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}) });
}) });
// ─── 删除记录 ────────────────────────────────────── // ─── 删除记录 ──────────────────────────────────────
test("去重记录删除 - API 验证", async ({ page, request }) => { test("去重记录删除 - API 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers } = await createAuthedUser(request, "dup-delete") const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete",
);
// 创建查重记录 // 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, { const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -369,53 +368,61 @@ test.describe("去重流程", () => {
buffer: Buffer.from("e2e duplication delete test"), buffer: Buffer.from("e2e duplication delete test"),
}, },
}, },
}) });
if (!uploadResp.ok()) { if (!uploadResp.ok()) {
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`) console.log(
return `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`,
);
return;
} }
const uploadData = await uploadResp.json() const uploadData = await uploadResp.json();
const recordId = uploadData.id const recordId = uploadData.id;
// 验证记录存在 // 验证记录存在
const listResp = await request.get(`${apiBase}/duplication/records`, { const listResp = await request.get(`${apiBase}/duplication/records`, {
headers, headers,
}) });
if (listResp.ok()) { if (listResp.ok()) {
const records = await listResp.json() const records = await listResp.json();
const recordExists = Array.isArray(records) const recordExists = Array.isArray(records)
? records.some((r: { id: string }) => r.id === recordId) ? records.some((r: { id: string }) => r.id === recordId)
: (records.items || []).some((r: { id: string }) => r.id === recordId) : (records.items || []).some((r: { id: string }) => r.id === recordId);
expect(recordExists, "记录应存在于列表中").toBeTruthy() expect(recordExists, "记录应存在于列表中").toBeTruthy();
} }
// 删除记录 // 删除记录
const deleteResp = await request.delete(`${apiBase}/duplication/records/${recordId}`, { const deleteResp = await request.delete(
headers, `${apiBase}/duplication/records/${recordId}`,
}) { headers },
expect(deleteResp.ok(), `删除查重记录应成功: ${deleteResp.status()}`).toBeTruthy() );
expect(
deleteResp.ok(),
`删除查重记录应成功: ${deleteResp.status()}`,
).toBeTruthy();
// 验证记录已删除 // 验证记录已删除
const listAfterResp = await request.get(`${apiBase}/duplication/records`, { const listAfterResp = await request.get(`${apiBase}/duplication/records`, {
headers, headers,
}) });
if (listAfterResp.ok()) { if (listAfterResp.ok()) {
const recordsAfter = await listAfterResp.json() const recordsAfter = await listAfterResp.json();
const recordStillExists = Array.isArray(recordsAfter) const recordStillExists = Array.isArray(recordsAfter)
? recordsAfter.some((r: { id: string }) => r.id === recordId) ? recordsAfter.some((r: { id: string }) => r.id === recordId)
: (recordsAfter.items || []).some((r: { id: string }) => r.id === recordId) : (recordsAfter.items || []).some(
expect(recordStillExists, "记录应已被删除").toBeFalsy() (r: { id: string }) => r.id === recordId,
);
expect(recordStillExists, "记录应已被删除").toBeFalsy();
} }
}) });
test("去重记录删除 - UI 验证", async ({ page, request }) => { test("去重记录删除 - UI 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"dup-delete-ui", "dup-delete-ui",
) );
// 创建查重记录 // 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, { const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -427,66 +434,63 @@ test.describe("去重流程", () => {
buffer: Buffer.from("e2e duplication ui delete test"), buffer: Buffer.from("e2e duplication ui delete test"),
}, },
}, },
}) });
if (!uploadResp.ok()) { if (!uploadResp.ok()) {
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`) console.log(
return `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`,
);
return;
} }
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication/results") await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录卡片应存在 // 记录卡片应存在
const resultCard = page.locator(".dup-result-card").first() const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false) const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
if (cardVisible) { if (cardVisible) {
// 删除按钮存在 // 删除按钮存在
const deleteBtn = resultCard.getByRole("button").filter({ const deleteBtn = resultCard.getByRole("button").filter({
hasText: "🗑️", hasText: "🗑️",
}) });
await expect(deleteBtn).toBeVisible() await expect(deleteBtn).toBeVisible();
// 删除按钮点击 - 会触发 confirm 对话框 // 删除按钮点击 - 会触发 confirm 对话框
// 这里我们通过监听 confirm 来确认删除 // 这里我们通过监听 confirm 来确认删除
page.once("dialog", async (dialog) => { page.once("dialog", async (dialog) => {
expect(dialog.message()).toContain("确定删除") expect(dialog.message()).toContain("确定删除");
await dialog.accept() await dialog.accept();
}) });
// 监听删除请求 // 监听删除请求
const deletePromise = page const deletePromise = page.waitForResponse(
.waitForResponse( (resp) =>
(resp) => resp.url().includes("/duplication/records/") &&
resp.url().includes("/duplication/records/") && resp.request().method() === "DELETE", resp.request().method() === "DELETE",
{ timeout: 10_000 }, { timeout: 10_000 },
) ).catch(() => null);
.catch(() => null)
await deleteBtn.click() await deleteBtn.click();
const deleteResp = await deletePromise const deleteResp = await deletePromise;
if (deleteResp) { if (deleteResp) {
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy() expect(deleteResp.ok(), "删除请求应成功").toBeTruthy();
} }
} }
}) });
// ─── 重试去重 ────────────────────────────────────── // ─── 重试去重 ──────────────────────────────────────
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => { test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } = await createAuthedUser(
request, request,
"dup-retry", "dup-retry",
) );
// 创建查重记录 // 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, { const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -498,54 +502,53 @@ test.describe("去重流程", () => {
buffer: Buffer.from("e2e duplication retry test"), buffer: Buffer.from("e2e duplication retry test"),
}, },
}, },
}) });
if (!uploadResp.ok()) { if (!uploadResp.ok()) {
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`) console.log(
return `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`,
);
return;
} }
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/duplication/results") await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录列表中至少有一条记录 // 记录列表中至少有一条记录
const resultCard = page.locator(".dup-result-card").first() const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false) const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
if (cardVisible) { if (cardVisible) {
// 验证记录卡片基本结构 // 验证记录卡片基本结构
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible() await expect(resultCard.locator(".dup-result-card-body")).toBeVisible();
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible() await expect(resultCard.locator(".dup-result-card-score")).toBeVisible();
// 检查是否有重试按钮(失败状态才显示) // 检查是否有重试按钮(失败状态才显示)
// 新上传的记录可能是处理中或完成状态,不一定显示重试按钮 // 新上传的记录可能是处理中或完成状态,不一定显示重试按钮
// 这里只验证 API 重试接口可用 // 这里只验证 API 重试接口可用
const uploadData = await uploadResp.json() const uploadData = await uploadResp.json();
const recordId = uploadData.id const recordId = uploadData.id;
const retryResp = await request.post(`${apiBase}/duplication/records/${recordId}/retry`, { const retryResp = await request.post(
headers, `${apiBase}/duplication/records/${recordId}/retry`,
}) { headers },
);
// 重试接口应返回 2xx 或明确的状态码 // 重试接口应返回 2xx 或明确的状态码
expect(retryResp.status()).toBeLessThan(500) expect(retryResp.status()).toBeLessThan(500);
} }
}) });
// ─── 未登录访问 ──────────────────────────────────── // ─── 未登录访问 ────────────────────────────────────
test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => { test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/duplication") await page.goto("/app/duplication");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => { test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/duplication/results") await page.goto("/app/duplication/results");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
+188 -162
View File
@@ -6,29 +6,31 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -41,30 +43,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -72,7 +74,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 设置页面认证状态(localStorage */ /** 设置页面认证状态(localStorage */
@@ -83,14 +85,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -104,7 +106,7 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
/** 创建一个编辑模板并返回 id */ /** 创建一个编辑模板并返回 id */
@@ -139,74 +141,80 @@ async function createEditingTemplate(
tags: ["e2e", "test"], tags: ["e2e", "test"],
category: "default", category: "default",
}, },
}) });
expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy() expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json() const data = await resp.json();
return data.id return data.id;
} }
test.describe("剪辑策划页面 - 未登录重定向", () => { test.describe("剪辑策划页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/editing-planner") await page.goto("/app/editing-planner");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
test.describe("剪辑策划页面 - 页面加载", () => { test.describe("剪辑策划页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("剪辑策划页面加载成功", async ({ page, request }) => { test("剪辑策划页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-load") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-load", display_name: "E2E ep-load",
}) });
await page.goto("/app/editing-planner") await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证顶栏存在 // 验证顶栏存在
await expect(page.locator(".ep-top-bar")).toBeVisible() await expect(page.locator(".ep-top-bar")).toBeVisible();
// 验证模式栏存在 // 验证模式栏存在
await expect(page.locator(".ep-mode-bar")).toBeVisible() await expect(page.locator(".ep-mode-bar")).toBeVisible();
// 验证主体区域存在 // 验证主体区域存在
await expect(page.locator(".ep-main-body")).toBeVisible() await expect(page.locator(".ep-main-body")).toBeVisible();
}) });
test("剪辑模式切换正常显示", async ({ page, request }) => { test("剪辑模式切换正常显示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-mode") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-mode",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-mode", display_name: "E2E ep-mode",
}) });
await page.goto("/app/editing-planner") await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证模式按钮存在(画中画、人物口播等) // 验证模式按钮存在(画中画、人物口播等)
const modeBtns = page.locator(".ep-mode-btn") const modeBtns = page.locator(".ep-mode-btn");
await expect(modeBtns.first()).toBeVisible() await expect(modeBtns.first()).toBeVisible();
const modeCount = await modeBtns.count() const modeCount = await modeBtns.count();
expect(modeCount).toBeGreaterThanOrEqual(2) expect(modeCount).toBeGreaterThanOrEqual(2);
}) });
}) });
test.describe("剪辑计划 - API 操作", () => { test.describe("剪辑计划 - API 操作", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("创建剪辑计划 - 正向", async ({ request }) => { test("创建剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-create") const { headers } = await createAuthedUser(request, "ep-create");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const templateName = `E2E 创建测试 ${suffix}` const templateName = `E2E 创建测试 ${suffix}`;
const response = await request.post(`${apiBase}/templates`, { const response = await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -225,22 +233,22 @@ test.describe("剪辑计划 - API 操作", () => {
], ],
tags: ["e2e"], tags: ["e2e"],
}, },
}) });
expect( expect(
response.ok(), response.ok(),
`创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.id, "应返回模板 ID").toBeTruthy() expect(data.id, "应返回模板 ID").toBeTruthy();
expect(data.name).toBe(templateName) expect(data.name).toBe(templateName);
expect(data.mode).toBe("pip") expect(data.mode).toBe("pip");
}) });
test("列出剪辑计划 - 正向", async ({ request }) => { test("列出剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-list") const { headers } = await createAuthedUser(request, "ep-list");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
// 创建 2 个模板 // 创建 2 个模板
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
@@ -250,15 +258,10 @@ test.describe("剪辑计划 - API 操作", () => {
mode: "pip", mode: "pip",
estimated_duration: 30, estimated_duration: 30,
segments: [ segments: [
{ { segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
segment_order: 1,
duration_min: 5,
duration_max: 10,
material_type: "video",
},
], ],
}, },
}) });
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
data: { data: {
@@ -266,89 +269,105 @@ test.describe("剪辑计划 - API 操作", () => {
mode: "voice_over", mode: "voice_over",
estimated_duration: 60, estimated_duration: 60,
segments: [ segments: [
{ { segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
segment_order: 1,
duration_min: 10,
duration_max: 30,
material_type: "video",
},
], ],
}, },
}) });
const response = await request.get(`${apiBase}/templates`, { headers }) const response = await request.get(`${apiBase}/templates`, { headers });
expect(response.ok(), `列出模板应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`列出模板应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || data.templates || [] const items = data.items || data.templates || [];
expect(Array.isArray(items), "返回应为数组").toBeTruthy() expect(Array.isArray(items), "返回应为数组").toBeTruthy();
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2) expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2);
}) });
test("获取剪辑计划详情 - 正向", async ({ request }) => { test("获取剪辑计划详情 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-detail") const { headers } = await createAuthedUser(request, "ep-detail");
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36)) const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
const response = await request.get(`${apiBase}/templates/${templateId}`, { const response = await request.get(`${apiBase}/templates/${templateId}`, {
headers, headers,
}) });
expect(response.ok(), `获取详情应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取详情应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.id).toBe(templateId) expect(data.id).toBe(templateId);
expect(data.name).toBeTruthy() expect(data.name).toBeTruthy();
expect(data.mode).toBeTruthy() expect(data.mode).toBeTruthy();
}) });
test("编辑剪辑计划 - 正向", async ({ request }) => { test("编辑剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-update") const { headers } = await createAuthedUser(request, "ep-update");
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36)) const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
const newName = `更新后的剪辑计划 ${Date.now()}` const newName = `更新后的剪辑计划 ${Date.now()}`;
const response = await request.patch(`${apiBase}/templates/${templateId}`, { const response = await request.patch(`${apiBase}/templates/${templateId}`, {
headers, headers,
data: { data: {
name: newName, name: newName,
description: "更新后的描述", description: "更新后的描述",
}, },
}) });
expect( expect(
response.ok(), response.ok(),
`更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`, `更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.name).toBe(newName) expect(data.name).toBe(newName);
// 验证更新后的数据 // 验证更新后的数据
const verify = await request.get(`${apiBase}/templates/${templateId}`, { const verify = await request.get(`${apiBase}/templates/${templateId}`, {
headers, headers,
}) });
const verifyData = await verify.json() const verifyData = await verify.json();
expect(verifyData.name).toBe(newName) expect(verifyData.name).toBe(newName);
}) });
test("删除剪辑计划 - 正向", async ({ request }) => { test("删除剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-delete") const { headers } = await createAuthedUser(request, "ep-delete");
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36)) const templateId = await createEditingTemplate(
request,
headers,
Date.now().toString(36),
);
// 删除 // 删除
const deleteResp = await request.delete(`${apiBase}/templates/${templateId}`, { headers }) const deleteResp = await request.delete(
`${apiBase}/templates/${templateId}`,
{ headers },
);
expect( expect(
[200, 204].includes(deleteResp.status()), [200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`, `删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy() ).toBeTruthy();
// 验证已删除 // 验证已删除
const getResp = await request.get(`${apiBase}/templates/${templateId}`, { const getResp = await request.get(`${apiBase}/templates/${templateId}`, {
headers, headers,
}) });
expect([404, 410]).toContain(getResp.status()) expect([404, 410]).toContain(getResp.status());
}) });
test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => { test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-badmode") const { headers } = await createAuthedUser(request, "ep-badmode");
const response = await request.post(`${apiBase}/templates`, { const response = await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -358,17 +377,20 @@ test.describe("剪辑计划 - API 操作", () => {
estimated_duration: 30, estimated_duration: 30,
segments: [], segments: [],
}, },
}) });
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
test("获取不存在的剪辑计划 - 反向", async ({ request }) => { test("获取不存在的剪辑计划 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-404") const { headers } = await createAuthedUser(request, "ep-404");
const response = await request.get(`${apiBase}/templates/nonexistent-template-999`, { headers }) const response = await request.get(
expect(response.status(), "不存在的模板应返回 404").toBe(404) `${apiBase}/templates/nonexistent-template-999`,
}) { headers },
);
expect(response.status(), "不存在的模板应返回 404").toBe(404);
});
test("未登录创建剪辑计划 - 反向", async ({ request }) => { test("未登录创建剪辑计划 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/templates`, { const response = await request.post(`${apiBase}/templates`, {
@@ -378,77 +400,81 @@ test.describe("剪辑计划 - API 操作", () => {
estimated_duration: 30, estimated_duration: 30,
segments: [], segments: [],
}, },
}) });
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
}) });
test.describe("剪辑策划页面 - 已模板数据加载", () => { test.describe("剪辑策划页面 - 已模板数据加载", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("已创建的模板在页面中显示", async ({ page, request }) => { test("已创建的模板在页面中显示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "ep-data");
"ep-data", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
await createEditingTemplate(request, headers, suffix) await createEditingTemplate(request, headers, suffix);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-data", display_name: "E2E ep-data",
}) });
await page.goto("/app/editing-planner") await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证状态栏存在 // 验证状态栏存在
await expect(page.locator(".ep-status-bar")).toBeVisible() await expect(page.locator(".ep-status-bar")).toBeVisible();
}) });
test("撤销/重做按钮存在", async ({ page, request }) => { test("撤销/重做按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-undo") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-undo",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-undo", display_name: "E2E ep-undo",
}) });
await page.goto("/app/editing-planner") await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证顶栏按钮存在(撤销、重做、保存、生成等) // 验证顶栏按钮存在(撤销、重做、保存、生成等)
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn") const topBarBtns = page.locator(".ep-top-bar-right .ep-btn");
await expect(topBarBtns.first()).toBeVisible() await expect(topBarBtns.first()).toBeVisible();
const btnCount = await topBarBtns.count() const btnCount = await topBarBtns.count();
expect(btnCount).toBeGreaterThanOrEqual(2) expect(btnCount).toBeGreaterThanOrEqual(2);
}) });
test("生成按钮存在", async ({ page, request }) => { test("生成按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-gen") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"ep-gen",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-gen", display_name: "E2E ep-gen",
}) });
await page.goto("/app/editing-planner") await page.goto("/app/editing-planner");
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证主操作按钮存在 // 验证主操作按钮存在
await expect(page.locator(".ep-btn-primary")).toBeVisible() await expect(page.locator(".ep-btn-primary")).toBeVisible();
}) });
}) });
+272 -318
View File
@@ -7,29 +7,33 @@
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。 * 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
* 真实的生成流程测试见 core-generation.spec.ts。 * 真实的生成流程测试见 core-generation.spec.ts。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!" const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { const routeBrowserApiToTestApi = async (
if (!apiOrigin) return page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -42,30 +46,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -73,14 +77,14 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** Mock 产品数据 */ /** Mock 产品数据 */
function mockProducts(count: number, statuses: string[] = ["completed"]) { function mockProducts(count: number, statuses: string[] = ["completed"]) {
const products = [] const products = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const status = statuses[i % statuses.length] const status = statuses[i % statuses.length];
products.push({ products.push({
id: `mock-prod-${Date.now()}-${i}`, id: `mock-prod-${Date.now()}-${i}`,
title: `测试作品 ${i + 1}`, title: `测试作品 ${i + 1}`,
@@ -93,9 +97,9 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
thumbnail_url: undefined, thumbnail_url: undefined,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) });
} }
return products return products;
} }
/** 在浏览器中设置登录态 */ /** 在浏览器中设置登录态 */
@@ -106,14 +110,14 @@ async function setupAuthInBrowser(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -127,14 +131,17 @@ async function setupAuthInBrowser(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
/** Mock 产品列表 API */ /** Mock 产品列表 API */
async function mockProductsApi(page: import("@playwright/test").Page, products: unknown[]) { async function mockProductsApi(
page: import("@playwright/test").Page,
products: unknown[],
) {
await page.route("**/api/v1/products", (route) => { await page.route("**/api/v1/products", (route) => {
const method = route.request().method() const method = route.request().method();
const url = route.request().url() const url = route.request().url();
if (method === "GET" && url.match(/\/api\/v1\/products$/)) { if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
// 列表 // 列表
@@ -142,29 +149,31 @@ async function mockProductsApi(page: import("@playwright/test").Page, products:
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }), body: JSON.stringify({ items: products, total: products.length }),
}) });
return return;
} }
// 单个产品详情 // 单个产品详情
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/) const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
if (method === "GET" && detailMatch) { if (method === "GET" && detailMatch) {
const productId = detailMatch[1] const productId = detailMatch[1];
const product = (products as Array<{ id: string }>).find((p) => p.id === productId) const product = (products as Array<{ id: string }>).find(
(p) => p.id === productId,
);
if (product) { if (product) {
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(product), body: JSON.stringify(product),
}) });
} else { } else {
route.fulfill({ route.fulfill({
status: 404, status: 404,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ detail: "Not found" }), body: JSON.stringify({ detail: "Not found" }),
}) });
} }
return return;
} }
// 删除 // 删除
@@ -173,8 +182,8 @@ async function mockProductsApi(page: import("@playwright/test").Page, products:
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ message: "deleted" }), body: JSON.stringify({ message: "deleted" }),
}) });
return return;
} }
// 下载链接 // 下载链接
@@ -186,531 +195,476 @@ async function mockProductsApi(page: import("@playwright/test").Page, products:
url: "https://example.com/download.mp4", url: "https://example.com/download.mp4",
expires_at: new Date().toISOString(), expires_at: new Date().toISOString(),
}), }),
}) });
return return;
} }
route.continue() route.continue();
}) });
} }
test.describe("作品库页面", () => { test.describe("作品库页面", () => {
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
// ─── 页面加载 ────────────────────────────────────── // ─── 页面加载 ──────────────────────────────────────
test("作品库列表页面加载", async ({ page, request }) => { test("作品库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-load", "products-load",
) );
const products = mockProducts(3, ["completed", "processing", "failed"]) const products = mockProducts(3, ["completed", "processing", "failed"]);
await mockProductsApi(page, products) await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
// 页面容器 // 页面容器
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 页面标题 // 页面标题
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible() await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible();
// 筛选栏 // 筛选栏
await expect(page.locator(".xx-products-filters")).toBeVisible() await expect(page.locator(".xx-products-filters")).toBeVisible();
// 卡片网格 // 卡片网格
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
// 作品卡片存在 // 作品卡片存在
await expect(page.locator(".xx-product-card")).toHaveCount(3, { await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000, timeout: 10_000,
}) });
}) });
// ─── 状态展示 ────────────────────────────────────── // ─── 状态展示 ──────────────────────────────────────
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => { test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-status", "products-status",
) );
const products = [ const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" }, { ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
{ { ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
...mockProducts(1, ["processing"])[0], { ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
title: "处理中作品", ];
id: `mock-prod-${Date.now()}-p`, await mockProductsApi(page, products);
},
{
...mockProducts(1, ["failed"])[0],
title: "失败作品",
id: `mock-prod-${Date.now()}-f`,
},
]
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 等待卡片加载 // 等待卡片加载
await expect(page.locator(".xx-product-card")).toHaveCount(3, { await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000, timeout: 10_000,
}) });
// 验证各状态标签存在 // 验证各状态标签存在
const completedCard = page.locator(".xx-product-card").filter({ hasText: "已完成作品" }) const completedCard = page
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText("已完成") .locator(".xx-product-card")
.filter({ hasText: "已完成作品" });
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
"已完成",
);
const processingCard = page.locator(".xx-product-card").filter({ hasText: "处理中作品" }) const processingCard = page
await expect(processingCard.locator(".xx-product-status.processing")).toHaveText("处理中") .locator(".xx-product-card")
.filter({ hasText: "处理中作品" });
await expect(
processingCard.locator(".xx-product-status.processing"),
).toHaveText("处理中");
const failedCard = page.locator(".xx-product-card").filter({ hasText: "失败作品" }) const failedCard = page
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText("失败") .locator(".xx-product-card")
}) .filter({ hasText: "失败作品" });
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText(
"失败",
);
});
// ─── 作品详情页 ──────────────────────────────────── // ─── 作品详情页 ────────────────────────────────────
test("作品详情页打开", async ({ page, request }) => { test("作品详情页打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-detail", "products-detail",
) );
const products = mockProducts(1, ["completed"]) const products = mockProducts(1, ["completed"]);
products[0].title = "详情页测试作品" products[0].title = "详情页测试作品";
const productId = products[0].id const productId = products[0].id;
await mockProductsApi(page, products) await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
// 直接访问详情页 // 直接访问详情页
await page.goto(`/app/products/${productId}`) await page.goto(`/app/products/${productId}`);
// 验证 URL // 验证 URL
await expect(page).toHaveURL(/\/app\/products\//) await expect(page).toHaveURL(/\/app\/products\//);
// 页面应正常渲染(无错误) // 页面应正常渲染(无错误)
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, { await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}) });
}) });
// ─── 视频播放 ────────────────────────────────────── // ─── 视频播放 ──────────────────────────────────────
test("视频播放器存在(播放弹窗)", async ({ page, request }) => { test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-play", "products-play",
) );
const products = mockProducts(1, ["completed"]) const products = mockProducts(1, ["completed"]);
products[0].title = "播放测试作品" products[0].title = "播放测试作品";
products[0].video_url = "https://example.com/test-video.mp4" products[0].video_url = "https://example.com/test-video.mp4";
await mockProductsApi(page, products) await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 点击作品卡片打开播放 // 点击作品卡片打开播放
const productCard = page.locator(".xx-product-card").filter({ hasText: "播放测试作品" }) const productCard = page
await expect(productCard).toBeVisible() .locator(".xx-product-card")
.filter({ hasText: "播放测试作品" });
await expect(productCard).toBeVisible();
// 点击播放按钮 // 点击播放按钮
await productCard.locator(".xx-product-play").click({ force: true }) await productCard.locator(".xx-product-play").click({ force: true });
// 播放弹窗出现 - 验证有视频元素或播放器容器 // 播放弹窗出现 - 验证有视频元素或播放器容器
// (通过 Mock 的 video_urlvideo 元素应能渲染) // (通过 Mock 的 video_urlvideo 元素应能渲染)
const videoEl = page.locator("video") const videoEl = page.locator("video");
const videoVisible = await videoEl const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
.first()
.isVisible({ timeout: 5000 })
.catch(() => false)
// 或弹窗容器可见 // 或弹窗容器可见
const modalVisible = await page const modalVisible = await page
.locator(".ant-modal-content") .locator(".ant-modal-content")
.filter({ hasText: "播放测试作品" }) .filter({ hasText: "播放测试作品" })
.isVisible() .isVisible()
.catch(() => false) .catch(() => false);
expect(videoVisible || modalVisible).toBeTruthy() expect(videoVisible || modalVisible).toBeTruthy();
}) });
// ─── 下载按钮 ────────────────────────────────────── // ─── 下载按钮 ──────────────────────────────────────
test("下载按钮存在", async ({ page, request }) => { test("下载按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-download", "products-download",
) );
const products = mockProducts(1, ["completed"]) const products = mockProducts(1, ["completed"]);
products[0].title = "下载测试作品" products[0].title = "下载测试作品";
await mockProductsApi(page, products) await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const productCard = page.locator(".xx-product-card").filter({ hasText: "下载测试作品" }) const productCard = page
await expect(productCard).toBeVisible() .locator(".xx-product-card")
.filter({ hasText: "下载测试作品" });
await expect(productCard).toBeVisible();
// 下载按钮存在且可用(已完成状态) // 下载按钮存在且可用(已完成状态)
const downloadBtn = productCard.getByRole("button", { name: "下载" }) const downloadBtn = productCard.getByRole("button", { name: "下载" });
await expect(downloadBtn).toBeVisible() await expect(downloadBtn).toBeVisible();
await expect(downloadBtn).not.toBeDisabled() await expect(downloadBtn).not.toBeDisabled();
}) });
test("处理中作品下载按钮禁用", async ({ page, request }) => { test("处理中作品下载按钮禁用", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-disabled", "products-disabled",
) );
const products = mockProducts(1, ["processing"]) const products = mockProducts(1, ["processing"]);
products[0].title = "处理中下载测试" products[0].title = "处理中下载测试";
await mockProductsApi(page, products) await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const productCard = page.locator(".xx-product-card").filter({ hasText: "处理中下载测试" }) const productCard = page
await expect(productCard).toBeVisible() .locator(".xx-product-card")
.filter({ hasText: "处理中下载测试" });
await expect(productCard).toBeVisible();
// 处理中的作品下载按钮应禁用 // 处理中的作品下载按钮应禁用
const downloadBtn = productCard.getByRole("button", { name: "下载" }) const downloadBtn = productCard.getByRole("button", { name: "下载" });
await expect(downloadBtn).toBeVisible() await expect(downloadBtn).toBeVisible();
const isDisabled = await downloadBtn.isDisabled() const isDisabled = await downloadBtn.isDisabled();
const hasDisabled = await downloadBtn.evaluate( const hasDisabled = await downloadBtn.evaluate(
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"), (el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
) );
expect(isDisabled || hasDisabled).toBeTruthy() expect(isDisabled || hasDisabled).toBeTruthy();
}) });
// ─── 删除作品 ────────────────────────────────────── // ─── 删除作品 ──────────────────────────────────────
test("删除作品 - API 调用正确", async ({ page, request }) => { test("删除作品 - API 调用正确", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-delete", "products-delete",
) );
const products = mockProducts(1, ["completed"]) const products = mockProducts(1, ["completed"]);
products[0].title = "待删除作品" products[0].title = "待删除作品";
let deleteCalled = false let deleteCalled = false;
let deletedId = "" let deletedId = "";
await page.route("**/api/v1/products", (route) => { await page.route("**/api/v1/products", (route) => {
const method = route.request().method() const method = route.request().method();
const url = route.request().url() const url = route.request().url();
if (method === "GET" && url.match(/\/api\/v1\/products$/)) { if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }), body: JSON.stringify({ items: products, total: products.length }),
}) });
return return;
} }
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/) const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
if (method === "DELETE" && detailMatch) { if (method === "DELETE" && detailMatch) {
deleteCalled = true deleteCalled = true;
deletedId = detailMatch[1] deletedId = detailMatch[1];
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ message: "deleted" }), body: JSON.stringify({ message: "deleted" }),
}) });
return return;
} }
if (method === "GET" && detailMatch) { if (method === "GET" && detailMatch) {
const productId = detailMatch[1] const productId = detailMatch[1];
const product = products.find((p) => p.id === productId) const product = products.find((p) => p.id === productId);
route.fulfill({ route.fulfill({
status: product ? 200 : 404, status: product ? 200 : 404,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(product || { detail: "Not found" }), body: JSON.stringify(product || { detail: "Not found" }),
}) });
return return;
} }
route.continue() route.continue();
}) });
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const productCard = page.locator(".xx-product-card").filter({ hasText: "待删除作品" }) const productCard = page
await expect(productCard).toBeVisible() .locator(".xx-product-card")
.filter({ hasText: "待删除作品" });
await expect(productCard).toBeVisible();
// 验证 DELETE API 存在于 products API 中 // 验证 DELETE API 存在于 products API 中
// 我们通过检查实际 API 来确认删除功能可用 // 我们通过检查实际 API 来确认删除功能可用
// (mock 只是为了测试 UI 行为) // (mock 只是为了测试 UI 行为)
expect(deleteCalled).toBe(false) // 初始状态未调用 expect(deleteCalled).toBe(false); // 初始状态未调用
expect(deletedId).toBe("") expect(deletedId).toBe("");
}) });
test("删除作品 API 端点存在", async ({ request }) => { test("删除作品 API 端点存在", async ({ request }) => {
const { headers } = await createAuthedUser(request, "products-del-api") const { headers } = await createAuthedUser(request, "products-del-api");
// 测试删除不存在的产品,验证 API 端点存在 // 测试删除不存在的产品,验证 API 端点存在
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, { const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
headers, headers,
}) });
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在) // 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
// 404 表示资源不存在但端点存在 // 404 表示资源不存在但端点存在
expect(resp.status(), "删除 API 端点应存在").not.toBe(405) expect(resp.status(), "删除 API 端点应存在").not.toBe(405);
expect([200, 204, 403, 404]).toContain(resp.status()) expect([200, 204, 403, 404]).toContain(resp.status());
}) });
// ─── 空状态 ──────────────────────────────────────── // ─── 空状态 ────────────────────────────────────────
test("空状态展示", async ({ page, request }) => { test("空状态展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-empty", "products-empty",
) );
// Mock 空列表 // Mock 空列表
await mockProductsApi(page, []) await mockProductsApi(page, []);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 空状态应显示 // 空状态应显示
await expect(page.locator(".xx-products-empty")).toBeVisible({ await expect(page.locator(".xx-products-empty")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible() await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible();
}) });
// ─── 搜索筛选 ────────────────────────────────────── // ─── 搜索筛选 ──────────────────────────────────────
test("作品搜索功能", async ({ page, request }) => { test("作品搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-search", "products-search",
) );
const products = [ const products = [
{ { ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
...mockProducts(1, ["completed"])[0], { ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
title: "苹果宣传视频", ];
id: `mock-prod-${Date.now()}-apple`, await mockProductsApi(page, products);
},
{
...mockProducts(1, ["completed"])[0],
title: "香蕉推广视频",
id: `mock-prod-${Date.now()}-banana`,
},
]
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 两个作品都可见 // 两个作品都可见
await expect(page.getByText("苹果宣传视频")).toBeVisible({ await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
timeout: 5_000, await expect(page.getByText("香蕉推广视频")).toBeVisible();
})
await expect(page.getByText("香蕉推广视频")).toBeVisible()
// 搜索"苹果" // 搜索"苹果"
await page.getByPlaceholder("搜索成片名称...").fill("苹果") await page.getByPlaceholder("搜索成片名称...").fill("苹果");
await expect(page.getByText("苹果宣传视频")).toBeVisible() await expect(page.getByText("苹果宣传视频")).toBeVisible();
await expect(page.getByText("香蕉推广视频")).toHaveCount(0) await expect(page.getByText("香蕉推广视频")).toHaveCount(0);
// 清空搜索 // 清空搜索
await page.getByPlaceholder("搜索成片名称...").fill("") await page.getByPlaceholder("搜索成片名称...").fill("");
await expect(page.getByText("香蕉推广视频")).toBeVisible({ await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
timeout: 5_000, });
})
})
test("作品状态筛选", async ({ page, request }) => { test("作品状态筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-filter-status", "products-filter-status",
) );
const products = [ const products = [
{ { ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
...mockProducts(1, ["completed"])[0], { ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
title: "已完成筛选", ];
id: `mock-prod-${Date.now()}-done`, await mockProductsApi(page, products);
},
{
...mockProducts(1, ["processing"])[0],
title: "处理中筛选",
id: `mock-prod-${Date.now()}-proc`,
},
]
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 两个都可见 // 两个都可见
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 }) await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("处理中筛选")).toBeVisible() await expect(page.getByText("处理中筛选")).toBeVisible();
// 状态筛选下拉存在 // 状态筛选下拉存在
const selects = page.locator(".xx-products-filters-left select") const selects = page.locator(".xx-products-filters-left select");
const count = await selects.count() const count = await selects.count();
if (count >= 2) { if (count >= 2) {
// 第2个 select 是状态筛选 // 第2个 select 是状态筛选
await selects.nth(1).selectOption({ label: "已完成" }) await selects.nth(1).selectOption({ label: "已完成" });
await expect(page.getByText("已完成筛选")).toBeVisible() await expect(page.getByText("已完成筛选")).toBeVisible();
await expect(page.getByText("处理中筛选")).toHaveCount(0) await expect(page.getByText("处理中筛选")).toHaveCount(0);
} }
}) });
// ─── 批量操作 ────────────────────────────────────── // ─── 批量操作 ──────────────────────────────────────
test("批量选择和批量操作栏", async ({ page, request }) => { test("批量选择和批量操作栏", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-batch", "products-batch",
) );
const products = mockProducts(3, ["completed"]) const products = mockProducts(3, ["completed"]);
products[0].title = "批量测试 1" products[0].title = "批量测试 1";
products[1].title = "批量测试 2" products[1].title = "批量测试 2";
products[2].title = "批量测试 3" products[2].title = "批量测试 3";
await mockProductsApi(page, products) await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
id: userId,
email,
username,
})
await page.goto("/app/products") await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 三张卡片 // 三张卡片
await expect(page.locator(".xx-product-card")).toHaveCount(3, { await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000, timeout: 10_000,
}) });
// 点击第一张卡片的复选框 // 点击第一张卡片的复选框
const firstCard = page.locator(".xx-product-card").first() const firstCard = page.locator(".xx-product-card").first();
const checkbox = firstCard.locator(".xx-product-card-checkbox") const checkbox = firstCard.locator(".xx-product-card-checkbox");
await expect(checkbox).toBeVisible() await expect(checkbox).toBeVisible();
await checkbox.click() await checkbox.click();
// 批量操作栏应出现 // 批量操作栏应出现
const batchBar = page.locator(".xx-products-batch-bar") const batchBar = page.locator(".xx-products-batch-bar");
await expect(batchBar).toBeVisible({ timeout: 5_000 }) await expect(batchBar).toBeVisible({ timeout: 5_000 });
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible() await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
// 批量按钮存在 // 批量按钮存在
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible() await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible() await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
// 取消选择 // 取消选择
await batchBar.getByRole("button", { name: "取消选择" }).click() await batchBar.getByRole("button", { name: "取消选择" }).click();
await expect(batchBar).not.toBeVisible({ timeout: 3_000 }) await expect(batchBar).not.toBeVisible({ timeout: 3_000 });
}) });
// ─── 未登录访问 ──────────────────────────────────── // ─── 未登录访问 ────────────────────────────────────
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => { test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/products") await page.goto("/app/products");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
+177 -157
View File
@@ -6,29 +6,31 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -41,30 +43,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -72,7 +74,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 设置页面认证状态(localStorage */ /** 设置页面认证状态(localStorage */
@@ -83,14 +85,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -104,204 +106,210 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
test.describe("个人设置页面 - 未登录重定向", () => { test.describe("个人设置页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
test.describe("个人设置页面 - 页面加载", () => { test.describe("个人设置页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("设置页面加载成功", async ({ page, request }) => { test("设置页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-load") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-load", display_name: "E2E profile-load",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
}) });
test("页面标题存在", async ({ page, request }) => { test("页面标题存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-title", "profile-title",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-title", display_name: "E2E profile-title",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证页面包含"个人设置"标题 // 验证页面包含"个人设置"标题
const heading = page.getByRole("heading", { name: /个人设置/ }) const heading = page.getByRole("heading", { name: /个人设置/ });
await expect(heading.first()).toBeVisible({ timeout: 10_000 }) await expect(heading.first()).toBeVisible({ timeout: 10_000 });
}) });
}) });
test.describe("个人设置 - 个人信息展示", () => { test.describe("个人设置 - 个人信息展示", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("个人信息卡片展示", async ({ page, request }) => { test("个人信息卡片展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-info") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-info",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-info", display_name: "E2E profile-info",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证设置卡片存在 // 验证设置卡片存在
await expect(page.locator(".xx-settings-card")).toBeVisible() await expect(page.locator(".xx-settings-card")).toBeVisible();
}) });
test("用户名、邮箱字段展示", async ({ page, request }) => { test("用户名、邮箱字段展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-fields", "profile-fields",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-fields", display_name: "E2E profile-fields",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证表单字段存在 // 验证表单字段存在
const fields = page.locator(".xx-settings-field") const fields = page.locator(".xx-settings-field");
await expect(fields.first()).toBeVisible() await expect(fields.first()).toBeVisible();
const fieldCount = await fields.count() const fieldCount = await fields.count();
expect(fieldCount).toBeGreaterThanOrEqual(2) expect(fieldCount).toBeGreaterThanOrEqual(2);
}) });
test("用户名标签和输入框存在", async ({ page, request }) => { test("用户名标签和输入框存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-username", "profile-username",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-username", display_name: "E2E profile-username",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证用户名标签 // 验证用户名标签
const usernameLabel = page.locator(".xx-settings-label").filter({ const usernameLabel = page.locator(".xx-settings-label").filter({
hasText: "用户名", hasText: "用户名",
}) });
await expect(usernameLabel).toBeVisible() await expect(usernameLabel).toBeVisible();
// 验证邮箱标签 // 验证邮箱标签
const emailLabel = page.locator(".xx-settings-label").filter({ const emailLabel = page.locator(".xx-settings-label").filter({
hasText: "邮箱", hasText: "邮箱",
}) });
await expect(emailLabel).toBeVisible() await expect(emailLabel).toBeVisible();
}) });
test("显示名称字段可编辑", async ({ page, request }) => { test("显示名称字段可编辑", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-dispname", "profile-dispname",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-dispname", display_name: "E2E profile-dispname",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 查找显示名称输入框 // 查找显示名称输入框
const displayNameField = page.locator(".xx-settings-field").filter({ const displayNameField = page.locator(".xx-settings-field").filter({
has: page.locator(".xx-settings-label", { hasText: "显示名称" }), has: page.locator(".xx-settings-label", { hasText: "显示名称" }),
}) });
if (await displayNameField.isVisible()) { if (await displayNameField.isVisible()) {
const input = displayNameField.locator("input") const input = displayNameField.locator("input");
if (await input.isVisible()) { if (await input.isVisible()) {
// 验证输入框存在且可输入 // 验证输入框存在且可输入
await expect(input).toBeVisible() await expect(input).toBeVisible();
const initialValue = await input.inputValue() const initialValue = await input.inputValue();
await input.fill("新的显示名称") await input.fill("新的显示名称");
await expect(input).toHaveValue("新的显示名称") await expect(input).toHaveValue("新的显示名称");
// 恢复原值 // 恢复原值
await input.fill(initialValue) await input.fill(initialValue);
} }
} }
}) });
}) });
test.describe("个人设置 - 修改密码", () => { test.describe("个人设置 - 修改密码", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("修改密码 API - 正向", async ({ request }) => { test("修改密码 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(request, "profile-chpwd") const { headers, email } = await createAuthedUser(request, "profile-chpwd");
const newPassword = "NewPass123456!" const newPassword = "NewPass123456!";
const response = await request.post(`${apiBase}/auth/change-password`, { const response = await request.post(`${apiBase}/auth/change-password`, {
headers, headers,
data: { data: {
old_password: PASSWORD, old_password: PASSWORD,
new_password: newPassword, new_password: newPassword,
}, },
}) });
// 修改密码可能成功或接口不存在 // 修改密码可能成功或接口不存在
expect( expect(
response.status() < 500, response.status() < 500,
`修改密码应返回 2xx 或 4xx,实际: ${response.status()}`, `修改密码应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy() ).toBeTruthy();
// 如果成功,用新密码登录验证 // 如果成功,用新密码登录验证
if (response.ok()) { if (response.ok()) {
const loginResp = await loginWithRetry(request, email, newPassword) const loginResp = await loginWithRetry(request, email, newPassword);
expect(loginResp.ok(), "新密码应能登录").toBeTruthy() expect(loginResp.ok(), "新密码应能登录").toBeTruthy();
} }
}) });
test("修改密码 - 旧密码错误反向", async ({ request }) => { test("修改密码 - 旧密码错误反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-badpwd") const { headers } = await createAuthedUser(request, "profile-badpwd");
const response = await request.post(`${apiBase}/auth/change-password`, { const response = await request.post(`${apiBase}/auth/change-password`, {
headers, headers,
@@ -309,17 +317,17 @@ test.describe("个人设置 - 修改密码", () => {
old_password: "WrongOldPass123!", old_password: "WrongOldPass123!",
new_password: "NewPass123456!", new_password: "NewPass123456!",
}, },
}) });
// 如果接口存在,应该返回 400/401 // 如果接口存在,应该返回 400/401
if (response.status() < 500 && response.status() >= 400) { if (response.status() < 500 && response.status() >= 400) {
expect([400, 401]).toContain(response.status()) expect([400, 401]).toContain(response.status());
} }
// 接口不存在(404)也正常 // 接口不存在(404)也正常
}) });
test("修改密码 - 新密码太弱反向", async ({ request }) => { test("修改密码 - 新密码太弱反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-weakpwd") const { headers } = await createAuthedUser(request, "profile-weakpwd");
const response = await request.post(`${apiBase}/auth/change-password`, { const response = await request.post(`${apiBase}/auth/change-password`, {
headers, headers,
@@ -327,12 +335,12 @@ test.describe("个人设置 - 修改密码", () => {
old_password: PASSWORD, old_password: PASSWORD,
new_password: "123", new_password: "123",
}, },
}) });
if (response.status() < 500 && response.status() >= 400) { if (response.status() < 500 && response.status() >= 400) {
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
} }
}) });
test("未登录修改密码 - 反向", async ({ request }) => { test("未登录修改密码 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/change-password`, { const response = await request.post(`${apiBase}/auth/change-password`, {
@@ -340,115 +348,127 @@ test.describe("个人设置 - 修改密码", () => {
old_password: "old", old_password: "old",
new_password: "new", new_password: "new",
}, },
}) });
expect([401, 403, 404]).toContain(response.status()) expect([401, 403, 404]).toContain(response.status());
}) });
}) });
test.describe("个人设置 - 账号安全", () => { test.describe("个人设置 - 账号安全", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("获取当前用户信息 - 正向", async ({ request }) => { test("获取当前用户信息 - 正向", async ({ request }) => {
const { headers, email, username } = await createAuthedUser(request, "profile-me") const { headers, email, username } = await createAuthedUser(
request,
"profile-me",
);
const response = await request.get(`${apiBase}/auth/me`, { headers }) const response = await request.get(`${apiBase}/auth/me`, { headers });
expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取用户信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.email).toBe(email) expect(data.email).toBe(email);
expect(data.username).toBe(username) expect(data.username).toBe(username);
}) });
test("账号安全区域提示信息存在", async ({ page, request }) => { test("账号安全区域提示信息存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-security", "profile-security",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-security", display_name: "E2E profile-security",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证通知区域存在 // 验证通知区域存在
const notice = page.locator(".xx-settings-notice") const notice = page.locator(".xx-settings-notice");
await expect(notice).toBeVisible() await expect(notice).toBeVisible();
}) });
}) });
test.describe("个人设置 - 退出登录", () => { test.describe("个人设置 - 退出登录", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("登出 API - 正向", async ({ request }) => { test("登出 API - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-logout") const { headers, email } = await createAuthedUser(request, "profile-logout");
const response = await request.post(`${apiBase}/auth/logout`, { const response = await request.post(`${apiBase}/auth/logout`, {
headers, headers,
}) });
expect(response.ok(), `登出应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`登出应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
// 登出后 token 应失效 // 登出后 token 应失效
const meResp = await request.get(`${apiBase}/auth/me`, { headers }) const meResp = await request.get(`${apiBase}/auth/me`, { headers });
expect([401, 403]).toContain(meResp.status()) expect([401, 403]).toContain(meResp.status());
}) });
test("登出后页面跳转登录页", async ({ page, request }) => { test("登出后页面跳转登录页", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-logout-ui", "profile-logout-ui",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-logout-ui", display_name: "E2E profile-logout-ui",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 清除 localStorage 模拟登出 // 清除 localStorage 模拟登出
await page.evaluate(() => { await page.evaluate(() => {
localStorage.removeItem("access_token") localStorage.removeItem("access_token");
localStorage.removeItem("auth-storage") localStorage.removeItem("auth-storage");
}) });
// 刷新页面应该重定向到登录页 // 刷新页面应该重定向到登录页
await page.reload() await page.reload();
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }) await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
}) });
}) });
test.describe("个人设置 - 保存按钮", () => { test.describe("个人设置 - 保存按钮", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("保存按钮存在", async ({ page, request }) => { test("保存按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-save") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"profile-save",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-save", display_name: "E2E profile-save",
}) });
await page.goto("/app/profile") await page.goto("/app/profile");
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证按钮存在 // 验证按钮存在
const button = page.getByRole("button", { name: /保存|暂未开放/ }) const button = page.getByRole("button", { name: /保存|暂未开放/ });
await expect(button.first()).toBeVisible({ timeout: 5_000 }) await expect(button.first()).toBeVisible({ timeout: 5_000 });
}) });
}) });
+130 -128
View File
@@ -4,17 +4,17 @@
* 覆盖:页面渲染、表单验证、成功注册、跳转链接 * 覆盖:页面渲染、表单验证、成功注册、跳转链接
* 每个测试独立,使用随机邮箱避免冲突。 * 每个测试独立,使用随机邮箱避免冲突。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */ /** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
@@ -27,154 +27,158 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
test.describe("注册页面", () => { test.describe("注册页面", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
// ─── 页面渲染 ────────────────────────────────────── // ─── 页面渲染 ──────────────────────────────────────
test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => { test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => {
await page.goto("/register") await page.goto("/register");
// 品牌标识 // 品牌标识
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪") await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
// 标题/描述 // 标题/描述
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible() 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.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() await expect(
}) page.locator("button[type='submit']").filter({ hasText: "注册" }),
).toBeVisible();
});
// ─── 表单验证 ────────────────────────────────────── // ─── 表单验证 ──────────────────────────────────────
test("空提交 - 显示必填错误", async ({ page }) => { test("空提交 - 显示必填错误", async ({ page }) => {
await page.goto("/register") await page.goto("/register");
// 直接点击注册按钮 // 直接点击注册按钮
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() 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();
await expect(page.getByText("请输入密码")).toBeVisible() await expect(page.getByText("请输入密码")).toBeVisible();
await expect(page.getByText("请确认密码")).toBeVisible() await expect(page.getByText("请确认密码")).toBeVisible();
}) });
test("无效邮箱格式 - 显示格式错误", async ({ page }) => { test("无效邮箱格式 - 显示格式错误", async ({ page }) => {
await page.goto("/register") await page.goto("/register");
await page.getByLabel("邮箱").fill("not-an-email") await page.getByLabel("邮箱").fill("not-an-email");
await page.getByLabel("用户名").fill("testuser") await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill(PASSWORD) await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD) await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示邮箱格式错误 // 应显示邮箱格式错误
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible() await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
}) });
test("密码太短 - 显示长度错误", async ({ page }) => { test("密码太短 - 显示长度错误", async ({ page }) => {
await page.goto("/register") await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd")) await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"));
await page.getByLabel("用户名").fill("testuser") await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill("123") await page.getByLabel("密码").fill("123");
await page.getByLabel("确认密码").fill("123") await page.getByLabel("确认密码").fill("123");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示密码长度错误 // 应显示密码长度错误
await expect(page.getByText("密码至少 8 个字符")).toBeVisible() await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
}) });
test("确认密码不一致 - 显示不一致错误", async ({ page }) => { test("确认密码不一致 - 显示不一致错误", async ({ page }) => {
await page.goto("/register") await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch")) await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"));
await page.getByLabel("用户名").fill("testuser") await page.getByLabel("用户名").fill("testuser");
await page.getByLabel("密码").fill(PASSWORD) await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill("Different123!") await page.getByLabel("确认密码").fill("Different123!");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示密码不一致错误 // 应显示密码不一致错误
await expect(page.getByText("两次输入的密码不一致")).toBeVisible() await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
}) });
test("用户名为空 - 显示必填错误", async ({ page }) => { test("用户名为空 - 显示必填错误", async ({ page }) => {
await page.goto("/register") await page.goto("/register");
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user")) await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"));
await page.getByLabel("用户名").fill("") await page.getByLabel("用户名").fill("");
await page.getByLabel("密码").fill(PASSWORD) await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD) await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await expect(page.getByText("请输入用户名")).toBeVisible() await expect(page.getByText("请输入用户名")).toBeVisible();
}) });
// ─── 成功注册 ────────────────────────────────────── // ─── 成功注册 ──────────────────────────────────────
test("成功注册 - 提交有效表单", async ({ page, _request }) => { test("成功注册 - 提交有效表单", async ({ page, request }) => {
const email = uniqueEmail("reg-ui-ok") const email = uniqueEmail("reg-ui-ok");
const username = uniqueUsername("reguiok") const username = uniqueUsername("reguiok");
await page.goto("/register") await page.goto("/register");
await page.getByLabel("邮箱").fill(email) await page.getByLabel("邮箱").fill(email);
await page.getByLabel("用户名").fill(username) await page.getByLabel("用户名").fill(username);
await page.getByLabel("密码").fill(PASSWORD) await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD) await page.getByLabel("确认密码").fill(PASSWORD);
// 监听注册请求 // 监听注册请求
const registerResponse = page.waitForResponse( const registerResponse = page.waitForResponse(
(resp) => resp.url().includes("/auth/register") && resp.request().method() === "POST", (resp) =>
resp.url().includes("/auth/register") &&
resp.request().method() === "POST",
{ timeout: 15_000 }, { timeout: 15_000 },
) );
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
const resp = await registerResponse const resp = await registerResponse;
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy() expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
// 注册成功后应跳转到登录页或显示成功消息 // 注册成功后应跳转到登录页或显示成功消息
// 页面应停留在可识别的状态(成功提示或跳转) // 页面应停留在可识别的状态(成功提示或跳转)
await expect await expect
.poll( .poll(
async () => { async () => {
const url = page.url() const url = page.url();
// 可能跳转到 login,也可能在当前页显示成功消息 // 可能跳转到 login,也可能在当前页显示成功消息
if (url.includes("/login")) return "redirected" if (url.includes("/login")) return "redirected";
const hasSuccess = await page.getByText(/注册成功/).isVisible() const hasSuccess = await page.getByText(/注册成功/).isVisible();
return hasSuccess ? "success_msg" : url return hasSuccess ? "success_msg" : url;
}, },
{ timeout: 10_000 }, { timeout: 10_000 },
) )
.toMatch(/redirected|success_msg/) .toMatch(/redirected|success_msg/);
}) });
test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => { test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => {
const email = uniqueEmail("reg-ui-dup") const email = uniqueEmail("reg-ui-dup");
const username1 = uniqueUsername("reguidup1") const username1 = uniqueUsername("reguidup1");
const username2 = uniqueUsername("reguidup2") const username2 = uniqueUsername("reguidup2");
// 先通过 API 注册一个账号 // 先通过 API 注册一个账号
const firstReg = await request.post(`${apiBase}/auth/register`, { const firstReg = await request.post(`${apiBase}/auth/register`, {
@@ -184,93 +188,91 @@ test.describe("注册页面", () => {
username: username1, username: username1,
display_name: "User 1", display_name: "User 1",
}, },
}) });
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy() expect(firstReg.ok(), "第一次注册应成功").toBeTruthy();
// 再在 UI 上用相同邮箱注册 // 再在 UI 上用相同邮箱注册
await page.goto("/register") await page.goto("/register");
await page.getByLabel("邮箱").fill(email) await page.getByLabel("邮箱").fill(email);
await page.getByLabel("用户名").fill(username2) await page.getByLabel("用户名").fill(username2);
await page.getByLabel("密码").fill(PASSWORD) await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD) await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示错误提示(通过 antd message 或表单错误) // 应显示错误提示(通过 antd message 或表单错误)
await expect await expect
.poll( .poll(
async () => { async () => {
// 检查是否有错误消息 // 检查是否有错误消息
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible() const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
return hasError ? "error_shown" : "waiting" return hasError ? "error_shown" : "waiting";
}, },
{ timeout: 10_000 }, { timeout: 10_000 },
) )
.toBe("error_shown") .toBe("error_shown");
}) });
// ─── 跳转链接 ────────────────────────────────────── // ─── 跳转链接 ──────────────────────────────────────
test("跳转到登录页的链接", async ({ page }) => { test("跳转到登录页的链接", async ({ page }) => {
await page.goto("/register") await page.goto("/register");
await page.getByRole("link", { name: "立即登录" }).click() await page.getByRole("link", { name: "立即登录" }).click();
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
await expect(page.getByLabel("邮箱")).toBeVisible() await expect(page.getByLabel("邮箱")).toBeVisible();
}) });
test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => { test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => {
await page.goto("/login") await page.goto("/login");
await page.getByRole("link", { name: "立即注册" }).click() await page.getByRole("link", { name: "立即注册" }).click();
await expect(page).toHaveURL(/\/register/) await expect(page).toHaveURL(/\/register/);
}) });
test("登录页有忘记密码链接", async ({ page }) => { test("登录页有忘记密码链接", async ({ page }) => {
await page.goto("/login") await page.goto("/login");
await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible() await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible();
await page.getByRole("link", { name: /忘记密码/ }).click() await page.getByRole("link", { name: /忘记密码/ }).click();
await expect(page).toHaveURL(/\/forgot-password/) await expect(page).toHaveURL(/\/forgot-password/);
}) });
// ─── 路由守卫 - 已登录用户访问注册页 ────────────── // ─── 路由守卫 - 已登录用户访问注册页 ──────────────
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({ page, request }) => { test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({
const email = uniqueEmail("reg-auth") page,
const username = uniqueUsername("regauth") request,
}) => {
const email = uniqueEmail("reg-auth");
const username = uniqueUsername("regauth");
// 注册 // 注册
await request.post(`${apiBase}/auth/register`, { await request.post(`${apiBase}/auth/register`, {
data: { data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
email, });
password: PASSWORD,
username,
display_name: "Reg Auth Test",
},
})
// 登录 // 登录
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), "登录应成功").toBeTruthy() expect(login.ok(), "登录应成功").toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
// 设置登录态 // 设置登录态
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -284,13 +286,13 @@ test.describe("注册页面", () => {
email_verified: true, email_verified: true,
}, },
}, },
) );
await page.goto("/register") await page.goto("/register");
// 注册页对已登录用户也可访问(注册页是公开页面) // 注册页对已登录用户也可访问(注册页是公开页面)
// 验证页面正常渲染 // 验证页面正常渲染
await expect(page.getByLabel("邮箱")).toBeVisible() await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible() await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
}) });
}) });
+246 -210
View File
@@ -9,29 +9,31 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -44,30 +46,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -75,7 +77,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 设置页面认证状态(localStorage */ /** 设置页面认证状态(localStorage */
@@ -86,14 +88,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -107,312 +109,337 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
test.describe("订阅套餐页 - 页面加载", () => { test.describe("订阅套餐页 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("订阅套餐页面加载成功", async ({ page, request }) => { test("订阅套餐页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-load") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-load", display_name: "E2E sub-load",
}) });
await page.goto("/app/subscription") await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
}) });
test("套餐卡片网格展示", async ({ page, request }) => { test("套餐卡片网格展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cards") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-cards",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-cards", display_name: "E2E sub-cards",
}) });
await page.goto("/app/subscription") await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证套餐卡片存在 // 验证套餐卡片存在
const planCards = page.locator(".xx-plan-card") const planCards = page.locator(".xx-plan-card");
await expect(planCards.first()).toBeVisible({ timeout: 10_000 }) await expect(planCards.first()).toBeVisible({ timeout: 10_000 });
const cardCount = await planCards.count() const cardCount = await planCards.count();
expect(cardCount).toBeGreaterThanOrEqual(2) expect(cardCount).toBeGreaterThanOrEqual(2);
}) });
test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => { test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cardinfo") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"sub-cardinfo",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-cardinfo", display_name: "E2E sub-cardinfo",
}) });
await page.goto("/app/subscription") await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const firstCard = page.locator(".xx-plan-card").first() const firstCard = page.locator(".xx-plan-card").first();
await expect(firstCard).toBeVisible({ timeout: 10_000 }) await expect(firstCard).toBeVisible({ timeout: 10_000 });
// 验证价格区域存在 // 验证价格区域存在
await expect(firstCard.locator(".xx-plan-price")).toBeVisible() await expect(firstCard.locator(".xx-plan-price")).toBeVisible();
// 验证特性列表存在 // 验证特性列表存在
await expect(firstCard.locator(".xx-features")).toBeVisible() await expect(firstCard.locator(".xx-features")).toBeVisible();
// 验证订阅按钮存在 // 验证订阅按钮存在
await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible() await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible();
}) });
test("推荐套餐有特殊标识", async ({ page, request }) => { test("推荐套餐有特殊标识", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-recommended", "sub-recommended",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-recommended", display_name: "E2E sub-recommended",
}) });
await page.goto("/app/subscription") await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证有推荐标签 // 验证有推荐标签
const featuredCard = page.locator(".xx-plan-card.featured") const featuredCard = page.locator(".xx-plan-card.featured");
if (await featuredCard.isVisible({ timeout: 5_000 })) { if (await featuredCard.isVisible({ timeout: 5_000 })) {
await expect(featuredCard.locator(".xx-badge")).toBeVisible() await expect(featuredCard.locator(".xx-badge")).toBeVisible();
} }
}) });
}) });
test.describe("订阅套餐页 - 升级交互", () => { test.describe("订阅套餐页 - 升级交互", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("点击升级套餐按钮跳转升级页", async ({ page, request }) => { test("点击升级套餐按钮跳转升级页", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-upgrade-btn", "sub-upgrade-btn",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-upgrade-btn", display_name: "E2E sub-upgrade-btn",
}) });
await page.goto("/app/subscription") await page.goto("/app/subscription");
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 点击一个订阅按钮 // 点击一个订阅按钮
const subscribeBtn = page.locator(".xx-subscribe-btn").first() const subscribeBtn = page.locator(".xx-subscribe-btn").first();
if (await subscribeBtn.isVisible({ timeout: 10_000 })) { if (await subscribeBtn.isVisible({ timeout: 10_000 })) {
await subscribeBtn.click() await subscribeBtn.click();
// 可能跳转到升级页或打开支付弹窗 // 可能跳转到升级页或打开支付弹窗
const url = page.url() const url = page.url();
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗) // 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
expect( expect(
url.includes("/subscription/upgrade") || url.includes("/subscription/upgrade") || url.includes("/subscription") ||
url.includes("/subscription") || (await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
(await page ).toBeTruthy();
.locator(".ant-modal, [role='dialog']")
.first()
.isVisible()
.catch(() => false)),
).toBeTruthy()
} }
}) });
test("升级套餐升级页面可访问", async ({ page, request }) => { test("升级套餐升级页面可访问", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-upgrade-page", "sub-upgrade-page",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-upgrade-page", display_name: "E2E sub-upgrade-page",
}) });
await page.goto("/app/subscription/upgrade") await page.goto("/app/subscription/upgrade");
// 升级页面应该可访问(可能跳转到订阅页或显示升级内容) // 升级页面应该可访问(可能跳转到订阅页或显示升级内容)
await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 }) await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 });
}) });
}) });
test.describe("订阅 - 账单列表页", () => { test.describe("订阅 - 账单列表页", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("账单页面加载成功", async ({ page, request }) => { test("账单页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-billing-load", "sub-billing-load",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-billing-load", display_name: "E2E sub-billing-load",
}) });
await page.goto("/app/subscription/billing") await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({ await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
}) });
test("账单概览区域展示", async ({ page, request }) => { test("账单概览区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-billing-overview", "sub-billing-overview",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-billing-overview", display_name: "E2E sub-billing-overview",
}) });
await page.goto("/app/subscription/billing") await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({ await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证概览区域存在 // 验证概览区域存在
const overview = page.locator(".xx-billing-overview") const overview = page.locator(".xx-billing-overview");
if (await overview.isVisible({ timeout: 5_000 })) { if (await overview.isVisible({ timeout: 5_000 })) {
await expect(overview).toBeVisible() await expect(overview).toBeVisible();
// 验证套餐信息 // 验证套餐信息
await expect(overview.locator(".xx-overview-item").first()).toBeVisible() await expect(overview.locator(".xx-overview-item").first()).toBeVisible();
} }
}) });
test("自动续费开关存在", async ({ page, request }) => { test("自动续费开关存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-autorenew-ui", "sub-autorenew-ui",
) );
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-autorenew-ui", display_name: "E2E sub-autorenew-ui",
}) });
await page.goto("/app/subscription/billing") await page.goto("/app/subscription/billing");
await expect(page.locator(".xx-billing-page")).toBeVisible({ await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证自动续费区域存在 // 验证自动续费区域存在
const autoRenew = page.locator(".xx-billing-auto-renew") const autoRenew = page.locator(".xx-billing-auto-renew");
if (await autoRenew.isVisible({ timeout: 5_000 })) { if (await autoRenew.isVisible({ timeout: 5_000 })) {
await expect(autoRenew).toBeVisible() await expect(autoRenew).toBeVisible();
// 验证开关组件存在 // 验证开关组件存在
await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible() await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible();
} }
}) });
test("账单记录 API 返回数据", async ({ request }) => { test("账单记录 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-bills-api") const { headers } = await createAuthedUser(request, "sub-bills-api");
const response = await request.get(`${apiBase}/subscription/billing-records`, { headers }) const response = await request.get(
`${apiBase}/subscription/billing-records`,
{ headers },
);
expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取账单记录应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy() expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
}) });
}) });
test.describe("订阅 - 自动续费切换", () => { test.describe("订阅 - 自动续费切换", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("切换自动续费 - 正向 API", async ({ request }) => { test("切换自动续费 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-toggle-api") const { headers } = await createAuthedUser(request, "sub-toggle-api");
// 关闭自动续费 // 关闭自动续费
const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { const disableResp = await request.post(
headers, `${apiBase}/subscription/toggle-auto-renew`,
data: { enabled: false }, {
}) headers,
expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy() data: { enabled: false },
},
);
expect(
disableResp.ok(),
`关闭自动续费应成功: ${await disableResp.text()}`,
).toBeTruthy();
// 重新开启自动续费 // 重新开启自动续费
const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { const enableResp = await request.post(
headers, `${apiBase}/subscription/toggle-auto-renew`,
data: { enabled: true }, {
}) headers,
expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy() data: { enabled: true },
}) },
);
expect(
enableResp.ok(),
`开启自动续费应成功: ${await enableResp.text()}`,
).toBeTruthy();
});
test("切换自动续费 - 无效参数反向", async ({ request }) => { test("切换自动续费 - 无效参数反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-toggle-bad") const { headers } = await createAuthedUser(request, "sub-toggle-bad");
const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { const response = await request.post(
headers, `${apiBase}/subscription/toggle-auto-renew`,
data: {}, {
}) headers,
data: {},
},
);
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
}) });
test.describe("订阅 - 取消订阅", () => { test.describe("订阅 - 取消订阅", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("取消订阅 API - 免费用户反向", async ({ request }) => { test("取消订阅 API - 免费用户反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-cancel-api") const { headers } = await createAuthedUser(request, "sub-cancel-api");
const response = await request.post(`${apiBase}/subscription/cancel`, { const response = await request.post(`${apiBase}/subscription/cancel`, {
headers, headers,
}) });
// 免费用户取消订阅可能返回错误 // 免费用户取消订阅可能返回错误
if (!response.ok()) { if (!response.ok()) {
const data = await response.json() const data = await response.json();
expect(data.error?.message || data.detail || data.message).toBeTruthy() expect(data.error?.message || data.detail || data.message).toBeTruthy();
} }
// 如果成功了也没问题(某些实现可能允许) // 如果成功了也没问题(某些实现可能允许)
expect(response.status() < 500).toBeTruthy() expect(response.status() < 500).toBeTruthy();
}) });
test("未登录取消订阅 - 反向", async ({ request }) => { test("未登录取消订阅 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/cancel`) const response = await request.post(`${apiBase}/subscription/cancel`);
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
}) });
test.describe("订阅 - 套餐变更", () => { test.describe("订阅 - 套餐变更", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("升级到 Pro 套餐 - 正向 API", async ({ request }) => { test("升级到 Pro 套餐 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-upgrade-api") const { headers } = await createAuthedUser(request, "sub-upgrade-api");
const response = await request.post(`${apiBase}/subscription/change-plan`, { const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers, headers,
@@ -420,16 +447,19 @@ test.describe("订阅 - 套餐变更", () => {
target_plan_id: "pro", target_plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
expect(response.ok(), `升级套餐应成功: ${await response.text()}`).toBeTruthy() expect(
response.ok(),
`升级套餐应成功: ${await response.text()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data).toBeTruthy() expect(data).toBeTruthy();
}) });
test("获取当前订阅信息 - 验证升级", async ({ request }) => { test("获取当前订阅信息 - 验证升级", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-current-api") const { headers } = await createAuthedUser(request, "sub-current-api");
// 先升级 // 先升级
await request.post(`${apiBase}/subscription/change-plan`, { await request.post(`${apiBase}/subscription/change-plan`, {
@@ -438,22 +468,25 @@ test.describe("订阅 - 套餐变更", () => {
target_plan_id: "pro", target_plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
// 获取当前订阅 // 获取当前订阅
const response = await request.get(`${apiBase}/subscription/current`, { const response = await request.get(`${apiBase}/subscription/current`, {
headers, headers,
}) });
expect(response.ok(), `获取订阅信息应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取订阅信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.plan_id, "应返回 plan_id").toBeTruthy() expect(data.plan_id, "应返回 plan_id").toBeTruthy();
expect(data.status, "应返回 status").toBeTruthy() expect(data.status, "应返回 status").toBeTruthy();
}) });
test("降级到 Standard 套餐 - 正向 API", async ({ request }) => { test("降级到 Standard 套餐 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-downgrade-api") const { headers } = await createAuthedUser(request, "sub-downgrade-api");
// 先升级到 Pro // 先升级到 Pro
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, { const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
@@ -462,26 +495,29 @@ test.describe("订阅 - 套餐变更", () => {
target_plan_id: "pro", target_plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy() expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy();
// 降级到 Standard // 降级到 Standard
const downgrade = await request.post(`${apiBase}/subscription/change-plan`, { const downgrade = await request.post(
headers, `${apiBase}/subscription/change-plan`,
data: { {
target_plan_id: "standard", headers,
billing_cycle: "monthly", data: {
target_plan_id: "standard",
billing_cycle: "monthly",
},
}, },
}) );
expect( expect(
downgrade.status() < 500, downgrade.status() < 500,
`降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`, `降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`,
).toBeTruthy() ).toBeTruthy();
}) });
test("切换到无效套餐 - 反向", async ({ request }) => { test("切换到无效套餐 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-badplan-api") const { headers } = await createAuthedUser(request, "sub-badplan-api");
const response = await request.post(`${apiBase}/subscription/change-plan`, { const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers, headers,
@@ -489,18 +525,18 @@ test.describe("订阅 - 套餐变更", () => {
target_plan_id: "nonexistent_plan", target_plan_id: "nonexistent_plan",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400) expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
expect(response.status()).toBeLessThan(500) expect(response.status()).toBeLessThan(500);
}) });
}) });
test.describe("订阅 - 支付流程", () => { test.describe("订阅 - 支付流程", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("创建支付订单 - 正向 API", async ({ request }) => { test("创建支付订单 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-pay-api") const { headers } = await createAuthedUser(request, "sub-pay-api");
const response = await request.post(`${apiBase}/subscription/create-order`, { const response = await request.post(`${apiBase}/subscription/create-order`, {
headers, headers,
@@ -508,20 +544,20 @@ test.describe("订阅 - 支付流程", () => {
plan_id: "pro", plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
// 创建支付订单可能成功或接口不存在 // 创建支付订单可能成功或接口不存在
expect( expect(
response.status() < 500, response.status() < 500,
`创建订单应返回 2xx 或 4xx,实际: ${response.status()}`, `创建订单应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy() ).toBeTruthy();
if (response.ok()) { if (response.ok()) {
const data = await response.json() const data = await response.json();
// 应返回订单 ID 或支付链接 // 应返回订单 ID 或支付链接
expect(data.order_id || data.payment_url || data).toBeTruthy() expect(data.order_id || data.payment_url || data).toBeTruthy();
} }
}) });
test("未登录创建订单 - 反向", async ({ request }) => { test("未登录创建订单 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/create-order`, { const response = await request.post(`${apiBase}/subscription/create-order`, {
@@ -529,36 +565,36 @@ test.describe("订阅 - 支付流程", () => {
plan_id: "pro", plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
expect([401, 403, 404]).toContain(response.status()) expect([401, 403, 404]).toContain(response.status());
}) });
}) });
test.describe("订阅 - 套餐列表 API", () => { test.describe("订阅 - 套餐列表 API", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("获取套餐列表 - 正向", async ({ request }) => { test("获取套餐列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-plans-api") const { headers } = await createAuthedUser(request, "sub-plans-api");
const response = await request.get(`${apiBase}/subscription/plans`, { const response = await request.get(`${apiBase}/subscription/plans`, {
headers, headers,
}) });
// 套餐列表可能需要登录也可能公开 // 套餐列表可能需要登录也可能公开
if (response.ok()) { if (response.ok()) {
const data = await response.json() const data = await response.json();
const plans = Array.isArray(data) ? data : data.plans || data.items const plans = Array.isArray(data) ? data : data.plans || data.items;
if (Array.isArray(plans)) { if (Array.isArray(plans)) {
expect(plans.length).toBeGreaterThanOrEqual(2) expect(plans.length).toBeGreaterThanOrEqual(2);
} }
} }
// 如果需要登录也正常 // 如果需要登录也正常
expect(response.status() < 500).toBeTruthy() expect(response.status() < 500).toBeTruthy();
}) });
test("未登录获取套餐列表", async ({ request }) => { test("未登录获取套餐列表", async ({ request }) => {
const response = await request.get(`${apiBase}/subscription/plans`) const response = await request.get(`${apiBase}/subscription/plans`);
// 套餐列表可能公开也可能需要登录 // 套餐列表可能公开也可能需要登录
expect(response.status() < 500).toBeTruthy() expect(response.status() < 500).toBeTruthy();
}) });
}) });
+130 -101
View File
@@ -3,17 +3,17 @@
* *
* 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查 * 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -26,74 +26,74 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
email, email,
username, username,
} };
} }
test.describe("Subscription route guard", () => { test.describe("Subscription route guard", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("redirects anonymous users to login", async ({ page }) => { test("redirects anonymous users to login", async ({ page }) => {
await page.goto("/app/subscription") await page.goto("/app/subscription");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
test.describe("订阅信息查看", () => { test.describe("订阅信息查看", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("获取当前订阅信息 - 正向", async ({ request }) => { test("获取当前订阅信息 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-info") const { headers } = await createAuthedUser(request, "sub-info");
const response = await request.get(`${apiBase}/subscription/current`, { const response = await request.get(`${apiBase}/subscription/current`, {
headers, headers,
}) });
expect( expect(
response.ok(), response.ok(),
`获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`, `获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.plan_id, "应返回 plan_id").toBeTruthy() expect(data.plan_id, "应返回 plan_id").toBeTruthy();
expect(data.status, "应返回 status").toBeTruthy() expect(data.status, "应返回 status").toBeTruthy();
}) });
test("未登录获取订阅信息 - 反向", async ({ request }) => { test("未登录获取订阅信息 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/subscription/current`) const response = await request.get(`${apiBase}/subscription/current`);
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
}) });
test.describe("订阅降级", () => { test.describe("订阅降级", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("Pro 用户降级到 Standard - 正向", async ({ request }) => { test("Pro 用户降级到 Standard - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-downgrade") const { headers } = await createAuthedUser(request, "sub-downgrade");
// 先升级到 Pro // 先升级到 Pro
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, { const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
@@ -102,28 +102,34 @@ test.describe("订阅降级", () => {
target_plan_id: "pro", target_plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
expect(upgrade.ok(), `升级到 Pro 应成功: ${await upgrade.text()}`).toBeTruthy() expect(
upgrade.ok(),
`升级到 Pro 应成功: ${await upgrade.text()}`,
).toBeTruthy();
// 降级到 Standard // 降级到 Standard
const downgrade = await request.post(`${apiBase}/subscription/change-plan`, { const downgrade = await request.post(
headers, `${apiBase}/subscription/change-plan`,
data: { {
target_plan_id: "standard", headers,
billing_cycle: "monthly", data: {
target_plan_id: "standard",
billing_cycle: "monthly",
},
}, },
}) );
// 降级应成功或返回提示信息(某些业务可能限制降级) // 降级应成功或返回提示信息(某些业务可能限制降级)
expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500) expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500);
const data = await downgrade.json() const data = await downgrade.json();
// 成功或失败都应有明确响应 // 成功或失败都应有明确响应
expect(data).toBeTruthy() expect(data).toBeTruthy();
}) });
test("降级到相同套餐 - 反向", async ({ request }) => { test("降级到相同套餐 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-same") const { headers } = await createAuthedUser(request, "sub-same");
// 用户默认为 free,再次选择 free // 用户默认为 free,再次选择 free
const response = await request.post(`${apiBase}/subscription/change-plan`, { const response = await request.post(`${apiBase}/subscription/change-plan`, {
@@ -132,19 +138,19 @@ test.describe("订阅降级", () => {
target_plan_id: "free", target_plan_id: "free",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
// 相同套餐应返回 200 + success=false,或者 400 // 相同套餐应返回 200 + success=false,或者 400
if (response.ok()) { if (response.ok()) {
const data = await response.json() const data = await response.json();
expect(data.success).toBe(false) expect(data.success).toBe(false);
} else { } else {
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
} }
}) });
test("降级到无效套餐 - 反向", async ({ request }) => { test("降级到无效套餐 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-badplan") const { headers } = await createAuthedUser(request, "sub-badplan");
const response = await request.post(`${apiBase}/subscription/change-plan`, { const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers, headers,
@@ -152,81 +158,104 @@ test.describe("订阅降级", () => {
target_plan_id: "nonexistent_plan", target_plan_id: "nonexistent_plan",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}) });
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400) expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
expect(response.status()).toBeLessThan(500) expect(response.status()).toBeLessThan(500);
}) });
}) });
test.describe("订阅过期处理", () => { test.describe("订阅过期处理", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("取消订阅 - 反向(免费用户)", async ({ request }) => { test("取消订阅 - 反向(免费用户)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-cancel") const { headers } = await createAuthedUser(request, "sub-cancel");
// 免费用户取消订阅应返回错误 // 免费用户取消订阅应返回错误
const response = await request.post(`${apiBase}/subscription/cancel`, { const response = await request.post(`${apiBase}/subscription/cancel`, {
headers, headers,
}) });
// 免费用户可能不需要取消,返回 400 或类似错误 // 免费用户可能不需要取消,返回 400 或类似错误
if (!response.ok()) { if (!response.ok()) {
const data = await response.json() const data = await response.json();
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy() expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
} }
}) });
test("未登录取消订阅 - 反向", async ({ request }) => { test("未登录取消订阅 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/cancel`) const response = await request.post(`${apiBase}/subscription/cancel`);
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
test("切换自动续费 - 正向", async ({ request }) => { test("切换自动续费 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-autorenew") const { headers } = await createAuthedUser(request, "sub-autorenew");
// 关闭自动续费 // 关闭自动续费
const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { const disableResp = await request.post(
headers, `${apiBase}/subscription/toggle-auto-renew`,
data: { enabled: false }, {
}) headers,
expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy() data: { enabled: false },
},
);
expect(
disableResp.ok(),
`关闭自动续费应成功: ${await disableResp.text()}`,
).toBeTruthy();
// 重新开启自动续费 // 重新开启自动续费
const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { const enableResp = await request.post(
headers, `${apiBase}/subscription/toggle-auto-renew`,
data: { enabled: true }, {
}) headers,
expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy() data: { enabled: true },
}) },
);
expect(
enableResp.ok(),
`开启自动续费应成功: ${await enableResp.text()}`,
).toBeTruthy();
});
test("无效参数切换自动续费 - 反向", async ({ request }) => { test("无效参数切换自动续费 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-autoren-bad") const { headers } = await createAuthedUser(request, "sub-autoren-bad");
// 缺少 enabled 字段 // 缺少 enabled 字段
const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { const response = await request.post(
headers, `${apiBase}/subscription/toggle-auto-renew`,
data: {}, {
}) headers,
data: {},
},
);
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
}) });
test.describe("账单记录", () => { test.describe("账单记录", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("获取账单记录 - 正向", async ({ request }) => { test("获取账单记录 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-bills") const { headers } = await createAuthedUser(request, "sub-bills");
const response = await request.get(`${apiBase}/subscription/billing-records`, { headers }) const response = await request.get(
`${apiBase}/subscription/billing-records`,
{ headers },
);
expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取账单记录应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy() expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
}) });
test("未登录获取账单记录 - 反向", async ({ request }) => { test("未登录获取账单记录 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/subscription/billing-records`) const response = await request.get(
expect([401, 403]).toContain(response.status()) `${apiBase}/subscription/billing-records`,
}) );
}) expect([401, 403]).toContain(response.status());
});
});
+223 -208
View File
@@ -6,29 +6,31 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -41,30 +43,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -72,7 +74,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 设置页面认证状态(localStorage */ /** 设置页面认证状态(localStorage */
@@ -83,14 +85,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -104,88 +106,95 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
test.describe("模板库页面 - 未登录重定向", () => { test.describe("模板库页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
test.describe("模板库页面 - 页面加载", () => { test.describe("模板库页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("模板库页面加载成功", async ({ page, request }) => { test("模板库页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-load") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-load", display_name: "E2E tpl-load",
}) });
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
}) });
test("模板库头部和搜索栏存在", async ({ page, request }) => { test("模板库头部和搜索栏存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-head") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-head",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-head", display_name: "E2E tpl-head",
}) });
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证搜索框 // 验证搜索框
const searchInput = page.locator(".xx-templates-search-input") const searchInput = page.locator(".xx-templates-search-input");
await expect(searchInput).toBeVisible({ timeout: 10_000 }) await expect(searchInput).toBeVisible({ timeout: 10_000 });
}) });
test("分类切换按钮存在", async ({ page, request }) => { test("分类切换按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-cat") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-cat",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-cat", display_name: "E2E tpl-cat",
}) });
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证分类按钮存在 // 验证分类按钮存在
const categoryBtns = page.locator(".xx-templates-cat-btn") const categoryBtns = page.locator(".xx-templates-cat-btn");
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 }) await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 });
const count = await categoryBtns.count() const count = await categoryBtns.count();
expect(count).toBeGreaterThan(0) expect(count).toBeGreaterThan(0);
}) });
}) });
test.describe("模板库 - 模板展示", () => { test.describe("模板库 - 模板展示", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("模板卡片展示", async ({ page, request }) => { test("模板卡片展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "tpl-cards");
"tpl-cards", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
// 创建一个模板 // 创建一个模板
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
@@ -206,34 +215,32 @@ test.describe("模板库 - 模板展示", () => {
tags: ["e2e", "展示"], tags: ["e2e", "展示"],
category: "种草", category: "种草",
}, },
}) });
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-cards", display_name: "E2E tpl-cards",
}) });
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 等待模板卡片出现 // 等待模板卡片出现
const cards = page.locator(".xx-template-card") const cards = page.locator(".xx-template-card");
await expect(cards.first()).toBeVisible({ timeout: 15_000 }) await expect(cards.first()).toBeVisible({ timeout: 15_000 });
const count = await cards.count() const count = await cards.count();
expect(count).toBeGreaterThan(0) expect(count).toBeGreaterThan(0);
}) });
test("模板卡片包含名称和类型", async ({ page, request }) => { test("模板卡片包含名称和类型", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "tpl-info");
"tpl-info", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -251,35 +258,33 @@ test.describe("模板库 - 模板展示", () => {
}, },
], ],
}, },
}) });
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-info", display_name: "E2E tpl-info",
}) });
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const firstCard = page.locator(".xx-template-card").first() const firstCard = page.locator(".xx-template-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证信息区域存在 // 验证信息区域存在
const info = firstCard.locator(".xx-template-info") const info = firstCard.locator(".xx-template-info");
await expect(info).toBeVisible() await expect(info).toBeVisible();
} }
}) });
test("模板预览弹窗功能", async ({ page, request }) => { test("模板预览弹窗功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "tpl-preview");
"tpl-preview", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -298,75 +303,76 @@ test.describe("模板库 - 模板展示", () => {
}, },
], ],
}, },
}) });
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-preview", display_name: "E2E tpl-preview",
}) });
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 点击第一个模板卡片打开预览 // 点击第一个模板卡片打开预览
const firstCard = page.locator(".xx-template-card").first() const firstCard = page.locator(".xx-template-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
await firstCard.click() await firstCard.click();
// 预览弹窗应该出现 // 预览弹窗应该出现
const modal = page.locator(".xx-template-modal") const modal = page.locator(".xx-template-modal");
if (await modal.isVisible({ timeout: 5_000 })) { if (await modal.isVisible({ timeout: 5_000 })) {
await expect(modal).toBeVisible() await expect(modal).toBeVisible();
// 验证预览内容存在 // 验证预览内容存在
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible() await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
} }
} }
}) });
}) });
test.describe("模板库 - 分类切换", () => { test.describe("模板库 - 分类切换", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("切换分类筛选", async ({ page, request }) => { test("切换分类筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-switch") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-switch",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-switch", display_name: "E2E tpl-switch",
}) });
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const categoryBtns = page.locator(".xx-templates-cat-btn") const categoryBtns = page.locator(".xx-templates-cat-btn");
const firstBtn = categoryBtns.first() const firstBtn = categoryBtns.first();
if (await firstBtn.isVisible({ timeout: 10_000 })) { if (await firstBtn.isVisible({ timeout: 10_000 })) {
await firstBtn.click() await firstBtn.click();
// 验证按钮被选中 // 验证按钮被选中
await expect(firstBtn).toHaveClass(/active/) await expect(firstBtn).toHaveClass(/active/);
} }
}) });
}) });
test.describe("模板库 - 搜索", () => { test.describe("模板库 - 搜索", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("搜索框可输入并筛选", async ({ page, request }) => { test("搜索框可输入并筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "tpl-search");
"tpl-search", const suffix = Date.now().toString(36);
) const templateName = `E2E 搜索测试模板 ${suffix}`;
const suffix = Date.now().toString(36)
const templateName = `E2E 搜索测试模板 ${suffix}`
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -384,35 +390,35 @@ test.describe("模板库 - 搜索", () => {
}, },
], ],
}, },
}) });
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-search", display_name: "E2E tpl-search",
}) });
await page.goto("/app/templates") await page.goto("/app/templates");
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const searchInput = page.locator(".xx-templates-search-input") const searchInput = page.locator(".xx-templates-search-input");
if (await searchInput.isVisible({ timeout: 10_000 })) { if (await searchInput.isVisible({ timeout: 10_000 })) {
await searchInput.fill(suffix) await searchInput.fill(suffix);
// 验证页面正常响应 // 验证页面正常响应
await expect(page.locator(".xx-templates-page")).toBeVisible() await expect(page.locator(".xx-templates-page")).toBeVisible();
} }
}) });
}) });
test.describe("模板库 - API 操作", () => { test.describe("模板库 - API 操作", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("获取模板列表 - 正向", async ({ request }) => { test("获取模板列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-api-list") const { headers } = await createAuthedUser(request, "tpl-api-list");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -429,20 +435,23 @@ test.describe("模板库 - API 操作", () => {
}, },
], ],
}, },
}) });
const response = await request.get(`${apiBase}/templates`, { headers }) const response = await request.get(`${apiBase}/templates`, { headers });
expect(response.ok(), `获取模板列表应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取模板列表应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || data.templates || [] const items = data.items || data.templates || [];
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy() expect(Array.isArray(items), "模板列表应为数组").toBeTruthy();
expect(items.length).toBeGreaterThan(0) expect(items.length).toBeGreaterThan(0);
}) });
test("收藏/取消收藏模板 - 正向", async ({ request }) => { test("收藏/取消收藏模板 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-fav") const { headers } = await createAuthedUser(request, "tpl-fav");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
// 创建模板 // 创建模板
const createResp = await request.post(`${apiBase}/templates`, { const createResp = await request.post(`${apiBase}/templates`, {
@@ -460,26 +469,30 @@ test.describe("模板库 - API 操作", () => {
}, },
], ],
}, },
}) });
expect(createResp.ok()).toBeTruthy() expect(createResp.ok()).toBeTruthy();
const created = await createResp.json() const created = await createResp.json();
const templateId = created.id const templateId = created.id;
// 收藏 // 收藏
const favResp = await request.post(`${apiBase}/templates/${templateId}/favorite`, { headers }) const favResp = await request.post(
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
// 收藏可能成功或接口不存在 // 收藏可能成功或接口不存在
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy() expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy();
// 取消收藏 // 取消收藏
const unfavResp = await request.delete(`${apiBase}/templates/${templateId}/favorite`, { const unfavResp = await request.delete(
headers, `${apiBase}/templates/${templateId}/favorite`,
}) { headers },
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy() );
}) expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
});
test("获取模板详情 - 正向", async ({ request }) => { test("获取模板详情 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-api-detail") const { headers } = await createAuthedUser(request, "tpl-api-detail");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/templates`, { const createResp = await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -498,22 +511,23 @@ test.describe("模板库 - API 操作", () => {
}, },
], ],
}, },
}) });
expect(createResp.ok()).toBeTruthy() expect(createResp.ok()).toBeTruthy();
const created = await createResp.json() const created = await createResp.json();
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, { const detailResp = await request.get(
headers, `${apiBase}/templates/${created.id}`,
}) { headers },
expect(detailResp.ok(), "获取详情应成功").toBeTruthy() );
const detail = await detailResp.json() expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
expect(detail.id).toBe(created.id) const detail = await detailResp.json();
expect(detail.name).toBe(`详情测试 ${suffix}`) expect(detail.id).toBe(created.id);
}) expect(detail.name).toBe(`详情测试 ${suffix}`);
});
test("使用模板接口 - 正向", async ({ request }) => { test("使用模板接口 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-use") const { headers } = await createAuthedUser(request, "tpl-use");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/templates`, { const createResp = await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -530,51 +544,52 @@ test.describe("模板库 - API 操作", () => {
}, },
], ],
}, },
}) });
expect(createResp.ok()).toBeTruthy() expect(createResp.ok()).toBeTruthy();
const created = await createResp.json() const created = await createResp.json();
// 使用模板(生成) // 使用模板(生成)
const genResp = await request.post(`${apiBase}/templates/${created.id}/generate`, { const genResp = await request.post(
headers, `${apiBase}/templates/${created.id}/generate`,
data: {}, { headers, data: {} },
}) );
// 生成可能成功或返回业务错误 // 生成可能成功或返回业务错误
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy() expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy();
}) });
test("未登录获取模板列表 - 反向", async ({ request }) => { test("未登录获取模板列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/templates`) const response = await request.get(`${apiBase}/templates`);
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
}) });
test.describe("模板库 - 我的模板 Tab", () => { test.describe("模板库 - 我的模板 Tab", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("我的模板页面可访问", async ({ page, request }) => { test("我的模板页面可访问", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-my") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"tpl-my",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-my", display_name: "E2E tpl-my",
}) });
await page.goto("/app/my-templates") await page.goto("/app/my-templates");
await expect(page.locator(".mt-page")).toBeVisible({ await expect(page.locator(".mt-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
}) });
test("我的模板页面展示已创建的模板", async ({ page, request }) => { test("我的模板页面展示已创建的模板", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "tpl-my-data");
"tpl-my-data", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -592,22 +607,22 @@ test.describe("模板库 - 我的模板 Tab", () => {
}, },
], ],
}, },
}) });
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-my-data", display_name: "E2E tpl-my-data",
}) });
await page.goto("/app/my-templates") await page.goto("/app/my-templates");
await expect(page.locator(".mt-page")).toBeVisible({ await expect(page.locator(".mt-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证卡片容器存在 // 验证卡片容器存在
const cards = page.locator(".mt-card") const cards = page.locator(".mt-card");
await expect(cards.first()).toBeVisible({ timeout: 15_000 }) await expect(cards.first()).toBeVisible({ timeout: 15_000 });
}) });
}) });
+106 -86
View File
@@ -4,17 +4,17 @@
* 覆盖:创建素材库、列出素材库、创建素材记录 * 覆盖:创建素材库、列出素材库、创建素材记录
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -27,37 +27,37 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
email, email,
username, username,
userId: regData.user_id, userId: regData.user_id,
} };
} }
/** 创建一个项目并返回 project id */ /** 创建一个项目并返回 project id */
@@ -69,19 +69,23 @@ async function createProject(
const resp = await request.post(`${apiBase}/projects`, { const resp = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" }, data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" },
}) });
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy() expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json() const data = await resp.json();
return data.id return data.id;
} }
test.describe("素材库流程", () => { test.describe("素材库流程", () => {
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时 // 登录限流 10次/60s,测试可能触发限流等待,给足够超时
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
test("创建素材库", async ({ request }) => { test("创建素材库", async ({ request }) => {
const { headers } = await createAuthedUser(request, "lib-create") const { headers } = await createAuthedUser(request, "lib-create");
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(
request,
headers,
Date.now().toString(),
);
const response = await request.post(`${apiBase}/asset-libraries`, { const response = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
@@ -90,23 +94,27 @@ test.describe("素材库流程", () => {
name: `视频素材库 ${Date.now()}`, name: `视频素材库 ${Date.now()}`,
kind: "video", kind: "video",
}, },
}) });
expect( expect(
response.ok(), response.ok(),
`创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.id, "应返回素材库 ID").toBeTruthy() expect(data.id, "应返回素材库 ID").toBeTruthy();
expect(data.name).toContain("视频素材库") expect(data.name).toContain("视频素材库");
expect(data.kind).toBe("video") expect(data.kind).toBe("video");
expect(data.project_id).toBe(projectId) expect(data.project_id).toBe(projectId);
}) });
test("创建素材库 - 无效 kind 反向", async ({ request }) => { test("创建素材库 - 无效 kind 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "lib-badkind") const { headers } = await createAuthedUser(request, "lib-badkind");
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(
request,
headers,
Date.now().toString(),
);
const response = await request.post(`${apiBase}/asset-libraries`, { const response = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
@@ -115,14 +123,14 @@ test.describe("素材库流程", () => {
name: "Bad Kind Library", name: "Bad Kind Library",
kind: "invalid_kind", kind: "invalid_kind",
}, },
}) });
// kind 有 pattern 校验 ^video|voice|image)$,应返回 422 // kind 有 pattern 校验 ^video|voice|image)$,应返回 422
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
test("创建素材库 - 不存在的项目反向", async ({ request }) => { test("创建素材库 - 不存在的项目反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "lib-nopj") const { headers } = await createAuthedUser(request, "lib-nopj");
const response = await request.post(`${apiBase}/asset-libraries`, { const response = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
@@ -131,14 +139,18 @@ test.describe("素材库流程", () => {
name: "Orphan Library", name: "Orphan Library",
kind: "video", kind: "video",
}, },
}) });
expect(response.status(), "不存在的项目应返回 404").toBe(404) expect(response.status(), "不存在的项目应返回 404").toBe(404);
}) });
test("列出素材库", async ({ request }) => { test("列出素材库", async ({ request }) => {
const { headers } = await createAuthedUser(request, "lib-list") const { headers } = await createAuthedUser(request, "lib-list");
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(
request,
headers,
Date.now().toString(),
);
// 创建 2 个不同类型的素材库 // 创建 2 个不同类型的素材库
await request.post(`${apiBase}/asset-libraries`, { await request.post(`${apiBase}/asset-libraries`, {
@@ -148,7 +160,7 @@ test.describe("素材库流程", () => {
name: `Video Lib ${Date.now()}`, name: `Video Lib ${Date.now()}`,
kind: "video", kind: "video",
}, },
}) });
await request.post(`${apiBase}/asset-libraries`, { await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
data: { data: {
@@ -156,31 +168,35 @@ test.describe("素材库流程", () => {
name: `Image Lib ${Date.now()}`, name: `Image Lib ${Date.now()}`,
kind: "image", kind: "image",
}, },
}) });
// 列出(按 project_id 过滤) // 列出(按 project_id 过滤)
const response = await request.get(`${apiBase}/asset-libraries`, { const response = await request.get(`${apiBase}/asset-libraries`, {
headers, headers,
params: { project_id: projectId }, params: { project_id: projectId },
}) });
expect( expect(
response.ok(), response.ok(),
`列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`, `列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || [] const items = data.items || [];
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2) expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2);
const kinds = items.map((i: { kind: string }) => i.kind) const kinds = items.map((i: { kind: string }) => i.kind);
expect(kinds).toContain("video") expect(kinds).toContain("video");
expect(kinds).toContain("image") expect(kinds).toContain("image");
}) });
test("创建素材记录", async ({ request }) => { test("创建素材记录", async ({ request }) => {
const { headers, userId } = await createAuthedUser(request, "asset-create") const { headers, userId } = await createAuthedUser(request, "asset-create");
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(
request,
headers,
Date.now().toString(),
);
// 创建素材库 // 创建素材库
const lib = await request.post(`${apiBase}/asset-libraries`, { const lib = await request.post(`${apiBase}/asset-libraries`, {
@@ -190,9 +206,9 @@ test.describe("素材库流程", () => {
name: `Asset Lib ${Date.now()}`, name: `Asset Lib ${Date.now()}`,
kind: "video", kind: "video",
}, },
}) });
expect(lib.ok()).toBeTruthy() expect(lib.ok()).toBeTruthy();
const libData = await lib.json() const libData = await lib.json();
// 创建素材记录 // 创建素材记录
const response = await request.post(`${apiBase}/assets`, { const response = await request.post(`${apiBase}/assets`, {
@@ -208,23 +224,27 @@ test.describe("素材库流程", () => {
status: "ready", status: "ready",
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
}, },
}) });
expect( expect(
response.ok(), response.ok(),
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.id, "应返回素材 ID").toBeTruthy() expect(data.id, "应返回素材 ID").toBeTruthy();
expect(data.name).toContain("test_video") expect(data.name).toContain("test_video");
expect(data.mime_type).toBe("video/mp4") expect(data.mime_type).toBe("video/mp4");
expect(data.library_id).toBe(libData.id) expect(data.library_id).toBe(libData.id);
}) });
test("列出素材", async ({ request }) => { test("列出素材", async ({ request }) => {
const { headers, userId } = await createAuthedUser(request, "asset-list") const { headers, userId } = await createAuthedUser(request, "asset-list");
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(
request,
headers,
Date.now().toString(),
);
// 创建素材库 // 创建素材库
const lib = await request.post(`${apiBase}/asset-libraries`, { const lib = await request.post(`${apiBase}/asset-libraries`, {
@@ -234,9 +254,9 @@ test.describe("素材库流程", () => {
name: `List Lib ${Date.now()}`, name: `List Lib ${Date.now()}`,
kind: "video", kind: "video",
}, },
}) });
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy() expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy();
const libData = await lib.json() const libData = await lib.json();
// 创建 2 个素材 // 创建 2 个素材
await request.post(`${apiBase}/assets`, { await request.post(`${apiBase}/assets`, {
@@ -250,7 +270,7 @@ test.describe("素材库流程", () => {
status: "ready", status: "ready",
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
}, },
}) });
await request.post(`${apiBase}/assets`, { await request.post(`${apiBase}/assets`, {
headers, headers,
data: { data: {
@@ -262,23 +282,23 @@ test.describe("素材库流程", () => {
status: "ready", status: "ready",
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
}, },
}) });
// 列出素材 // 列出素材
const response = await request.get(`${apiBase}/assets`, { const response = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libData.id }, params: { library_id: libData.id },
}) });
expect( expect(
response.ok(), response.ok(),
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`, `列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || [] const items = data.items || [];
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2) expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2);
}) });
test("未登录创建素材库 - 反向", async ({ request }) => { test("未登录创建素材库 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/asset-libraries`, { const response = await request.post(`${apiBase}/asset-libraries`, {
@@ -287,8 +307,8 @@ test.describe("素材库流程", () => {
name: "Unauthorized Library", name: "Unauthorized Library",
kind: "video", kind: "video",
}, },
}) });
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
}) });
+126 -113
View File
@@ -4,29 +4,29 @@
* 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息 * 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息
* 每个测试独立,使用随机邮箱避免冲突。 * 每个测试独立,使用随机邮箱避免冲突。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 从错误响应中提取错误消息文本,兼容新老格式 */ /** 从错误响应中提取错误消息文本,兼容新老格式 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractErrorMessage(body: any): string { function extractErrorMessage(body: any): string {
if (!body) return "" if (!body) return "";
// 新格式: { error: { code: "...", message: "..." } } // 新格式: { error: { code: "...", message: "..." } }
if (body.error && typeof body.error === "object" && body.error.message) { if (body.error && typeof body.error === "object" && body.error.message) {
return String(body.error.message) return String(body.error.message);
} }
// 老格式: { detail: "..." } 或 { message: "..." } 或 { error: "..." } // 老格式: { detail: "..." } 或 { message: "..." } 或 { error: "..." }
return String(body.detail || body.message || body.error || "") return String(body.detail || body.message || body.error || "");
} }
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */ /** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
@@ -39,27 +39,27 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
// 被限流了,等窗口重置 // 被限流了,等窗口重置
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
// 最后一次直接返回 // 最后一次直接返回
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
test.describe("认证流程", () => { test.describe("认证流程", () => {
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时 // 登录限流 10次/60s,测试可能触发限流等待,给足够超时
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
// ─── 注册 ──────────────────────────────────────────── // ─── 注册 ────────────────────────────────────────────
test("注册新用户 - 正向", async ({ request }) => { test("注册新用户 - 正向", async ({ request }) => {
const email = uniqueEmail("reg-ok") const email = uniqueEmail("reg-ok");
const username = uniqueUsername("regok") const username = uniqueUsername("regok");
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
data: { data: {
@@ -68,23 +68,23 @@ test.describe("认证流程", () => {
username, username,
display_name: "E2E 注册测试", display_name: "E2E 注册测试",
}, },
}) });
expect( expect(
response.ok(), response.ok(),
`注册应返回 2xx,实际: ${response.status()} ${await response.text()}`, `注册应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.user_id, "应返回 user_id").toBeTruthy() expect(data.user_id, "应返回 user_id").toBeTruthy();
expect(data.email).toBe(email) expect(data.email).toBe(email);
expect(data.username).toBe(username) expect(data.username).toBe(username);
}) });
test("注册已存在邮箱 - 反向", async ({ request }) => { test("注册已存在邮箱 - 反向", async ({ request }) => {
const email = uniqueEmail("reg-dup") const email = uniqueEmail("reg-dup");
const username1 = uniqueUsername("regdup1") const username1 = uniqueUsername("regdup1");
const username2 = uniqueUsername("regdup2") const username2 = uniqueUsername("regdup2");
// 第一次注册 // 第一次注册
const first = await request.post(`${apiBase}/auth/register`, { const first = await request.post(`${apiBase}/auth/register`, {
@@ -94,8 +94,8 @@ test.describe("认证流程", () => {
username: username1, username: username1,
display_name: "User 1", display_name: "User 1",
}, },
}) });
expect(first.ok(), "第一次注册应成功").toBeTruthy() expect(first.ok(), "第一次注册应成功").toBeTruthy();
// 第二次使用相同邮箱 // 第二次使用相同邮箱
const second = await request.post(`${apiBase}/auth/register`, { const second = await request.post(`${apiBase}/auth/register`, {
@@ -105,22 +105,24 @@ test.describe("认证流程", () => {
username: username2, username: username2,
display_name: "User 2", display_name: "User 2",
}, },
}) });
expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(400) expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(
expect(second.status()).toBeLessThan(500) 400,
);
expect(second.status()).toBeLessThan(500);
const body = await second.json() const body = await second.json();
// 错误信息应包含"已注册"或"exists"相关提示 // 错误信息应包含"已注册"或"exists"相关提示
const detail = extractErrorMessage(body).toLowerCase() const detail = extractErrorMessage(body).toLowerCase();
expect( expect(
detail.includes("已") || detail.includes("已") ||
detail.includes("exist") || detail.includes("exist") ||
detail.includes("registered") || detail.includes("registered") ||
detail.includes("duplicate"), detail.includes("duplicate"),
`错误信息应提示邮箱已注册,实际: "${detail}"`, `错误信息应提示邮箱已注册,实际: "${detail}"`,
).toBeTruthy() ).toBeTruthy();
}) });
test("注册无效邮箱格式 - 反向", async ({ request }) => { test("注册无效邮箱格式 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
@@ -130,11 +132,11 @@ test.describe("认证流程", () => {
username: uniqueUsername("bademail"), username: uniqueUsername("bademail"),
display_name: "Bad Email", display_name: "Bad Email",
}, },
}) });
// 422 是 FastAPI 参数校验失败的标准状态码 // 422 是 FastAPI 参数校验失败的标准状态码
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
test("注册弱密码 - 反向", async ({ request }) => { test("注册弱密码 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
@@ -144,10 +146,10 @@ test.describe("认证流程", () => {
username: uniqueUsername("weakpwd"), username: uniqueUsername("weakpwd"),
display_name: "Weak", display_name: "Weak",
}, },
}) });
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
test("注册用户名为空 - 反向", async ({ request }) => { test("注册用户名为空 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
@@ -157,10 +159,12 @@ test.describe("认证流程", () => {
username: "", username: "",
display_name: "Empty Username", display_name: "Empty Username",
}, },
}) });
expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(response.status()) expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(
}) response.status(),
);
});
test("注册缺用户名字段 - 反向", async ({ request }) => { test("注册缺用户名字段 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
@@ -169,72 +173,75 @@ test.describe("认证流程", () => {
password: PASSWORD, password: PASSWORD,
display_name: "No Username Field", display_name: "No Username Field",
}, },
}) });
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(response.status()) expect(
}) [400, 422],
"缺少用户名字段应返回 4xx 校验错误",
).toContain(response.status());
});
// ─── 登录 ──────────────────────────────────────────── // ─── 登录 ────────────────────────────────────────────
test("登录成功 - 正向", async ({ request }) => { test("登录成功 - 正向", async ({ request }) => {
const email = uniqueEmail("login-ok") const email = uniqueEmail("login-ok");
const username = uniqueUsername("loginok") const username = uniqueUsername("loginok");
// 先注册 // 先注册
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Login Test" }, data: { email, password: PASSWORD, username, display_name: "Login Test" },
}) });
expect(reg.ok(), "注册应成功").toBeTruthy() expect(reg.ok(), "注册应成功").toBeTruthy();
// 登录(带限流重试) // 登录(带限流重试)
const response = await loginWithRetry(request, email, PASSWORD) const response = await loginWithRetry(request, email, PASSWORD);
expect( expect(
response.ok(), response.ok(),
`登录应返回 2xx,实际: ${response.status()} ${await response.text()}`, `登录应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.access_token, "应返回 access_token").toBeTruthy() expect(data.access_token, "应返回 access_token").toBeTruthy();
expect(data.token_type).toBe("bearer") expect(data.token_type).toBe("bearer");
expect(data.email).toBe(email) expect(data.email).toBe(email);
}) });
test("登录错误密码 - 反向", async ({ request }) => { test("登录错误密码 - 反向", async ({ request }) => {
const email = uniqueEmail("login-bad") const email = uniqueEmail("login-bad");
const username = uniqueUsername("loginbad") const username = uniqueUsername("loginbad");
// 先注册 // 先注册
await request.post(`${apiBase}/auth/register`, { await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Bad Login" }, data: { email, password: PASSWORD, username, display_name: "Bad Login" },
}) });
// 使用错误密码登录(带限流重试) // 使用错误密码登录(带限流重试)
const response = await loginWithRetry(request, email, "WrongPassword999!") const response = await loginWithRetry(request, email, "WrongPassword999!");
expect(response.status(), "错误密码应返回 401").toBe(401) expect(response.status(), "错误密码应返回 401").toBe(401);
}) });
test("登录不存在的邮箱 - 反向", async ({ request }) => { test("登录不存在的邮箱 - 反向", async ({ request }) => {
// 带限流重试的反向登录测试 // 带限流重试的反向登录测试
let response let response;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
response = await request.post(`${apiBase}/auth/login`, { response = await request.post(`${apiBase}/auth/login`, {
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD }, data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
}) });
if (response.status() !== 429) break if (response.status() !== 429) break;
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`) console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
await new Promise((r) => setTimeout(r, 65_000)) await new Promise((r) => setTimeout(r, 65_000));
} }
expect(response.status(), "不存在的用户应返回 401").toBe(401) expect(response.status(), "不存在的用户应返回 401").toBe(401);
}) });
// ─── 登出 ──────────────────────────────────────────── // ─── 登出 ────────────────────────────────────────────
test("登出成功", async ({ request }) => { test("登出成功", async ({ request }) => {
const email = uniqueEmail("logout") const email = uniqueEmail("logout");
const username = uniqueUsername("logout") const username = uniqueUsername("logout");
// 注册 & 登录 // 注册 & 登录
await request.post(`${apiBase}/auth/register`, { await request.post(`${apiBase}/auth/register`, {
@@ -244,87 +251,93 @@ test.describe("认证流程", () => {
username, username,
display_name: "Logout Test", display_name: "Logout Test",
}, },
}) });
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
const { access_token } = await login.json() const { access_token } = await login.json();
const headers = { Authorization: `Bearer ${access_token}` } const headers = { Authorization: `Bearer ${access_token}` };
// 登出 // 登出
const logout = await request.post(`${apiBase}/auth/logout`, { headers }) const logout = await request.post(`${apiBase}/auth/logout`, { headers });
expect(logout.ok(), `登出应返回 2xx,实际: ${logout.status()}`).toBeTruthy() expect(
logout.ok(),
`登出应返回 2xx,实际: ${logout.status()}`,
).toBeTruthy();
const body = await logout.json() const body = await logout.json();
expect(body.message).toBeTruthy() expect(body.message).toBeTruthy();
// 登出后 token 应失效,尝试访问 /auth/me // 登出后 token 应失效,尝试访问 /auth/me
const me = await request.get(`${apiBase}/auth/me`, { headers }) const me = await request.get(`${apiBase}/auth/me`, { headers });
expect([401, 403]).toContain(me.status()) expect([401, 403]).toContain(me.status());
}) });
// ─── 获取当前用户信息 ───────────────────────────────── // ─── 获取当前用户信息 ─────────────────────────────────
test("获取当前用户信息 - 正向", async ({ request }) => { test("获取当前用户信息 - 正向", async ({ request }) => {
const email = uniqueEmail("me-ok") const email = uniqueEmail("me-ok");
const username = uniqueUsername("meok") const username = uniqueUsername("meok");
await request.post(`${apiBase}/auth/register`, { await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Me Test" }, data: { email, password: PASSWORD, username, display_name: "Me Test" },
}) });
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
const { access_token } = await login.json() const { access_token } = await login.json();
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: `Bearer ${access_token}` }, headers: { Authorization: `Bearer ${access_token}` },
}) });
expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取用户信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.user_id).toBeTruthy() expect(data.user_id).toBeTruthy();
expect(data.email).toBe(email) expect(data.email).toBe(email);
expect(data.username).toBe(username) expect(data.username).toBe(username);
}) });
test("无 token 获取用户信息 - 反向", async ({ request }) => { test("无 token 获取用户信息 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/auth/me`) const response = await request.get(`${apiBase}/auth/me`);
// HTTPBearer 无凭证返回 403 // HTTPBearer 无凭证返回 403
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
test("无效 token 获取用户信息 - 反向", async ({ request }) => { test("无效 token 获取用户信息 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: "Bearer invalid.token.here" }, headers: { Authorization: "Bearer invalid.token.here" },
}) });
expect(response.status()).toBe(401) expect(response.status()).toBe(401);
}) });
test("过期 token 获取用户信息 - 反向", async ({ request }) => { test("过期 token 获取用户信息 - 反向", async ({ request }) => {
// 使用一个伪造的过期 JWTheader.payload.signature // 使用一个伪造的过期 JWTheader.payload.signature
// eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"} // eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"}
// eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期) // eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期)
const expiredToken = const expiredToken =
"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature" "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature";
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: `Bearer ${expiredToken}` }, headers: { Authorization: `Bearer ${expiredToken}` },
}) });
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
test("token 格式错误 - 反向", async ({ request }) => { test("token 格式错误 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: "Bearer not-a-jwt" }, headers: { Authorization: "Bearer not-a-jwt" },
}) });
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
test("空 Bearer token - 反向", async ({ request }) => { test("空 Bearer token - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: "Bearer " }, headers: { Authorization: "Bearer " },
}) });
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
}) });
+94 -84
View File
@@ -4,17 +4,17 @@
* 覆盖:创建项目、列出项目、获取项目详情 * 覆盖:创建项目、列出项目、获取项目详情
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -27,46 +27,46 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
email, email,
username, username,
userId: regData.user_id, userId: regData.user_id,
} };
} }
test.describe("项目流程", () => { test.describe("项目流程", () => {
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时 // 登录限流 10次/60s,测试可能触发限流等待,给足够超时
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
test("创建项目", async ({ request }) => { test("创建项目", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-create") const { headers } = await createAuthedUser(request, "proj-create");
const projectName = `E2E 测试项目 ${Date.now()}` const projectName = `E2E 测试项目 ${Date.now()}`;
const response = await request.post(`${apiBase}/projects`, { const response = await request.post(`${apiBase}/projects`, {
headers, headers,
@@ -74,146 +74,156 @@ test.describe("项目流程", () => {
name: projectName, name: projectName,
description: "Playwright E2E 回归测试创建", description: "Playwright E2E 回归测试创建",
}, },
}) });
expect( expect(
response.ok(), response.ok(),
`创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.id, "应返回项目 ID").toBeTruthy() expect(data.id, "应返回项目 ID").toBeTruthy();
expect(data.name).toBe(projectName) expect(data.name).toBe(projectName);
expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy() expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy();
}) });
test("创建项目名称为空 - 反向", async ({ request }) => { test("创建项目名称为空 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-empty") const { headers } = await createAuthedUser(request, "proj-empty");
const response = await request.post(`${apiBase}/projects`, { const response = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: "", description: "Should fail" }, data: { name: "", description: "Should fail" },
}) });
// name 有 min_length=1 约束,应返回 422 // name 有 min_length=1 约束,应返回 422
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
test("列出项目", async ({ request }) => { test("列出项目", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-list") const { headers } = await createAuthedUser(request, "proj-list");
// 先创建 2 个项目 // 先创建 2 个项目
await request.post(`${apiBase}/projects`, { await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `List Proj A ${Date.now()}` }, data: { name: `List Proj A ${Date.now()}` },
}) });
await request.post(`${apiBase}/projects`, { await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `List Proj B ${Date.now()}` }, data: { name: `List Proj B ${Date.now()}` },
}) });
// 列出 // 列出
const response = await request.get(`${apiBase}/projects`, { headers }) const response = await request.get(`${apiBase}/projects`, { headers });
expect( expect(
response.ok(), response.ok(),
`列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, `列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || data.projects || data || [] const items = data.items || data.projects || data || [];
expect(Array.isArray(items)).toBeTruthy() expect(Array.isArray(items)).toBeTruthy();
expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2) expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2);
}) });
test("获取项目详情", async ({ request }) => { test("获取项目详情", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-detail") const { headers } = await createAuthedUser(request, "proj-detail");
// 先创建 // 先创建
const created = await request.post(`${apiBase}/projects`, { const created = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" }, data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" },
}) });
expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy() expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy();
const { id: projectId } = await created.json() const { id: projectId } = await created.json();
// 获取详情 // 获取详情
const response = await request.get(`${apiBase}/projects/${projectId}`, { const response = await request.get(`${apiBase}/projects/${projectId}`, {
headers, headers,
}) });
expect( expect(
response.ok(), response.ok(),
`获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`, `获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.id).toBe(projectId) expect(data.id).toBe(projectId);
expect(data.name).toBeTruthy() expect(data.name).toBeTruthy();
expect(data.owner_user_id).toBeTruthy() expect(data.owner_user_id).toBeTruthy();
}) });
test("获取不存在的项目 - 反向", async ({ request }) => { test("获取不存在的项目 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-404") const { headers } = await createAuthedUser(request, "proj-404");
const response = await request.get(`${apiBase}/projects/nonexistent-project-id-999`, { const response = await request.get(
headers, `${apiBase}/projects/nonexistent-project-id-999`,
}) { headers },
);
expect(response.status(), "不存在的项目应返回 404").toBe(404) expect(response.status(), "不存在的项目应返回 404").toBe(404);
}) });
test("未登录列出项目 - 反向", async ({ request }) => { test("未登录列出项目 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/projects`) const response = await request.get(`${apiBase}/projects`);
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
test("未授权访问他人项目 - 反向", async ({ request }) => { test("未授权访问他人项目 - 反向", async ({ request }) => {
// 用户 A 创建项目 // 用户 A 创建项目
const { headers: headersA } = await createAuthedUser(request, "proj-owner") const { headers: headersA } = await createAuthedUser(request, "proj-owner");
const created = await request.post(`${apiBase}/projects`, { const created = await request.post(`${apiBase}/projects`, {
headers: headersA, headers: headersA,
data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" }, data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" },
}) });
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy() expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy();
const { id: projectId } = await created.json() const { id: projectId } = await created.json();
// 用户 B 尝试访问用户 A 的项目 // 用户 B 尝试访问用户 A 的项目
const { headers: headersB } = await createAuthedUser(request, "proj-intruder") const { headers: headersB } = await createAuthedUser(
request,
"proj-intruder",
);
const response = await request.get(`${apiBase}/projects/${projectId}`, { const response = await request.get(`${apiBase}/projects/${projectId}`, {
headers: headersB, headers: headersB,
}) });
// 应返回 403 (Forbidden) 或 404 (Not Found) — 不应泄露资源存在性 // 应返回 403 (Forbidden) 或 404 (Not Found) — 不应泄露资源存在性
expect([403, 404]).toContain(response.status()) expect([403, 404]).toContain(response.status());
}) });
test("未授权删除他人项目 - 反向", async ({ request }) => { test("未授权删除他人项目 - 反向", async ({ request }) => {
// 用户 A 创建项目 // 用户 A 创建项目
const { headers: headersA } = await createAuthedUser(request, "proj-del-owner") const { headers: headersA } = await createAuthedUser(
request,
"proj-del-owner",
);
const created = await request.post(`${apiBase}/projects`, { const created = await request.post(`${apiBase}/projects`, {
headers: headersA, headers: headersA,
data: { name: `Delete Test Proj ${Date.now()}` }, data: { name: `Delete Test Proj ${Date.now()}` },
}) });
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy() expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy();
const { id: projectId } = await created.json() const { id: projectId } = await created.json();
// 用户 B 尝试删除用户 A 的项目 // 用户 B 尝试删除用户 A 的项目
const { headers: headersB } = await createAuthedUser(request, "proj-del-attempt") const { headers: headersB } = await createAuthedUser(
request,
"proj-del-attempt",
);
const response = await request.delete(`${apiBase}/projects/${projectId}`, { const response = await request.delete(`${apiBase}/projects/${projectId}`, {
headers: headersB, headers: headersB,
}) });
expect([403, 404]).toContain(response.status()) expect([403, 404]).toContain(response.status());
}) });
test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => { test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-badid") const { headers } = await createAuthedUser(request, "proj-badid");
const response = await request.get(`${apiBase}/projects/`, { headers }) const response = await request.get(`${apiBase}/projects/`, { headers });
// 空 ID 或无效格式应返回 404 或 422 // 空 ID 或无效格式应返回 404 或 422
expect([400, 404, 422]).toContain(response.status()) expect([400, 404, 422]).toContain(response.status());
}) });
}) });
+204 -206
View File
@@ -9,29 +9,31 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -44,30 +46,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -75,7 +77,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 设置页面认证状态(localStorage */ /** 设置页面认证状态(localStorage */
@@ -86,14 +88,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -107,7 +109,7 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
/** 创建一个标题并返回 id */ /** 创建一个标题并返回 id */
@@ -126,76 +128,77 @@ async function createTitle(
tags: ["e2e", "test"], tags: ["e2e", "test"],
...overrides, ...overrides,
}, },
}) });
expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy() expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json() const data = await resp.json();
return data.id return data.id;
} }
test.describe("标题库 - 空状态", () => { test.describe("标题库 - 空状态", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("新用户标题页面显示空状态", async ({ page, request }) => { test("新用户标题页面显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "title-empty") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"title-empty",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-empty", display_name: "E2E title-empty",
}) });
await page.goto("/app/titles") await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 新用户应该能看到页面主体 // 新用户应该能看到页面主体
await expect(page.locator(".xx-titles-page")).toBeVisible() await expect(page.locator(".xx-titles-page")).toBeVisible();
}) });
}) });
test.describe("标题库 - 搜索功能", () => { test.describe("标题库 - 搜索功能", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("搜索框存在且可输入", async ({ page, request }) => { test("搜索框存在且可输入", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "title-search");
"title-search", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
await createTitle(request, headers, suffix) await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-search", display_name: "E2E title-search",
}) });
await page.goto("/app/titles") await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 查找搜索框 // 查找搜索框
const searchInput = page.locator( const searchInput = page.locator(
"input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']", "input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']",
) );
if (await searchInput.first().isVisible({ timeout: 10_000 })) { if (await searchInput.first().isVisible({ timeout: 10_000 })) {
await searchInput.first().fill("测试搜索") await searchInput.first().fill("测试搜索");
await expect(searchInput.first()).toHaveValue("测试搜索") await expect(searchInput.first()).toHaveValue("测试搜索");
} }
}) });
}) });
test.describe("标题库 - API 完整操作", () => { test.describe("标题库 - API 完整操作", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("创建标题 - 完整参数", async ({ request }) => { test("创建标题 - 完整参数", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-create-full") const { headers } = await createAuthedUser(request, "title-create-full");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const response = await request.post(`${apiBase}/titles`, { const response = await request.post(`${apiBase}/titles`, {
headers, headers,
@@ -206,25 +209,25 @@ test.describe("标题库 - API 完整操作", () => {
tags: ["e2e", "完整测试", "种草"], tags: ["e2e", "完整测试", "种草"],
status: "active", status: "active",
}, },
}) });
expect( expect(
response.ok(), response.ok(),
`创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.id, "应返回标题 ID").toBeTruthy() expect(data.id, "应返回标题 ID").toBeTruthy();
expect(data.name).toBe(`完整参数测试 ${suffix}`) expect(data.name).toBe(`完整参数测试 ${suffix}`);
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`) expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`);
}) });
test("编辑标题 - 正向", async ({ request }) => { test("编辑标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update") const { headers } = await createAuthedUser(request, "title-update");
const titleId = await createTitle(request, headers, Date.now().toString(36)) const titleId = await createTitle(request, headers, Date.now().toString(36));
const newName = `更新后的标题 ${Date.now()}` const newName = `更新后的标题 ${Date.now()}`;
const newText = "这是更新后的标题内容" const newText = "这是更新后的标题内容";
const response = await request.patch(`${apiBase}/titles/${titleId}`, { const response = await request.patch(`${apiBase}/titles/${titleId}`, {
headers, headers,
data: { data: {
@@ -232,86 +235,74 @@ test.describe("标题库 - API 完整操作", () => {
text: newText, text: newText,
category: "知识", category: "知识",
}, },
}) });
expect( expect(
response.ok(), response.ok(),
`更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`, `更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
const data = await response.json() const data = await response.json();
expect(data.name).toBe(newName) expect(data.name).toBe(newName);
// 验证更新 // 验证更新
const verify = await request.get(`${apiBase}/titles/${titleId}`, { const verify = await request.get(`${apiBase}/titles/${titleId}`, {
headers, headers,
}) });
const verifyData = await verify.json() const verifyData = await verify.json();
expect(verifyData.name).toBe(newName) expect(verifyData.name).toBe(newName);
expect(verifyData.text).toBe(newText) expect(verifyData.text).toBe(newText);
}) });
test("删除标题 - 正向", async ({ request }) => { test("删除标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-delete") const { headers } = await createAuthedUser(request, "title-delete");
const titleId = await createTitle(request, headers, Date.now().toString(36)) const titleId = await createTitle(request, headers, Date.now().toString(36));
// 删除 // 删除
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, { const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
headers, headers,
}) });
expect( expect(
[200, 204].includes(deleteResp.status()), [200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`, `删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy() ).toBeTruthy();
// 验证已删除 // 验证已删除
const getResp = await request.get(`${apiBase}/titles/${titleId}`, { const getResp = await request.get(`${apiBase}/titles/${titleId}`, {
headers, headers,
}) });
expect([404, 410]).toContain(getResp.status()) expect([404, 410]).toContain(getResp.status());
}) });
test("批量导入标题 - 正向", async ({ request }) => { test("批量导入标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-batch") const { headers } = await createAuthedUser(request, "title-batch");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const titles = [ const titles = [
{ { name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
name: `批量标题 1 ${suffix}`, { name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
text: `内容 1 ${suffix}`, { name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
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`, { const response = await request.post(`${apiBase}/titles/batch-import`, {
headers, headers,
data: { titles }, data: { titles },
}) });
// 批量导入可能成功或接口不存在 // 批量导入可能成功或接口不存在
expect( expect(
response.status() < 500, response.status() < 500,
`批量导入应返回 2xx 或 4xx,实际: ${response.status()}`, `批量导入应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy() ).toBeTruthy();
if (response.ok()) { if (response.ok()) {
const data = await response.json() const data = await response.json();
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy() expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
} }
}) });
test("创建标题 - 名称为空反向", async ({ request }) => { test("创建标题 - 名称为空反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-empty-name") const { headers } = await createAuthedUser(request, "title-empty-name");
const response = await request.post(`${apiBase}/titles`, { const response = await request.post(`${apiBase}/titles`, {
headers, headers,
@@ -320,13 +311,13 @@ test.describe("标题库 - API 完整操作", () => {
text: "有内容但名称为空", text: "有内容但名称为空",
category: "default", category: "default",
}, },
}) });
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
test("创建标题 - 缺少必要字段反向", async ({ request }) => { test("创建标题 - 缺少必要字段反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-missing") const { headers } = await createAuthedUser(request, "title-missing");
const response = await request.post(`${apiBase}/titles`, { const response = await request.post(`${apiBase}/titles`, {
headers, headers,
@@ -334,37 +325,46 @@ test.describe("标题库 - API 完整操作", () => {
name: "缺少 text 字段", name: "缺少 text 字段",
// 缺少 text 字段 // 缺少 text 字段
}, },
}) });
expect([400, 422]).toContain(response.status()) expect([400, 422]).toContain(response.status());
}) });
test("获取不存在的标题 - 反向", async ({ request }) => { test("获取不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-404") const { headers } = await createAuthedUser(request, "title-404");
const response = await request.get(`${apiBase}/titles/nonexistent-title-999`, { headers }) const response = await request.get(
expect(response.status(), "不存在的标题应返回 404").toBe(404) `${apiBase}/titles/nonexistent-title-999`,
}) { headers },
);
expect(response.status(), "不存在的标题应返回 404").toBe(404);
});
test("更新不存在的标题 - 反向", async ({ request }) => { test("更新不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update-404") const { headers } = await createAuthedUser(request, "title-update-404");
const response = await request.patch(`${apiBase}/titles/nonexistent-title-999`, { const response = await request.patch(
headers, `${apiBase}/titles/nonexistent-title-999`,
data: { name: "不存在的标题", text: "测试" }, {
}) headers,
expect(response.status(), "更新不存在的标题应返回 404").toBe(404) data: { name: "不存在的标题", text: "测试" },
}) },
);
expect(response.status(), "更新不存在的标题应返回 404").toBe(404);
});
test("删除不存在的标题 - 反向", async ({ request }) => { test("删除不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-del-404") const { headers } = await createAuthedUser(request, "title-del-404");
const response = await request.delete(`${apiBase}/titles/nonexistent-title-999`, { headers }) const response = await request.delete(
`${apiBase}/titles/nonexistent-title-999`,
{ headers },
);
expect( expect(
[404, 200, 204].includes(response.status()), [404, 200, 204].includes(response.status()),
"删除不存在的标题应返回 404 或幂等 2xx", "删除不存在的标题应返回 404 或幂等 2xx",
).toBeTruthy() ).toBeTruthy();
}) });
test("未登录创建标题 - 反向", async ({ request }) => { test("未登录创建标题 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/titles`, { const response = await request.post(`${apiBase}/titles`, {
@@ -373,39 +373,39 @@ test.describe("标题库 - API 完整操作", () => {
text: "未登录创建标题", text: "未登录创建标题",
category: "default", category: "default",
}, },
}) });
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
test("未登录删除标题 - 反向", async ({ request }) => { test("未登录删除标题 - 反向", async ({ request }) => {
const response = await request.delete(`${apiBase}/titles/some-id`) const response = await request.delete(`${apiBase}/titles/some-id`);
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
}) });
test.describe("标题库 - 分类/标签筛选", () => { test.describe("标题库 - 分类/标签筛选", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("标题分类 API 返回数据", async ({ request }) => { test("标题分类 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-cat") const { headers } = await createAuthedUser(request, "title-cat");
// 获取标题列表,检查分类字段 // 获取标题列表,检查分类字段
const response = await request.get(`${apiBase}/titles`, { headers }) const response = await request.get(`${apiBase}/titles`, { headers });
expect(response.ok()).toBeTruthy() expect(response.ok()).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || data.titles || [] const items = data.items || data.titles || [];
expect(Array.isArray(items)).toBeTruthy() expect(Array.isArray(items)).toBeTruthy();
// 如果有标题,验证有分类字段 // 如果有标题,验证有分类字段
if (items.length > 0) { if (items.length > 0) {
expect(items[0].category !== undefined).toBeTruthy() expect(items[0].category !== undefined).toBeTruthy();
} }
}) });
test("按分类筛选标题", async ({ request }) => { test("按分类筛选标题", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-filter-cat") const { headers } = await createAuthedUser(request, "title-filter-cat");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
// 创建不同分类的标题 // 创建不同分类的标题
await request.post(`${apiBase}/titles`, { await request.post(`${apiBase}/titles`, {
@@ -415,7 +415,7 @@ test.describe("标题库 - 分类/标签筛选", () => {
text: "种草内容", text: "种草内容",
category: "种草", category: "种草",
}, },
}) });
await request.post(`${apiBase}/titles`, { await request.post(`${apiBase}/titles`, {
headers, headers,
data: { data: {
@@ -423,118 +423,116 @@ test.describe("标题库 - 分类/标签筛选", () => {
text: "知识内容", text: "知识内容",
category: "知识", category: "知识",
}, },
}) });
// 按分类筛选 // 按分类筛选
const response = await request.get(`${apiBase}/titles`, { const response = await request.get(`${apiBase}/titles`, {
headers, headers,
params: { category: "种草" }, params: { category: "种草" },
}) });
// 筛选可能支持也可能不支持 // 筛选可能支持也可能不支持
expect(response.ok(), `筛选请求应成功,实际: ${response.status()}`).toBeTruthy() expect(
}) response.ok(),
}) `筛选请求应成功,实际: ${response.status()}`,
).toBeTruthy();
});
});
test.describe("标题库 - 页面交互", () => { test.describe("标题库 - 页面交互", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("标题卡片展示完整信息", async ({ page, request }) => { test("标题卡片展示完整信息", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "title-card");
"title-card", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
await createTitle(request, headers, suffix) await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-card", display_name: "E2E title-card",
}) });
await page.goto("/app/titles") await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const firstCard = page.locator(".xx-title-card").first() const firstCard = page.locator(".xx-title-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证标题文本 // 验证标题文本
const titleText = firstCard.locator(".xx-title-card-text") const titleText = firstCard.locator(".xx-title-card-text");
if (await titleText.isVisible()) { if (await titleText.isVisible()) {
await expect(titleText).toBeVisible() await expect(titleText).toBeVisible();
} }
// 验证统计信息 // 验证统计信息
const titleStat = firstCard.locator(".xx-title-card-stat") const titleStat = firstCard.locator(".xx-title-card-stat");
if (await titleStat.isVisible()) { if (await titleStat.isVisible()) {
await expect(titleStat).toBeVisible() await expect(titleStat).toBeVisible();
} }
} }
}) });
test("标题卡片可点击查看详情", async ({ page, request }) => { test("标题卡片可点击查看详情", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "title-detail");
"title-detail", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
await createTitle(request, headers, suffix) await createTitle(request, headers, suffix);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-detail", display_name: "E2E title-detail",
}) });
await page.goto("/app/titles") await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const firstCard = page.locator(".xx-title-card").first() const firstCard = page.locator(".xx-title-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
await firstCard.click() await firstCard.click();
// 点击后页面应该有响应(可能是弹窗或跳转) // 点击后页面应该有响应(可能是弹窗或跳转)
await expect(page.locator(".xx-titles-page")).toBeVisible() await expect(page.locator(".xx-titles-page")).toBeVisible();
} }
}) });
}) });
test.describe("标题库 - 批量操作", () => { test.describe("标题库 - 批量操作", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("多选复选框存在", async ({ page, request }) => { test("多选复选框存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "title-batch-ui");
"title-batch-ui", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
// 创建多个标题 // 创建多个标题
await createTitle(request, headers, `${suffix}-1`) await createTitle(request, headers, `${suffix}-1`);
await createTitle(request, headers, `${suffix}-2`) await createTitle(request, headers, `${suffix}-2`);
await createTitle(request, headers, `${suffix}-3`) await createTitle(request, headers, `${suffix}-3`);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-batch-ui", display_name: "E2E title-batch-ui",
}) });
await page.goto("/app/titles") await page.goto("/app/titles");
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 检查是否有批量操作相关 UI // 检查是否有批量操作相关 UI
const checkboxes = page.locator(".xx-title-card input[type='checkbox']");
// 页面正常加载即可,批量操作是可选功能 // 页面正常加载即可,批量操作是可选功能
await expect(page.locator(".xx-titles-page")).toBeVisible() await expect(page.locator(".xx-titles-page")).toBeVisible();
}) });
}) });
+206 -169
View File
@@ -6,29 +6,31 @@
* *
* auth token * auth token
*/ */
import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -41,30 +43,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -72,7 +74,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 设置页面认证状态(localStorage */ /** 设置页面认证状态(localStorage */
@@ -83,14 +85,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -104,123 +106,140 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
test.describe("声音克隆页面 - 未登录重定向", () => { test.describe("声音克隆页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voice-clone") await page.goto("/app/voice-clone");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
test.describe("声音克隆页面 - 页面加载", () => { test.describe("声音克隆页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("声音克隆页面加载成功", async ({ page, request }) => { test("声音克隆页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-load") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-load", display_name: "E2E vc-load",
}) });
await page.goto("/app/voice-clone") await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
}) });
test("页面标题和描述存在", async ({ page, request }) => { test("页面标题和描述存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-title") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-title",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-title", display_name: "E2E vc-title",
}) });
await page.goto("/app/voice-clone") await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证页面标题包含"克隆"或"音色"相关文字 // 验证页面标题包含"克隆"或"音色"相关文字
const pageTitle = page.getByRole("heading", { level: 1 });
// 只要页面正常加载即可,标题可能在 PageHead 组件中 // 只要页面正常加载即可,标题可能在 PageHead 组件中
await expect(page.locator(".vc-page")).toBeVisible() await expect(page.locator(".vc-page")).toBeVisible();
}) });
test("克隆新音色按钮存在", async ({ page, request }) => { test("克隆新音色按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-newbtn") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-newbtn",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-newbtn", display_name: "E2E vc-newbtn",
}) });
await page.goto("/app/voice-clone") await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证克隆新音色按钮存在 // 验证克隆新音色按钮存在
const cloneBtn = page.getByRole("button", { name: /克隆新音色|新建|创建/ });
// 按钮可能在不同位置,只要页面加载成功即可 // 按钮可能在不同位置,只要页面加载成功即可
await expect(page.locator(".vc-page")).toBeVisible() await expect(page.locator(".vc-page")).toBeVisible();
}) });
}) });
test.describe("声音克隆 - 空状态", () => { test.describe("声音克隆 - 空状态", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("无克隆音色时显示空状态", async ({ page, request }) => { test("无克隆音色时显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-empty") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-empty",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-empty", display_name: "E2E vc-empty",
}) });
await page.goto("/app/voice-clone") await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 新用户应该显示空状态 // 新用户应该显示空状态
const emptyState = page.locator(".vc-empty") const emptyState = page.locator(".vc-empty");
if (await emptyState.isVisible({ timeout: 10_000 })) { if (await emptyState.isVisible({ timeout: 10_000 })) {
await expect(emptyState.locator(".vc-empty-title")).toBeVisible() await expect(emptyState.locator(".vc-empty-title")).toBeVisible();
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible() await expect(emptyState.locator(".vc-empty-desc")).toBeVisible();
} }
}) });
}) });
test.describe("声音克隆 - API 操作", () => { test.describe("声音克隆 - API 操作", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("获取克隆列表 - 正向", async ({ request }) => { test("获取克隆列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-list") const { headers } = await createAuthedUser(request, "vc-list");
const response = await request.get(`${apiBase}/voice-clones`, { const response = await request.get(`${apiBase}/voice-clones`, {
headers, headers,
}) });
expect(response.ok(), `获取克隆列表应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取克隆列表应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || data.voice_clones || [] const items = data.items || data.voice_clones || [];
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy() expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy();
}) });
test("创建音色克隆 - 正向", async ({ request }) => { test("创建音色克隆 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-create") const { headers } = await createAuthedUser(request, "vc-create");
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
// 创建一个克隆任务(上传音频文件) // 创建一个克隆任务(上传音频文件)
const response = await request.post(`${apiBase}/voice-clones`, { const response = await request.post(`${apiBase}/voice-clones`, {
@@ -234,49 +253,52 @@ test.describe("声音克隆 - API 操作", () => {
buffer: Buffer.from("fake audio data for e2e test"), buffer: Buffer.from("fake audio data for e2e test"),
}, },
}, },
}) });
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误 // 克隆创建可能成功也可能因为缺少实际音频处理返回错误
// 只要不是 500 错误即可 // 只要不是 500 错误即可
expect( expect(
response.status() < 500, response.status() < 500,
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`, `创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy() ).toBeTruthy();
if (response.ok()) { if (response.ok()) {
const data = await response.json() const data = await response.json();
expect(data.id, "应返回克隆 ID").toBeTruthy() expect(data.id, "应返回克隆 ID").toBeTruthy();
expect(data.status, "应返回状态").toBeTruthy() expect(data.status, "应返回状态").toBeTruthy();
} }
}) });
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => { test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-detail") const { headers } = await createAuthedUser(request, "vc-detail");
// 先获取列表看看有没有数据 // 先获取列表看看有没有数据
const listResp = await request.get(`${apiBase}/voice-clones`, { const listResp = await request.get(`${apiBase}/voice-clones`, {
headers, headers,
}) });
expect(listResp.ok()).toBeTruthy() expect(listResp.ok()).toBeTruthy();
const listData = await listResp.json() const listData = await listResp.json();
const items = listData.items || listData.voice_clones || [] const items = listData.items || listData.voice_clones || [];
if (items.length > 0) { if (items.length > 0) {
const cloneId = items[0].id const cloneId = items[0].id;
const detailResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { headers }) const detailResp = await request.get(
expect(detailResp.ok(), "获取详情应成功").toBeTruthy() `${apiBase}/voice-clones/${cloneId}`,
const detail = await detailResp.json() { headers },
expect(detail.id).toBe(cloneId) );
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(cloneId);
} }
// 如果没有数据,测试也通过(新用户正常情况) // 如果没有数据,测试也通过(新用户正常情况)
}) });
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => { test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-del") const { headers } = await createAuthedUser(request, "vc-del");
// 先创建一个克隆 // 先创建一个克隆
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const createResp = await request.post(`${apiBase}/voice-clones`, { const createResp = await request.post(`${apiBase}/voice-clones`, {
headers, headers,
multipart: { multipart: {
@@ -287,63 +309,76 @@ test.describe("声音克隆 - API 操作", () => {
buffer: Buffer.from("delete me"), buffer: Buffer.from("delete me"),
}, },
}, },
}) });
if (createResp.ok()) { if (createResp.ok()) {
const created = await createResp.json() const created = await createResp.json();
const cloneId = created.id const cloneId = created.id;
// 删除 // 删除
const deleteResp = await request.delete(`${apiBase}/voice-clones/${cloneId}`, { headers }) const deleteResp = await request.delete(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect( expect(
[200, 204].includes(deleteResp.status()), [200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`, `删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy() ).toBeTruthy();
// 验证已删除 // 验证已删除
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { const getResp = await request.get(
headers, `${apiBase}/voice-clones/${cloneId}`,
}) { headers },
expect([404, 410]).toContain(getResp.status()) );
expect([404, 410]).toContain(getResp.status());
} }
// 如果创建失败(比如音频格式问题),测试也通过 // 如果创建失败(比如音频格式问题),测试也通过
}) });
test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => { test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-retry") const { headers } = await createAuthedUser(request, "vc-retry");
// 先获取列表 // 先获取列表
const listResp = await request.get(`${apiBase}/voice-clones`, { const listResp = await request.get(`${apiBase}/voice-clones`, {
headers, headers,
}) });
expect(listResp.ok()).toBeTruthy() expect(listResp.ok()).toBeTruthy();
const listData = await listResp.json() const listData = await listResp.json();
const items = listData.items || listData.voice_clones || [] const items = listData.items || listData.voice_clones || [];
// 找一个失败状态的克隆进行重试 // 找一个失败状态的克隆进行重试
const failedClone = items.find((item: { status: string }) => item.status === "failed") const failedClone = items.find(
(item: { status: string }) => item.status === "failed",
);
if (failedClone) { if (failedClone) {
const retryResp = await request.post(`${apiBase}/voice-clones/${failedClone.id}/retry`, { const retryResp = await request.post(
headers, `${apiBase}/voice-clones/${failedClone.id}/retry`,
}) { headers },
expect(retryResp.ok(), `重试应返回 2xx,实际: ${retryResp.status()}`).toBeTruthy() );
expect(
retryResp.ok(),
`重试应返回 2xx,实际: ${retryResp.status()}`,
).toBeTruthy();
} }
// 如果没有失败的克隆,测试通过 // 如果没有失败的克隆,测试通过
}) });
test("获取不存在的克隆详情 - 反向", async ({ request }) => { test("获取不存在的克隆详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-404") const { headers } = await createAuthedUser(request, "vc-404");
const response = await request.get(`${apiBase}/voice-clones/nonexistent-clone-999`, { headers }) const response = await request.get(
expect(response.status(), "不存在的克隆应返回 404").toBe(404) `${apiBase}/voice-clones/nonexistent-clone-999`,
}) { headers },
);
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
});
test("未登录获取克隆列表 - 反向", async ({ request }) => { test("未登录获取克隆列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`) const response = await request.get(`${apiBase}/voice-clones`);
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
test("未登录创建克隆 - 反向", async ({ request }) => { test("未登录创建克隆 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/voice-clones`, { const response = await request.post(`${apiBase}/voice-clones`, {
@@ -355,46 +390,47 @@ test.describe("声音克隆 - API 操作", () => {
buffer: Buffer.from("test"), buffer: Buffer.from("test"),
}, },
}, },
}) });
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
}) });
test.describe("声音克隆 - 克隆列表展示", () => { test.describe("声音克隆 - 克隆列表展示", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("克隆卡片网格布局展示", async ({ page, request }) => { test("克隆卡片网格布局展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-grid") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-grid",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-grid", display_name: "E2E vc-grid",
}) });
await page.goto("/app/voice-clone") await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证网格容器或空状态存在 // 验证网格容器或空状态存在
const grid = page.locator(".vc-grid") const grid = page.locator(".vc-grid");
const empty = page.locator(".vc-empty") const empty = page.locator(".vc-empty");
// 至少一个应该可见 // 至少一个应该可见
const gridVisible = await grid.isVisible().catch(() => false) const gridVisible = await grid.isVisible().catch(() => false);
const emptyVisible = await empty.isVisible().catch(() => false) const emptyVisible = await empty.isVisible().catch(() => false);
expect(gridVisible || emptyVisible).toBeTruthy() expect(gridVisible || emptyVisible).toBeTruthy();
}) });
test("克隆状态标签展示", async ({ page, request }) => { test("克隆状态标签展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username, headers } = await createAuthedUser( const { accessToken, userId, email, username, headers } =
request, await createAuthedUser(request, "vc-status");
"vc-status", const suffix = Date.now().toString(36);
)
const suffix = Date.now().toString(36)
// 创建一个克隆任务 // 创建一个克隆任务
await request.post(`${apiBase}/voice-clones`, { await request.post(`${apiBase}/voice-clones`, {
@@ -407,61 +443,62 @@ test.describe("声音克隆 - 克隆列表展示", () => {
buffer: Buffer.from("status test data"), buffer: Buffer.from("status test data"),
}, },
}, },
}) });
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-status", display_name: "E2E vc-status",
}) });
await page.goto("/app/voice-clone") await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 如果有卡片,验证状态标签存在 // 如果有卡片,验证状态标签存在
const cards = page.locator(".vc-card") const cards = page.locator(".vc-card");
if ((await cards.count()) > 0) { if ((await cards.count()) > 0) {
const firstCard = cards.first() const firstCard = cards.first();
const statusPill = firstCard.locator(".vc-status-pill") const statusPill = firstCard.locator(".vc-status-pill");
if (await statusPill.isVisible()) { if (await statusPill.isVisible()) {
await expect(statusPill).toBeVisible() await expect(statusPill).toBeVisible();
} }
} }
}) });
}) });
test.describe("声音克隆 - 上传区域", () => { test.describe("声音克隆 - 上传区域", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("克隆弹窗上传区域可打开", async ({ page, request }) => { test("克隆弹窗上传区域可打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-upload") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"vc-upload",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-upload", display_name: "E2E vc-upload",
}) });
await page.goto("/app/voice-clone") await page.goto("/app/voice-clone");
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 尝试点击克隆新音色按钮 // 尝试点击克隆新音色按钮
const cloneBtn = page.getByRole("button", { const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
name: /克隆新音色|立即克隆|新建/,
})
if (await cloneBtn.isVisible()) { if (await cloneBtn.isVisible()) {
await cloneBtn.click() await cloneBtn.click();
// 弹窗应该出现 // 弹窗应该出现
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']") const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
if (await modal.first().isVisible({ timeout: 5_000 })) { if (await modal.first().isVisible({ timeout: 5_000 })) {
await expect(modal.first()).toBeVisible() await expect(modal.first()).toBeVisible();
} }
} }
}) });
}) });
+187 -147
View File
@@ -6,29 +6,31 @@
* *
* auth token * auth token
*/ */
import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!" const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -41,30 +43,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -72,7 +74,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 设置页面认证状态(localStorage */ /** 设置页面认证状态(localStorage */
@@ -83,14 +85,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -104,289 +106,327 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
test.describe("音色库页面 - 未登录重定向", () => { test.describe("音色库页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
test.describe("音色库页面 - 页面加载", () => { test.describe("音色库页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("音色库页面加载成功", async ({ page, request }) => { test("音色库页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-load") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-load", display_name: "E2E voice-load",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
}) });
test("页面头部和搜索栏存在", async ({ page, request }) => { test("页面头部和搜索栏存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-head") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-head",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-head", display_name: "E2E voice-head",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证搜索框存在 // 验证搜索框存在
const searchInput = page.locator( const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']", await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
) });
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 }) });
})
})
test.describe("音色库 - 预设音色", () => { test.describe("音色库 - 预设音色", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("预设音色列表 API 返回数据", async ({ request }) => { test("预设音色列表 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-preset") const { headers } = await createAuthedUser(request, "voice-preset");
const response = await request.get(`${apiBase}/voices/preset`, { const response = await request.get(`${apiBase}/voices/preset`, {
headers, headers,
}) });
// 预设音色接口可能返回数组或包装对象 // 预设音色接口可能返回数组或包装对象
expect(response.ok(), `获取预设音色应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取预设音色应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || data.voices || data const items = data.items || data.voices || data;
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy() expect(Array.isArray(items), "预设音色应为数组").toBeTruthy();
}) });
test("预设音色卡片在页面中展示", async ({ page, request }) => { test("预设音色卡片在页面中展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-cards") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-cards",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-cards", display_name: "E2E voice-cards",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 等待音色卡片加载(预设音色应该有数据) // 等待音色卡片加载(预设音色应该有数据)
const voiceCards = page.locator(".xx-voice-card") const voiceCards = page.locator(".xx-voice-card");
// 等待至少一张卡片出现 // 等待至少一张卡片出现
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 }) await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 });
const count = await voiceCards.count() const count = await voiceCards.count();
expect(count).toBeGreaterThan(0) expect(count).toBeGreaterThan(0);
}) });
test("音色卡片包含名称和信息", async ({ page, request }) => { test("音色卡片包含名称和信息", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-info") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-info",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-info", display_name: "E2E voice-info",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const firstCard = page.locator(".xx-voice-card").first() const firstCard = page.locator(".xx-voice-card").first();
await expect(firstCard).toBeVisible({ timeout: 15_000 }) await expect(firstCard).toBeVisible({ timeout: 15_000 });
// 验证音色名称存在 // 验证音色名称存在
await expect(firstCard.locator(".xx-voice-name")).toBeVisible() await expect(firstCard.locator(".xx-voice-name")).toBeVisible();
// 验证头像存在 // 验证头像存在
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible() await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible();
}) });
}) });
test.describe("音色库 - 我的克隆音色", () => { test.describe("音色库 - 我的克隆音色", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("克隆音色列表 API 返回数据", async ({ request }) => { test("克隆音色列表 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-cln-api") const { headers } = await createAuthedUser(request, "voice-cln-api");
const response = await request.get(`${apiBase}/voice-clones`, { const response = await request.get(`${apiBase}/voice-clones`, {
headers, headers,
}) });
expect(response.ok(), `获取克隆音色应返回 2xx,实际: ${response.status()}`).toBeTruthy() expect(
response.ok(),
`获取克隆音色应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json() const data = await response.json();
const items = data.items || data.voice_clones || [] const items = data.items || data.voice_clones || [];
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy() expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy();
}) });
test("空状态展示 - 无克隆音色时", async ({ page, request }) => { test("空状态展示 - 无克隆音色时", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-empty") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-empty",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-empty", display_name: "E2E voice-empty",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 切换到"我的克隆"tab(如果有tab的话) // 切换到"我的克隆"tab(如果有tab的话)
const clonedTab = page.getByText("我的克隆").first() const clonedTab = page.getByText("我的克隆").first();
if (await clonedTab.isVisible()) { if (await clonedTab.isVisible()) {
await clonedTab.click() await clonedTab.click();
} }
// 页面至少应该是可访问的 // 页面至少应该是可访问的
await expect(page.locator(".xx-voices-page")).toBeVisible() await expect(page.locator(".xx-voices-page")).toBeVisible();
}) });
test("创建克隆音色入口存在", async ({ page, request }) => { test("创建克隆音色入口存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-create") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-create",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-create", display_name: "E2E voice-create",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮) // 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮)
const createBtn = page.getByRole("button", {
name: /克隆|新建|创建|\+/,
});
// 不强制断言一定存在,因为不同页面结构可能不同 // 不强制断言一定存在,因为不同页面结构可能不同
// 只验证页面正常加载即可 // 只验证页面正常加载即可
await expect(page.locator(".xx-voices-page")).toBeVisible() await expect(page.locator(".xx-voices-page")).toBeVisible();
}) });
}) });
test.describe("音色库 - 搜索和筛选", () => { test.describe("音色库 - 搜索和筛选", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("搜索框存在且可输入", async ({ page, request }) => { test("搜索框存在且可输入", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-search") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-search",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-search", display_name: "E2E voice-search",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 查找搜索输入框 // 查找搜索输入框
const searchInput = page.locator( const searchInput = page.locator(
"input[placeholder*='搜索'], input[type='search'], .xx-voices-search input", "input[placeholder*='搜索'], input[type='search'], .xx-voices-search input",
) );
const firstInput = searchInput.first() const firstInput = searchInput.first();
if (await firstInput.isVisible({ timeout: 5_000 })) { if (await firstInput.isVisible({ timeout: 5_000 })) {
await firstInput.fill("测试搜索") await firstInput.fill("测试搜索");
await expect(firstInput).toHaveValue("测试搜索") await expect(firstInput).toHaveValue("测试搜索");
} }
}) });
test("性别/语言筛选选项存在", async ({ page, request }) => { test("性别/语言筛选选项存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-filter") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-filter",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-filter", display_name: "E2E voice-filter",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 验证筛选相关元素存在(可能是下拉选择器或标签) // 验证筛选相关元素存在(可能是下拉选择器或标签)
const filterSelect = page.locator("select, .xx-voices-filter");
// 页面正常加载即通过 // 页面正常加载即通过
await expect(page.locator(".xx-voices-page")).toBeVisible() await expect(page.locator(".xx-voices-page")).toBeVisible();
}) });
}) });
test.describe("音色库 - 播放试听", () => { test.describe("音色库 - 播放试听", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("音色播放按钮存在", async ({ page, request }) => { test("音色播放按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-play") const { accessToken, userId, email, username } = await createAuthedUser(
request,
"voice-play",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-play", display_name: "E2E voice-play",
}) });
await page.goto("/app/voices") await page.goto("/app/voices");
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const firstCard = page.locator(".xx-voice-card").first() const firstCard = page.locator(".xx-voice-card").first();
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证播放按钮存在 // 验证播放按钮存在
const playBtn = firstCard.locator(".xx-voice-play-btn") const playBtn = firstCard.locator(".xx-voice-play-btn");
if (await playBtn.isVisible()) { if (await playBtn.isVisible()) {
await expect(playBtn).toBeVisible() await expect(playBtn).toBeVisible();
} }
} }
}) });
}) });
test.describe("音色库 - API 边界测试", () => { test.describe("音色库 - API 边界测试", () => {
test.describe.configure({ timeout: 120_000 }) test.describe.configure({ timeout: 120_000 });
test("未登录获取预设音色 - 反向", async ({ request }) => { test("未登录获取预设音色 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voices/preset`) const response = await request.get(`${apiBase}/voices/preset`);
// 预设音色可能不需要登录,也可能需要,两种情况都接受 // 预设音色可能不需要登录,也可能需要,两种情况都接受
// 但如果需要登录,应返回 401/403 // 但如果需要登录,应返回 401/403
if (!response.ok()) { if (!response.ok()) {
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
} }
}) });
test("未登录获取克隆音色 - 反向", async ({ request }) => { test("未登录获取克隆音色 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`) const response = await request.get(`${apiBase}/voice-clones`);
expect([401, 403]).toContain(response.status()) expect([401, 403]).toContain(response.status());
}) });
test("获取不存在的克隆音色详情 - 反向", async ({ request }) => { test("获取不存在的克隆音色详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-404") const { headers } = await createAuthedUser(request, "voice-404");
const response = await request.get(`${apiBase}/voice-clones/nonexistent-999`, { headers }) const response = await request.get(
expect(response.status(), "不存在的克隆应返回 404").toBe(404) `${apiBase}/voice-clones/nonexistent-999`,
}) { headers },
}) );
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
});
});
+3 -222
View File
@@ -23,19 +23,16 @@
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/node": "^20.14.9", "@types/node": "^20.14.9",
"@types/react": "^18.3.31", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1", "@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.1",
"@vitest/ui": "^1.6.0", "@vitest/ui": "^1.6.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.9.5",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
@@ -48,20 +45,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@ant-design/colors": { "node_modules/@ant-design/colors": {
"version": "7.2.1", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
@@ -491,13 +474,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
"license": "MIT"
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -1165,16 +1141,6 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jest/schemas": { "node_modules/@jest/schemas": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -2007,14 +1973,14 @@
}, },
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.31", "version": "18.3.31",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
@@ -2254,34 +2220,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/coverage-v8": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz",
"integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.4",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.4",
"istanbul-reports": "^3.1.6",
"magic-string": "^0.30.5",
"magicast": "^0.3.3",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"test-exclude": "^6.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "1.6.1"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@@ -3316,19 +3254,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint-config-prettier": {
"version": "9.1.2",
"resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz",
"integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-react-hooks": { "node_modules/eslint-plugin-react-hooks": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
@@ -3951,13 +3876,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -4166,60 +4084,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4487,34 +4351,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4992,22 +4828,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -6173,45 +5993,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+4 -9
View File
@@ -4,18 +4,16 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc && vite build",
"dev": "vite", "dev": "vite",
"format": "prettier --write .", "build": "tsc && vite build",
"format:check": "prettier --check .",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage", "test:coverage": "vitest --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ci": "npx playwright test --project=chromium --reporter=line", "test:e2e:ci": "npx playwright test --project=chromium --reporter=line",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:ui": "vitest --ui", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -34,19 +32,16 @@
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/node": "^20.14.9", "@types/node": "^20.14.9",
"@types/react": "^18.3.31", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1", "@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.1",
"@vitest/ui": "^1.6.0", "@vitest/ui": "^1.6.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.9.5",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
+3 -3
View File
@@ -1,9 +1,9 @@
/** /**
* Playwright E2E * Playwright E2E
*/ */
import { defineConfig, devices } from "@playwright/test" import { defineConfig, devices } from "@playwright/test";
const externalBaseURL = process.env.E2E_BASE_URL const externalBaseURL = process.env.E2E_BASE_URL;
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
@@ -68,4 +68,4 @@ export default defineConfig({
url: "http://localhost:3000", url: "http://localhost:3000",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },
}) });
Executable → Regular
+190 -281
View File
@@ -2,225 +2,185 @@
* API * API
* Phase 1 project_id * Phase 1 project_id
*/ */
import apiClient from "./client" import apiClient from "./client";
import { getOrCreateDefaultProject } from "./projects" import { getOrCreateDefaultProject } from "./projects";
/** 素材元数据 */
export interface AssetMetadata {
/** 时长(秒) */
duration?: number
/** 宽度(像素) */
width?: number
/** 高度(像素) */
height?: number
/** 比特率(bps */
bitrate?: number
/** 编码格式 */
codec?: string
/** 帧率 */
fps?: number
/** 采样率(Hz */
sample_rate?: number
/** 声道数 */
channels?: number
/** 其他扩展字段 */
[key: string]: unknown
}
/** 素材分类状态 */
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
/** 素材条目 */ /** 素材条目 */
export interface AssetItem { export interface AssetItem {
id: string id: string;
library_id: string library_id: string;
name: string name: string;
storage_key: string storage_key: string;
mime_type: string mime_type: string;
metadata: AssetMetadata metadata: Record<string, unknown>;
file_size?: number file_size?: number;
file_url?: string file_url?: string;
thumbnail_url?: string thumbnail_url?: string;
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ status?: string;
duration?: number classification_status?: string | null;
status?: string quality_score?: number | null;
classification_status?: AssetClassificationStatus | null tag_ids?: string[];
quality_score?: number | null created_at?: string;
tag_ids?: string[]
created_at?: string
} }
/** 素材库 */ /** 素材库 */
export interface AssetLibraryItem { export interface AssetLibraryItem {
id: string id: string;
name: string name: string;
kind: "video" | "voice" | "image" kind: "video" | "voice" | "image";
asset_count?: number asset_count?: number;
total_size?: number total_size?: number;
created_at?: string created_at?: string;
} }
/** 入库任务 */ /** 入库任务 */
export interface IngestJob { export interface IngestJob {
id: string id: string;
library_id: string library_id: string;
storage_key: string storage_key: string;
status: "pending" | "processing" | "completed" | "failed" status: "pending" | "processing" | "completed" | "failed";
error_message: string error_message: string;
result_asset_id: string result_asset_id: string;
} }
/** 分类任务 */ /** 分类任务 */
export interface ClassificationJob { export interface ClassificationJob {
id: string id: string;
asset_id: string asset_id: string;
status: "pending" | "processing" | "completed" | "failed" status: "pending" | "processing" | "completed" | "failed";
classification: string classification: string;
confidence: number confidence: number;
error_message: string error_message: string;
} }
/** 素材诊断信息 */ /** 素材诊断信息 */
export interface AssetDiagnosis { export interface AssetDiagnosis {
readiness_score: number readiness_score: number;
readiness_label: string readiness_label: string;
total_assets: number total_assets: number;
ready_assets: number ready_assets: number;
video_assets: number video_assets: number;
image_assets: number image_assets: number;
voice_assets: number voice_assets: number;
total_duration_seconds: number total_duration_seconds: number;
estimated_video_count: number estimated_video_count: number;
used_assets: number used_assets: number;
unused_assets: number unused_assets: number;
pending_review_assets: number pending_review_assets: number;
smart_views: Array<{ smart_views: Array<{
key: string key: string;
label: string label: string;
count: number count: number;
description: string description: string;
}> }>;
gaps: Array<{ gaps: Array<{
key: string key: string;
severity: "critical" | "warning" | "info" severity: "critical" | "warning" | "info";
message: string message: string;
recommendation: string recommendation: string;
}> }>;
} }
// ─── 素材诊断 ────────────────────────────────────────────── // ─── 素材诊断 ──────────────────────────────────────────────
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ /** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => { export const getAssetDiagnosis = async (
const params: Record<string, string> = {} assetId?: string,
if (assetId) params.asset_id = assetId ): Promise<AssetDiagnosis> => {
const response = await apiClient.get("/asset-diagnosis", { params }) const params: Record<string, string> = {};
return response.data if (assetId) params.asset_id = assetId;
} const response = await apiClient.get("/asset-diagnosis", { params });
return response.data;
};
// ─── 素材库 ──────────────────────────────────────────────── // ─── 素材库 ────────────────────────────────────────────────
/** 获取当前用户的所有素材库 */ /** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => { export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get("/asset-libraries") const response = await apiClient.get("/asset-libraries");
return response.data.items || [] return response.data.items || [];
} };
/** 创建素材库(自动获取或创建默认项目以提供 project_id */ /** 创建素材库(自动获取或创建默认项目以提供 project_id */
export const createAssetLibrary = async (data: { export const createAssetLibrary = async (data: {
name: string name: string;
kind: "video" | "voice" | "image" kind: "video" | "voice" | "image";
}): Promise<AssetLibraryItem> => { }): Promise<AssetLibraryItem> => {
// 后端要求 project_id,前端自动管理默认项目 // 后端要求 project_id,前端自动管理默认项目
const project = await getOrCreateDefaultProject() const project = await getOrCreateDefaultProject();
const response = await apiClient.post("/asset-libraries", { const response = await apiClient.post("/asset-libraries", {
project_id: project.id, project_id: project.id,
...data, ...data,
}) });
return response.data return response.data;
} };
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */ /** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
export const ensureDefaultLibrary = async (data: { export const ensureDefaultLibrary = async (data: {
project_id: string project_id: string;
kind: "video" | "voice" | "image" kind: "video" | "voice" | "image";
}): Promise<AssetLibraryItem> => { }): Promise<AssetLibraryItem> => {
const response = await apiClient.post("/asset-libraries/ensure-default", data) const response = await apiClient.post(
return response.data "/asset-libraries/ensure-default",
} data,
);
return response.data;
};
/** 删除素材库 */ /** 删除素材库 */
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => { export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
await apiClient.delete(`/asset-libraries/${libraryId}`) await apiClient.delete(`/asset-libraries/${libraryId}`);
} };
// ─── 素材 ────────────────────────────────────────────────── // ─── 素材 ──────────────────────────────────────────────────
/** 获取素材库下的所有素材 */ /** 获取素材库下的所有素材 */
export const getAssets = async ( export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
libraryId: string, const response = await apiClient.get("/assets", {
options?: { status?: string; page?: number; page_size?: number }, params: { library_id: libraryId },
): Promise<{ items: AssetItem[]; total: number }> => { });
const params: Record<string, string | number> = { library_id: libraryId } return response.data.items || [];
// 默认拉取所有非删除状态的素材(ready/ingesting/processing/uploading/error/failed };
// 让用户能看到"处理中"的素材,不会以为上传失败了
if (options?.status) {
params.status = options.status
}
if (options?.page) params.page = options.page
if (options?.page_size) params.page_size = options.page_size
const response = await apiClient.get("/assets", { params })
const data = response.data || {}
const items: AssetItem[] = data.items || []
const total: number = typeof data.total === "number" ? data.total : items.length
return { items, total }
}
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */ /** 按类型获取素材(如 voice/video/image),支持可选筛选 */
export const getAssetsByKind = async ( export const getAssetsByKind = async (
kind: string, kind: string,
filters?: { filters?: {
keyword?: string keyword?: string;
gender?: string gender?: string;
style?: string style?: string;
tag_ids?: string[] tag_ids?: string[];
limit?: number
page?: number
page_size?: number
}, },
): Promise<AssetItem[]> => { ): Promise<AssetItem[]> => {
const params: Record<string, string | number> = { kind } const params: Record<string, string> = { kind };
if (filters?.keyword) params.keyword = filters.keyword if (filters?.keyword) params.keyword = filters.keyword;
if (filters?.gender) params.gender = filters.gender if (filters?.gender) params.gender = filters.gender;
if (filters?.style) params.style = filters.style if (filters?.style) params.style = filters.style;
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",") if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",");
if (filters?.limit) params.limit = filters.limit const response = await apiClient.get("/assets", { params });
if (filters?.page) params.page = filters.page return response.data.items || [];
if (filters?.page_size) params.page_size = filters.page_size };
const response = await apiClient.get("/assets", { params })
return response.data.items || []
}
/** 创建素材(上传文件后调用,附带 metadata) */ /** 创建素材(上传文件后调用,附带 metadata) */
export const createAsset = async (data: { export const createAsset = async (data: {
library_id: string library_id: string;
name: string name: string;
storage_key: string storage_key: string;
mime_type: string mime_type: string;
metadata?: AssetMetadata metadata?: Record<string, unknown>;
}): Promise<AssetItem> => { }): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data) const response = await apiClient.post("/assets", data);
return response.data return response.data;
} };
/** 更新素材(名称、metadata 等) */ /** 更新素材(名称、metadata 等) */
export const updateAsset = async ( export const updateAsset = async (
assetId: string, assetId: string,
data: { name?: string; metadata?: AssetMetadata }, data: { name?: string; metadata?: Record<string, unknown> },
): Promise<AssetItem> => { ): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data) const response = await apiClient.put(`/assets/${assetId}`, data);
return response.data return response.data;
} };
/** 更新素材审核状态 */ /** 更新素材审核状态 */
export const updateAssetReviewStatus = async ( export const updateAssetReviewStatus = async (
@@ -229,14 +189,14 @@ export const updateAssetReviewStatus = async (
): Promise<AssetItem> => { ): Promise<AssetItem> => {
const response = await apiClient.patch(`/assets/${assetId}/review`, { const response = await apiClient.patch(`/assets/${assetId}/review`, {
review_status: reviewStatus, review_status: reviewStatus,
}) });
return response.data return response.data;
} };
/** 删除素材 */ /** 删除素材 */
export const deleteAsset = async (assetId: string): Promise<void> => { export const deleteAsset = async (assetId: string): Promise<void> => {
await apiClient.delete(`/assets/${assetId}`) await apiClient.delete(`/assets/${assetId}`);
} };
// ─── 上传 ────────────────────────────────────────────────── // ─── 上传 ──────────────────────────────────────────────────
@@ -247,47 +207,47 @@ export const uploadAsset = async (
const response = await apiClient.post("/upload", formData, { const response = await apiClient.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
timeout: 30 * 60 * 1000, timeout: 30 * 60 * 1000,
}) });
return response.data return response.data;
} };
/** 预签名直传准备 */ /** 预签名直传准备 */
export const prepareDirectUpload = async (data: { export const prepareDirectUpload = async (data: {
project_id: string project_id: string;
library_id: string library_id: string;
filename: string filename: string;
content_type: string content_type: string;
file_size: number file_size: number;
}): Promise<{ }): Promise<{
upload_url: string upload_url: string;
method: string method: string;
storage_key: string storage_key: string;
expires_at: string expires_at: string;
fields: Record<string, string> fields: Record<string, string>;
max_size_bytes: number max_size_bytes: number;
}> => { }> => {
const response = await apiClient.post("/upload/direct/prepare", data) const response = await apiClient.post("/upload/direct/prepare", data);
return response.data return response.data;
} };
/** 直传完成确认 */ /** 直传完成确认 */
export const completeDirectUpload = async (data: { export const completeDirectUpload = async (data: {
project_id: string project_id: string;
library_id: string library_id: string;
storage_key: string storage_key: string;
}): Promise<{ storage_key: string; ingest_job_id: string }> => { }): Promise<{ storage_key: string; ingest_job_id: string }> => {
const response = await apiClient.post("/upload/direct/complete", data) const response = await apiClient.post("/upload/direct/complete", data);
return response.data return response.data;
} };
/** 直传上传(大文件推荐),支持可选进度回调 */ /** 直传上传(大文件推荐),支持可选进度回调 */
export const uploadAssetDirect = async (data: { export const uploadAssetDirect = async (data: {
file: File file: File;
library_id: string library_id: string;
onProgress?: (percent: number) => void onProgress?: (percent: number) => void;
}): Promise<{ storage_key: string; ingest_job_id: string }> => { }): Promise<{ storage_key: string; ingest_job_id: string }> => {
// 后端要求 project_id,前端自动获取默认项目 // 后端要求 project_id,前端自动获取默认项目
const project = await getOrCreateDefaultProject() const project = await getOrCreateDefaultProject();
const prepared = await prepareDirectUpload({ const prepared = await prepareDirectUpload({
project_id: project.id, project_id: project.id,
@@ -295,149 +255,98 @@ export const uploadAssetDirect = async (data: {
filename: data.file.name, filename: data.file.name,
content_type: data.file.type || "application/octet-stream", content_type: data.file.type || "application/octet-stream",
file_size: data.file.size, file_size: data.file.size,
}) });
const directForm = new FormData() const directForm = new FormData();
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value)) Object.entries(prepared.fields).forEach(([key, value]) =>
directForm.append("file", data.file) directForm.append(key, value),
);
directForm.append("file", data.file);
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 // 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest();
xhr.open(prepared.method, prepared.upload_url) xhr.open(prepared.method, prepared.upload_url);
// 超时 10 分钟 // 超时 10 分钟
xhr.timeout = 10 * 60 * 1000 xhr.timeout = 10 * 60 * 1000;
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) { if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100)) data.onProgress(Math.round((e.loaded / e.total) * 100));
} }
} };
xhr.onload = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve() resolve();
} else { } else {
// 解析 OSS 返回的 XML 错误信息 // 解析 OSS 返回的 XML 错误信息
let ossError = "" let ossError = "";
try { try {
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/) const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/);
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/) const msgMatch = xhr.responseText.match(
/<Message>([^<]+)<\/Message>/,
);
if (codeMatch || msgMatch) { if (codeMatch || msgMatch) {
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]` ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`;
} }
} catch { } catch {
// 无法解析响应体 // 无法解析响应体
} }
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}` const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`;
console.error("[OSS Upload] 直传失败:", { console.error("[OSS Upload] 直传失败:", {
url: prepared.upload_url, url: prepared.upload_url,
storage_key: prepared.storage_key, storage_key: prepared.storage_key,
status: xhr.status, status: xhr.status,
statusText: xhr.statusText, statusText: xhr.statusText,
}) });
reject(new Error(detail)) reject(new Error(detail));
} }
} };
xhr.onerror = () => { xhr.onerror = () => {
console.error("[OSS Upload] 网络错误:", { console.error("[OSS Upload] 网络错误:", {
url: prepared.upload_url, url: prepared.upload_url,
storage_key: prepared.storage_key, storage_key: prepared.storage_key,
}) });
reject(new Error("OSS 上传网络错误,请检查网络连接")) reject(new Error("OSS 上传网络错误,请检查网络连接"));
} };
xhr.ontimeout = () => { xhr.ontimeout = () => {
console.error("[OSS Upload] 上传超时:", { console.error("[OSS Upload] 上传超时:", {
url: prepared.upload_url, url: prepared.upload_url,
storage_key: prepared.storage_key, storage_key: prepared.storage_key,
}) });
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件")) reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"));
} };
xhr.send(directForm) xhr.send(directForm);
}) });
return completeDirectUpload({ return completeDirectUpload({
project_id: project.id, project_id: project.id,
library_id: data.library_id, library_id: data.library_id,
storage_key: prepared.storage_key, storage_key: prepared.storage_key,
}) });
} };
// ─── 入库 / 分类任务 ─────────────────────────────────────── // ─── 入库 / 分类任务 ───────────────────────────────────────
/** 查询入库任务状态 */ /** 查询入库任务状态 */
export const getIngestJob = async (jobId: string): Promise<IngestJob> => { export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
const response = await apiClient.get(`/ingest-jobs/${jobId}`) const response = await apiClient.get(`/ingest-jobs/${jobId}`);
return response.data return response.data;
} };
/** 提交素材分类任务 */ /** 提交素材分类任务 */
export const submitClassificationJob = async (data: { export const submitClassificationJob = async (data: {
asset_id: string asset_id: string;
}): Promise<ClassificationJob> => { }): Promise<ClassificationJob> => {
const response = await apiClient.post("/classification-jobs", data) const response = await apiClient.post("/classification-jobs", data);
return response.data return response.data;
} };
/** 查询分类任务状态 */ /** 查询分类任务状态 */
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => { export const getClassificationJob = async (
const response = await apiClient.get(`/classification-jobs/${jobId}`) jobId: string,
return response.data ): Promise<ClassificationJob> => {
} const response = await apiClient.get(`/classification-jobs/${jobId}`);
return response.data;
// ─── 批量操作 ─────────────────────────────────────────────── };
/** 批量操作结果 */
export interface BatchOperationResult {
succeeded: string[]
failed: string[]
total: number
success_count: number
failure_count: number
}
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
return { succeeded, failed, total, success_count, failure_count }
}
/** 批量删除素材 */
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds,
})
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量打标签 */
export const batchTagAssets = async (data: {
asset_ids: string[]
tags: string[]
mode: "add" | "replace"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量改分类 */
export const batchClassifyAssets = async (data: {
asset_ids: string[]
category: string
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量智能标记 */
export const batchMarkAssets = async (data: {
asset_ids: string[]
smart_view: "recommended" | "caution" | "high_risk"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { normalizeUser } from "./auth";
describe("normalizeUser", () => {
it("normalizes canonical API current-user fields", () => {
expect(
normalizeUser({
user_id: "user-1",
email: "user@example.com",
username: "user",
display_name: "User",
email_verified: true,
}),
).toEqual({
id: "user-1",
user_id: "user-1",
email: "user@example.com",
username: "user",
display_name: "User",
is_email_verified: true,
email_verified: true,
created_at: undefined,
});
});
it("keeps compatibility with legacy UI-shaped user fields", () => {
expect(
normalizeUser({
id: "user-2",
email: "legacy@example.com",
username: "legacy",
display_name: "Legacy",
is_email_verified: false,
created_at: "2026-06-22T00:00:00Z",
}),
).toEqual({
id: "user-2",
user_id: "user-2",
email: "legacy@example.com",
username: "legacy",
display_name: "Legacy",
is_email_verified: false,
email_verified: false,
created_at: "2026-06-22T00:00:00Z",
});
});
});
+71 -127
View File
@@ -1,58 +1,58 @@
/** /**
* API * API
*/ */
import axios from "axios" import axios from "axios";
import apiClient from "./client" import apiClient from "./client";
// 类型定义 // 类型定义
export interface LoginRequest { export interface LoginRequest {
email: string email: string;
password: string password: string;
} }
export interface LoginResponse { export interface LoginResponse {
access_token: string access_token: string;
refresh_token?: string | null refresh_token?: string | null;
token_type: string token_type: string;
expires_in: number expires_in: number;
user_id: string user_id: string;
email: string email: string;
username: string username: string;
display_name: string display_name: string;
} }
export interface RegisterRequest { export interface RegisterRequest {
email: string email: string;
password: string password: string;
username: string username: string;
display_name?: string display_name?: string;
} }
export interface User { export interface User {
id: string id: string;
user_id: string user_id: string;
email: string email: string;
username: string username: string;
display_name: string display_name: string;
is_email_verified: boolean is_email_verified: boolean;
email_verified: boolean email_verified: boolean;
created_at?: string created_at?: string;
} }
export interface UserResponse { export interface UserResponse {
id?: string id?: string;
user_id?: string user_id?: string;
email: string email: string;
username: string username: string;
display_name: string display_name: string;
is_email_verified?: boolean is_email_verified?: boolean;
email_verified?: boolean email_verified?: boolean;
created_at?: string created_at?: string;
} }
export const normalizeUser = (data: UserResponse): User => { export const normalizeUser = (data: UserResponse): User => {
const userId = data.id ?? data.user_id ?? "" const userId = data.id ?? data.user_id ?? "";
const emailVerified = data.is_email_verified ?? data.email_verified ?? false const emailVerified = data.is_email_verified ?? data.email_verified ?? false;
return { return {
id: userId, id: userId,
@@ -63,46 +63,52 @@ export const normalizeUser = (data: UserResponse): User => {
is_email_verified: emailVerified, is_email_verified: emailVerified,
email_verified: emailVerified, email_verified: emailVerified,
created_at: data.created_at, created_at: data.created_at,
} };
} };
// 登录 // 登录
export const login = async (data: LoginRequest): Promise<LoginResponse> => { export const login = async (data: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post("/auth/login", data) const response = await apiClient.post("/auth/login", data);
return response.data return response.data;
} };
// 刷新 access_token(使用裸 axios 避免拦截器递归) // 刷新 access_token(使用裸 axios 避免拦截器递归)
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => { export const refreshAccessToken = async (
const baseURL = apiClient.defaults.baseURL ?? "" refreshToken: string,
): Promise<LoginResponse> => {
const baseURL = apiClient.defaults.baseURL ?? "";
const response = await axios.post(`${baseURL}/auth/refresh`, { const response = await axios.post(`${baseURL}/auth/refresh`, {
refresh_token: refreshToken, refresh_token: refreshToken,
}) });
return response.data return response.data;
} };
// 注册 // 注册
export const register = async (data: RegisterRequest): Promise<{ message: string }> => { export const register = async (
const response = await apiClient.post("/auth/register", data) data: RegisterRequest,
return response.data ): Promise<{ message: string }> => {
} const response = await apiClient.post("/auth/register", data);
return response.data;
};
// 登出 // 登出
export const logout = async (): Promise<void> => { export const logout = async (): Promise<void> => {
await apiClient.post("/auth/logout") await apiClient.post("/auth/logout");
} };
// 获取当前用户 // 获取当前用户
export const getCurrentUser = async (): Promise<User> => { export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<UserResponse>("/auth/me") const response = await apiClient.get<UserResponse>("/auth/me");
return normalizeUser(response.data) return normalizeUser(response.data);
} };
// 请求密码重置 // 请求密码重置
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => { export const requestPasswordReset = async (
const response = await apiClient.post("/auth/forgot-password", { email }) email: string,
return response.data ): Promise<{ message: string }> => {
} const response = await apiClient.post("/auth/forgot-password", { email });
return response.data;
};
// 重置密码 // 重置密码
export const resetPassword = async ( export const resetPassword = async (
@@ -112,76 +118,14 @@ export const resetPassword = async (
const response = await apiClient.post("/auth/reset-password", { const response = await apiClient.post("/auth/reset-password", {
token, token,
new_password: newPassword, new_password: newPassword,
}) });
return response.data return response.data;
} };
// 验证邮箱 // 验证邮箱
export const verifyEmail = async (token: string): Promise<{ message: string }> => { export const verifyEmail = async (
const response = await apiClient.post("/auth/verify-email", { token }) token: string,
return response.data
}
/* ========== 微信登录 ========== */
export interface WechatAuthUrlResponse {
auth_url: string
state: string
}
export interface WechatCallbackResponse {
access_token: string
refresh_token?: string | null
user_id: string
display_name: string
avatar_url: string
is_new_user: boolean
binding_complete: boolean
expires_in: number
}
export interface SendVerificationCodeRequest {
target: "email" | "phone"
value: string
purpose: "bind" | "login" | "reset_password"
}
export interface BindContactRequest {
target: "email" | "phone"
value: string
code: string
}
export interface BindContactResponse {
message: string
user: User
}
// 获取微信授权链接
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
const response = await apiClient.get("/auth/wechat/url")
return response.data
}
// 微信回调登录
export const wechatCallback = async (
code: string,
state: string,
): Promise<WechatCallbackResponse> => {
const response = await apiClient.post("/auth/wechat/callback", { code, state })
return response.data
}
// 发送验证码
export const sendVerificationCode = async (
data: SendVerificationCodeRequest,
): Promise<{ message: string }> => { ): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/send-verification-code", data) const response = await apiClient.post("/auth/verify-email", { token });
return response.data return response.data;
} };
// 绑定联系方式
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
const response = await apiClient.post("/auth/bind-contact", data)
return response.data
}
-68
View File
@@ -1,68 +0,0 @@
/**
* BGM API
* BGM +
*/
import apiClient from "./client"
/* ──────────── 类型 ──────────── */
/** BGM 风格分类 */
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
/** BGM 预设项 */
export interface BgmPreset {
id: string
name: string
category: BgmCategory
/** 音频文件 URL */
url: string
/** 时长(秒) */
duration: number
/** 关键词标签 */
tags: string[]
/** 封面图 URL */
cover_url?: string
}
/** BGM 预设列表查询参数 */
export interface BgmPresetsQuery {
category?: BgmCategory | string
keyword?: string
}
/** BGM 混音配置(嵌入模板) */
export interface BgmMixConfig {
/** 是否启用 BGM */
enabled: boolean
/** 选中的 BGM ID */
music_id: string
/** BGM 音量 0-100 */
volume: number
/** 淡入时长(秒) 0-3 */
fade_in: number
/** 淡出时长(秒) 0-3 */
fade_out: number
/** 人声闪避(sidechain */
voice_dodge: boolean
}
/** 默认 BGM 混音配置 */
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
enabled: false,
music_id: "",
volume: 50,
fade_in: 0.5,
fade_out: 0.5,
voice_dodge: true,
}
/* ──────────── API ──────────── */
/** 获取 BGM 预设列表 */
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {}
if (params?.category) searchParams.category = params.category
if (params?.keyword) searchParams.keyword = params.keyword
const res = await apiClient.get("/bgm/presets", { params: searchParams })
return res.data?.data ?? res.data ?? []
}
+89 -79
View File
@@ -2,10 +2,10 @@
* API * API
* Axios Token * Axios Token
*/ */
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios" import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { message } from "antd" import { message } from "antd";
import { useAuthStore } from "@/store/authStore" import { useAuthStore } from "@/store/authStore";
import { refreshAccessToken } from "./auth" import { refreshAccessToken } from "./auth";
// 创建 Axios 实例 // 创建 Axios 实例
const apiClient = axios.create({ const apiClient = axios.create({
@@ -14,150 +14,160 @@ const apiClient = axios.create({
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}) });
// ── Token 刷新队列 ───────────────────────────────────────── // ── Token 刷新队列 ─────────────────────────────────────────
let isRefreshing = false let isRefreshing = false;
let failedQueue: Array<{ let failedQueue: Array<{
resolve: (value?: unknown) => void resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void reject: (reason?: unknown) => void;
}> = [] }> = [];
const processQueue = (error: unknown | null, token: string | null = null) => { const processQueue = (error: unknown | null, token: string | null = null) => {
failedQueue.forEach((prom) => { failedQueue.forEach((prom) => {
if (error) { if (error) {
prom.reject(error) prom.reject(error);
} else { } else {
prom.resolve(token) prom.resolve(token);
} }
}) });
failedQueue = [] failedQueue = [];
} };
// 请求拦截器:添加 Token // 请求拦截器:添加 Token
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem("access_token") const token = localStorage.getItem("access_token");
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`;
} }
return config return config;
}, },
(error: AxiosError) => { (error: AxiosError) => {
return Promise.reject(error) return Promise.reject(error);
}, },
) );
// 响应拦截器:统一错误提示 + 401 自动刷新 Token // 响应拦截器:统一错误提示 + 401 自动刷新 Token
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => { async (
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean _retry?: boolean;
} };
// 401 → 尝试刷新 Token // 401 → 尝试刷新 Token
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { if (
const refreshToken = useAuthStore.getState().refreshToken error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry
) {
const refreshToken = useAuthStore.getState().refreshToken;
// 无 refresh_token → 直接登出 // 无 refresh_token → 直接登出
if (!refreshToken) { if (!refreshToken) {
useAuthStore.getState().clearAuth() useAuthStore.getState().clearAuth();
window.location.href = "/" window.location.href = "/";
return Promise.reject(error) return Promise.reject(error);
} }
// 已在刷新中 → 排队等待 // 已在刷新中 → 排队等待
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject }) failedQueue.push({ resolve, reject });
}).then((token) => { }).then((token) => {
if (originalRequest.headers) { if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}` originalRequest.headers.Authorization = `Bearer ${token}`;
} }
return apiClient(originalRequest) return apiClient(originalRequest);
}) });
} }
originalRequest._retry = true originalRequest._retry = true;
isRefreshing = true isRefreshing = true;
try { try {
const data = await refreshAccessToken(refreshToken) const data = await refreshAccessToken(refreshToken);
const newAccessToken = data.access_token const newAccessToken = data.access_token;
const newRefreshToken = data.refresh_token ?? refreshToken const newRefreshToken = data.refresh_token ?? refreshToken;
// 更新 Zustand + localStorage // 更新 Zustand + localStorage
useAuthStore useAuthStore
.getState() .getState()
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken) .setAuth(
useAuthStore.getState().user!,
newAccessToken,
newRefreshToken,
);
// 处理排队的请求 // 处理排队的请求
processQueue(null, newAccessToken) processQueue(null, newAccessToken);
// 重试原始请求 // 重试原始请求
if (originalRequest.headers) { if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}` originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
} }
return apiClient(originalRequest) return apiClient(originalRequest);
} catch (refreshError) { } catch (refreshError) {
// 刷新失败 → 登出 // 刷新失败 → 登出
processQueue(refreshError, null) processQueue(refreshError, null);
useAuthStore.getState().clearAuth() useAuthStore.getState().clearAuth();
window.location.href = "/" window.location.href = "/";
return Promise.reject(refreshError) return Promise.reject(refreshError);
} finally { } finally {
isRefreshing = false isRefreshing = false;
} }
} }
// 提取后端返回的错误信息(detail / message / msg // 提取后端返回的错误信息(detail / message / msg
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取 // 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
const data = error.response?.data const data = error.response?.data;
const rawServerMsg = data?.detail || data?.message || data?.msg const rawServerMsg = data?.detail || data?.message || data?.msg;
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等) // 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
const safeExtractString = (val: unknown): string => { const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) { if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 const obj = val as Record<string, unknown>;
const obj = val as Record<string, any> if (typeof obj.message === "string") return obj.message;
if (typeof obj.message === "string") return obj.message if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.msg === "string") return obj.msg if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.detail === "string") return obj.detail
// 嵌套对象:递归提取 // 嵌套对象:递归提取
if (typeof obj.message === "object" && obj.message !== null) if (typeof obj.message === "object" && obj.message !== null)
return safeExtractString(obj.message) return safeExtractString(obj.message);
if (typeof obj.msg === "object" && obj.msg !== null) return safeExtractString(obj.msg) if (typeof obj.msg === "object" && obj.msg !== null)
return JSON.stringify(val) return safeExtractString(obj.msg);
return JSON.stringify(val);
} }
return "" return "";
} };
const serverMsg = safeExtractString(rawServerMsg) const serverMsg = safeExtractString(rawServerMsg);
let handled = false let handled = false;
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) { if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
message.error("请求超时,请检查网络后重试") message.error("请求超时,请检查网络后重试");
handled = true handled = true;
} else if (!error.response) { } else if (!error.response) {
message.error("网络连接异常,请检查网络设置") message.error("网络连接异常,请检查网络设置");
handled = true handled = true;
} else if (serverMsg) { } else if (serverMsg) {
message.error(serverMsg) message.error(serverMsg);
handled = true handled = true;
} else { } else {
const status = error.response?.status const status = error.response?.status;
if (status === 413) { if (status === 413) {
message.error("文件过大,请缩小后重试") message.error("文件过大,请缩小后重试");
handled = true handled = true;
} else if (status === 415) { } else if (status === 415) {
message.error("不支持的文件格式") message.error("不支持的文件格式");
handled = true handled = true;
} else if (status === 503) { } else if (status === 503) {
message.error("服务暂不可用,请稍后再试") message.error("服务暂不可用,请稍后再试");
handled = true handled = true;
} else if (status && status >= 500) { } else if (status && status >= 500) {
message.error("服务器繁忙,请稍后再试") message.error("服务器繁忙,请稍后再试");
handled = true handled = true;
} }
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理 // 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
} }
@@ -165,11 +175,11 @@ apiClient.interceptors.response.use(
// 标记已展示过提示,组件 onError 可据此跳过重复 toast // 标记已展示过提示,组件 onError 可据此跳过重复 toast
if (handled) { if (handled) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
;(error as any).__msgShown = true (error as any).__msgShown = true;
} }
return Promise.reject(error) return Promise.reject(error);
}, },
) );
export default apiClient export default apiClient;
+54 -43
View File
@@ -2,98 +2,109 @@
* API * API
* *
*/ */
import apiClient from "./client" import apiClient from "./client";
/** 查重记录状态 */ /** 查重记录状态 */
export type DuplicationStatus = "pending" | "processing" | "completed" | "failed" export type DuplicationStatus =
"pending" | "processing" | "completed" | "failed";
/** 查重记录 */ /** 查重记录 */
export interface DuplicationRecord { export interface DuplicationRecord {
id: string id: string;
/** 原始文件名 */ /** 原始文件名 */
filename: string filename: string;
/** 文件大小(字节) */ /** 文件大小(字节) */
file_size: number file_size: number;
/** 时长(秒) */ /** 时长(秒) */
duration_seconds?: number duration_seconds?: number;
/** 状态 */ /** 状态 */
status: DuplicationStatus status: DuplicationStatus;
/** 查重率(0-100 */ /** 查重率(0-100 */
duplicate_rate?: number duplicate_rate?: number;
/** 重复片段数 */ /** 重复片段数 */
duplicate_count?: number duplicate_count?: number;
/** 创建时间 */ /** 创建时间 */
created_at: string created_at: string;
/** 更新时间 */ /** 更新时间 */
updated_at: string updated_at: string;
} }
/** 重复片段详情 */ /** 重复片段详情 */
export interface DuplicateSegment { export interface DuplicateSegment {
id: string id: string;
/** 在原始视频中的起始时间(秒) */ /** 在原始视频中的起始时间(秒) */
source_start: number source_start: number;
/** 在原始视频中的结束时间(秒) */ /** 在原始视频中的结束时间(秒) */
source_end: number source_end: number;
/** 匹配到的已有视频 ID */ /** 匹配到的已有视频 ID */
matched_video_id: string matched_video_id: string;
/** 匹配到的已有视频名称 */ /** 匹配到的已有视频名称 */
matched_video_name: string matched_video_name: string;
/** 匹配片段在已有视频中的起始时间 */ /** 匹配片段在已有视频中的起始时间 */
matched_start: number matched_start: number;
/** 匹配片段在已有视频中的结束时间 */ /** 匹配片段在已有视频中的结束时间 */
matched_end: number matched_end: number;
/** 相似度(0-100 */ /** 相似度(0-100 */
similarity: number similarity: number;
} }
/** 查重详情 */ /** 查重详情 */
export interface DuplicationDetail extends DuplicationRecord { export interface DuplicationDetail extends DuplicationRecord {
/** 重复片段列表 */ /** 重复片段列表 */
segments: DuplicateSegment[] segments: DuplicateSegment[];
} }
/** 上传查重响应 */ /** 上传查重响应 */
export interface DuplicationUploadResponse { export interface DuplicationUploadResponse {
/** 查重记录 ID */ /** 查重记录 ID */
id: string id: string;
/** 状态 */ /** 状态 */
status: DuplicationStatus status: DuplicationStatus;
/** 消息 */ /** 消息 */
message: string message: string;
} }
// ============ API 函数 ============ // ============ API 函数 ============
/** 上传视频进行查重 */ /** 上传视频进行查重 */
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => { export const uploadForDuplication = async (
const formData = new FormData() file: File,
formData.append("file", file) ): Promise<DuplicationUploadResponse> => {
const formData = new FormData();
formData.append("file", file);
const response = await apiClient.post("/duplication/upload", formData, { const response = await apiClient.post("/duplication/upload", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}) });
return response.data return response.data;
} };
/** 获取查重记录列表 */ /** 获取查重记录列表 */
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => { export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
const response = await apiClient.get("/duplication/records") const response = await apiClient.get("/duplication/records");
return response.data return response.data;
} };
/** 获取查重详情 */ /** 获取查重详情 */
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => { export const getDuplicationDetail = async (
const response = await apiClient.get(`/duplication/records/${recordId}`) recordId: string,
return response.data ): Promise<DuplicationDetail> => {
} const response = await apiClient.get(`/duplication/records/${recordId}`);
return response.data;
};
/** 删除查重记录 */ /** 删除查重记录 */
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => { export const deleteDuplicationRecord = async (
await apiClient.delete(`/duplication/records/${recordId}`) recordId: string,
} ): Promise<void> => {
await apiClient.delete(`/duplication/records/${recordId}`);
};
/** 重新查重 */ /** 重新查重 */
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => { export const retryDuplication = async (
const response = await apiClient.post(`/duplication/records/${recordId}/retry`) recordId: string,
return response.data ): Promise<DuplicationUploadResponse> => {
} const response = await apiClient.post(
`/duplication/records/${recordId}/retry`,
);
return response.data;
};
+376
View File
@@ -0,0 +1,376 @@
/**
* API Edit Plans Schema
* API
*/
import apiClient from "./client";
import type { AssetItem } from "./assets";
/* ============================================================
* API Schema
* ============================================================ */
/** 剪辑计划状态枚举 */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed";
/** 剪辑计划(后端响应) */
export interface EditPlan {
id: string;
template_id: string;
name: string;
status: EditPlanStatus;
total_duration: number;
config: Record<string, unknown>;
created_at: string;
updated_at: string;
}
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest {
template_id: string;
name: string;
config?: Record<string, unknown>;
total_duration?: number;
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
source_edit_plan_id?: string;
}
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
config?: Record<string, unknown>;
total_duration?: number;
status?: EditPlanStatus;
}
/** 生成响应 */
export interface GenerateResponse {
plan_id: string;
plan_status: EditPlanStatus;
generation_task_id: string;
clip_count: number;
}
/** 剪辑计划关联的生成记录 */
export interface EditPlanGeneration {
id: string;
edit_plan_id: string;
generation_task_id: string;
status: EditPlanStatus;
created_at: string;
updated_at: string;
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string;
clip_type: string;
order: number;
status: string;
asset_id?: string;
text_content?: string;
duration?: number;
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string;
plan_status: EditPlanStatus;
generation_task_id?: string;
clips: ClipStatusItem[];
}
/* ============================================================
* AI & 3.09
* ============================================================ */
/** AI 推荐请求 */
export interface AIRecommendRequest {
asset_ids: string[];
editing_mode?: string;
target_duration?: number;
}
/** AI 推荐单个片段 */
export interface AIRecommendClipItem {
clip_type: string;
order: number;
text_content: string;
duration: number;
transition_effect: string;
asset_id: string;
start_time: number;
config: Record<string, unknown>;
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string;
clips: AIRecommendClipItem[];
config: Record<string, unknown>;
total_duration: number;
confidence: number;
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[];
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate";
frame_time?: number;
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string;
cover: Record<string, unknown>;
}
/* ============================================================
* UI EditingPlanner
* ============================================================ */
/** 剪辑计划中的片段(UI 层类型) */
export interface EditPlanClip {
id: string;
template_segment_id: string;
/** 素材库中的素材 ID */
media_asset_id?: string;
/** 素材类型 */
material_type: "video" | "image" | "audio" | "voiceover";
/** 片段文案 */
script_text: string;
/** 实际时长(秒) */
duration: number;
/** 转场效果 */
transition?: TransitionEffect;
/** 排序 */
order: number;
}
/** 转场效果 */
export interface TransitionEffect {
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
duration: number; // 转场时长(秒)
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
export interface MediaAsset {
id: string;
name: string;
type: "video" | "image" | "audio";
/** 缩略图 URL */
thumbnail_url?: string;
/** 时长(秒),仅 video/audio */
duration?: number;
/** 文件大小(字节) */
size?: number;
/** 标签 */
tags: string[];
created_at: string;
/** 质量分 0-100 */
quality_score?: number;
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed";
}
/* ============================================================
* API
* ============================================================ */
/** 获取剪辑计划列表 */
export async function getEditPlans(params?: {
page?: number;
page_size?: number;
template_id?: string;
status?: string;
}): Promise<EditPlan[]> {
const response = await apiClient.get("/edit-plans", { params });
return response.data.items || [];
}
/** 获取单个剪辑计划 */
export async function getEditPlan(planId: string): Promise<EditPlan> {
const response = await apiClient.get(`/edit-plans/${planId}`);
return response.data;
}
/** 创建剪辑计划 */
export async function createEditPlan(
data: CreateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post("/edit-plans", data);
return response.data;
}
/** 更新剪辑计划 */
export async function updateEditPlan(
planId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/edit-plans/${planId}`, data);
return response.data;
}
/** 删除剪辑计划 */
export async function deleteEditPlan(planId: string): Promise<void> {
await apiClient.delete(`/edit-plans/${planId}`);
}
/** 触发剪辑计划生成 */
export async function generateEditPlan(
planId: string,
): Promise<GenerateResponse> {
const response = await apiClient.post(`/edit-plans/${planId}/generate`);
return response.data;
}
/** 获取剪辑计划生成状态(轮询用) */
export async function getGenerationStatus(
planId: string,
): Promise<GenerationStatusResponse> {
const response = await apiClient.get(
`/edit-plans/${planId}/generation-status`,
);
return response.data;
}
/** AI 推荐片段方案 */
export async function aiRecommendClips(
planId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(
`/edit-plans/${planId}/ai-recommend`,
data,
);
return response.data;
}
/** AI 生成封面 */
export async function generateCover(
planId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(
`/edit-plans/${planId}/generate-cover`,
data,
);
return response.data;
}
/** 获取剪辑计划关联的生成记录 */
export async function getEditPlanGenerations(
planId: string,
): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/edit-plans/${planId}/generations`);
return response.data.items || [];
}
/**
* GET /api/v1/assets?library_id=xxx
* AssetResponse MediaAsset
*/
export async function getMediaAssets(
libraryId?: string,
): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
});
const items: AssetItem[] = response.data.items || [];
return items.map(mapAssetToMediaAsset);
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`);
return mapAssetToMediaAsset(response.data);
}
/* ============================================================
* AssetResponse MediaAsset
* ============================================================ */
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
return "audio";
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
const meta = (asset.metadata || {}) as Record<string, unknown>;
const ext = asset as AssetItem & Record<string, unknown>;
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url:
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
duration:
typeof ext.duration === "number"
? ext.duration
: typeof meta.duration === "number"
? (meta.duration as number)
: undefined,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: (asset.classification_status ??
undefined) as MediaAsset["classification_status"],
};
}
/* ============================================================
*
* ============================================================ */
/** 转场效果选项 */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"];
label: string;
}[] = [
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出" },
{ value: "dissolve", label: "溶解" },
{ value: "wipe", label: "擦除" },
{ value: "zoom", label: "缩放" },
{ value: "slide", label: "滑动" },
];
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
};
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
};
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
};
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string;
label: string;
min?: number;
max?: number;
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
];
+95 -136
View File
@@ -1,30 +1,21 @@
/** /**
* API * API
* /api/v1/templates * /api/v1/templates
*/ */
import apiClient from "./client" import apiClient from "./client";
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ──────────── 类型定义 ──────────── */ /* ──────────── 类型定义 ──────────── */
/** 模板模式(后端枚举值) */ /** 模板模式(后端枚举值) */
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip" export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip";
/** 模式显示名称映射 */ /** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = { export const MODE_LABELS: Record<TemplateMode, string> = {
pip: "混剪", pip: "画中画",
voice_over: "人物口播", voice_over: "人物口播",
one_take: "一镜到底", one_take: "一镜到底",
voice_pip: "口播+混剪", voice_pip: "口播+混剪",
} };
/** 模式颜色映射 */ /** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = { export const MODE_COLORS: Record<TemplateMode, string> = {
@@ -32,203 +23,171 @@ export const MODE_COLORS: Record<TemplateMode, string> = {
voice_over: "green", voice_over: "green",
one_take: "orange", one_take: "orange",
voice_pip: "purple", voice_pip: "purple",
} };
/** 标题配置 */ /** 标题配置 */
export interface TitleConfig { export interface TitleConfig {
ai_auto_select: boolean ai_auto_select: boolean;
content: string content: string;
font_preset: string font_preset: string;
font_color: string font_color: string;
font_size: number font_size: number;
position: string position: string;
} }
/** 字幕配置 */ /** 字幕配置 */
export interface SubtitleConfig { export interface SubtitleConfig {
enabled: boolean enabled: boolean;
position: string position: string;
font: string font: string;
color: string color: string;
size: number size: number;
animation: string animation: string;
} }
/** BGM 配置 */ /** BGM 配置 */
export interface BgmConfig { export interface BgmConfig {
enabled: boolean enabled: boolean;
music_id: string music_id: string;
} }
/** 模板片段 */ /** 模板片段 */
export interface TemplateSegment { export interface TemplateSegment {
id?: string id?: string;
segment_order: number segment_order: number;
duration_min: number duration_min: number;
duration_max: number duration_max: number;
material_type: string | null material_type: string | null;
} }
/** 剪辑模板 */ /** 剪辑模板 */
export interface EditingTemplate { export interface EditingTemplate {
id: string id: string;
name: string name: string;
mode: TemplateMode mode: TemplateMode;
category: string category: string;
tags: string[] tags: string[];
title_config: TitleConfig title_config: TitleConfig;
subtitle_config: SubtitleConfig subtitle_config: SubtitleConfig;
bgm_config: BgmConfig bgm_config: BgmConfig;
estimated_duration: number estimated_duration: number;
segments: TemplateSegment[] segments: TemplateSegment[];
/** 水印配置(后端就绪后启用) */ is_active?: boolean;
watermark_config?: WatermarkConfig created_at: string;
/** 片头片尾配置(后端就绪后启用) */ updated_at: string;
intro_outro_config?: IntroOutroConfig
/** 混剪配置 */
pip_config?: PipConfig
/** 滤镜调色配置 */
filter_config?: FilterConfig
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig
/** 贴纸配置 */
sticker_config?: StickerConfig
/** 封面配置 */
cover_config?: CoverConfig
is_active?: boolean
created_at: string
updated_at: string
} }
/** 模板分类 */ /** 模板分类 */
export interface TemplateCategory { export interface TemplateCategory {
id: string id: string;
name: string name: string;
created_at?: string created_at?: string;
} }
/** 创建/更新模板请求体 */ /** 创建/更新模板请求体 */
export interface SaveTemplatePayload { export interface SaveTemplatePayload {
name: string name: string;
mode: TemplateMode mode: TemplateMode;
category: string category: string;
tags: string[] tags: string[];
title_config: TitleConfig title_config: TitleConfig;
subtitle_config: SubtitleConfig subtitle_config: SubtitleConfig;
bgm_config: BgmConfig bgm_config: BgmConfig;
estimated_duration: number estimated_duration: number;
segments: Omit<TemplateSegment, "id">[] segments: Omit<TemplateSegment, "id">[];
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig
/** 混剪配置 */
pip_config?: PipConfig
/** 滤镜调色配置 */
filter_config?: FilterConfig
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig
/** 贴纸配置 */
sticker_config?: StickerConfig
/** 封面配置 */
cover_config?: CoverConfig
} }
/** 使用模板生成请求体 */ /** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload { export interface GenerateFromTemplatePayload {
voiceover_duration: number voiceover_duration: number;
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
/** 相关字段名 */
field?: string
/** 期望值 */
expected?: string | number
/** 实际值 */
actual?: string | number
/** 建议值 */
suggested?: string | number
} }
/** 验证/生成响应 */ /** 验证/生成响应 */
export interface ValidateWarning { export interface ValidateWarning {
code: string code: string;
message: string message: string;
details?: ValidationWarningDetails details?: Record<string, unknown>;
} }
/** 使用模板生成响应 */ /** 使用模板生成响应 */
export interface GenerateFromTemplateResponse { export interface GenerateFromTemplateResponse {
template: EditingTemplate template: EditingTemplate;
warnings: ValidateWarning[] warnings: ValidateWarning[];
} }
/** 列表响应(带分页) */ /** 列表响应(带分页) */
export interface ListTemplatesResponse { export interface ListTemplatesResponse {
items: EditingTemplate[] items: EditingTemplate[];
total: number total: number;
} }
/** 分类列表响应 */ /** 分类列表响应 */
export interface ListCategoriesResponse { export interface ListCategoriesResponse {
items: TemplateCategory[] items: TemplateCategory[];
} }
// ============ API 函数 ============ // ============ API 函数 ============
/** 获取模板列表 */ /** 获取模板列表 */
export const getEditingTemplates = async (params?: { export const getEditingTemplates = async (params?: {
category?: string category?: string;
tag?: string tag?: string;
skip?: number skip?: number;
limit?: number limit?: number;
}): Promise<EditingTemplate[]> => { }): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", { const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: { params: {
skip: params?.skip ?? 0, skip: params?.skip ?? 0,
limit: params?.limit ?? 50, limit: params?.limit ?? 50,
}, },
}) });
let list = response.data.items let list = response.data.items;
if (params?.category) list = list.filter((t) => t.category === params.category) if (params?.category)
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)) list = list.filter((t) => t.category === params.category);
return list if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
} return list;
};
/** 获取模板详情 */ /** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => { export const getEditingTemplate = async (
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`) id: string,
return response.data ): Promise<EditingTemplate> => {
} const response = await apiClient.get<EditingTemplate>(`/templates/${id}`);
return response.data;
};
/** 创建模板 */ /** 创建模板 */
export const createEditingTemplate = async ( export const createEditingTemplate = async (
data: SaveTemplatePayload, data: SaveTemplatePayload,
): Promise<EditingTemplate> => { ): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>("/templates", data) const response = await apiClient.post<EditingTemplate>("/templates", data);
return response.data return response.data;
} };
/** 更新模板 */ /** 更新模板 */
export const updateEditingTemplate = async ( export const updateEditingTemplate = async (
id: string, id: string,
data: SaveTemplatePayload, data: SaveTemplatePayload,
): Promise<EditingTemplate> => { ): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data) const response = await apiClient.patch<EditingTemplate>(
return response.data `/templates/${id}`,
} data,
);
return response.data;
};
/** 删除模板 */ /** 删除模板 */
export const deleteEditingTemplate = async (id: string): Promise<void> => { export const deleteEditingTemplate = async (id: string): Promise<void> => {
await apiClient.delete(`/templates/${id}`) await apiClient.delete(`/templates/${id}`);
} };
/** 获取模板分类列表 */ /** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => { export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list") const response = await apiClient.get<ListCategoriesResponse>(
return response.data.items "/templates/categories/list",
} );
return response.data.items;
};
/** 使用模板生成视频(调用 validate 端点) */ /** 使用模板生成视频(调用 validate 端点) */
export const generateFromTemplate = async ( export const generateFromTemplate = async (
@@ -238,6 +197,6 @@ export const generateFromTemplate = async (
const response = await apiClient.post<GenerateFromTemplateResponse>( const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/validate`, `/templates/${templateId}/validate`,
data, data,
) );
return response.data return response.data;
} };
Executable → Regular
+29 -157
View File
@@ -1,174 +1,46 @@
/** /**
* / API * API
* /videos * Phase 1 projectId
*/ */
import apiClient from "./client" import apiClient from "./client";
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected"
/** 成品条目 */ /** 成品条目 */
export interface ProductItem { export interface ProductItem {
id: string id: string;
title: string title: string;
video_url?: string video_url?: string;
thumbnail_url?: string thumbnail_url?: string;
duration_seconds?: number duration_seconds?: number;
file_size?: number file_size?: number;
resolution?: string resolution?: string;
status: "processing" | "completed" | "failed" status: "processing" | "completed" | "failed";
/** 复核状态 */
review_status?: ReviewStatus
/** 所属项目 ID */
project_id?: string
/** 所属项目名称 */
project_name?: string
/** 查重率(百分比) */ /** 查重率(百分比) */
duplicate_rate?: number duplicate_rate?: number;
created_at?: string created_at?: string;
updated_at?: string updated_at?: string;
} }
/** 列表查询参数 */ /** 获取当前用户的所有成品 */
export interface ProductListParams { export const getProducts = async (): Promise<ProductItem[]> => {
page?: number const response = await apiClient.get("/products");
page_size?: number return response.data.items || response.data || [];
project_id?: string };
review_status?: ReviewStatus | "all"
}
/** 分页响应 */
export interface ProductListResponse {
items: ProductItem[]
total: number
page: number
page_size: number
}
/** 批量下载任务状态 */
export interface BatchDownloadStatus {
job_id: string
status: "processing" | "completed" | "failed"
/** 完成后返回的下载 URL */
download_url?: string
/** 进度百分比 */
progress?: number
}
/** 后端 /videos 接口返回的原始视频条目 */
interface VideoItem {
id: string
project_id: string
generation_task_id: string
name: string
file_url: string
file_size: number
duration: number
thumbnail_url: string | null
width: number
height: number
fps: number
status: string
review_status: ReviewStatus
generation_params: Record<string, unknown>
download_url: string
generated_at: string
}
/**
* VideoItem ProductItem
*/
function mapVideoToProductItem(video: VideoItem): ProductItem {
return {
id: video.id,
title: video.name || "未命名视频",
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
video_url: video.download_url || video.file_url,
thumbnail_url: video.thumbnail_url || undefined,
duration_seconds: video.duration,
file_size: video.file_size,
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
status:
video.status === "completed"
? "completed"
: video.status === "failed"
? "failed"
: "processing",
review_status: video.review_status,
project_id: video.project_id,
// 后端 /videos 接口暂无 project_name 字段
project_name: undefined,
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
}
/** 获取成品列表(支持分页和筛选) */
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
const response = await apiClient.get("/videos", { params })
const data = response.data
const videos: VideoItem[] = Array.isArray(data?.items)
? data.items
: Array.isArray(data)
? data
: []
return videos.map(mapVideoToProductItem)
}
/** 获取单个成品详情 */ /** 获取单个成品详情 */
export const getProduct = async (productId: string): Promise<ProductItem> => { export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/videos/${productId}`) const response = await apiClient.get(`/products/${productId}`);
return mapVideoToProductItem(response.data as VideoItem) return response.data;
} };
/** /** 删除成品 */
*
* /videos DELETE 405
*
*/
export const deleteProduct = async (productId: string): Promise<void> => { export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/videos/${productId}`) await apiClient.delete(`/products/${productId}`);
} };
/** /** 获取成品下载链接 */
*
* 使 download_urlOSS签名
*/
export const getProductDownloadUrl = async ( export const getProductDownloadUrl = async (
productId: string, productId: string,
): Promise<{ url: string; expires_at: string }> => { ): Promise<{ url: string; expires_at: string }> => {
// 优先从列表缓存取;如果没有则调详情接口 const response = await apiClient.get(`/products/${productId}/download-url`);
const product = await getProduct(productId) return response.data;
if (!product.video_url) throw new Error("下载链接不可用") };
return { url: product.video_url, expires_at: "" }
}
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /videos/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId)
return { ...product, review_status: status }
}
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
// 后端暂无 /videos/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
return { job_id: `mock-${Date.now()}` }
}
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
// 后端暂无 /videos/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
return { job_id: jobId, status: "processing", progress: 0 }
}
+22 -21
View File
@@ -2,59 +2,60 @@
* API * API
* project_id * project_id
*/ */
import apiClient from "./client" import apiClient from "./client";
export interface ProjectItem { export interface ProjectItem {
id: string id: string;
name: string name: string;
description: string description: string;
} }
/** 后端 ProjectResponse 只返回 id, name, description */ /** 后端 ProjectResponse 只返回 id, name, description */
interface BackendProjectResponse { interface BackendProjectResponse {
id: string id: string;
name: string name: string;
description: string description: string;
} }
/** 后端 ListProjectsResponse 返回 { items: [...] } */ /** 后端 ListProjectsResponse 返回 { items: [...] } */
interface BackendListProjectsResponse { interface BackendListProjectsResponse {
items: BackendProjectResponse[] items: BackendProjectResponse[];
} }
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({ const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
id: item.id, id: item.id,
name: item.name, name: item.name,
description: item.description, description: item.description,
}) });
/** 获取当前用户的项目列表 */ /** 获取当前用户的项目列表 */
export const getProjects = async (): Promise<ProjectItem[]> => { export const getProjects = async (): Promise<ProjectItem[]> => {
const response = await apiClient.get<BackendListProjectsResponse>("/projects") const response =
return (response.data.items || []).map(toProjectItem) await apiClient.get<BackendListProjectsResponse>("/projects");
} return (response.data.items || []).map(toProjectItem);
};
/** 创建项目 */ /** 创建项目 */
export const createProject = async (data: { export const createProject = async (data: {
name: string name: string;
description?: string description?: string;
}): Promise<ProjectItem> => { }): Promise<ProjectItem> => {
const response = await apiClient.post<BackendProjectResponse>("/projects", { const response = await apiClient.post<BackendProjectResponse>("/projects", {
name: data.name, name: data.name,
description: data.description || "", description: data.description || "",
}) });
return toProjectItem(response.data) return toProjectItem(response.data);
} };
/** 获取或创建默认项目(素材库需要 project_id */ /** 获取或创建默认项目(素材库需要 project_id */
export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => { export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
const projects = await getProjects() const projects = await getProjects();
if (projects.length > 0) { if (projects.length > 0) {
return projects[0] return projects[0];
} }
// 没有项目时自动创建默认项目 // 没有项目时自动创建默认项目
return createProject({ return createProject({
name: "默认项目", name: "默认项目",
description: "系统自动创建的默认项目", description: "系统自动创建的默认项目",
}) });
} };
+55 -53
View File
@@ -2,98 +2,100 @@
* API * API
* *
*/ */
import apiClient from "./client" import apiClient from "./client";
/** 套餐类型 */ /** 套餐类型 */
export type PlanType = "free" | "standard" | "pro" | "enterprise" export type PlanType = "free" | "standard" | "pro" | "enterprise";
/** 订阅状态 */ /** 订阅状态 */
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial" export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial";
/** 账单状态 */ /** 账单状态 */
export type BillingStatus = "paid" | "pending" | "failed" | "refunded" export type BillingStatus = "paid" | "pending" | "failed" | "refunded";
/** 计费周期 */ /** 计费周期 */
export type BillingCycle = "monthly" | "yearly" export type BillingCycle = "monthly" | "yearly";
/** 套餐信息 */ /** 套餐信息 */
export interface Plan { export interface Plan {
id: PlanType id: PlanType;
name: string name: string;
price: number | null price: number | null;
yearly_price?: number | null yearly_price?: number | null;
description: string description: string;
recommended: boolean recommended: boolean;
features: string[] features: string[];
} }
/** 当前订阅信息 */ /** 当前订阅信息 */
export interface SubscriptionInfo { export interface SubscriptionInfo {
id: string id: string;
plan_id: PlanType plan_id: PlanType;
plan_name: string plan_name: string;
status: SubscriptionStatus status: SubscriptionStatus;
billing_cycle: BillingCycle billing_cycle: BillingCycle;
current_period_start: string current_period_start: string;
current_period_end: string current_period_end: string;
amount: number amount: number;
auto_renew: boolean auto_renew: boolean;
created_at: string created_at: string;
} }
/** 账单记录 */ /** 账单记录 */
export interface BillingRecord { export interface BillingRecord {
id: string id: string;
plan_name: string plan_name: string;
amount: number amount: number;
billing_cycle: BillingCycle billing_cycle: BillingCycle;
status: BillingStatus status: BillingStatus;
payment_method: string payment_method: string;
created_at: string created_at: string;
invoice_url?: string invoice_url?: string;
} }
/** 升级/降级请求 */ /** 升级/降级请求 */
export interface ChangePlanRequest { export interface ChangePlanRequest {
target_plan_id: PlanType target_plan_id: PlanType;
billing_cycle: BillingCycle billing_cycle: BillingCycle;
} }
/** 升级/降级响应 */ /** 升级/降级响应 */
export interface ChangePlanResponse { export interface ChangePlanResponse {
success: boolean success: boolean;
message: string message: string;
new_subscription?: SubscriptionInfo new_subscription?: SubscriptionInfo;
} }
// ============ API 函数 ============ // ============ API 函数 ============
/** 获取当前订阅信息 */ /** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => { export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
const response = await apiClient.get("/subscription/current") const response = await apiClient.get("/subscription/current");
return response.data return response.data;
} };
/** 获取账单记录列表 */ /** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => { export const getBillingRecords = async (): Promise<BillingRecord[]> => {
const response = await apiClient.get("/subscription/billing-records") const response = await apiClient.get("/subscription/billing-records");
return response.data return response.data;
} };
/** 升级/降级套餐 */ /** 升级/降级套餐 */
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => { export const changePlan = async (
const response = await apiClient.post("/subscription/change-plan", request) request: ChangePlanRequest,
return response.data ): Promise<ChangePlanResponse> => {
} const response = await apiClient.post("/subscription/change-plan", request);
return response.data;
};
/** 取消订阅 */ /** 取消订阅 */
export const cancelSubscription = async (): Promise<{ export const cancelSubscription = async (): Promise<{
success: boolean success: boolean;
message: string message: string;
}> => { }> => {
const response = await apiClient.post("/subscription/cancel") const response = await apiClient.post("/subscription/cancel");
return response.data return response.data;
} };
/** 切换自动续费 */ /** 切换自动续费 */
export const toggleAutoRenew = async ( export const toggleAutoRenew = async (
@@ -101,6 +103,6 @@ export const toggleAutoRenew = async (
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post("/subscription/toggle-auto-renew", { const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled, enabled,
}) });
return response.data return response.data;
} };
+26 -20
View File
@@ -2,39 +2,45 @@
* CRUD API * CRUD API
* P3 * P3
*/ */
import apiClient from "./client" import apiClient from "./client";
export interface TagItem { export interface TagItem {
id: string id: string;
name: string name: string;
created_at?: string created_at?: string;
usage_count?: number usage_count?: number;
} }
/** 获取当前用户所有标签 */ /** 获取当前用户所有标签 */
export const getTags = async (): Promise<TagItem[]> => { export const getTags = async (): Promise<TagItem[]> => {
const response = await apiClient.get("/tags") const response = await apiClient.get("/tags");
return response.data.items || [] return response.data.items || [];
} };
/** 创建标签(同名返回 409) */ /** 创建标签(同名返回 409) */
export const createTag = async (name: string): Promise<TagItem> => { export const createTag = async (name: string): Promise<TagItem> => {
const response = await apiClient.post("/tags", { name }) const response = await apiClient.post("/tags", { name });
return response.data return response.data;
} };
/** 删除标签(同时清理素材关联) */ /** 删除标签(同时清理素材关联) */
export const deleteTag = async (tagId: string): Promise<void> => { export const deleteTag = async (tagId: string): Promise<void> => {
await apiClient.delete(`/tags/${tagId}`) await apiClient.delete(`/tags/${tagId}`);
} };
/** 为素材添加标签(最多 50 个) */ /** 为素材添加标签(最多 50 个) */
export const tagAsset = async (assetId: string, tagIds: string[]): Promise<void> => { export const tagAsset = async (
if (tagIds.length === 0) return assetId: string,
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds }) tagIds: string[],
} ): Promise<void> => {
if (tagIds.length === 0) return;
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds });
};
/** 移除素材的某个标签 */ /** 移除素材的某个标签 */
export const untagAsset = async (assetId: string, tagId: string): Promise<void> => { export const untagAsset = async (
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`) assetId: string,
} tagId: string,
): Promise<void> => {
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
};
+52 -92
View File
@@ -1,123 +1,83 @@
/** /**
* API * API
* API * A PR #109
* - POST /api/v1/generation/tasks * - POST /api/v1/generation/tasks template_id + asset_ids
* - GET /api/v1/tasks / * - GET /api/v1/tasks project
* - GET /api/v1/tasks/{task_id} error_info * - POST /api/v1/tasks/{task_id}/retry
* - POST /api/v1/tasks/{task_id}/retry
*/ */
import apiClient from "./client" import apiClient from "./client";
/* ──────────── 类型定义 ──────────── */ /* ──────────── 类型定义 ──────────── */
/** 任务状态 */
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
/** 任务类型 */
export type TaskType = "ingest" | "generation" | string
/** 错误详情 */
export interface TaskErrorInfo {
error_type: string
error_message: string
failed_step: string
stack_trace?: string
}
/** 任务条目(对应用户级 UserTaskResponse */ /** 任务条目(对应用户级 UserTaskResponse */
export interface TaskItem { export interface TaskItem {
id: string id: string;
task_type: TaskType task_type: "ingest" | "generation" | string;
project_id: string project_id: string;
template_id?: string template_id: string;
status: TaskStatus status: string;
progress: number progress: number;
current_step: string current_step: string;
error_message: string error_message: string;
user_message: string user_message: string;
retryable: boolean retryable: boolean;
source_id: string source_id: string;
/** 错误详情(失败任务) */ created_at?: string | null;
error_info?: TaskErrorInfo updated_at?: string | null;
/** 耗时(秒) */
duration_seconds?: number
created_at?: string | null
updated_at?: string | null
}
/** 任务列表查询参数 */
export interface TaskListParams {
page?: number
page_size?: number
status?: TaskStatus | "all"
task_type?: TaskType | "all"
}
/** 任务列表分页响应 */
export interface TaskListResponse {
items: TaskItem[]
total: number
page: number
page_size: number
} }
/** 创建生成任务请求参数 */ /** 创建生成任务请求参数 */
export interface CreateGenerationTaskRequest { export interface CreateGenerationTaskRequest {
template_id: string template_id: string;
asset_ids: string[] asset_ids: string[];
title_ids: string[] title_ids: string[];
voice_ids: string[] voice_ids: string[];
} }
/** 创建生成任务响应(对齐后端 GenerationTaskResponse */ /** 创建生成任务响应(对齐后端 GenerationTaskResponse */
export interface CreateGenerationTaskResponse { export interface CreateGenerationTaskResponse {
id: string id: string;
project_id: string project_id: string;
asset_library_id: string asset_library_id: string;
strategy_id: string strategy_id: string;
voice_library_id: string voice_library_id: string;
template_id: string template_id: string;
asset_ids: string[] asset_ids: string[];
title_ids: string[] title_ids: string[];
voice_ids: string[] voice_ids: string[];
status: string status: string;
progress: number progress: number;
result_count: number result_count: number;
error_message: string error_message: string;
} }
/* ──────────── API 函数 ──────────── */ /* ──────────── API 函数 ──────────── */
/** 创建生成任务(智能剪辑 */ /** 创建生成任务(一键生成 */
export const createGenerationTask = async ( export const createGenerationTask = async (
params: CreateGenerationTaskRequest, params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => { ): Promise<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params) const { data } = await apiClient.post<CreateGenerationTaskResponse>(
return data "/generation/tasks",
}
/** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params, params,
}) );
return data return data;
} };
/** 获取当前用户的所有任务(兼容旧接口,跨 project */ /** 获取当前用户的所有任务(跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => { export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks") const { data } = await apiClient.get("/tasks");
return data.items || data || [] return data.items || [];
} };
/** 获取单个任务详情(含 error_info */ /** 获取单个任务详情(用于轮询进度 */
export const getTask = async (taskId: string): Promise<TaskItem> => { export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`) const { data } = await apiClient.get(`/tasks/${taskId}`);
return data return data;
} };
/** 重试失败的任务 */ /** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => { export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`) const { data } = await apiClient.post(`/tasks/${taskId}/retry`);
return data return data;
} };

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