Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea65033b01 |
+1
-2
@@ -1,2 +1 @@
|
||||
CI trigger file - safe to delete
|
||||
updated!
|
||||
trigger: 1784009947
|
||||
|
||||
@@ -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
@@ -1,78 +0,0 @@
|
||||
name: CI Failure Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # 每6小时检查一次
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days:
|
||||
description: '统计最近N天的失败'
|
||||
required: false
|
||||
default: '7'
|
||||
fail_threshold:
|
||||
description: '失败次数阈值'
|
||||
required: false
|
||||
default: '3'
|
||||
fail_rate_threshold:
|
||||
description: '失败率阈值(%)'
|
||||
required: false
|
||||
default: '30'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: CI重复失败检测
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
|
||||
- name: Run failure detection
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
GITEA_URL: https://git.xiaoxiajianji.com
|
||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
FAIL_CHECK_DAYS: ${{ inputs.days || 7 }}
|
||||
FAIL_THRESHOLD: ${{ inputs.fail_threshold || 3 }}
|
||||
FAIL_RATE_THRESHOLD: ${{ inputs.fail_rate_threshold || 30 }}
|
||||
run: |
|
||||
set +e
|
||||
python3 scripts/ci/ci_repeated_failure_detector.py
|
||||
EXIT_CODE=$?
|
||||
echo "检测完成,退出码: $EXIT_CODE"
|
||||
# 0=无异常, 1=有警告, 2=有严重问题
|
||||
# 监控脚本永远不fail,避免告警风暴
|
||||
exit 0
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
@@ -1,103 +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: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Generate CI Dashboard HTML
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
echo "=== 生成 CI 健康度 HTML 看板 ==="
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output ci_dashboard.html
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -eq 0 ] && [ -f ci_dashboard.html ]; then
|
||||
HTML_SIZE=$(wc -c < ci_dashboard.html)
|
||||
echo ""
|
||||
echo "✅ HTML 看板生成成功 (${HTML_SIZE} bytes)"
|
||||
echo "路径: $(pwd)/ci_dashboard.html"
|
||||
# 输出文件内容前几行,方便在 Actions 日志中确认
|
||||
echo ""
|
||||
echo "--- 看板预览 (前 5 行) ---"
|
||||
head -5 ci_dashboard.html
|
||||
echo "...(完整内容见产物文件)"
|
||||
else
|
||||
echo "❌ HTML 看板生成失败 (exit code: $EXIT_CODE)"
|
||||
fi
|
||||
echo ""
|
||||
# 永远成功,看板生成失败不影响主流程
|
||||
exit 0
|
||||
|
||||
- name: Run CI health check and report
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI健康度每日巡检 ==="
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
python3 scripts/ci/ci_health_report.py --limit 30
|
||||
EXIT_CODE=$?
|
||||
echo ""
|
||||
echo "巡检完成 (exit code: $EXIT_CODE)"
|
||||
# 永远成功,不影响CI状态(通知失败不应该标红)
|
||||
exit 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,18 +106,6 @@ jobs:
|
||||
echo "======================================"
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
@@ -255,18 +243,6 @@ jobs:
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
@@ -356,18 +332,6 @@ jobs:
|
||||
echo "=========================================="
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
@@ -616,18 +580,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
@@ -704,15 +656,3 @@ jobs:
|
||||
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
|
||||
# 但其他失败的 job 已经让整体流水线标记为失败
|
||||
fi
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
name: PR Auto Scan
|
||||
# 定时扫描所有open PR,对CI全绿的触发审批/合并
|
||||
# 作为短作业模式的兜底,防止事件驱动遗漏
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/5 * * * *" # 每5分钟扫描一次
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
auto-scan:
|
||||
name: Auto Scan Open PRs
|
||||
runs-on: ci-check
|
||||
timeout-minutes: 5
|
||||
if: github.repository == 'xiaoxia/xiaoxia-saas'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/pr_auto_scan.py?ref=develop" -o /tmp/pr_auto_scan.py
|
||||
python3 /tmp/pr_auto_scan.py --help > /dev/null 2>&1 || {
|
||||
# fallback: checkout
|
||||
echo "使用checkout方式"
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=develop" | bash
|
||||
}
|
||||
|
||||
- name: Scan and auto process PRs
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "=== 扫描所有open PR并自动处理 ==="
|
||||
echo "时间: $(date)"
|
||||
echo
|
||||
|
||||
python3 /tmp/pr_auto_scan.py --token "$REVIEW_TOKEN" --repo "$GITHUB_REPOSITORY" --base develop --approve --merge --dry-run false
|
||||
|
||||
echo ""
|
||||
echo "✅ 扫描完成"
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "" || true
|
||||
@@ -1,368 +0,0 @@
|
||||
name: PR Automation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
workflow_dispatch:
|
||||
|
||||
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: 3 # 短作业模式:最多3分钟,不占runner
|
||||
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 (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
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 12); do # 短作业模式:最多等2分钟(12次x10秒),不满足就退出等下次触发
|
||||
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}/12次),超时后将退出等待下次触发..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
# 所有CI都跑完了但有失败 → 退出
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
|
||||
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: 3 # 短作业模式:最多3分钟,不占runner
|
||||
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 (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)"
|
||||
)
|
||||
echo "检查required门禁(与分支保护一致)"
|
||||
fi
|
||||
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 18); do # 短作业模式:最多等3分钟(18次x10秒),不满足就退出等下次触发
|
||||
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
|
||||
|
||||
# 分支保护required_approvals=0,无需审批检查,直接检查CI状态即可
|
||||
fi
|
||||
|
||||
# CI全绿 → 合并
|
||||
if [ "$ALL_SUCCESS" = "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 "快速检查超时(3分钟),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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
name: Worker Base Image Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements-worker.txt'
|
||||
- 'infra/docker/worker-base-builder.Dockerfile'
|
||||
- 'infra/docker/worker-base-runtime.Dockerfile'
|
||||
workflow_dispatch: # 支持手动触发
|
||||
|
||||
jobs:
|
||||
build-worker-base:
|
||||
name: Build Worker Base Images
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: builder
|
||||
dockerfile: infra/docker/worker-base-builder.Dockerfile
|
||||
image_name: worker-base-builder
|
||||
cache_name: worker-base-builder-cache
|
||||
- name: runtime
|
||||
dockerfile: infra/docker/worker-base-runtime.Dockerfile
|
||||
image_name: worker-base-runtime
|
||||
cache_name: worker-base-runtime-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: Docker login to Registry
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Setup buildx builder
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
echo "Created $BUILDER_NAME"
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
echo "Using existing $BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
|
||||
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
|
||||
|
||||
echo "=== Building ${{ matrix.name }} base image ==="
|
||||
echo "Image: ${IMAGE_TAG}"
|
||||
echo "Cache: ${CACHE_REF}"
|
||||
|
||||
# 用通用构建脚本
|
||||
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
|
||||
|
||||
# 同时推送到 Gitea Packages 作为备份(可选)
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
|
||||
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
|
||||
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
|
||||
|
||||
echo ""
|
||||
echo "✅ ${{ matrix.name }} base image built and pushed"
|
||||
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
@@ -49,8 +49,3 @@ build/
|
||||
tracker_tasks.json
|
||||
|
||||
frontend-v21-ui-prototype-final.html
|
||||
|
||||
!.vscode/
|
||||
!.vscode/settings.json
|
||||
.vscode/extensions.json
|
||||
.coverage
|
||||
|
||||
@@ -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
|
||||
Vendored
-49
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
@@ -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.task_center import router as task_center_router
|
||||
from app.api.routes.templates import router as templates_router
|
||||
from app.api.routes.templates_editor import router as templates_editor_router
|
||||
from app.api.routes.titles import router as titles_router
|
||||
from app.api.routes.tts import router as tts_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
@@ -120,9 +120,9 @@ api_router.include_router(
|
||||
tags=["Template"],
|
||||
)
|
||||
api_router.include_router(
|
||||
templates_editor_router,
|
||||
prefix="/templates/{template_id}/editor",
|
||||
tags=["TemplateEditor"],
|
||||
edit_plans_router,
|
||||
prefix="/edit-plans",
|
||||
tags=["EditPlan"],
|
||||
)
|
||||
api_router.include_router(
|
||||
tts_router,
|
||||
|
||||
Executable → Regular
-93
@@ -1,6 +1,5 @@
|
||||
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -47,95 +46,3 @@ def require_project_and_library(
|
||||
libraries = asset_library_repository.find_by_project(project_id)
|
||||
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")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
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.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
@@ -71,7 +71,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
status=item.status.value,
|
||||
classification_status=item.classification_status.value,
|
||||
quality_score=item.quality_score,
|
||||
created_at=format_utc_datetime(item.created_at),
|
||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||
tag_ids=getattr(item, "tag_ids", []),
|
||||
)
|
||||
@@ -95,12 +94,6 @@ def list_assets(
|
||||
None,
|
||||
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),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -110,26 +103,6 @@ def list_assets(
|
||||
) -> ListAssetsResponse:
|
||||
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_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")
|
||||
check_project_access(library.project_id, user_id, project_repository)
|
||||
if ft:
|
||||
items = asset_repository.find_by_library_and_file_type(
|
||||
library_id, ft, skip=skip, limit=limit, status=status_list
|
||||
)
|
||||
total = asset_repository.count_by_library_and_file_type(library_id, ft, status=status_list)
|
||||
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
|
||||
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
|
||||
else:
|
||||
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit, status=status_list)
|
||||
total = asset_repository.count_by_project(library.project_id, status=status_list)
|
||||
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
|
||||
total = asset_repository.count_by_project(library.project_id)
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in items],
|
||||
total=total,
|
||||
@@ -216,14 +187,14 @@ def list_assets(
|
||||
if project_id:
|
||||
check_project_access(project_id, user_id, project_repository)
|
||||
if ft:
|
||||
items = asset_repository.find_by_project_and_file_type(
|
||||
project_id, ft, skip=skip, limit=limit, status=status_list
|
||||
)
|
||||
total = asset_repository.count_by_project_and_file_type(project_id, ft, status=status_list)
|
||||
paged = items
|
||||
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
||||
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 = len(items)
|
||||
paged = items[skip : skip + limit]
|
||||
else:
|
||||
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit, status=status_list)
|
||||
total = asset_repository.count_by_project(project_id, status=status_list)
|
||||
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
|
||||
total = asset_repository.count_by_project(project_id)
|
||||
paged = items
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged],
|
||||
@@ -243,43 +214,22 @@ def list_assets(
|
||||
if not project_ids:
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
|
||||
if ft:
|
||||
# 有 kind 过滤:逐项目查 file_type,凑够一页
|
||||
total = 0
|
||||
paged_items: list = []
|
||||
offset = skip
|
||||
remaining = limit
|
||||
for pid in project_ids:
|
||||
proj_total = asset_repository.count_by_project_and_file_type(pid, ft, status=status_list)
|
||||
total += proj_total
|
||||
if offset >= proj_total:
|
||||
offset -= proj_total
|
||||
continue
|
||||
proj_items = asset_repository.find_by_project_and_file_type(
|
||||
pid, ft, skip=offset, limit=remaining, status=status_list
|
||||
)
|
||||
paged_items.extend(proj_items)
|
||||
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
|
||||
total = asset_repository.count_by_project_ids(project_ids)
|
||||
# 跨项目分页:逐项目累积直到凑够一页
|
||||
paged_items: list = []
|
||||
offset = skip
|
||||
remaining = limit
|
||||
for pid in project_ids:
|
||||
proj_total = asset_repository.count_by_project(pid)
|
||||
if offset >= proj_total:
|
||||
offset -= proj_total
|
||||
continue
|
||||
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
|
||||
paged_items.extend(proj_items)
|
||||
remaining -= len(proj_items)
|
||||
offset = 0
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
return ListAssetsResponse(
|
||||
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")
|
||||
check_project_access(library.project_id, user_id, project_repository)
|
||||
if kind:
|
||||
all_items = asset_repository.find_by_library_and_file_type(
|
||||
library_id, kind_to_file_type[kind], status=status_list
|
||||
)
|
||||
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
|
||||
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:
|
||||
check_project_access(project_id, user_id, project_repository)
|
||||
if kind:
|
||||
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)
|
||||
all_items = asset_repository.find_by_project(project_id)
|
||||
else:
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
@@ -318,18 +259,12 @@ def list_assets(
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
all_items = []
|
||||
for proj in projects:
|
||||
if kind and kind_to_file_type.get(kind):
|
||||
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))
|
||||
all_items.extend(asset_repository.find_by_project(proj.id))
|
||||
|
||||
# 应用 kind 过滤(如果有)+ keyword/gender/style
|
||||
if kind:
|
||||
ft = kind_to_file_type.get(kind)
|
||||
if ft:
|
||||
all_items = [i for i in all_items if i.file_type == ft]
|
||||
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
|
||||
filtered = _apply_memory_filters(all_items)
|
||||
total = len(filtered)
|
||||
paged = filtered[skip : skip + limit]
|
||||
|
||||
@@ -81,9 +81,6 @@ class CurrentUserResponse(BaseModel):
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -262,16 +259,12 @@ async def get_current_user_info(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CurrentUserResponse:
|
||||
user = authenticated_user.user
|
||||
binding_complete = user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
return CurrentUserResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email_verified=user.email_verified,
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
)
|
||||
|
||||
|
||||
@@ -387,202 +380,3 @@ async def wechat_sync(
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
return WechatSyncResponse(**response.to_dict())
|
||||
|
||||
|
||||
# ==================== 微信网页登录(OAuth) ====================
|
||||
|
||||
|
||||
class WechatAuthUrlResponse(BaseModel):
|
||||
auth_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class WechatCallbackRequest(BaseModel):
|
||||
code: str
|
||||
state: str = ""
|
||||
|
||||
|
||||
class WechatLoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
user_id: str
|
||||
display_name: str
|
||||
avatar_url: str = ""
|
||||
is_new_user: bool
|
||||
binding_complete: bool
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.get("/wechat/url", response_model=WechatAuthUrlResponse)
|
||||
async def get_wechat_auth_url() -> WechatAuthUrlResponse:
|
||||
"""获取微信扫码登录授权链接"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
auth_url, state = oauth_service.generate_auth_url()
|
||||
return WechatAuthUrlResponse(auth_url=auth_url, state=state)
|
||||
|
||||
|
||||
@router.post("/wechat/callback", response_model=WechatLoginResponse)
|
||||
async def wechat_callback(
|
||||
request: WechatCallbackRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatLoginResponse:
|
||||
"""微信登录回调处理"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
|
||||
|
||||
# 1. 用 code 换微信用户信息
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
|
||||
use_case = WechatSyncUseCase(user_repository=user_repository)
|
||||
sync_request = SyncRequest(
|
||||
openid=wechat_user.openid,
|
||||
unionid=wechat_user.unionid,
|
||||
nickname=wechat_user.nickname,
|
||||
avatar_url=wechat_user.avatar_url,
|
||||
source="web",
|
||||
)
|
||||
response, err = use_case.execute(sync_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
# 3. 判断绑定状态
|
||||
user = user_repository.find_by_id(response.user_id)
|
||||
binding_complete = False
|
||||
if user:
|
||||
binding_complete = (
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
|
||||
return WechatLoginResponse(
|
||||
access_token=response.access_token,
|
||||
refresh_token=response.refresh_token,
|
||||
user_id=response.user_id,
|
||||
display_name=response.nickname,
|
||||
avatar_url=response.avatar_url or wechat_user.avatar_url,
|
||||
is_new_user=response.is_new_user,
|
||||
binding_complete=binding_complete,
|
||||
expires_in=response.expires_in,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 验证码 & 绑定 ====================
|
||||
|
||||
|
||||
class SendVerificationCodeRequest(BaseModel):
|
||||
target: str # phone / email
|
||||
value: str
|
||||
purpose: str # bind / login / reset_password
|
||||
|
||||
|
||||
class SendVerificationCodeResponse(BaseModel):
|
||||
expires_in: int
|
||||
resend_after: int
|
||||
|
||||
|
||||
class BindContactRequest(BaseModel):
|
||||
phone: str = ""
|
||||
phone_code: str = ""
|
||||
email: str = ""
|
||||
email_code: str = ""
|
||||
|
||||
|
||||
class BindContactResponse(BaseModel):
|
||||
success: bool
|
||||
user: dict
|
||||
|
||||
|
||||
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
|
||||
async def send_verification_code(
|
||||
request: SendVerificationCodeRequest,
|
||||
) -> SendVerificationCodeResponse:
|
||||
"""发送验证码(手机或邮箱)"""
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
from packages.adapters.sms.sms_service import get_sms_service
|
||||
from packages.adapters.smtp import get_email_service
|
||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
||||
SQLAlchemyVerificationCodeRepository,
|
||||
)
|
||||
from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest
|
||||
from packages.application.auth.bind_contact_use_case import (
|
||||
SendVerificationCodeUseCase,
|
||||
)
|
||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
||||
|
||||
db = next(get_db_session())
|
||||
repo = SQLAlchemyVerificationCodeRepository(db)
|
||||
vc_service = VerificationCodeService(repo=repo)
|
||||
sms_service = get_sms_service()
|
||||
email_service = get_email_service()
|
||||
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=vc_service,
|
||||
sms_service=sms_service,
|
||||
email_service=email_service,
|
||||
)
|
||||
uc_request = UseCaseRequest(
|
||||
target=request.target,
|
||||
value=request.value,
|
||||
purpose=request.purpose,
|
||||
)
|
||||
response, err = use_case.execute(uc_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
return SendVerificationCodeResponse(
|
||||
expires_in=response.expires_in,
|
||||
resend_after=response.resend_after,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bind-contact", response_model=BindContactResponse)
|
||||
async def bind_contact(
|
||||
request: BindContactRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> BindContactResponse:
|
||||
"""绑定手机号和/或邮箱(需登录态)"""
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
||||
SQLAlchemyVerificationCodeRepository,
|
||||
)
|
||||
from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest
|
||||
from packages.application.auth.bind_contact_use_case import (
|
||||
BindContactUseCase,
|
||||
)
|
||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
||||
|
||||
db = next(get_db_session())
|
||||
vc_repo = SQLAlchemyVerificationCodeRepository(db)
|
||||
vc_service = VerificationCodeService(repo=vc_repo)
|
||||
|
||||
use_case = BindContactUseCase(
|
||||
user_repository=user_repository,
|
||||
verification_code_service=vc_service,
|
||||
)
|
||||
uc_request = UseCaseRequest(
|
||||
user_id=current_user.user.id,
|
||||
phone=request.phone,
|
||||
phone_code=request.phone_code,
|
||||
email=request.email,
|
||||
email_code=request.email_code,
|
||||
)
|
||||
response, err = use_case.execute(uc_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
return BindContactResponse(success=True, user=response.to_dict()["user"])
|
||||
|
||||
|
||||
# ==================== 当前用户信息扩展 ====================
|
||||
|
||||
# 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充)
|
||||
# 通过给 get_current_user_info 返回值补充字段实现
|
||||
|
||||
@@ -239,9 +239,7 @@ def get_duplication_detail(
|
||||
return _to_detail_response(record)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
||||
)
|
||||
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_duplication_record(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -289,7 +287,7 @@ def retry_duplication(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -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 config(cover/title/subtitle/bgm)")
|
||||
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
# ── 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)
|
||||
@@ -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. 更新计划 config(cover/title/subtitle/bgm)和 total_duration
|
||||
5. 返回推荐方案详情
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
try:
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
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
@@ -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
|
||||
],
|
||||
)
|
||||
@@ -32,7 +32,9 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
||||
|
||||
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
||||
ALLOWED_FLAGS: set[str] = set()
|
||||
ALLOWED_FLAGS = {
|
||||
"render_engine",
|
||||
}
|
||||
|
||||
|
||||
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
||||
@@ -103,7 +105,7 @@ async def list_feature_flags(
|
||||
return sorted(result, key=lambda x: x.name)
|
||||
except Exception as 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)
|
||||
@@ -118,7 +120,7 @@ async def get_feature_flag(
|
||||
return FeatureFlagResponse.from_config(config)
|
||||
except Exception as 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)
|
||||
@@ -134,7 +136,7 @@ async def check_feature_flag(
|
||||
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
|
||||
except Exception as 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)
|
||||
@@ -168,7 +170,7 @@ async def update_feature_flag(
|
||||
return FeatureFlagResponse.from_config(config)
|
||||
except Exception as 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)
|
||||
@@ -176,7 +178,7 @@ async def delete_feature_flag(
|
||||
name: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
) :
|
||||
"""删除 Feature Flag。
|
||||
|
||||
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
||||
@@ -189,4 +191,4 @@ async def delete_feature_flag(
|
||||
pass
|
||||
except Exception as 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}")
|
||||
|
||||
Executable → Regular
+5
-59
@@ -43,7 +43,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
@@ -58,7 +57,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -269,7 +267,6 @@ def create_generation_task(
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
video_title=request.video_title,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
)
|
||||
@@ -285,28 +282,28 @@ def create_generation_task(
|
||||
created_tasks.append(task)
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
except UserPendingLimitExceeded:
|
||||
# 兜底:如果预检查后又并发提交了,在这里也拦住
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from _e
|
||||
)
|
||||
break
|
||||
except GlobalQueueFull as _e:
|
||||
except GlobalQueueFull:
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from _e
|
||||
)
|
||||
break
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
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]
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
@@ -407,7 +404,6 @@ def retry_generation_task(
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -430,53 +426,3 @@ def retry_generation_task(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
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)
|
||||
|
||||
@@ -81,11 +81,11 @@ def delete_project(
|
||||
use_case = DeleteProjectUseCase(project_repository)
|
||||
try:
|
||||
deleted = use_case.execute(project_id, authenticated_user.user.id)
|
||||
except PermissionError as _e:
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the project owner can delete this project",
|
||||
) from _e
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return # type: ignore[return-value]
|
||||
return
|
||||
|
||||
Executable → Regular
+1
-6
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
@@ -21,8 +20,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -257,9 +254,7 @@ async def payment_callback(
|
||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
|
||||
# 不返回原始异常信息,避免泄漏内部实现细节
|
||||
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
|
||||
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes._helpers import format_utc_datetime
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.task_enqueue import (
|
||||
@@ -25,6 +24,8 @@ from app.schemas.task_center import (
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
RetryGenerationTaskUseCase,
|
||||
SubmitIngestJobCommand,
|
||||
SubmitIngestJobUseCase,
|
||||
@@ -100,8 +101,8 @@ def _generation_task_to_user_response(task) -> UserTaskResponse:
|
||||
retryable=_status_value(task.status) == "failed",
|
||||
retry_count=task.retry_count or 0,
|
||||
source_id=task.id,
|
||||
created_at=format_utc_datetime(task.created_at),
|
||||
updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at),
|
||||
created_at=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,
|
||||
source_id=task.id,
|
||||
template_id=task.template_id,
|
||||
created_at=format_utc_datetime(task.created_at),
|
||||
updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at),
|
||||
created_at=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),
|
||||
retryable=_status_value(job.status) == "failed",
|
||||
source_id=job.id,
|
||||
created_at=format_utc_datetime(job.created_at),
|
||||
updated_at=format_utc_datetime(job.updated_at),
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -367,9 +368,9 @@ def retry_project_task(
|
||||
raise HTTPException(status_code=404, detail="Ingest job not found")
|
||||
if _status_value(job.status) != "failed":
|
||||
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(
|
||||
SubmitIngestJobCommand( # type: ignore[arg-type]
|
||||
SubmitIngestJobCommand(
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
@@ -384,7 +385,7 @@ def retry_project_task(
|
||||
progress=0,
|
||||
current_step=_ingest_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=format_utc_datetime(retried.created_at),
|
||||
updated_at=format_utc_datetime(retried.updated_at), # type: ignore[attr-defined]
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.updated_at,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Unsupported task type")
|
||||
|
||||
@@ -147,9 +147,9 @@ def get_template(
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
template = use_case.execute(template_id, user_id)
|
||||
usage = template_repository.get_usage_count(template_id)
|
||||
except Exception as _e:
|
||||
except Exception:
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return _to_response(template, usage_count=usage)
|
||||
@@ -186,7 +186,7 @@ def create_template(
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
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)
|
||||
|
||||
|
||||
@@ -226,10 +226,10 @@ def update_template(
|
||||
use_case = UpdateTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
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)
|
||||
|
||||
|
||||
@@ -264,10 +264,10 @@ def copy_template(
|
||||
use_case = CopyTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
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)
|
||||
|
||||
|
||||
@@ -299,9 +299,9 @@ def toggle_favorite(
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(template_id, user_id)
|
||||
except Exception as _e:
|
||||
except Exception:
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
|
||||
@@ -326,10 +326,10 @@ def validate_template(
|
||||
use_case = ValidateTemplateUseCase(template_repository)
|
||||
try:
|
||||
result = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
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(
|
||||
template=_to_response(result.template),
|
||||
@@ -375,9 +375,7 @@ def create_category(
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
||||
)
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_category(
|
||||
category_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,7 +148,7 @@ def create_title(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
) from exc
|
||||
)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@@ -172,8 +172,8 @@ def update_title(
|
||||
use_case = UpdateTitleLibraryUseCase(title_repository)
|
||||
try:
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
|
||||
@@ -236,8 +236,8 @@ def get_tts_job(
|
||||
use_case = GetTTSJobUseCase(repository)
|
||||
try:
|
||||
job = use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
return _to_response(job, sign_url)
|
||||
|
||||
|
||||
@@ -253,8 +253,8 @@ def get_tts_job_status(
|
||||
use_case = GetTTSJobStatusUseCase(repository)
|
||||
try:
|
||||
job = use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
output_url = job.output_audio_url
|
||||
if output_url:
|
||||
output_url = sign_url(output_url)
|
||||
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
|
||||
get_use_case = GetTTSJobUseCase(tts_repository)
|
||||
try:
|
||||
job = get_use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
|
||||
# 校验已完成
|
||||
if not job.is_completed:
|
||||
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
) from exc
|
||||
)
|
||||
|
||||
return SaveToLibraryResponse(
|
||||
id=item.id,
|
||||
|
||||
Regular → Executable
+10
-69
@@ -1,20 +1,19 @@
|
||||
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.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_generated_video_repository
|
||||
from app.schemas.video_center import (
|
||||
BatchDeleteRequest,
|
||||
BatchDownloadRequest,
|
||||
BatchDownloadResponse,
|
||||
BatchOperationResponse,
|
||||
ListVideosResponse,
|
||||
UpdateVideoReviewRequest,
|
||||
VideoItemResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from packages.application import (
|
||||
GetGeneratedVideoUseCase,
|
||||
@@ -51,13 +50,13 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
|
||||
review_status=item.review_status,
|
||||
generation_params=item.generation_params,
|
||||
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)
|
||||
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="按状态筛选"),
|
||||
review_status: str | None = Query(None, description="按复核状态筛选"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
@@ -66,10 +65,9 @@ def list_videos(
|
||||
storage: OSSStorageService = Depends(get_storage_service),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""成片列表,默认返回当前用户的所有成片,支持按项目/状态/复核状态筛选。"""
|
||||
"""成片列表,支持分页、按项目/状态/复核状态筛选。"""
|
||||
use_case = ListGeneratedVideosPaginatedUseCase(repo)
|
||||
items, total = use_case.execute(
|
||||
user_id=current_user.user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
review_status=review_status,
|
||||
@@ -112,67 +110,10 @@ def update_video_review_status(
|
||||
item = use_case.execute(video_id, request.review_status)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
logger.info(
|
||||
"Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user.id
|
||||
)
|
||||
logger.info("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)
|
||||
|
||||
|
||||
@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)
|
||||
def batch_download_videos(
|
||||
request: BatchDownloadRequest,
|
||||
@@ -180,7 +121,7 @@ def batch_download_videos(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""批量下载成片,异步打包 zip。
|
||||
|
||||
|
||||
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
|
||||
"""
|
||||
if not request.video_ids:
|
||||
@@ -197,7 +138,7 @@ def batch_download_videos(
|
||||
# 发送 celery 任务
|
||||
task = celery_app.send_task(
|
||||
"worker.batch_download_videos",
|
||||
args=[request.video_ids, current_user.user.id],
|
||||
args=[request.video_ids, current_user.user_id],
|
||||
)
|
||||
|
||||
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
|
||||
@@ -213,7 +154,7 @@ def get_batch_download_status(
|
||||
from celery.result import AsyncResult
|
||||
|
||||
task = AsyncResult(job_id, app=celery_app)
|
||||
|
||||
|
||||
status_map = {
|
||||
"PENDING": "pending",
|
||||
"STARTED": "running",
|
||||
|
||||
@@ -141,8 +141,8 @@ def get_voice_clone(
|
||||
use_case = GetVoiceCloneUseCase(repository)
|
||||
try:
|
||||
profile = use_case.execute(clone_id, user_id)
|
||||
except VoiceCloneNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
||||
except VoiceCloneNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
@@ -157,8 +157,8 @@ def get_voice_clone_status(
|
||||
use_case = GetVoiceCloneStatusUseCase(repository)
|
||||
try:
|
||||
profile = use_case.execute(clone_id, user_id)
|
||||
except VoiceCloneNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
||||
except VoiceCloneNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||
return VoiceCloneStatusResponse(
|
||||
id=profile.id,
|
||||
status=profile.status,
|
||||
@@ -201,13 +201,13 @@ def retry_voice_clone(
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
profile = workflow.retry_clone(clone_id, user_id)
|
||||
except VoiceCloneNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
||||
except VoiceCloneNotRetryableError as _e:
|
||||
except VoiceCloneNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||
except VoiceCloneNotRetryableError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Voice clone is not retryable (only failed clones can be retried)",
|
||||
) from _e
|
||||
)
|
||||
|
||||
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Literal, Optional
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
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 (
|
||||
PresetVoiceItemResponse,
|
||||
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_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.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
@@ -38,18 +37,11 @@ from packages.application.voice_library.use_cases import (
|
||||
QuotaExceededError,
|
||||
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
|
||||
|
||||
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:
|
||||
return SQLAlchemyVoiceLibraryRepository(session)
|
||||
@@ -224,58 +216,6 @@ def list_preset_voices() -> PresetVoiceListResponse:
|
||||
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 端点(保持向后兼容)====================
|
||||
|
||||
|
||||
@@ -347,7 +287,7 @@ def create_voice(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
) from exc
|
||||
)
|
||||
return _to_response(item, sign_url)
|
||||
|
||||
|
||||
@@ -377,8 +317,8 @@ def update_voice(
|
||||
use_case = UpdateVoiceLibraryUseCase(voice_repository)
|
||||
try:
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return _to_response(item, sign_url)
|
||||
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
|
||||
global_pending_limit,
|
||||
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))
|
||||
raise exc
|
||||
|
||||
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
|
||||
if global_over or user_over:
|
||||
if global_over:
|
||||
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:
|
||||
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
|
||||
|
||||
@@ -132,7 +132,7 @@ def get_tag_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> TagRepository:
|
||||
"""Provide the SQLAlchemy tag repository implementation."""
|
||||
return SQLAlchemyTagRepository(session) # type: ignore[return-value]
|
||||
return SQLAlchemyTagRepository(session)
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
|
||||
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
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):
|
||||
# 如果配置了路径过滤,只对指定路径限流
|
||||
|
||||
@@ -50,7 +50,6 @@ class AssetResponse(BaseModel):
|
||||
status: str
|
||||
classification_status: str
|
||||
quality_score: float | None = None
|
||||
created_at: str
|
||||
uploaded_by_user_id: str
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── 视频标题 ──
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
# ── 批量生成 ──
|
||||
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
|
||||
# ── 素材库自动匹配 ──
|
||||
@@ -73,7 +71,6 @@ class GenerationTaskResponse(BaseModel):
|
||||
source_edit_plan_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -15,8 +17,8 @@ class ProjectTaskResponse(BaseModel):
|
||||
retry_count: int = 0
|
||||
source_id: str = ""
|
||||
template_id: str = ""
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ListProjectTasksResponse(BaseModel):
|
||||
@@ -40,8 +42,8 @@ class UserTaskResponse(BaseModel):
|
||||
retryable: bool = False
|
||||
retry_count: int = 0
|
||||
source_id: str = ""
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ListTasksResponse(BaseModel):
|
||||
|
||||
@@ -35,19 +35,6 @@ class UpdateVideoReviewRequest(BaseModel):
|
||||
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):
|
||||
video_ids: list[str]
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -156,7 +155,7 @@ class AutoClipService:
|
||||
self,
|
||||
clip: EditPlanClip,
|
||||
project_id: str,
|
||||
config_map: Mapping[str, object],
|
||||
config_map: dict[str, object],
|
||||
) -> ClipAssignDetail:
|
||||
"""为单个片段分配素材。"""
|
||||
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +39,70 @@ class EditPlanService:
|
||||
|
||||
# ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
def list_plans(
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[EditPlan]:
|
||||
"""列出剪辑计划
|
||||
|
||||
Args:
|
||||
template_id: 按模板 ID 筛选
|
||||
project_id: 按项目 ID 筛选
|
||||
status: 按状态筛选
|
||||
skip: 分页偏移
|
||||
limit: 每页数量
|
||||
"""
|
||||
if project_id:
|
||||
return self._plan_repo.list_by_project(
|
||||
project_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
if template_id:
|
||||
return self._plan_repo.list_by_template(
|
||||
template_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
|
||||
|
||||
def count_plans(
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
) -> int:
|
||||
"""统计计划数量
|
||||
|
||||
Note:
|
||||
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
|
||||
"""
|
||||
if project_id:
|
||||
all_matching = self._plan_repo.list_by_project(
|
||||
project_id,
|
||||
status=status,
|
||||
skip=0,
|
||||
limit=10000,
|
||||
)
|
||||
return len(all_matching)
|
||||
if template_id:
|
||||
all_matching = self._plan_repo.list_by_template(
|
||||
template_id,
|
||||
status=status,
|
||||
skip=0,
|
||||
limit=10000,
|
||||
)
|
||||
return len(all_matching)
|
||||
return self._plan_repo.count(status=status)
|
||||
|
||||
def get_plan(self, plan_id: str) -> Optional[EditPlan]:
|
||||
"""获取计划详情"""
|
||||
return self._plan_repo.get(plan_id)
|
||||
@@ -60,7 +124,7 @@ class EditPlanService:
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
) -> EditPlan:
|
||||
"""创建剪辑计划(基础 CRUD,供内部测试与脚本使用)
|
||||
"""创建剪辑计划
|
||||
|
||||
Raises:
|
||||
ValueError: 参数校验失败
|
||||
@@ -77,19 +141,6 @@ class EditPlanService:
|
||||
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
|
||||
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(
|
||||
self,
|
||||
plan_id: str,
|
||||
@@ -105,10 +156,6 @@ class EditPlanService:
|
||||
"""
|
||||
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(
|
||||
id=existing.id,
|
||||
template_id=existing.template_id,
|
||||
@@ -126,6 +173,23 @@ class EditPlanService:
|
||||
logger.info("更新剪辑计划: id=%s", plan_id)
|
||||
return result
|
||||
|
||||
def delete_plan(self, plan_id: str) -> bool:
|
||||
"""删除剪辑计划及其所有片段
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
existing = self._plan_repo.get(plan_id)
|
||||
if existing is None:
|
||||
return False
|
||||
|
||||
# 先删除所有片段
|
||||
self._clip_repo.delete_by_plan(plan_id)
|
||||
# 再删除计划
|
||||
self._plan_repo.delete(plan_id)
|
||||
logger.info("删除剪辑计划: id=%s", plan_id)
|
||||
return True
|
||||
|
||||
# ── 状态机流转 ──────────────────────────────────────────────────────────
|
||||
|
||||
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
|
||||
@@ -148,24 +212,8 @@ class EditPlanService:
|
||||
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 = {
|
||||
EditPlanStatus.EDITING: plan.start_editing,
|
||||
EditPlanStatus.RENDERING: plan.start_rendering,
|
||||
EditPlanStatus.COMPLETED: plan.mark_completed,
|
||||
EditPlanStatus.FAILED: plan.mark_failed,
|
||||
@@ -244,8 +292,6 @@ class EditPlanService:
|
||||
"""
|
||||
# 确保计划存在
|
||||
self.get_plan_or_raise(plan_id)
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
@@ -293,9 +339,6 @@ class EditPlanService:
|
||||
"""
|
||||
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 <= 0:
|
||||
@@ -338,8 +381,6 @@ class EditPlanService:
|
||||
ValueError: 片段不存在或 asset_id 为空
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
clip.assign_asset(asset_id)
|
||||
result = self._clip_repo.update(clip)
|
||||
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)
|
||||
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]:
|
||||
"""获取渲染进度状态
|
||||
|
||||
@@ -547,8 +431,6 @@ class EditPlanService:
|
||||
"clips": List[EditPlanClip],
|
||||
"generation_task_id": Optional[str],
|
||||
"generation_task_status": Optional[str],
|
||||
"progress": float,
|
||||
"error_message": str,
|
||||
}
|
||||
|
||||
Raises:
|
||||
@@ -560,23 +442,17 @@ class EditPlanService:
|
||||
# 从 plan.config 中获取 generation_task_id
|
||||
generation_task_id = plan.config.get("generation_task_id")
|
||||
generation_task_status = None
|
||||
progress = 0.0
|
||||
error_message = ""
|
||||
|
||||
if generation_task_id:
|
||||
task = self._generation_task_repo.get(generation_task_id)
|
||||
if task:
|
||||
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 {
|
||||
"plan": plan,
|
||||
"clips": clips,
|
||||
"generation_task_id": generation_task_id,
|
||||
"generation_task_status": generation_task_status,
|
||||
"progress": progress,
|
||||
"error_message": error_message,
|
||||
}
|
||||
|
||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||
@@ -627,9 +503,6 @@ class EditPlanService:
|
||||
更新后的计划
|
||||
"""
|
||||
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}
|
||||
|
||||
updated = EditPlan(
|
||||
|
||||
Executable → Regular
-563
@@ -12,8 +12,6 @@ from typing import Any, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
SQLAlchemyEditPlanRepository,
|
||||
SQLAlchemyEditTemplateRepository,
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
@@ -39,14 +37,6 @@ class EditTemplateService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._template_repo = SQLAlchemyEditTemplateRepository(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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -404,556 +394,3 @@ class EditTemplateService:
|
||||
"template": template,
|
||||
"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
|
||||
|
||||
Executable → Regular
+6
-52
@@ -99,11 +99,6 @@ class PlanGeneratorService:
|
||||
# 3. 生成片段列表
|
||||
if clip_configs:
|
||||
clips = self._create_clips_from_configs(plan.id, clip_configs)
|
||||
# 模板 clip_config 的 clip_type 是 ClipType 枚举(main/intro/outro 等),
|
||||
# 但 PIP / VOICE_PIP 模式需要特定的 clip_type(overlay/background/corner_voice/b_roll)
|
||||
# 才能让素材分配和渲染分层正确工作。
|
||||
# 这里将 MAIN 类型的片段按顺序映射为对应模式的角色类型。
|
||||
self._map_clip_types_for_mode(clips, editing_mode)
|
||||
else:
|
||||
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
|
||||
|
||||
@@ -151,8 +146,8 @@ class PlanGeneratorService:
|
||||
plan_config: dict[str, Any] = {
|
||||
"editing_mode": editing_mode,
|
||||
}
|
||||
# 继承模板的 cover/title/subtitle/bgm/export/filter 配置
|
||||
for key in ("cover", "title", "subtitle", "bgm", "export", "filter"):
|
||||
# 继承模板的 cover/title/subtitle/bgm 配置
|
||||
for key in ("cover", "title", "subtitle", "bgm"):
|
||||
if key in template_config:
|
||||
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
|
||||
)
|
||||
|
||||
# 从 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(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
@@ -199,48 +190,11 @@ class PlanGeneratorService:
|
||||
text_content=getattr(cfg, "text_template", "") or "",
|
||||
duration=duration,
|
||||
transition_effect=transition or "cut",
|
||||
playback_speed=playback_speed,
|
||||
config=clip_cfg,
|
||||
)
|
||||
clips.append(clip)
|
||||
|
||||
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(
|
||||
self,
|
||||
plan_id: str,
|
||||
@@ -270,7 +224,7 @@ class PlanGeneratorService:
|
||||
)
|
||||
order += 1
|
||||
# 剩余为 overlay
|
||||
for _ in range(1, n):
|
||||
for i in range(1, n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
@@ -283,7 +237,7 @@ class PlanGeneratorService:
|
||||
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
# N 个 main clips(B-roll)
|
||||
for _ in range(n):
|
||||
for i in range(n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
@@ -317,7 +271,7 @@ class PlanGeneratorService:
|
||||
)
|
||||
order += 1
|
||||
# 剩余为 b_roll
|
||||
for _ in range(2, n):
|
||||
for i in range(2, n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
@@ -330,7 +284,7 @@ class PlanGeneratorService:
|
||||
|
||||
else:
|
||||
# ONE_TAKE: N 个 main clips
|
||||
for _ in range(n):
|
||||
for i in range(n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
|
||||
Executable → Regular
+18
-43
@@ -219,7 +219,7 @@ class VideoComposeService:
|
||||
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
|
||||
|
||||
滤镜链逻辑:
|
||||
- 每个片段:scale → crop → fps → setpts → trim → atrim
|
||||
- 每个片段:scale → crop → setpts → trim → atrim
|
||||
- 多片段之间:concat 滤镜 或 xfade 转场
|
||||
- 最终输出:-map '[outv]' -map '[outa]'(如有音频)
|
||||
"""
|
||||
@@ -406,30 +406,20 @@ class VideoComposeService:
|
||||
滤镜顺序:
|
||||
1. scale — 等比缩放到目标分辨率(保证覆盖)
|
||||
2. crop — 居中裁剪到目标分辨率
|
||||
3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
|
||||
4. setpts — 重置时间戳 + 偏移
|
||||
5. trim — 视频时长裁剪
|
||||
6. atrim — 音频时长裁剪(如有音频流)
|
||||
3. setpts — 重置时间戳 + 偏移
|
||||
4. trim — 视频时长裁剪
|
||||
5. atrim — 音频时长裁剪(如有音频流)
|
||||
"""
|
||||
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
|
||||
start = clip.start_time
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
# 1. scale: 等比缩放(保持比例,不裁剪)
|
||||
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
|
||||
# 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop)
|
||||
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase")
|
||||
|
||||
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
|
||||
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
|
||||
|
||||
# 3. format: 统一像素格式为 yuv420p(H.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}")
|
||||
# 2. crop: 居中裁剪
|
||||
filters.append(f"crop={output_width}:{output_height}")
|
||||
|
||||
# 3. setpts: 重置时间戳
|
||||
if start > 0:
|
||||
@@ -542,17 +532,11 @@ def _build_concat_filter(
|
||||
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
|
||||
parts.append(concat_filter)
|
||||
|
||||
# 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
|
||||
# 音频 concat(如果有)
|
||||
audio_parts: list[str] = []
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
if chain.audio_label:
|
||||
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
|
||||
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}]")
|
||||
audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]")
|
||||
|
||||
if audio_parts:
|
||||
parts.extend(audio_parts)
|
||||
@@ -627,22 +611,13 @@ def _build_xfade_filter(
|
||||
# 总时长需要减去转场重叠部分
|
||||
total_duration -= transition_duration * (n - 1)
|
||||
|
||||
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
|
||||
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
|
||||
if len(audio_chains_with_label) >= 2:
|
||||
normalized_audio_labels: list[str] = []
|
||||
for chain, _ in audio_chains_with_label:
|
||||
norm_label = f"anorm_{chain.video_label}"
|
||||
audio_filters = [
|
||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||
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]")
|
||||
# 音频 crossfade(简化处理:使用 adelay + amix)
|
||||
audio_labels = [c.audio_label for c in clip_chains if c.audio_label]
|
||||
if len(audio_labels) >= 2:
|
||||
# 简单拼接音频(不做 crossfade)
|
||||
audio_inputs = "".join(f"[{label}]" for label in audio_labels)
|
||||
parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]")
|
||||
elif len(audio_labels) == 1:
|
||||
parts.append(f"[{audio_labels[0]}]acopy[outa]")
|
||||
|
||||
return ";".join(parts), max(0.0, total_duration)
|
||||
|
||||
Executable → Regular
+6
-4
@@ -8,14 +8,16 @@ module.exports = {
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier", // 关掉与 Prettier 冲突的 ESLint 规则
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
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-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -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
@@ -91,17 +91,17 @@ VITE_API_URL=http://localhost:8000
|
||||
使用 Zustand 创建 Store:
|
||||
|
||||
```typescript
|
||||
import { create } from "zustand"
|
||||
import { create } from "zustand";
|
||||
|
||||
interface MyStore {
|
||||
data: any
|
||||
setData: (data: any) => void
|
||||
data: any;
|
||||
setData: (data: any) => void;
|
||||
}
|
||||
|
||||
export const useMyStore = create<MyStore>((set) => ({
|
||||
data: null,
|
||||
setData: (data) => set({ data }),
|
||||
}))
|
||||
}));
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
+399
-240
@@ -5,29 +5,33 @@
|
||||
* 删除素材、批量删除、空状态
|
||||
* 注意: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 apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -40,30 +44,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -71,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建项目 */
|
||||
@@ -86,10 +90,10 @@ async function createProject(
|
||||
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 +107,10 @@ async function createLibrary(
|
||||
const resp = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectId, name, kind },
|
||||
})
|
||||
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
});
|
||||
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** 创建素材记录 */
|
||||
@@ -132,10 +136,10 @@ async function createAsset(
|
||||
uploaded_by_user_id: userId,
|
||||
metadata: { duration: 15.5, resolution: "1080p" },
|
||||
},
|
||||
})
|
||||
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
});
|
||||
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
@@ -146,14 +150,14 @@ async function setupAuthInBrowser(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -167,400 +171,551 @@ async function setupAuthInBrowser(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("素材库页面 - 完整交互测试", () => {
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 页面加载 ──────────────────────────────────────
|
||||
|
||||
test("素材库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-load");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-load",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video")
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
|
||||
// 页面布局容器
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 左侧素材库列表
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible()
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||
|
||||
// 右侧内容区(上传区 + 筛选 + 素材网格)
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible()
|
||||
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible()
|
||||
await expect(page.locator(".xx-assets-filters")).toBeVisible()
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible();
|
||||
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
|
||||
await expect(page.locator(".xx-assets-filters")).toBeVisible();
|
||||
|
||||
// 无错误提示
|
||||
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 创建素材库 ────────────────────────────────────
|
||||
|
||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-create",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
await createLibrary(request, headers, projectId, "初始库", "video")
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
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: "新建素材库" })
|
||||
await expect(modal).toBeVisible()
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "新建素材库" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 填写表单
|
||||
const newLibName = `E2E 新建库 ${Date.now()}`
|
||||
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName)
|
||||
const newLibName = `E2E 新建库 ${Date.now()}`;
|
||||
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
|
||||
// 类型选择默认是 video,保持即可
|
||||
|
||||
// 监听创建请求
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/asset-libraries") && resp.request().method() === "POST",
|
||||
(resp) =>
|
||||
resp.url().includes("/asset-libraries") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
);
|
||||
|
||||
// 点击创建
|
||||
await modal.getByRole("button", { name: "创建" }).click()
|
||||
await modal.getByRole("button", { name: "创建" }).click();
|
||||
|
||||
const resp = await createPromise
|
||||
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy()
|
||||
const resp = await createPromise;
|
||||
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 新素材库应出现在列表中
|
||||
await expect(
|
||||
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ─── 切换素材库 ────────────────────────────────────
|
||||
|
||||
test("切换不同素材库", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-switch");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-switch",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
const videoLibName = "视频素材库 A"
|
||||
const imageLibName = "图片素材库 B"
|
||||
const videoLibId = await createLibrary(request, headers, projectId, videoLibName, "video")
|
||||
const videoLibName = "视频素材库 A";
|
||||
const imageLibName = "图片素材库 B";
|
||||
const videoLibId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
videoLibName,
|
||||
"video",
|
||||
);
|
||||
const imageLibId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
imageLibName,
|
||||
"image",
|
||||
);
|
||||
|
||||
// 在视频库里创建一个素材
|
||||
await createAsset(request, headers, projectId, videoLibId, userId, "demo_video.mp4")
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
videoLibId,
|
||||
userId,
|
||||
"demo_video.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 点击视频库,应显示素材
|
||||
const videoLibItem = page.locator(".xx-asset-library-item").filter({ hasText: videoLibName })
|
||||
await videoLibItem.click({ force: true })
|
||||
await expect(videoLibItem).toHaveClass(/active/)
|
||||
const videoLibItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: videoLibName });
|
||||
await videoLibItem.click({ force: true });
|
||||
await expect(videoLibItem).toHaveClass(/active/);
|
||||
|
||||
// 验证视频素材出现
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 点击图片库,应切换且不显示视频
|
||||
const imageLibItem = page.locator(".xx-asset-library-item").filter({ hasText: imageLibName })
|
||||
await imageLibItem.click({ force: true })
|
||||
await expect(imageLibItem).toHaveClass(/active/)
|
||||
const imageLibItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: imageLibName });
|
||||
await imageLibItem.click({ force: true });
|
||||
await expect(imageLibItem).toHaveClass(/active/);
|
||||
|
||||
// 空状态或图片库内容
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 素材搜索 ──────────────────────────────────────
|
||||
|
||||
test("素材搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-search");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-search",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "搜索测试库", "video")
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"搜索测试库",
|
||||
"video",
|
||||
);
|
||||
|
||||
// 创建两个不同名称的素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4")
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"apple_clip.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"banana_clip.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 确保在测试库中
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "搜索测试库" })
|
||||
await libItem.click({ force: true })
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "搜索测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 两个素材都应可见
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible()
|
||||
});
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
|
||||
// 搜索 apple,只显示 apple
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("apple")
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible()
|
||||
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0)
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("apple");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible();
|
||||
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({
|
||||
timeout: 5_000,
|
||||
})
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible()
|
||||
})
|
||||
});
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 筛选类型 ──────────────────────────────────────
|
||||
|
||||
test("素材类型筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-filter");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-filter",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "筛选测试库", "video")
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
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, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "筛选测试库" })
|
||||
await libItem.click({ force: true })
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "筛选测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 素材应可见
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 筛选类型下拉存在
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first()
|
||||
await expect(filterSelect).toBeVisible()
|
||||
})
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||
await expect(filterSelect).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 素材详情/播放 ────────────────────────────────
|
||||
|
||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-detail");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-detail",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "详情测试库", "video")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4")
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"详情测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"play_test.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "详情测试库" })
|
||||
await libItem.click({ force: true })
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "详情测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 找到素材卡片并点击播放按钮
|
||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "play_test.mp4" })
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 })
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "play_test.mp4" });
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 点击播放按钮
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true })
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||
|
||||
// 播放弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" })
|
||||
await expect(modal).toBeVisible()
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 关闭弹窗
|
||||
await modal.locator(".ant-modal-close").click()
|
||||
await expect(modal).not.toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
await modal.locator(".ant-modal-close").click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ─── 删除素材 ──────────────────────────────────────
|
||||
|
||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-delete");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-delete",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "删除测试库", "video")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4")
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"删除测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"to_delete.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "删除测试库" })
|
||||
await libItem.click({ force: true })
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "删除测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "to_delete.mp4" })
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 })
|
||||
const assetCard = page
|
||||
.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")
|
||||
await expect(deleteBtn).toBeVisible()
|
||||
await deleteBtn.click({ force: true })
|
||||
const deleteBtn = assetCard.locator(".xx-asset-delete");
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
await deleteBtn.click({ force: true });
|
||||
|
||||
// 确认对话框出现
|
||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" })
|
||||
await expect(confirmModal).toBeVisible()
|
||||
const confirmModal = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确认删除" });
|
||||
await expect(confirmModal).toBeVisible();
|
||||
|
||||
// 监听删除请求
|
||||
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 },
|
||||
)
|
||||
);
|
||||
|
||||
// 点击确认删除
|
||||
await confirmModal.getByRole("button", { name: "删除" }).click()
|
||||
await confirmModal.getByRole("button", { name: "删除" }).click();
|
||||
|
||||
const resp = await deletePromise
|
||||
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy()
|
||||
const resp = await deletePromise;
|
||||
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 素材应从列表中消失
|
||||
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 批量删除素材 ──────────────────────────────────
|
||||
|
||||
test("批量删除素材", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-batch");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-batch",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "批量删除库", "video")
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"批量删除库",
|
||||
"video",
|
||||
);
|
||||
|
||||
// 创建多个素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4")
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_1.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_2.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_3.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "批量删除库" })
|
||||
await libItem.click({ force: true })
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "批量删除库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 所有素材应可见
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible()
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible()
|
||||
});
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||
|
||||
// 点击全选
|
||||
const selectAllBtn = page.getByRole("button", { name: "全选" })
|
||||
await expect(selectAllBtn).toBeVisible()
|
||||
await selectAllBtn.click()
|
||||
const selectAllBtn = page.getByRole("button", { name: "全选" });
|
||||
await expect(selectAllBtn).toBeVisible();
|
||||
await selectAllBtn.click();
|
||||
|
||||
// 批量操作栏出现
|
||||
const batchBar = page.locator(".xx-assets-batch-bar")
|
||||
await expect(batchBar).toBeVisible()
|
||||
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible()
|
||||
const batchBar = page.locator(".xx-assets-batch-bar");
|
||||
await expect(batchBar).toBeVisible();
|
||||
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
|
||||
|
||||
// 点击批量删除
|
||||
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" })
|
||||
await expect(batchDeleteBtn).toBeVisible()
|
||||
await batchDeleteBtn.click()
|
||||
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
|
||||
await expect(batchDeleteBtn).toBeVisible();
|
||||
await batchDeleteBtn.click();
|
||||
|
||||
// 确认对话框
|
||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" })
|
||||
await expect(confirmPop).toBeVisible()
|
||||
const confirmPop = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确定删除" });
|
||||
await expect(confirmPop).toBeVisible();
|
||||
|
||||
// 确认删除
|
||||
await confirmPop.getByRole("button", { name: "删除" }).click()
|
||||
await confirmPop.getByRole("button", { name: "删除" }).click();
|
||||
|
||||
// 验证素材已删除(通过 API 确认)
|
||||
await expect
|
||||
@@ -569,53 +724,57 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
const resp = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryId },
|
||||
})
|
||||
if (!resp.ok()) return "error"
|
||||
const data = await resp.json()
|
||||
const items = data.items || []
|
||||
return items.length
|
||||
});
|
||||
if (!resp.ok()) return "error";
|
||||
const data = await resp.json();
|
||||
const items = data.items || [];
|
||||
return items.length;
|
||||
},
|
||||
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
// ─── 空状态 ────────────────────────────────────────
|
||||
|
||||
test("空素材库展示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-empty");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-empty",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video")
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "空素材库" })
|
||||
await libItem.click({ force: true })
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "空素材库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible()
|
||||
})
|
||||
});
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/assets")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/assets");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("App route guard", () => {
|
||||
test("redirects anonymous users to login", async ({ page }) => {
|
||||
await page.goto("/app/dashboard")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/dashboard");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication page", () => {
|
||||
test("renders login form", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible()
|
||||
await expect(page.getByLabel("密码")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "登录" })).toBeVisible()
|
||||
})
|
||||
})
|
||||
await page.goto("/login");
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.getByLabel("密码")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
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"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
@@ -28,72 +28,73 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type TemplateResponse = { id: string }
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type TemplateResponse = { id: string };
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
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.setTimeout(180_000)
|
||||
test("walks through 5-step wizard and starts generation", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-${suffix}@example.com`
|
||||
const username = `e2e_gen_${suffix}`
|
||||
const libraryName = `E2E Gen Lib ${suffix}`
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-gen-${suffix}@example.com`;
|
||||
const username = `e2e_gen_${suffix}`;
|
||||
const libraryName = `E2E Gen Lib ${suffix}`;
|
||||
|
||||
// Register
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
const registerData = (await register.json()) as { user_id: string }
|
||||
});
|
||||
expect(register.status()).toBe(201);
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
// Login
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
// Create project
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E Gen Proj ${suffix}` },
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
const projectData = (await project.json()) as ProjectResponse
|
||||
});
|
||||
expect(project.status()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
|
||||
// Create asset library
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectData.id, name: libraryName, kind: "video" },
|
||||
})
|
||||
expect(library.status()).toBe(200)
|
||||
const libraryData = (await library.json()) as LibraryResponse
|
||||
});
|
||||
expect(library.status()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
// Upload source video
|
||||
const sourceFileName = "e2e-gen-source.mp4"
|
||||
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
|
||||
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
|
||||
const sourceFileName = "e2e-gen-source.mp4";
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
@@ -102,11 +103,11 @@ test.describe("Core generation flow", () => {
|
||||
file: {
|
||||
name: sourceFileName,
|
||||
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
|
||||
await expect
|
||||
@@ -115,16 +116,16 @@ test.describe("Core generation flow", () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
})
|
||||
if (!assets.ok()) return `http_${assets.status()}`
|
||||
const data = (await assets.json()) as AssetListResponse
|
||||
const asset = data.items.find((a) => a.name === sourceFileName)
|
||||
if (!asset) return "missing"
|
||||
return asset.status
|
||||
});
|
||||
if (!assets.ok()) return `http_${assets.status()}`;
|
||||
const data = (await assets.json()) as AssetListResponse;
|
||||
const asset = data.items.find((a) => a.name === sourceFileName);
|
||||
if (!asset) return "missing";
|
||||
return asset.status;
|
||||
},
|
||||
{ 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
|
||||
// (templates are now loaded from API; new users have none by default)
|
||||
@@ -144,22 +145,22 @@ test.describe("Core generation flow", () => {
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
})
|
||||
expect(template.status(), await template.text()).toBe(201)
|
||||
const templateData = (await template.json()) as TemplateResponse
|
||||
expect(templateData.id).toBeTruthy()
|
||||
});
|
||||
expect(template.status(), await template.text()).toBe(201);
|
||||
const templateData = (await template.json()) as TemplateResponse;
|
||||
expect(templateData.id).toBeTruthy();
|
||||
|
||||
// Set auth in localStorage
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -173,127 +174,113 @@ test.describe("Core generation flow", () => {
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// Navigate to generate page
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
await page.goto("/app/generate");
|
||||
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// Step 1: template - default selected, click next
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 2: select material
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..")
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible();
|
||||
const librarySelect = page.locator("select").first();
|
||||
await librarySelect.selectOption({ label: libraryName });
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..");
|
||||
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
});
|
||||
await materialLabel.locator("input[type='checkbox']").check();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 3: preview (纯展示页,AI 智能匹配预览)
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// Step 3: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible();
|
||||
const titleText = `E2E Test ${suffix}`;
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText);
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 4: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// Step 4: voice
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible();
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first();
|
||||
await firstVoiceCard.click();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 5: voice
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
|
||||
await firstVoiceCard.click()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// Step 5: confirm and generate
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible();
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 7: confirm and generate
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
|
||||
// Wait for 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")
|
||||
},
|
||||
// Wait for plan creation API to be called
|
||||
const createPlanPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/edit-plans") &&
|
||||
response.request().method() === "POST" &&
|
||||
!response.url().includes("/generate"),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
);
|
||||
|
||||
// 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
|
||||
const genResp = await generatePromise
|
||||
if (!genResp.ok()) {
|
||||
const body = await genResp.text()
|
||||
console.error(
|
||||
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
|
||||
)
|
||||
}
|
||||
expect(genResp.ok()).toBeTruthy()
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
// Verify plan was created successfully
|
||||
const planResp = await createPlanPromise;
|
||||
expect(planResp.ok()).toBeTruthy();
|
||||
const planData = (await planResp.json()) as { id: string };
|
||||
expect(planData.id).toBeTruthy();
|
||||
|
||||
// Generation may fail in test env (no worker), that's OK
|
||||
// Just verify the flow started - check page shows generation-related UI
|
||||
await page
|
||||
.getByText(/生成中|生成完成|生成失败/)
|
||||
.isVisible({ timeout: 15_000 })
|
||||
.catch(() => false)
|
||||
.catch(() => false);
|
||||
// If we see progress or result, great; if not, flow still reached the end
|
||||
// which is sufficient for an E2E smoke test
|
||||
|
||||
// Verify product library page loads (smoke: just verify page renders)
|
||||
await page.goto("/app/products")
|
||||
await expect(page).toHaveURL(/\/app\/products/)
|
||||
await page.goto("/app/products");
|
||||
await expect(page).toHaveURL(/\/app\/products/);
|
||||
// Verify page container exists = page rendered correctly
|
||||
// (works in all states: loading/error/success - more reliable than checking search input)
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
})
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||
});
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-api-${suffix}@example.com`
|
||||
const username = `e2e_gen_api_${suffix}`
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-gen-api-${suffix}@example.com`;
|
||||
const username = `e2e_gen_api_${suffix}`;
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
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)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E API Proj ${suffix}` },
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
});
|
||||
expect(project.status()).toBe(200);
|
||||
|
||||
// List generation tasks via task center API
|
||||
const tasks = await request.get(`${apiBase}/tasks`, { headers })
|
||||
expect(tasks.status()).toBe(200)
|
||||
const tasksData = await tasks.json()
|
||||
expect(Array.isArray(tasksData.items)).toBe(true)
|
||||
})
|
||||
})
|
||||
const tasks = await request.get(`${apiBase}/tasks`, { headers });
|
||||
expect(tasks.status()).toBe(200);
|
||||
const tasksData = await tasks.json();
|
||||
expect(Array.isArray(tasksData.items)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
@@ -25,35 +29,38 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Title library flow", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test("loads title library page and displays titles", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-title-${suffix}@example.com`
|
||||
const username = `e2e_title_${suffix}`
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test("loads title library page and displays titles", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
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`, {
|
||||
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)
|
||||
expect(login.status(), await login.text()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
// 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)
|
||||
@@ -64,19 +71,19 @@ test.describe("Title library flow", () => {
|
||||
text: `E2E 测试标题内容 ${suffix}`,
|
||||
category: "default",
|
||||
},
|
||||
})
|
||||
expect(createTitle.status(), await createTitle.text()).toBe(201)
|
||||
});
|
||||
expect(createTitle.status(), await createTitle.text()).toBe(201);
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -90,42 +97,45 @@ test.describe("Title library flow", () => {
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
await page.goto("/app/titles")
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
await expect(page.locator(".xx-title-card").first()).toBeVisible({
|
||||
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) {
|
||||
await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText)
|
||||
await expect(page.getByText(firstTitleText)).toBeVisible()
|
||||
await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText);
|
||||
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 }) => {
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-title-api-${suffix}@example.com`
|
||||
const username = `e2e_title_api_${suffix}`
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-title-api-${suffix}@example.com`;
|
||||
const username = `e2e_title_api_${suffix}`;
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
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)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
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`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -133,20 +143,20 @@ test.describe("Title library flow", () => {
|
||||
text: titleText,
|
||||
category: "default",
|
||||
},
|
||||
})
|
||||
expect(createResp.status(), await createResp.text()).toBe(201)
|
||||
});
|
||||
expect(createResp.status(), await createResp.text()).toBe(201);
|
||||
const created = (await createResp.json()) as {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
expect(created.id).toBeTruthy()
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
const listResp = await request.get(`${apiBase}/titles`, { headers })
|
||||
expect(listResp.status()).toBe(200)
|
||||
const listResp = await request.get(`${apiBase}/titles`, { headers });
|
||||
expect(listResp.status()).toBe(200);
|
||||
const listData = (await listResp.json()) as {
|
||||
items: Array<{ id: string; text: string }>
|
||||
}
|
||||
const found = listData.items.find((t) => t.id === created.id)
|
||||
expect(found).toBeTruthy()
|
||||
})
|
||||
})
|
||||
items: Array<{ id: string; text: string }>;
|
||||
};
|
||||
const found = listData.items.find((t) => t.id === created.id);
|
||||
expect(found).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
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"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
@@ -29,28 +29,31 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
|
||||
test.describe("Core media upload flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test("uploads a video asset and shows it in the asset library", async ({ page, request }) => {
|
||||
test.setTimeout(120_000)
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test("uploads a video asset and shows it in the asset library", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-mov-${suffix}@example.com`
|
||||
const username = `e2e_mov_${suffix}`
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-mov-${suffix}@example.com`;
|
||||
const username = `e2e_mov_${suffix}`;
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
@@ -59,15 +62,15 @@ test.describe("Core media upload flow", () => {
|
||||
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)
|
||||
expect(login.status(), await login.text()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
@@ -75,9 +78,9 @@ test.describe("Core media upload flow", () => {
|
||||
name: `E2E Project ${suffix}`,
|
||||
description: "Playwright upload smoke",
|
||||
},
|
||||
})
|
||||
expect(project.status(), await project.text()).toBe(200)
|
||||
const projectData = (await project.json()) as ProjectResponse
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
@@ -86,20 +89,20 @@ test.describe("Core media upload flow", () => {
|
||||
name: `E2E Video Library ${suffix}`,
|
||||
kind: "video",
|
||||
},
|
||||
})
|
||||
expect(library.status(), await library.text()).toBe(200)
|
||||
const libraryData = (await library.json()) as LibraryResponse
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -113,32 +116,30 @@ test.describe("Core media upload flow", () => {
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
await page.goto("/app/assets")
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
|
||||
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: "e2e-sample.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: sampleVideoBuffer,
|
||||
name: "e2e-sample.MOV",
|
||||
mimeType: "video/quicktime",
|
||||
buffer: Buffer.from("playwright mov upload smoke"),
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(upload.status(), await upload.text()).toBe(200)
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
|
||||
await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
await expect(
|
||||
page.getByText(/上传失败|素材列表加载失败|素材库加载失败/),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -146,44 +147,52 @@ test.describe("Core media upload flow", () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
})
|
||||
});
|
||||
if (!assets.ok()) {
|
||||
return `http_${assets.status()}`
|
||||
return `http_${assets.status()}`;
|
||||
}
|
||||
const data = (await assets.json()) as {
|
||||
items: Array<{
|
||||
name: string
|
||||
status: string
|
||||
file_type?: string
|
||||
mime_type?: string
|
||||
}>
|
||||
}
|
||||
const asset = data.items.find((item) => item.name === "e2e-sample.mp4")
|
||||
return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing"
|
||||
name: string;
|
||||
status: string;
|
||||
file_type?: string;
|
||||
mime_type?: string;
|
||||
}>;
|
||||
};
|
||||
const asset = data.items.find(
|
||||
(item) => item.name === "e2e-sample.MOV",
|
||||
);
|
||||
return asset
|
||||
? `${asset.mime_type || asset.file_type || ""}:${asset.status}`
|
||||
: "missing";
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
// Select the test library from sidebar
|
||||
await page
|
||||
.locator(".xx-asset-library-item")
|
||||
.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({
|
||||
timeout: 20_000,
|
||||
})
|
||||
await expect(page.getByText("e2e-sample.mp4", { exact: true })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
|
||||
{
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify asset card shows status
|
||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.mp4" })
|
||||
await expect(assetCard).toBeVisible()
|
||||
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible()
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "e2e-sample.MOV" });
|
||||
await expect(assetCard).toBeVisible();
|
||||
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible();
|
||||
|
||||
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
+239
-198
@@ -4,29 +4,33 @@
|
||||
* 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、
|
||||
* 删除记录、重试去重
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -39,30 +43,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -70,7 +74,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
@@ -81,14 +85,14 @@ async function setupAuthInBrowser(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -102,216 +106,236 @@ async function setupAuthInBrowser(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("去重流程", () => {
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 上传页面加载 ──────────────────────────────────
|
||||
|
||||
test("去重上传页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-load")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-load",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication")
|
||||
await 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 }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-upload-zone",
|
||||
)
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 拖拽上传区域
|
||||
const uploadZone = page.locator(".dup-upload-zone")
|
||||
await expect(uploadZone).toBeVisible()
|
||||
const uploadZone = page.locator(".dup-upload-zone");
|
||||
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: "选择文件" })
|
||||
await expect(selectBtn).toBeVisible()
|
||||
const selectBtn = page.getByRole("button", { name: "选择文件" });
|
||||
await expect(selectBtn).toBeVisible();
|
||||
|
||||
// 隐藏的文件 input
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
await expect(fileInput).toHaveCount(1)
|
||||
})
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await expect(fileInput).toHaveCount(1);
|
||||
});
|
||||
|
||||
// ─── 格式说明区 ────────────────────────────────────
|
||||
|
||||
test("格式说明和提示区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-info")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-info",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 右侧说明区
|
||||
const infoCard = page.locator(".dup-info-card")
|
||||
await expect(infoCard).toBeVisible()
|
||||
const infoCard = page.locator(".dup-info-card");
|
||||
await expect(infoCard).toBeVisible();
|
||||
|
||||
// 查重说明
|
||||
await expect(infoCard.getByText("查重说明")).toBeVisible()
|
||||
await expect(infoCard.getByText("查重说明")).toBeVisible();
|
||||
|
||||
// 支持格式
|
||||
await expect(infoCard.getByText("支持格式")).toBeVisible()
|
||||
await expect(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 }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-list")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-list",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results")
|
||||
await 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 }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-list-empty",
|
||||
)
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 空状态(新用户没有记录)
|
||||
const emptyState = page.locator(".dup-results-empty")
|
||||
await expect(emptyState).toBeVisible({ timeout: 10_000 })
|
||||
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible()
|
||||
})
|
||||
const emptyState = page.locator(".dup-results-empty");
|
||||
await expect(emptyState).toBeVisible({ timeout: 10_000 });
|
||||
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("去重记录列表 - 风险等级筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-filter")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-filter",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 筛选按钮存在
|
||||
const filterBtns = page.locator(".dup-filter-btn")
|
||||
await expect(filterBtns).toHaveCount(4) // 全部、低风险、中风险、高风险
|
||||
const filterBtns = page.locator(".dup-filter-btn");
|
||||
await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险
|
||||
|
||||
// 验证按钮文本
|
||||
await expect(filterBtns.nth(0)).toHaveText("全部")
|
||||
await expect(filterBtns.nth(1)).toHaveText("低风险")
|
||||
await expect(filterBtns.nth(2)).toHaveText("中风险")
|
||||
await expect(filterBtns.nth(3)).toHaveText("高风险")
|
||||
await expect(filterBtns.nth(0)).toHaveText("全部");
|
||||
await expect(filterBtns.nth(1)).toHaveText("低风险");
|
||||
await expect(filterBtns.nth(2)).toHaveText("中风险");
|
||||
await expect(filterBtns.nth(3)).toHaveText("高风险");
|
||||
|
||||
// 默认选中"全部"
|
||||
await expect(filterBtns.nth(0)).toHaveClass(/active/)
|
||||
await expect(filterBtns.nth(0)).toHaveClass(/active/);
|
||||
|
||||
// 点击低风险
|
||||
await filterBtns.nth(1).click()
|
||||
await expect(filterBtns.nth(1)).toHaveClass(/active/)
|
||||
})
|
||||
await filterBtns.nth(1).click();
|
||||
await expect(filterBtns.nth(1)).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-nav")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-nav",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
await page.goto("/app/duplication/results");
|
||||
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.locator(".dup-upload-zone")).toBeVisible()
|
||||
})
|
||||
await expect(page).toHaveURL(/\/app\/duplication$/);
|
||||
await expect(page.locator(".dup-upload-zone")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 去重详情页 ────────────────────────────────────
|
||||
|
||||
test("去重详情页 - 通过 API 创建测试数据后访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-detail",
|
||||
)
|
||||
test("去重详情页 - 通过 API 创建测试数据后访问", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-detail");
|
||||
|
||||
// 先上传一个文件进行查重,获取 record id
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -323,41 +347,44 @@ test.describe("去重流程", () => {
|
||||
buffer: Buffer.from("e2e duplication test data"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 如果查重 API 不可用,跳过详情页测试
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`)
|
||||
return
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadData = await uploadResp.json()
|
||||
const recordId = uploadData.id
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy()
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
// 访问详情页
|
||||
await page.goto(`/app/duplication/${recordId}`)
|
||||
await 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, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 删除记录 ──────────────────────────────────────
|
||||
|
||||
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers } = await createAuthedUser(request, "dup-delete")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -369,53 +396,59 @@ test.describe("去重流程", () => {
|
||||
buffer: Buffer.from("e2e duplication delete test"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`)
|
||||
return
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadData = await uploadResp.json()
|
||||
const recordId = uploadData.id
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
|
||||
// 验证记录存在
|
||||
const listResp = await request.get(`${apiBase}/duplication/records`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
if (listResp.ok()) {
|
||||
const records = await listResp.json()
|
||||
const records = await listResp.json();
|
||||
const recordExists = Array.isArray(records)
|
||||
? records.some((r: { id: string }) => r.id === recordId)
|
||||
: (records.items || []).some((r: { id: string }) => r.id === recordId)
|
||||
expect(recordExists, "记录应存在于列表中").toBeTruthy()
|
||||
: (records.items || []).some((r: { id: string }) => r.id === recordId);
|
||||
expect(recordExists, "记录应存在于列表中").toBeTruthy();
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
const deleteResp = await request.delete(`${apiBase}/duplication/records/${recordId}`, {
|
||||
headers,
|
||||
})
|
||||
expect(deleteResp.ok(), `删除查重记录应成功: ${deleteResp.status()}`).toBeTruthy()
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/duplication/records/${recordId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
deleteResp.ok(),
|
||||
`删除查重记录应成功: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证记录已删除
|
||||
const listAfterResp = await request.get(`${apiBase}/duplication/records`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
if (listAfterResp.ok()) {
|
||||
const recordsAfter = await listAfterResp.json()
|
||||
const recordsAfter = await listAfterResp.json();
|
||||
const recordStillExists = Array.isArray(recordsAfter)
|
||||
? recordsAfter.some((r: { id: string }) => r.id === recordId)
|
||||
: (recordsAfter.items || []).some((r: { id: string }) => r.id === recordId)
|
||||
expect(recordStillExists, "记录应已被删除").toBeFalsy()
|
||||
: (recordsAfter.items || []).some(
|
||||
(r: { id: string }) => r.id === recordId,
|
||||
);
|
||||
expect(recordStillExists, "记录应已被删除").toBeFalsy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete-ui",
|
||||
)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete-ui");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -427,66 +460,69 @@ test.describe("去重流程", () => {
|
||||
buffer: Buffer.from("e2e duplication ui delete test"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`)
|
||||
return
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录卡片应存在
|
||||
const resultCard = page.locator(".dup-result-card").first()
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false)
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 删除按钮存在
|
||||
const deleteBtn = resultCard.getByRole("button").filter({
|
||||
hasText: "🗑️",
|
||||
})
|
||||
await expect(deleteBtn).toBeVisible()
|
||||
});
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
|
||||
// 删除按钮点击 - 会触发 confirm 对话框
|
||||
// 这里我们通过监听 confirm 来确认删除
|
||||
page.once("dialog", async (dialog) => {
|
||||
expect(dialog.message()).toContain("确定删除")
|
||||
await dialog.accept()
|
||||
})
|
||||
expect(dialog.message()).toContain("确定删除");
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page
|
||||
.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") && resp.request().method() === "DELETE",
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => null)
|
||||
.catch(() => null);
|
||||
|
||||
await deleteBtn.click()
|
||||
await deleteBtn.click();
|
||||
|
||||
const deleteResp = await deletePromise
|
||||
const deleteResp = await deletePromise;
|
||||
if (deleteResp) {
|
||||
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy()
|
||||
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ─── 重试去重 ──────────────────────────────────────
|
||||
|
||||
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-retry",
|
||||
)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-retry");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -498,54 +534,59 @@ test.describe("去重流程", () => {
|
||||
buffer: Buffer.from("e2e duplication retry test"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`)
|
||||
return
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录列表中至少有一条记录
|
||||
const resultCard = page.locator(".dup-result-card").first()
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false)
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 验证记录卡片基本结构
|
||||
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible()
|
||||
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible()
|
||||
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible();
|
||||
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible();
|
||||
|
||||
// 检查是否有重试按钮(失败状态才显示)
|
||||
// 新上传的记录可能是处理中或完成状态,不一定显示重试按钮
|
||||
// 这里只验证 API 重试接口可用
|
||||
const uploadData = await uploadResp.json()
|
||||
const recordId = uploadData.id
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
|
||||
const retryResp = await request.post(`${apiBase}/duplication/records/${recordId}/retry`, {
|
||||
headers,
|
||||
})
|
||||
const retryResp = await request.post(
|
||||
`${apiBase}/duplication/records/${recordId}/retry`,
|
||||
{ headers },
|
||||
);
|
||||
// 重试接口应返回 2xx 或明确的状态码
|
||||
expect(retryResp.status()).toBeLessThan(500)
|
||||
expect(retryResp.status()).toBeLessThan(500);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/duplication")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,29 +6,36 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 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 apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return
|
||||
if (!apiOrigin) return;
|
||||
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({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -41,30 +48,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -72,7 +79,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -83,14 +90,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -104,7 +111,7 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建一个编辑模板并返回 id */
|
||||
@@ -139,74 +146,80 @@ async function createEditingTemplate(
|
||||
tags: ["e2e", "test"],
|
||||
category: "default",
|
||||
},
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
test.describe("剪辑策划页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/editing-planner")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑策划页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("剪辑策划页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-load")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-load",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner")
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
// 验证顶栏存在
|
||||
await expect(page.locator(".ep-top-bar")).toBeVisible()
|
||||
await expect(page.locator(".ep-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 }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-mode")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-mode",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-mode",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner")
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证模式按钮存在(画中画、人物口播等)
|
||||
const modeBtns = page.locator(".ep-mode-btn")
|
||||
await expect(modeBtns.first()).toBeVisible()
|
||||
const modeCount = await modeBtns.count()
|
||||
expect(modeCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
const modeBtns = page.locator(".ep-mode-btn");
|
||||
await expect(modeBtns.first()).toBeVisible();
|
||||
const modeCount = await modeBtns.count();
|
||||
expect(modeCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑计划 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("创建剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-create")
|
||||
const suffix = Date.now().toString(36)
|
||||
const templateName = `E2E 创建测试 ${suffix}`
|
||||
const { headers } = await createAuthedUser(request, "ep-create");
|
||||
const suffix = Date.now().toString(36);
|
||||
const templateName = `E2E 创建测试 ${suffix}`;
|
||||
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -225,22 +238,22 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回模板 ID").toBeTruthy()
|
||||
expect(data.name).toBe(templateName)
|
||||
expect(data.mode).toBe("pip")
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回模板 ID").toBeTruthy();
|
||||
expect(data.name).toBe(templateName);
|
||||
expect(data.mode).toBe("pip");
|
||||
});
|
||||
|
||||
test("列出剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-list")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "ep-list");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建 2 个模板
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
@@ -258,7 +271,7 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -274,81 +287,102 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const response = await request.get(`${apiBase}/templates`, { headers })
|
||||
expect(response.ok(), `列出模板应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出模板应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || data.templates || []
|
||||
expect(Array.isArray(items), "返回应为数组").toBeTruthy()
|
||||
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
const data = await response.json();
|
||||
const items = data.items || data.templates || [];
|
||||
expect(Array.isArray(items), "返回应为数组").toBeTruthy();
|
||||
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("获取剪辑计划详情 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-detail")
|
||||
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
|
||||
const { headers } = await createAuthedUser(request, "ep-detail");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
const response = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
})
|
||||
expect(response.ok(), `获取详情应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取详情应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.id).toBe(templateId)
|
||||
expect(data.name).toBeTruthy()
|
||||
expect(data.mode).toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(templateId);
|
||||
expect(data.name).toBeTruthy();
|
||||
expect(data.mode).toBeTruthy();
|
||||
});
|
||||
|
||||
test("编辑剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-update")
|
||||
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
|
||||
const { headers } = await createAuthedUser(request, "ep-update");
|
||||
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}`, {
|
||||
headers,
|
||||
data: {
|
||||
name: newName,
|
||||
description: "更新后的描述",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.name).toBe(newName)
|
||||
const data = await response.json();
|
||||
expect(data.name).toBe(newName);
|
||||
|
||||
// 验证更新后的数据
|
||||
const verify = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
})
|
||||
const verifyData = await verify.json()
|
||||
expect(verifyData.name).toBe(newName)
|
||||
})
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
expect(verifyData.name).toBe(newName);
|
||||
});
|
||||
|
||||
test("删除剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-delete")
|
||||
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
|
||||
const { headers } = await createAuthedUser(request, "ep-delete");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(`${apiBase}/templates/${templateId}`, { headers })
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/templates/${templateId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
})
|
||||
expect([404, 410]).toContain(getResp.status())
|
||||
})
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
@@ -358,17 +392,20 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
estimated_duration: 30,
|
||||
segments: [],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
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 })
|
||||
expect(response.status(), "不存在的模板应返回 404").toBe(404)
|
||||
})
|
||||
const response = await request.get(
|
||||
`${apiBase}/templates/nonexistent-template-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的模板应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("未登录创建剪辑计划 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
@@ -378,77 +415,81 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
estimated_duration: 30,
|
||||
segments: [],
|
||||
},
|
||||
})
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑策划页面 - 已模板数据加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("已创建的模板在页面中显示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"ep-data",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "ep-data");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createEditingTemplate(request, headers, suffix)
|
||||
await createEditingTemplate(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
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({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证状态栏存在
|
||||
await expect(page.locator(".ep-status-bar")).toBeVisible()
|
||||
})
|
||||
await expect(page.locator(".ep-status-bar")).toBeVisible();
|
||||
});
|
||||
|
||||
test("撤销/重做按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-undo")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-undo",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-undo",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner")
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证顶栏按钮存在(撤销、重做、保存、生成等)
|
||||
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn")
|
||||
await expect(topBarBtns.first()).toBeVisible()
|
||||
const btnCount = await topBarBtns.count()
|
||||
expect(btnCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn");
|
||||
await expect(topBarBtns.first()).toBeVisible();
|
||||
const btnCount = await topBarBtns.count();
|
||||
expect(btnCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("生成按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-gen")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-gen",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-gen",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner")
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证主操作按钮存在
|
||||
await expect(page.locator(".ep-btn-primary")).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(page.locator(".ep-btn-primary")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+277
-240
@@ -7,29 +7,33 @@
|
||||
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
|
||||
* 真实的生成流程测试见 core-generation.spec.ts。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -42,30 +46,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -73,14 +77,14 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock 产品数据 */
|
||||
function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
const products = []
|
||||
const products = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const status = statuses[i % statuses.length]
|
||||
const status = statuses[i % statuses.length];
|
||||
products.push({
|
||||
id: `mock-prod-${Date.now()}-${i}`,
|
||||
title: `测试作品 ${i + 1}`,
|
||||
@@ -89,13 +93,14 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
resolution: "1080x1920",
|
||||
file_size: (5 + i) * 1024 * 1024,
|
||||
duplicate_rate: i * 5,
|
||||
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
video_url:
|
||||
status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
thumbnail_url: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
});
|
||||
}
|
||||
return products
|
||||
return products;
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
@@ -106,14 +111,14 @@ async function setupAuthInBrowser(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -127,14 +132,17 @@ async function setupAuthInBrowser(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
const method = route.request().method()
|
||||
const url = route.request().url()
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
|
||||
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
|
||||
// 列表
|
||||
@@ -142,29 +150,31 @@ async function mockProductsApi(page: import("@playwright/test").Page, products:
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
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) {
|
||||
const productId = detailMatch[1]
|
||||
const product = (products as Array<{ id: string }>).find((p) => p.id === productId)
|
||||
const productId = detailMatch[1];
|
||||
const product = (products as Array<{ id: string }>).find(
|
||||
(p) => p.id === productId,
|
||||
);
|
||||
if (product) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(product),
|
||||
})
|
||||
});
|
||||
} else {
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Not found" }),
|
||||
})
|
||||
});
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// 删除
|
||||
@@ -173,8 +183,8 @@ async function mockProductsApi(page: import("@playwright/test").Page, products:
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "deleted" }),
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 下载链接
|
||||
@@ -186,67 +196,67 @@ async function mockProductsApi(page: import("@playwright/test").Page, products:
|
||||
url: "https://example.com/download.mp4",
|
||||
expires_at: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
route.continue()
|
||||
})
|
||||
route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("作品库页面", () => {
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 页面加载 ──────────────────────────────────────
|
||||
|
||||
test("作品库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-load",
|
||||
)
|
||||
);
|
||||
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"])
|
||||
await mockProductsApi(page, products)
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible()
|
||||
await expect(page.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({
|
||||
timeout: 10_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 作品卡片存在
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 状态展示 ──────────────────────────────────────
|
||||
|
||||
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-status",
|
||||
)
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
||||
@@ -260,311 +270,334 @@ test.describe("作品库页面", () => {
|
||||
title: "失败作品",
|
||||
id: `mock-prod-${Date.now()}-f`,
|
||||
},
|
||||
]
|
||||
await mockProductsApi(page, products)
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 等待卡片加载
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证各状态标签存在
|
||||
const completedCard = page.locator(".xx-product-card").filter({ hasText: "已完成作品" })
|
||||
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText("已完成")
|
||||
const completedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "已完成作品" });
|
||||
await expect(
|
||||
completedCard.locator(".xx-product-status.completed"),
|
||||
).toHaveText("已完成");
|
||||
|
||||
const processingCard = page.locator(".xx-product-card").filter({ hasText: "处理中作品" })
|
||||
await expect(processingCard.locator(".xx-product-status.processing")).toHaveText("处理中")
|
||||
const processingCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "处理中作品" });
|
||||
await expect(
|
||||
processingCard.locator(".xx-product-status.processing"),
|
||||
).toHaveText("处理中");
|
||||
|
||||
const failedCard = page.locator(".xx-product-card").filter({ hasText: "失败作品" })
|
||||
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText("失败")
|
||||
})
|
||||
const failedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "失败作品" });
|
||||
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText(
|
||||
"失败",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 作品详情页 ────────────────────────────────────
|
||||
|
||||
test("作品详情页打开", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-detail",
|
||||
)
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"])
|
||||
products[0].title = "详情页测试作品"
|
||||
const productId = products[0].id
|
||||
await mockProductsApi(page, products)
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "详情页测试作品";
|
||||
const productId = products[0].id;
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
// 直接访问详情页
|
||||
await page.goto(`/app/products/${productId}`)
|
||||
await page.goto(`/app/products/${productId}`);
|
||||
|
||||
// 验证 URL
|
||||
await expect(page).toHaveURL(/\/app\/products\//)
|
||||
await expect(page).toHaveURL(/\/app\/products\//);
|
||||
|
||||
// 页面应正常渲染(无错误)
|
||||
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 视频播放 ──────────────────────────────────────
|
||||
|
||||
test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-play",
|
||||
)
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"])
|
||||
products[0].title = "播放测试作品"
|
||||
products[0].video_url = "https://example.com/test-video.mp4"
|
||||
await mockProductsApi(page, products)
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "播放测试作品";
|
||||
products[0].video_url = "https://example.com/test-video.mp4";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 点击作品卡片打开播放
|
||||
const productCard = page.locator(".xx-product-card").filter({ hasText: "播放测试作品" })
|
||||
await expect(productCard).toBeVisible()
|
||||
const productCard = page
|
||||
.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_url,video 元素应能渲染)
|
||||
const videoEl = page.locator("video")
|
||||
const videoEl = page.locator("video");
|
||||
const videoVisible = await videoEl
|
||||
.first()
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false)
|
||||
.catch(() => false);
|
||||
// 或弹窗容器可见
|
||||
const modalVisible = await page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放测试作品" })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
.catch(() => false);
|
||||
|
||||
expect(videoVisible || modalVisible).toBeTruthy()
|
||||
})
|
||||
expect(videoVisible || modalVisible).toBeTruthy();
|
||||
});
|
||||
|
||||
// ─── 下载按钮 ──────────────────────────────────────
|
||||
|
||||
test("下载按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-download",
|
||||
)
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"])
|
||||
products[0].title = "下载测试作品"
|
||||
await mockProductsApi(page, products)
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "下载测试作品";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const productCard = page.locator(".xx-product-card").filter({ hasText: "下载测试作品" })
|
||||
await expect(productCard).toBeVisible()
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "下载测试作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 下载按钮存在且可用(已完成状态)
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).not.toBeDisabled()
|
||||
})
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" });
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
await expect(downloadBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("处理中作品下载按钮禁用", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-disabled",
|
||||
)
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["processing"])
|
||||
products[0].title = "处理中下载测试"
|
||||
await mockProductsApi(page, products)
|
||||
const products = mockProducts(1, ["processing"]);
|
||||
products[0].title = "处理中下载测试";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const productCard = page.locator(".xx-product-card").filter({ hasText: "处理中下载测试" })
|
||||
await expect(productCard).toBeVisible()
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "处理中下载测试" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 处理中的作品下载按钮应禁用
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
const isDisabled = await downloadBtn.isDisabled()
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" });
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
const isDisabled = await downloadBtn.isDisabled();
|
||||
const hasDisabled = await downloadBtn.evaluate(
|
||||
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
|
||||
)
|
||||
expect(isDisabled || hasDisabled).toBeTruthy()
|
||||
})
|
||||
);
|
||||
expect(isDisabled || hasDisabled).toBeTruthy();
|
||||
});
|
||||
|
||||
// ─── 删除作品 ──────────────────────────────────────
|
||||
|
||||
test("删除作品 - API 调用正确", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-delete",
|
||||
)
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"])
|
||||
products[0].title = "待删除作品"
|
||||
let deleteCalled = false
|
||||
let deletedId = ""
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "待删除作品";
|
||||
let deleteCalled = false;
|
||||
let deletedId = "";
|
||||
|
||||
await page.route("**/api/v1/products", (route) => {
|
||||
const method = route.request().method()
|
||||
const url = route.request().url()
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
|
||||
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: products, total: products.length }),
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/)
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
|
||||
if (method === "DELETE" && detailMatch) {
|
||||
deleteCalled = true
|
||||
deletedId = detailMatch[1]
|
||||
deleteCalled = true;
|
||||
deletedId = detailMatch[1];
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "deleted" }),
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && detailMatch) {
|
||||
const productId = detailMatch[1]
|
||||
const product = products.find((p) => p.id === productId)
|
||||
const productId = detailMatch[1];
|
||||
const product = products.find((p) => p.id === productId);
|
||||
route.fulfill({
|
||||
status: product ? 200 : 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(product || { detail: "Not found" }),
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
route.continue()
|
||||
})
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const productCard = page.locator(".xx-product-card").filter({ hasText: "待删除作品" })
|
||||
await expect(productCard).toBeVisible()
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "待删除作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 验证 DELETE API 存在于 products API 中
|
||||
// 我们通过检查实际 API 来确认删除功能可用
|
||||
// (mock 只是为了测试 UI 行为)
|
||||
expect(deleteCalled).toBe(false) // 初始状态未调用
|
||||
expect(deletedId).toBe("")
|
||||
})
|
||||
expect(deleteCalled).toBe(false); // 初始状态未调用
|
||||
expect(deletedId).toBe("");
|
||||
});
|
||||
|
||||
test("删除作品 API 端点存在", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "products-del-api")
|
||||
const { headers } = await createAuthedUser(request, "products-del-api");
|
||||
|
||||
// 测试删除不存在的产品,验证 API 端点存在
|
||||
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
|
||||
headers,
|
||||
})
|
||||
const resp = await request.delete(
|
||||
`${apiBase}/products/nonexistent-test-id`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
||||
// 404 表示资源不存在但端点存在
|
||||
expect(resp.status(), "删除 API 端点应存在").not.toBe(405)
|
||||
expect([200, 204, 403, 404]).toContain(resp.status())
|
||||
})
|
||||
expect(resp.status(), "删除 API 端点应存在").not.toBe(405);
|
||||
expect([200, 204, 403, 404]).toContain(resp.status());
|
||||
});
|
||||
|
||||
// ─── 空状态 ────────────────────────────────────────
|
||||
|
||||
test("空状态展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-empty",
|
||||
)
|
||||
);
|
||||
|
||||
// Mock 空列表
|
||||
await mockProductsApi(page, [])
|
||||
await mockProductsApi(page, []);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-products-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible()
|
||||
})
|
||||
});
|
||||
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 搜索筛选 ──────────────────────────────────────
|
||||
|
||||
test("作品搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-search",
|
||||
)
|
||||
);
|
||||
|
||||
const products = [
|
||||
{
|
||||
@@ -577,44 +610,44 @@ test.describe("作品库页面", () => {
|
||||
title: "香蕉推广视频",
|
||||
id: `mock-prod-${Date.now()}-banana`,
|
||||
},
|
||||
]
|
||||
await mockProductsApi(page, products)
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 两个作品都可见
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible()
|
||||
});
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
||||
|
||||
// 搜索"苹果"
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("苹果")
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible()
|
||||
await expect(page.getByText("香蕉推广视频")).toHaveCount(0)
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("苹果");
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible();
|
||||
await expect(page.getByText("香蕉推广视频")).toHaveCount(0);
|
||||
|
||||
// 清空搜索
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("")
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("");
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test("作品状态筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-filter-status",
|
||||
)
|
||||
);
|
||||
|
||||
const products = [
|
||||
{
|
||||
@@ -627,90 +660,94 @@ test.describe("作品库页面", () => {
|
||||
title: "处理中筛选",
|
||||
id: `mock-prod-${Date.now()}-proc`,
|
||||
},
|
||||
]
|
||||
await mockProductsApi(page, products)
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 两个都可见
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByText("处理中筛选")).toBeVisible()
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("处理中筛选")).toBeVisible();
|
||||
|
||||
// 状态筛选下拉存在
|
||||
const selects = page.locator(".xx-products-filters-left select")
|
||||
const count = await selects.count()
|
||||
const selects = page.locator(".xx-products-filters-left select");
|
||||
const count = await selects.count();
|
||||
if (count >= 2) {
|
||||
// 第2个 select 是状态筛选
|
||||
await selects.nth(1).selectOption({ label: "已完成" })
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible()
|
||||
await expect(page.getByText("处理中筛选")).toHaveCount(0)
|
||||
await selects.nth(1).selectOption({ label: "已完成" });
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible();
|
||||
await expect(page.getByText("处理中筛选")).toHaveCount(0);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ─── 批量操作 ──────────────────────────────────────
|
||||
|
||||
test("批量选择和批量操作栏", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-batch",
|
||||
)
|
||||
);
|
||||
|
||||
const products = mockProducts(3, ["completed"])
|
||||
products[0].title = "批量测试 1"
|
||||
products[1].title = "批量测试 2"
|
||||
products[2].title = "批量测试 3"
|
||||
await mockProductsApi(page, products)
|
||||
const products = mockProducts(3, ["completed"]);
|
||||
products[0].title = "批量测试 1";
|
||||
products[1].title = "批量测试 2";
|
||||
products[2].title = "批量测试 3";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/products")
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 三张卡片
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 点击第一张卡片的复选框
|
||||
const firstCard = page.locator(".xx-product-card").first()
|
||||
const checkbox = firstCard.locator(".xx-product-card-checkbox")
|
||||
await expect(checkbox).toBeVisible()
|
||||
await checkbox.click()
|
||||
const firstCard = page.locator(".xx-product-card").first();
|
||||
const checkbox = firstCard.locator(".xx-product-card-checkbox");
|
||||
await expect(checkbox).toBeVisible();
|
||||
await checkbox.click();
|
||||
|
||||
// 批量操作栏应出现
|
||||
const batchBar = page.locator(".xx-products-batch-bar")
|
||||
await expect(batchBar).toBeVisible({ timeout: 5_000 })
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible()
|
||||
const batchBar = page.locator(".xx-products-batch-bar");
|
||||
await expect(batchBar).toBeVisible({ timeout: 5_000 });
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
||||
|
||||
// 批量按钮存在
|
||||
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible()
|
||||
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible()
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量下载" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量删除" }),
|
||||
).toBeVisible();
|
||||
|
||||
// 取消选择
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click()
|
||||
await expect(batchBar).not.toBeVisible({ timeout: 3_000 })
|
||||
})
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
||||
await expect(batchBar).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/products")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/products");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
+185
-157
@@ -6,29 +6,36 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 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 apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return
|
||||
if (!apiOrigin) return;
|
||||
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({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -41,30 +48,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -72,7 +79,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -83,14 +90,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -104,204 +111,210 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("个人设置页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/profile")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/profile");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("设置页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-load")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-load",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test("页面标题存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-title",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-title",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证页面包含"个人设置"标题
|
||||
const heading = page.getByRole("heading", { name: /个人设置/ })
|
||||
await expect(heading.first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
const heading = page.getByRole("heading", { name: /个人设置/ });
|
||||
await expect(heading.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 个人信息展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("个人信息卡片展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-info")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-info",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-info",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证设置卡片存在
|
||||
await expect(page.locator(".xx-settings-card")).toBeVisible()
|
||||
})
|
||||
await expect(page.locator(".xx-settings-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("用户名、邮箱字段展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-fields",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-fields",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证表单字段存在
|
||||
const fields = page.locator(".xx-settings-field")
|
||||
await expect(fields.first()).toBeVisible()
|
||||
const fieldCount = await fields.count()
|
||||
expect(fieldCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
const fields = page.locator(".xx-settings-field");
|
||||
await expect(fields.first()).toBeVisible();
|
||||
const fieldCount = await fields.count();
|
||||
expect(fieldCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("用户名标签和输入框存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-username",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-username",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证用户名标签
|
||||
const usernameLabel = page.locator(".xx-settings-label").filter({
|
||||
hasText: "用户名",
|
||||
})
|
||||
await expect(usernameLabel).toBeVisible()
|
||||
});
|
||||
await expect(usernameLabel).toBeVisible();
|
||||
|
||||
// 验证邮箱标签
|
||||
const emailLabel = page.locator(".xx-settings-label").filter({
|
||||
hasText: "邮箱",
|
||||
})
|
||||
await expect(emailLabel).toBeVisible()
|
||||
})
|
||||
});
|
||||
await expect(emailLabel).toBeVisible();
|
||||
});
|
||||
|
||||
test("显示名称字段可编辑", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-dispname",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-dispname",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 查找显示名称输入框
|
||||
const displayNameField = page.locator(".xx-settings-field").filter({
|
||||
has: page.locator(".xx-settings-label", { hasText: "显示名称" }),
|
||||
})
|
||||
});
|
||||
if (await displayNameField.isVisible()) {
|
||||
const input = displayNameField.locator("input")
|
||||
const input = displayNameField.locator("input");
|
||||
if (await input.isVisible()) {
|
||||
// 验证输入框存在且可输入
|
||||
await expect(input).toBeVisible()
|
||||
const initialValue = await input.inputValue()
|
||||
await input.fill("新的显示名称")
|
||||
await expect(input).toHaveValue("新的显示名称")
|
||||
await expect(input).toBeVisible();
|
||||
const initialValue = await input.inputValue();
|
||||
await input.fill("新的显示名称");
|
||||
await expect(input).toHaveValue("新的显示名称");
|
||||
// 恢复原值
|
||||
await input.fill(initialValue)
|
||||
await input.fill(initialValue);
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 修改密码", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
data: {
|
||||
old_password: PASSWORD,
|
||||
new_password: newPassword,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 修改密码可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`修改密码应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
// 如果成功,用新密码登录验证
|
||||
if (response.ok()) {
|
||||
const loginResp = await loginWithRetry(request, email, newPassword)
|
||||
expect(loginResp.ok(), "新密码应能登录").toBeTruthy()
|
||||
const loginResp = await loginWithRetry(request, email, newPassword);
|
||||
expect(loginResp.ok(), "新密码应能登录").toBeTruthy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
@@ -309,17 +322,17 @@ test.describe("个人设置 - 修改密码", () => {
|
||||
old_password: "WrongOldPass123!",
|
||||
new_password: "NewPass123456!",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 如果接口存在,应该返回 400/401
|
||||
if (response.status() < 500 && response.status() >= 400) {
|
||||
expect([400, 401]).toContain(response.status())
|
||||
expect([400, 401]).toContain(response.status());
|
||||
}
|
||||
// 接口不存在(404)也正常
|
||||
})
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
@@ -327,12 +340,12 @@ test.describe("个人设置 - 修改密码", () => {
|
||||
old_password: PASSWORD,
|
||||
new_password: "123",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (response.status() < 500 && response.status() >= 400) {
|
||||
expect([400, 422]).toContain(response.status())
|
||||
expect([400, 422]).toContain(response.status());
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("未登录修改密码 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
@@ -340,115 +353,130 @@ test.describe("个人设置 - 修改密码", () => {
|
||||
old_password: "old",
|
||||
new_password: "new",
|
||||
},
|
||||
})
|
||||
expect([401, 403, 404]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
});
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 账号安全", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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 })
|
||||
expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
const response = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取用户信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.email).toBe(email)
|
||||
expect(data.username).toBe(username)
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
|
||||
test("账号安全区域提示信息存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-security",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-security",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证通知区域存在
|
||||
const notice = page.locator(".xx-settings-notice")
|
||||
await expect(notice).toBeVisible()
|
||||
})
|
||||
})
|
||||
const notice = page.locator(".xx-settings-notice");
|
||||
await expect(notice).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 退出登录", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
expect(response.ok(), `登出应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
`登出应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 登出后 token 应失效
|
||||
const meResp = await request.get(`${apiBase}/auth/me`, { headers })
|
||||
expect([401, 403]).toContain(meResp.status())
|
||||
})
|
||||
const meResp = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect([401, 403]).toContain(meResp.status());
|
||||
});
|
||||
|
||||
test("登出后页面跳转登录页", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-logout-ui",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-logout-ui",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 清除 localStorage 模拟登出
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("auth-storage")
|
||||
})
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("auth-storage");
|
||||
});
|
||||
|
||||
// 刷新页面应该重定向到登录页
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 保存按钮", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("保存按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-save")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-save",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-save",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/profile")
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证按钮存在
|
||||
const button = page.getByRole("button", { name: /保存|暂未开放/ })
|
||||
await expect(button.first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
const button = page.getByRole("button", { name: /保存|暂未开放/ });
|
||||
await expect(button.first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
+159
-122
@@ -4,17 +4,17 @@
|
||||
* 覆盖:页面渲染、表单验证、成功注册、跳转链接
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
|
||||
@@ -27,154 +27,181 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("注册页面", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
// ─── 页面渲染 ──────────────────────────────────────
|
||||
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
await page.goto("/register")
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill("not-an-email")
|
||||
await page.getByLabel("用户名").fill("testuser")
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill(PASSWORD)
|
||||
await page.getByLabel("邮箱").fill("not-an-email");
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示邮箱格式错误
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible()
|
||||
})
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
||||
});
|
||||
|
||||
test("密码太短 - 显示长度错误", async ({ page }) => {
|
||||
await page.goto("/register")
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"))
|
||||
await page.getByLabel("用户名").fill("testuser")
|
||||
await page.getByLabel("密码").fill("123")
|
||||
await page.getByLabel("确认密码").fill("123")
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"));
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill("123");
|
||||
await page.getByLabel("确认密码").fill("123");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示密码长度错误
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible()
|
||||
})
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
||||
});
|
||||
|
||||
test("确认密码不一致 - 显示不一致错误", async ({ page }) => {
|
||||
await page.goto("/register")
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"))
|
||||
await page.getByLabel("用户名").fill("testuser")
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill("Different123!")
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"));
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill("Different123!");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示密码不一致错误
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible()
|
||||
})
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
||||
});
|
||||
|
||||
test("用户名为空 - 显示必填错误", async ({ page }) => {
|
||||
await page.goto("/register")
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"))
|
||||
await page.getByLabel("用户名").fill("")
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill(PASSWORD)
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"));
|
||||
await page.getByLabel("用户名").fill("");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible()
|
||||
})
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 成功注册 ──────────────────────────────────────
|
||||
|
||||
test("成功注册 - 提交有效表单", async ({ page, _request }) => {
|
||||
const email = uniqueEmail("reg-ui-ok")
|
||||
const username = uniqueUsername("reguiok")
|
||||
test("成功注册 - 提交有效表单", async ({ page, request }) => {
|
||||
const email = uniqueEmail("reg-ui-ok");
|
||||
const username = uniqueUsername("reguiok");
|
||||
|
||||
await page.goto("/register")
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(email)
|
||||
await page.getByLabel("用户名").fill(username)
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill(PASSWORD)
|
||||
await page.getByLabel("邮箱").fill(email);
|
||||
await page.getByLabel("用户名").fill(username);
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
// 监听注册请求
|
||||
const registerResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/auth/register") && resp.request().method() === "POST",
|
||||
(resp) =>
|
||||
resp.url().includes("/auth/register") &&
|
||||
resp.request().method() === "POST",
|
||||
{ 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
|
||||
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy()
|
||||
const resp = await registerResponse;
|
||||
expect(
|
||||
resp.ok(),
|
||||
`注册请求应返回 2xx,实际: ${resp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 注册成功后应跳转到登录页或显示成功消息
|
||||
// 页面应停留在可识别的状态(成功提示或跳转)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const url = page.url()
|
||||
const url = page.url();
|
||||
// 可能跳转到 login,也可能在当前页显示成功消息
|
||||
if (url.includes("/login")) return "redirected"
|
||||
const hasSuccess = await page.getByText(/注册成功/).isVisible()
|
||||
return hasSuccess ? "success_msg" : url
|
||||
if (url.includes("/login")) return "redirected";
|
||||
const hasSuccess = await page.getByText(/注册成功/).isVisible();
|
||||
return hasSuccess ? "success_msg" : url;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toMatch(/redirected|success_msg/)
|
||||
})
|
||||
.toMatch(/redirected|success_msg/);
|
||||
});
|
||||
|
||||
test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => {
|
||||
const email = uniqueEmail("reg-ui-dup")
|
||||
const username1 = uniqueUsername("reguidup1")
|
||||
const username2 = uniqueUsername("reguidup2")
|
||||
const email = uniqueEmail("reg-ui-dup");
|
||||
const username1 = uniqueUsername("reguidup1");
|
||||
const username2 = uniqueUsername("reguidup2");
|
||||
|
||||
// 先通过 API 注册一个账号
|
||||
const firstReg = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -184,66 +211,74 @@ test.describe("注册页面", () => {
|
||||
username: username1,
|
||||
display_name: "User 1",
|
||||
},
|
||||
})
|
||||
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy()
|
||||
});
|
||||
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy();
|
||||
|
||||
// 再在 UI 上用相同邮箱注册
|
||||
await page.goto("/register")
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(email)
|
||||
await page.getByLabel("用户名").fill(username2)
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill(PASSWORD)
|
||||
await page.getByLabel("邮箱").fill(email);
|
||||
await page.getByLabel("用户名").fill(username2);
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示错误提示(通过 antd message 或表单错误)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
// 检查是否有错误消息
|
||||
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible()
|
||||
return hasError ? "error_shown" : "waiting"
|
||||
const hasError = await page
|
||||
.getByText(/注册失败|已注册|已存在|exists/)
|
||||
.isVisible();
|
||||
return hasError ? "error_shown" : "waiting";
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe("error_shown")
|
||||
})
|
||||
.toBe("error_shown");
|
||||
});
|
||||
|
||||
// ─── 跳转链接 ──────────────────────────────────────
|
||||
|
||||
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.getByLabel("邮箱")).toBeVisible()
|
||||
})
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
const email = uniqueEmail("reg-auth")
|
||||
const username = uniqueUsername("regauth")
|
||||
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const email = uniqueEmail("reg-auth");
|
||||
const username = uniqueUsername("regauth");
|
||||
|
||||
// 注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -253,24 +288,24 @@ test.describe("注册页面", () => {
|
||||
username,
|
||||
display_name: "Reg Auth Test",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 登录
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), "登录应成功").toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), "登录应成功").toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
// 设置登录态
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -284,13 +319,15 @@ test.describe("注册页面", () => {
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
await page.goto("/register")
|
||||
await page.goto("/register");
|
||||
|
||||
// 注册页对已登录用户也可访问(注册页是公开页面)
|
||||
// 验证页面正常渲染
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible()
|
||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(
|
||||
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,29 +9,36 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 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 apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return
|
||||
if (!apiOrigin) return;
|
||||
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({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -44,30 +51,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -75,7 +82,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -86,14 +93,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -107,129 +114,138 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("订阅套餐页 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("订阅套餐页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-load")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-load",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription")
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test("套餐卡片网格展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cards")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-cards",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-cards",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription")
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证套餐卡片存在
|
||||
const planCards = page.locator(".xx-plan-card")
|
||||
await expect(planCards.first()).toBeVisible({ timeout: 10_000 })
|
||||
const cardCount = await planCards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
const planCards = page.locator(".xx-plan-card");
|
||||
await expect(planCards.first()).toBeVisible({ timeout: 10_000 });
|
||||
const cardCount = await planCards.count();
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cardinfo")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-cardinfo",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-cardinfo",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription")
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-plan-card").first()
|
||||
await expect(firstCard).toBeVisible({ timeout: 10_000 })
|
||||
const firstCard = page.locator(".xx-plan-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 验证价格区域存在
|
||||
await expect(firstCard.locator(".xx-plan-price")).toBeVisible()
|
||||
await expect(firstCard.locator(".xx-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 }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-recommended",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-recommended",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription")
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
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 })) {
|
||||
await expect(featuredCard.locator(".xx-badge")).toBeVisible()
|
||||
await expect(featuredCard.locator(".xx-badge")).toBeVisible();
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅套餐页 - 升级交互", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("点击升级套餐按钮跳转升级页", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-upgrade-btn",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-upgrade-btn",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription")
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
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 })) {
|
||||
await subscribeBtn.click()
|
||||
await subscribeBtn.click();
|
||||
// 可能跳转到升级页或打开支付弹窗
|
||||
const url = page.url()
|
||||
const url = page.url();
|
||||
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
||||
expect(
|
||||
url.includes("/subscription/upgrade") ||
|
||||
@@ -239,180 +255,201 @@ test.describe("订阅套餐页 - 升级交互", () => {
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)),
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("升级套餐升级页面可访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-upgrade-page",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-upgrade-page",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/upgrade")
|
||||
await 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.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("账单页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-billing-load",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-billing-load",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing")
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test("账单概览区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-billing-overview",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-billing-overview",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing")
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证概览区域存在
|
||||
const overview = page.locator(".xx-billing-overview")
|
||||
const overview = page.locator(".xx-billing-overview");
|
||||
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 }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-autorenew-ui",
|
||||
)
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-autorenew-ui",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing")
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
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 })) {
|
||||
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 }) => {
|
||||
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()
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy()
|
||||
})
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 自动续费切换", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
})
|
||||
expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy()
|
||||
const disableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
disableResp.ok(),
|
||||
`关闭自动续费应成功: ${await disableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 重新开启自动续费
|
||||
const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
})
|
||||
expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy()
|
||||
})
|
||||
const enableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
enableResp.ok(),
|
||||
`开启自动续费应成功: ${await enableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("切换自动续费 - 无效参数反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-bad")
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-bad");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: {},
|
||||
})
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 取消订阅", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
// 免费用户取消订阅可能返回错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json()
|
||||
expect(data.error?.message || data.detail || data.message).toBeTruthy()
|
||||
const data = await response.json();
|
||||
expect(data.error?.message || data.detail || data.message).toBeTruthy();
|
||||
}
|
||||
// 如果成功了也没问题(某些实现可能允许)
|
||||
expect(response.status() < 500).toBeTruthy()
|
||||
})
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录取消订阅 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 套餐变更", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
@@ -420,16 +457,19 @@ test.describe("订阅 - 套餐变更", () => {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.ok(), `升级套餐应成功: ${await response.text()}`).toBeTruthy()
|
||||
expect(
|
||||
response.ok(),
|
||||
`升级套餐应成功: ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data).toBeTruthy();
|
||||
});
|
||||
|
||||
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`, {
|
||||
@@ -438,22 +478,25 @@ test.describe("订阅 - 套餐变更", () => {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 获取当前订阅
|
||||
const response = await request.get(`${apiBase}/subscription/current`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.ok(), `获取订阅信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取订阅信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy()
|
||||
expect(data.status, "应返回 status").toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
|
||||
expect(data.status, "应返回 status").toBeTruthy();
|
||||
});
|
||||
|
||||
test("降级到 Standard 套餐 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade-api")
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade-api");
|
||||
|
||||
// 先升级到 Pro
|
||||
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
@@ -462,26 +505,29 @@ test.describe("订阅 - 套餐变更", () => {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
})
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy()
|
||||
});
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy();
|
||||
|
||||
// 降级到 Standard
|
||||
const downgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
const downgrade = await request.post(
|
||||
`${apiBase}/subscription/change-plan`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
downgrade.status() < 500,
|
||||
`降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`,
|
||||
).toBeTruthy()
|
||||
})
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
@@ -489,76 +535,82 @@ test.describe("订阅 - 套餐变更", () => {
|
||||
target_plan_id: "nonexistent_plan",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400)
|
||||
expect(response.status()).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 支付流程", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// 创建支付订单可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`创建订单应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
// 应返回订单 ID 或支付链接
|
||||
expect(data.order_id || data.payment_url || data).toBeTruthy()
|
||||
expect(data.order_id || data.payment_url || data).toBeTruthy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("未登录创建订单 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect([401, 403, 404]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
);
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 套餐列表 API", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
// 套餐列表可能需要登录也可能公开
|
||||
if (response.ok()) {
|
||||
const data = await response.json()
|
||||
const plans = Array.isArray(data) ? data : data.plans || data.items
|
||||
const data = await response.json();
|
||||
const plans = Array.isArray(data) ? data : data.plans || data.items;
|
||||
if (Array.isArray(plans)) {
|
||||
expect(plans.length).toBeGreaterThanOrEqual(2)
|
||||
expect(plans.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
// 如果需要登录也正常
|
||||
expect(response.status() < 500).toBeTruthy()
|
||||
})
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+133
-101
@@ -3,17 +3,17 @@
|
||||
*
|
||||
* 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -26,74 +26,74 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
await page.goto("/app/subscription")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅信息查看", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy()
|
||||
expect(data.status, "应返回 status").toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
|
||||
expect(data.status, "应返回 status").toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录获取订阅信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/subscription/current`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
const response = await request.get(`${apiBase}/subscription/current`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅降级", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test("Pro 用户降级到 Standard - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade")
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade");
|
||||
|
||||
// 先升级到 Pro
|
||||
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
@@ -102,28 +102,34 @@ test.describe("订阅降级", () => {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
})
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功: ${await upgrade.text()}`).toBeTruthy()
|
||||
});
|
||||
expect(
|
||||
upgrade.ok(),
|
||||
`升级到 Pro 应成功: ${await upgrade.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 降级到 Standard
|
||||
const downgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
const downgrade = await request.post(
|
||||
`${apiBase}/subscription/change-plan`,
|
||||
{
|
||||
headers,
|
||||
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 }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-same")
|
||||
const { headers } = await createAuthedUser(request, "sub-same");
|
||||
|
||||
// 用户默认为 free,再次选择 free
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
@@ -132,19 +138,19 @@ test.describe("订阅降级", () => {
|
||||
target_plan_id: "free",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 相同套餐应返回 200 + success=false,或者 400
|
||||
if (response.ok()) {
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(false)
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
} else {
|
||||
expect([400, 422]).toContain(response.status())
|
||||
expect([400, 422]).toContain(response.status());
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
@@ -152,81 +158,107 @@ test.describe("订阅降级", () => {
|
||||
target_plan_id: "nonexistent_plan",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400)
|
||||
expect(response.status()).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅过期处理", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
// 免费用户可能不需要取消,返回 400 或类似错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json()
|
||||
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy()
|
||||
const data = await response.json();
|
||||
expect(
|
||||
data.error?.message || data.detail || data.message,
|
||||
"应返回错误信息",
|
||||
).toBeTruthy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("未登录取消订阅 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
})
|
||||
expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy()
|
||||
const disableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
disableResp.ok(),
|
||||
`关闭自动续费应成功: ${await disableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 重新开启自动续费
|
||||
const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
})
|
||||
expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy()
|
||||
})
|
||||
const enableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
enableResp.ok(),
|
||||
`开启自动续费应成功: ${await enableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("无效参数切换自动续费 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-autoren-bad")
|
||||
const { headers } = await createAuthedUser(request, "sub-autoren-bad");
|
||||
|
||||
// 缺少 enabled 字段
|
||||
const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: {},
|
||||
})
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("账单记录", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
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()
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录获取账单记录 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/subscription/billing-records`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
const response = await request.get(
|
||||
`${apiBase}/subscription/billing-records`,
|
||||
);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
+230
-206
@@ -6,29 +6,36 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 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 apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return
|
||||
if (!apiOrigin) return;
|
||||
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({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -41,30 +48,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -72,7 +79,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -83,14 +90,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -104,88 +111,95 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("模板库页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/templates")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/templates");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("模板库页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-load")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-load",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/templates")
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test("模板库头部和搜索栏存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-head")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-head",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-head",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/templates")
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证搜索框
|
||||
const searchInput = page.locator(".xx-templates-search-input")
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
const searchInput = page.locator(".xx-templates-search-input");
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("分类切换按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-cat")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-cat",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-cat",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/templates")
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证分类按钮存在
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn")
|
||||
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 })
|
||||
const count = await categoryBtns.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||||
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 });
|
||||
const count = await categoryBtns.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 模板展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("模板卡片展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-cards",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-cards");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个模板
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
@@ -206,34 +220,32 @@ test.describe("模板库 - 模板展示", () => {
|
||||
tags: ["e2e", "展示"],
|
||||
category: "种草",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-cards",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/templates")
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 等待模板卡片出现
|
||||
const cards = page.locator(".xx-template-card")
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 })
|
||||
const count = await cards.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
const cards = page.locator(".xx-template-card");
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("模板卡片包含名称和类型", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-info",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-info");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -251,35 +263,33 @@ test.describe("模板库 - 模板展示", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-info",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/templates")
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
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 })) {
|
||||
// 验证信息区域存在
|
||||
const info = firstCard.locator(".xx-template-info")
|
||||
await expect(info).toBeVisible()
|
||||
const info = firstCard.locator(".xx-template-info");
|
||||
await expect(info).toBeVisible();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("模板预览弹窗功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-preview",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-preview");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -298,75 +308,78 @@ test.describe("模板库 - 模板展示", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-preview",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/templates")
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
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 })) {
|
||||
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 })) {
|
||||
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.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("切换分类筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-switch")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-switch",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-switch",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/templates")
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn")
|
||||
const firstBtn = categoryBtns.first()
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||||
const firstBtn = categoryBtns.first();
|
||||
|
||||
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.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框可输入并筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-search",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
const templateName = `E2E 搜索测试模板 ${suffix}`
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-search");
|
||||
const suffix = Date.now().toString(36);
|
||||
const templateName = `E2E 搜索测试模板 ${suffix}`;
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -384,35 +397,35 @@ test.describe("模板库 - 搜索", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-search",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/templates")
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
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 })) {
|
||||
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.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取模板列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-list")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-list");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -429,20 +442,23 @@ test.describe("模板库 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const response = await request.get(`${apiBase}/templates`, { headers })
|
||||
expect(response.ok(), `获取模板列表应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取模板列表应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || data.templates || []
|
||||
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy()
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
})
|
||||
const data = await response.json();
|
||||
const items = data.items || data.templates || [];
|
||||
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy();
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("收藏/取消收藏模板 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-fav")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "tpl-fav");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建模板
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
@@ -460,26 +476,33 @@ test.describe("模板库 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(createResp.ok()).toBeTruthy()
|
||||
const created = await createResp.json()
|
||||
const templateId = created.id
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy()
|
||||
})
|
||||
const unfavResp = await request.delete(
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
unfavResp.status() < 500,
|
||||
"取消收藏请求应返回 2xx 或 4xx",
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("获取模板详情 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-detail")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-detail");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -498,22 +521,22 @@ test.describe("模板库 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(createResp.ok()).toBeTruthy()
|
||||
const created = await createResp.json()
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
|
||||
headers,
|
||||
})
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy()
|
||||
const detail = await detailResp.json()
|
||||
expect(detail.id).toBe(created.id)
|
||||
expect(detail.name).toBe(`详情测试 ${suffix}`)
|
||||
})
|
||||
});
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(created.id);
|
||||
expect(detail.name).toBe(`详情测试 ${suffix}`);
|
||||
});
|
||||
|
||||
test("使用模板接口 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-use")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "tpl-use");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -530,51 +553,52 @@ test.describe("模板库 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(createResp.ok()).toBeTruthy()
|
||||
const created = await createResp.json()
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
// 使用模板(生成)
|
||||
const genResp = await request.post(`${apiBase}/templates/${created.id}/generate`, {
|
||||
headers,
|
||||
data: {},
|
||||
})
|
||||
const genResp = await request.post(
|
||||
`${apiBase}/templates/${created.id}/generate`,
|
||||
{ headers, data: {} },
|
||||
);
|
||||
// 生成可能成功或返回业务错误
|
||||
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy()
|
||||
})
|
||||
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录获取模板列表 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/templates`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
const response = await request.get(`${apiBase}/templates`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 我的模板 Tab", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("我的模板页面可访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-my")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-my",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-my",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/my-templates")
|
||||
await page.goto("/app/my-templates");
|
||||
await expect(page.locator(".mt-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test("我的模板页面展示已创建的模板", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-my-data",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-my-data");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -592,22 +616,22 @@ test.describe("模板库 - 我的模板 Tab", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
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({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证卡片容器存在
|
||||
const cards = page.locator(".mt-card")
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
const cards = page.locator(".mt-card");
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
+106
-86
@@ -4,17 +4,17 @@
|
||||
* 覆盖:创建素材库、列出素材库、创建素材记录
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -27,37 +27,37 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建一个项目并返回 project id */
|
||||
@@ -69,19 +69,23 @@ async function createProject(
|
||||
const resp = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset 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;
|
||||
}
|
||||
|
||||
test.describe("素材库流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("创建素材库", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-create")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const { headers } = await createAuthedUser(request, "lib-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
@@ -90,23 +94,27 @@ test.describe("素材库流程", () => {
|
||||
name: `视频素材库 ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回素材库 ID").toBeTruthy()
|
||||
expect(data.name).toContain("视频素材库")
|
||||
expect(data.kind).toBe("video")
|
||||
expect(data.project_id).toBe(projectId)
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回素材库 ID").toBeTruthy();
|
||||
expect(data.name).toContain("视频素材库");
|
||||
expect(data.kind).toBe("video");
|
||||
expect(data.project_id).toBe(projectId);
|
||||
});
|
||||
|
||||
test("创建素材库 - 无效 kind 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-badkind")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const { headers } = await createAuthedUser(request, "lib-badkind");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
@@ -115,14 +123,14 @@ test.describe("素材库流程", () => {
|
||||
name: "Bad Kind Library",
|
||||
kind: "invalid_kind",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// kind 有 pattern 校验 ^(video|voice|image)$,应返回 422
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
@@ -131,14 +139,18 @@ test.describe("素材库流程", () => {
|
||||
name: "Orphan Library",
|
||||
kind: "video",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404)
|
||||
})
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("列出素材库", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-list")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const { headers } = await createAuthedUser(request, "lib-list");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
// 创建 2 个不同类型的素材库
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
@@ -148,7 +160,7 @@ test.describe("素材库流程", () => {
|
||||
name: `Video Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
})
|
||||
});
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -156,31 +168,35 @@ test.describe("素材库流程", () => {
|
||||
name: `Image Lib ${Date.now()}`,
|
||||
kind: "image",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 列出(按 project_id 过滤)
|
||||
const response = await request.get(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
params: { project_id: projectId },
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || []
|
||||
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2)
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2);
|
||||
|
||||
const kinds = items.map((i: { kind: string }) => i.kind)
|
||||
expect(kinds).toContain("video")
|
||||
expect(kinds).toContain("image")
|
||||
})
|
||||
const kinds = items.map((i: { kind: string }) => i.kind);
|
||||
expect(kinds).toContain("video");
|
||||
expect(kinds).toContain("image");
|
||||
});
|
||||
|
||||
test("创建素材记录", async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-create")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
// 创建素材库
|
||||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||||
@@ -190,9 +206,9 @@ test.describe("素材库流程", () => {
|
||||
name: `Asset Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
})
|
||||
expect(lib.ok()).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
});
|
||||
expect(lib.ok()).toBeTruthy();
|
||||
const libData = await lib.json();
|
||||
|
||||
// 创建素材记录
|
||||
const response = await request.post(`${apiBase}/assets`, {
|
||||
@@ -208,23 +224,27 @@ test.describe("素材库流程", () => {
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回素材 ID").toBeTruthy()
|
||||
expect(data.name).toContain("test_video")
|
||||
expect(data.mime_type).toBe("video/mp4")
|
||||
expect(data.library_id).toBe(libData.id)
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回素材 ID").toBeTruthy();
|
||||
expect(data.name).toContain("test_video");
|
||||
expect(data.mime_type).toBe("video/mp4");
|
||||
expect(data.library_id).toBe(libData.id);
|
||||
});
|
||||
|
||||
test("列出素材", async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-list")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-list");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
|
||||
// 创建素材库
|
||||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||||
@@ -234,9 +254,9 @@ test.describe("素材库流程", () => {
|
||||
name: `List Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
})
|
||||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
});
|
||||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy();
|
||||
const libData = await lib.json();
|
||||
|
||||
// 创建 2 个素材
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
@@ -250,7 +270,7 @@ test.describe("素材库流程", () => {
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
})
|
||||
});
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -262,23 +282,23 @@ test.describe("素材库流程", () => {
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 列出素材
|
||||
const response = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libData.id },
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || []
|
||||
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("未登录创建素材库 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
@@ -287,8 +307,8 @@ test.describe("素材库流程", () => {
|
||||
name: "Unauthorized Library",
|
||||
kind: "video",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
+127
-113
@@ -4,29 +4,29 @@
|
||||
* 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 从错误响应中提取错误消息文本,兼容新老格式 */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractErrorMessage(body: any): string {
|
||||
if (!body) return ""
|
||||
if (!body) return "";
|
||||
// 新格式: { error: { code: "...", message: "..." } }
|
||||
if (body.error && typeof body.error === "object" && body.error.message) {
|
||||
return String(body.error.message)
|
||||
return String(body.error.message);
|
||||
}
|
||||
// 老格式: { detail: "..." } 或 { message: "..." } 或 { error: "..." }
|
||||
return String(body.detail || body.message || body.error || "")
|
||||
return String(body.detail || body.message || body.error || "");
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
|
||||
@@ -39,27 +39,27 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
// 被限流了,等窗口重置
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
// 最后一次直接返回
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("认证流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 注册 ────────────────────────────────────────────
|
||||
|
||||
test("注册新用户 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("reg-ok")
|
||||
const username = uniqueUsername("regok")
|
||||
const email = uniqueEmail("reg-ok");
|
||||
const username = uniqueUsername("regok");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
@@ -68,23 +68,23 @@ test.describe("认证流程", () => {
|
||||
username,
|
||||
display_name: "E2E 注册测试",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`注册应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.user_id, "应返回 user_id").toBeTruthy()
|
||||
expect(data.email).toBe(email)
|
||||
expect(data.username).toBe(username)
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.user_id, "应返回 user_id").toBeTruthy();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
|
||||
test("注册已存在邮箱 - 反向", async ({ request }) => {
|
||||
const email = uniqueEmail("reg-dup")
|
||||
const username1 = uniqueUsername("regdup1")
|
||||
const username2 = uniqueUsername("regdup2")
|
||||
const email = uniqueEmail("reg-dup");
|
||||
const username1 = uniqueUsername("regdup1");
|
||||
const username2 = uniqueUsername("regdup2");
|
||||
|
||||
// 第一次注册
|
||||
const first = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -94,8 +94,8 @@ test.describe("认证流程", () => {
|
||||
username: username1,
|
||||
display_name: "User 1",
|
||||
},
|
||||
})
|
||||
expect(first.ok(), "第一次注册应成功").toBeTruthy()
|
||||
});
|
||||
expect(first.ok(), "第一次注册应成功").toBeTruthy();
|
||||
|
||||
// 第二次使用相同邮箱
|
||||
const second = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -105,22 +105,24 @@ test.describe("认证流程", () => {
|
||||
username: username2,
|
||||
display_name: "User 2",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(400)
|
||||
expect(second.status()).toBeLessThan(500)
|
||||
expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(
|
||||
400,
|
||||
);
|
||||
expect(second.status()).toBeLessThan(500);
|
||||
|
||||
const body = await second.json()
|
||||
const body = await second.json();
|
||||
// 错误信息应包含"已注册"或"exists"相关提示
|
||||
const detail = extractErrorMessage(body).toLowerCase()
|
||||
const detail = extractErrorMessage(body).toLowerCase();
|
||||
expect(
|
||||
detail.includes("已") ||
|
||||
detail.includes("exist") ||
|
||||
detail.includes("registered") ||
|
||||
detail.includes("duplicate"),
|
||||
`错误信息应提示邮箱已注册,实际: "${detail}"`,
|
||||
).toBeTruthy()
|
||||
})
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("注册无效邮箱格式 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -130,11 +132,11 @@ test.describe("认证流程", () => {
|
||||
username: uniqueUsername("bademail"),
|
||||
display_name: "Bad Email",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 422 是 FastAPI 参数校验失败的标准状态码
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("注册弱密码 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -144,10 +146,10 @@ test.describe("认证流程", () => {
|
||||
username: uniqueUsername("weakpwd"),
|
||||
display_name: "Weak",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("注册用户名为空 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -157,10 +159,12 @@ test.describe("认证流程", () => {
|
||||
username: "",
|
||||
display_name: "Empty Username",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(response.status())
|
||||
})
|
||||
expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(
|
||||
response.status(),
|
||||
);
|
||||
});
|
||||
|
||||
test("注册缺用户名字段 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -169,72 +173,76 @@ test.describe("认证流程", () => {
|
||||
password: PASSWORD,
|
||||
display_name: "No Username Field",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(response.status())
|
||||
})
|
||||
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
|
||||
response.status(),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 登录 ────────────────────────────────────────────
|
||||
|
||||
test("登录成功 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("login-ok")
|
||||
const username = uniqueUsername("loginok")
|
||||
const email = uniqueEmail("login-ok");
|
||||
const username = uniqueUsername("loginok");
|
||||
|
||||
// 先注册
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
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(
|
||||
response.ok(),
|
||||
`登录应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.access_token, "应返回 access_token").toBeTruthy()
|
||||
expect(data.token_type).toBe("bearer")
|
||||
expect(data.email).toBe(email)
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.access_token, "应返回 access_token").toBeTruthy();
|
||||
expect(data.token_type).toBe("bearer");
|
||||
expect(data.email).toBe(email);
|
||||
});
|
||||
|
||||
test("登录错误密码 - 反向", async ({ request }) => {
|
||||
const email = uniqueEmail("login-bad")
|
||||
const username = uniqueUsername("loginbad")
|
||||
const email = uniqueEmail("login-bad");
|
||||
const username = uniqueUsername("loginbad");
|
||||
|
||||
// 先注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
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 }) => {
|
||||
// 带限流重试的反向登录测试
|
||||
let response
|
||||
let response;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
})
|
||||
if (response.status() !== 429) break
|
||||
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`)
|
||||
await new Promise((r) => setTimeout(r, 65_000))
|
||||
});
|
||||
if (response.status() !== 429) break;
|
||||
console.log(
|
||||
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 65_000));
|
||||
}
|
||||
|
||||
expect(response.status(), "不存在的用户应返回 401").toBe(401)
|
||||
})
|
||||
expect(response.status(), "不存在的用户应返回 401").toBe(401);
|
||||
});
|
||||
|
||||
// ─── 登出 ────────────────────────────────────────────
|
||||
|
||||
test("登出成功", async ({ request }) => {
|
||||
const email = uniqueEmail("logout")
|
||||
const username = uniqueUsername("logout")
|
||||
const email = uniqueEmail("logout");
|
||||
const username = uniqueUsername("logout");
|
||||
|
||||
// 注册 & 登录
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -244,87 +252,93 @@ test.describe("认证流程", () => {
|
||||
username,
|
||||
display_name: "Logout Test",
|
||||
},
|
||||
})
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
const { access_token } = await login.json()
|
||||
const headers = { Authorization: `Bearer ${access_token}` }
|
||||
});
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
const { access_token } = await login.json();
|
||||
const headers = { Authorization: `Bearer ${access_token}` };
|
||||
|
||||
// 登出
|
||||
const logout = await request.post(`${apiBase}/auth/logout`, { headers })
|
||||
expect(logout.ok(), `登出应返回 2xx,实际: ${logout.status()}`).toBeTruthy()
|
||||
const logout = await request.post(`${apiBase}/auth/logout`, { headers });
|
||||
expect(
|
||||
logout.ok(),
|
||||
`登出应返回 2xx,实际: ${logout.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const body = await logout.json()
|
||||
expect(body.message).toBeTruthy()
|
||||
const body = await logout.json();
|
||||
expect(body.message).toBeTruthy();
|
||||
|
||||
// 登出后 token 应失效,尝试访问 /auth/me
|
||||
const me = await request.get(`${apiBase}/auth/me`, { headers })
|
||||
expect([401, 403]).toContain(me.status())
|
||||
})
|
||||
const me = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect([401, 403]).toContain(me.status());
|
||||
});
|
||||
|
||||
// ─── 获取当前用户信息 ─────────────────────────────────
|
||||
|
||||
test("获取当前用户信息 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("me-ok")
|
||||
const username = uniqueUsername("meok")
|
||||
const email = uniqueEmail("me-ok");
|
||||
const username = uniqueUsername("meok");
|
||||
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: "Me Test" },
|
||||
})
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
const { access_token } = await login.json()
|
||||
});
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
const { access_token } = await login.json();
|
||||
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${access_token}` },
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取用户信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.user_id).toBeTruthy()
|
||||
expect(data.email).toBe(email)
|
||||
expect(data.username).toBe(username)
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.user_id).toBeTruthy();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
|
||||
test("无 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`)
|
||||
const response = await request.get(`${apiBase}/auth/me`);
|
||||
// HTTPBearer 无凭证返回 403
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("无效 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: "Bearer invalid.token.here" },
|
||||
})
|
||||
expect(response.status()).toBe(401)
|
||||
})
|
||||
});
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test("过期 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
// 使用一个伪造的过期 JWT(header.payload.signature)
|
||||
// eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"}
|
||||
// eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期)
|
||||
const expiredToken =
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature"
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature";
|
||||
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${expiredToken}` },
|
||||
})
|
||||
});
|
||||
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("token 格式错误 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: "Bearer not-a-jwt" },
|
||||
})
|
||||
});
|
||||
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("空 Bearer token - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: "Bearer " },
|
||||
})
|
||||
});
|
||||
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
* 覆盖:创建项目、列出项目、获取项目详情
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -27,46 +27,46 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("项目流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("创建项目", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-create")
|
||||
const projectName = `E2E 测试项目 ${Date.now()}`
|
||||
const { headers } = await createAuthedUser(request, "proj-create");
|
||||
const projectName = `E2E 测试项目 ${Date.now()}`;
|
||||
|
||||
const response = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
@@ -74,146 +74,156 @@ test.describe("项目流程", () => {
|
||||
name: projectName,
|
||||
description: "Playwright E2E 回归测试创建",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回项目 ID").toBeTruthy()
|
||||
expect(data.name).toBe(projectName)
|
||||
expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回项目 ID").toBeTruthy();
|
||||
expect(data.name).toBe(projectName);
|
||||
expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy();
|
||||
});
|
||||
|
||||
test("创建项目名称为空 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-empty")
|
||||
const { headers } = await createAuthedUser(request, "proj-empty");
|
||||
|
||||
const response = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: "", description: "Should fail" },
|
||||
})
|
||||
});
|
||||
|
||||
// name 有 min_length=1 约束,应返回 422
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("列出项目", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-list")
|
||||
const { headers } = await createAuthedUser(request, "proj-list");
|
||||
|
||||
// 先创建 2 个项目
|
||||
await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `List Proj A ${Date.now()}` },
|
||||
})
|
||||
});
|
||||
await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `List Proj B ${Date.now()}` },
|
||||
})
|
||||
});
|
||||
|
||||
// 列出
|
||||
const response = await request.get(`${apiBase}/projects`, { headers })
|
||||
const response = await request.get(`${apiBase}/projects`, { headers });
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || data.projects || data || []
|
||||
expect(Array.isArray(items)).toBeTruthy()
|
||||
expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
const data = await response.json();
|
||||
const items = data.items || data.projects || data || [];
|
||||
expect(Array.isArray(items)).toBeTruthy();
|
||||
expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("获取项目详情", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-detail")
|
||||
const { headers } = await createAuthedUser(request, "proj-detail");
|
||||
|
||||
// 先创建
|
||||
const created = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" },
|
||||
})
|
||||
expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy()
|
||||
const { id: projectId } = await created.json()
|
||||
});
|
||||
expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
|
||||
// 获取详情
|
||||
const response = await request.get(`${apiBase}/projects/${projectId}`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.id).toBe(projectId)
|
||||
expect(data.name).toBeTruthy()
|
||||
expect(data.owner_user_id).toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(projectId);
|
||||
expect(data.name).toBeTruthy();
|
||||
expect(data.owner_user_id).toBeTruthy();
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
const response = await request.get(
|
||||
`${apiBase}/projects/nonexistent-project-id-999`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404)
|
||||
})
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
// 用户 A 创建项目
|
||||
const { headers: headersA } = await createAuthedUser(request, "proj-owner")
|
||||
const { headers: headersA } = await createAuthedUser(request, "proj-owner");
|
||||
const created = await request.post(`${apiBase}/projects`, {
|
||||
headers: headersA,
|
||||
data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" },
|
||||
})
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy()
|
||||
const { id: projectId } = await created.json()
|
||||
});
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
|
||||
// 用户 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}`, {
|
||||
headers: headersB,
|
||||
})
|
||||
});
|
||||
|
||||
// 应返回 403 (Forbidden) 或 404 (Not Found) — 不应泄露资源存在性
|
||||
expect([403, 404]).toContain(response.status())
|
||||
})
|
||||
expect([403, 404]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("未授权删除他人项目 - 反向", async ({ request }) => {
|
||||
// 用户 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`, {
|
||||
headers: headersA,
|
||||
data: { name: `Delete Test Proj ${Date.now()}` },
|
||||
})
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy()
|
||||
const { id: projectId } = await created.json()
|
||||
});
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
|
||||
// 用户 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}`, {
|
||||
headers: headersB,
|
||||
})
|
||||
});
|
||||
|
||||
expect([403, 404]).toContain(response.status())
|
||||
})
|
||||
expect([403, 404]).toContain(response.status());
|
||||
});
|
||||
|
||||
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
|
||||
expect([400, 404, 422]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
expect([400, 404, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
+216
-191
@@ -9,29 +9,36 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 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 apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return
|
||||
if (!apiOrigin) return;
|
||||
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({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -44,30 +51,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -75,7 +82,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -86,14 +93,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -107,7 +114,7 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建一个标题并返回 id */
|
||||
@@ -126,76 +133,77 @@ async function createTitle(
|
||||
tags: ["e2e", "test"],
|
||||
...overrides,
|
||||
},
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
test.describe("标题库 - 空状态", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("新用户标题页面显示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "title-empty")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"title-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-empty",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/titles")
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 新用户应该能看到页面主体
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 搜索功能", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框存在且可输入", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"title-search",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-search");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix)
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-search",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/titles")
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 查找搜索框
|
||||
const searchInput = page.locator(
|
||||
"input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']",
|
||||
)
|
||||
);
|
||||
if (await searchInput.first().isVisible({ timeout: 10_000 })) {
|
||||
await searchInput.first().fill("测试搜索")
|
||||
await expect(searchInput.first()).toHaveValue("测试搜索")
|
||||
await searchInput.first().fill("测试搜索");
|
||||
await expect(searchInput.first()).toHaveValue("测试搜索");
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - API 完整操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("创建标题 - 完整参数", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-create-full")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "title-create-full");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
@@ -206,25 +214,29 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
tags: ["e2e", "完整测试", "种草"],
|
||||
status: "active",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回标题 ID").toBeTruthy()
|
||||
expect(data.name).toBe(`完整参数测试 ${suffix}`)
|
||||
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`)
|
||||
})
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回标题 ID").toBeTruthy();
|
||||
expect(data.name).toBe(`完整参数测试 ${suffix}`);
|
||||
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`);
|
||||
});
|
||||
|
||||
test("编辑标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update")
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36))
|
||||
const { headers } = await createAuthedUser(request, "title-update");
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
const newName = `更新后的标题 ${Date.now()}`
|
||||
const newText = "这是更新后的标题内容"
|
||||
const newName = `更新后的标题 ${Date.now()}`;
|
||||
const newText = "这是更新后的标题内容";
|
||||
const response = await request.patch(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -232,48 +244,52 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
text: newText,
|
||||
category: "知识",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.name).toBe(newName)
|
||||
const data = await response.json();
|
||||
expect(data.name).toBe(newName);
|
||||
|
||||
// 验证更新
|
||||
const verify = await request.get(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
})
|
||||
const verifyData = await verify.json()
|
||||
expect(verifyData.name).toBe(newName)
|
||||
expect(verifyData.text).toBe(newText)
|
||||
})
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
expect(verifyData.name).toBe(newName);
|
||||
expect(verifyData.text).toBe(newText);
|
||||
});
|
||||
|
||||
test("删除标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-delete")
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36))
|
||||
const { headers } = await createAuthedUser(request, "title-delete");
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
})
|
||||
expect([404, 410]).toContain(getResp.status())
|
||||
})
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
});
|
||||
|
||||
test("批量导入标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-batch")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "title-batch");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const titles = [
|
||||
{
|
||||
@@ -291,27 +307,29 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
text: `内容 3 ${suffix}`,
|
||||
category: "知识",
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
||||
headers,
|
||||
data: { titles },
|
||||
})
|
||||
});
|
||||
|
||||
// 批量导入可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`批量导入应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json()
|
||||
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy()
|
||||
const data = await response.json();
|
||||
expect(
|
||||
Array.isArray(data) || data.success_count !== undefined,
|
||||
).toBeTruthy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("创建标题 - 名称为空反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-empty-name")
|
||||
const { headers } = await createAuthedUser(request, "title-empty-name");
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
@@ -320,13 +338,13 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
text: "有内容但名称为空",
|
||||
category: "default",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("创建标题 - 缺少必要字段反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-missing")
|
||||
const { headers } = await createAuthedUser(request, "title-missing");
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
@@ -334,37 +352,46 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
name: "缺少 text 字段",
|
||||
// 缺少 text 字段
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
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 })
|
||||
expect(response.status(), "不存在的标题应返回 404").toBe(404)
|
||||
})
|
||||
const response = await request.get(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的标题应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("更新不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update-404")
|
||||
const { headers } = await createAuthedUser(request, "title-update-404");
|
||||
|
||||
const response = await request.patch(`${apiBase}/titles/nonexistent-title-999`, {
|
||||
headers,
|
||||
data: { name: "不存在的标题", text: "测试" },
|
||||
})
|
||||
expect(response.status(), "更新不存在的标题应返回 404").toBe(404)
|
||||
})
|
||||
const response = await request.patch(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{
|
||||
headers,
|
||||
data: { name: "不存在的标题", text: "测试" },
|
||||
},
|
||||
);
|
||||
expect(response.status(), "更新不存在的标题应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("删除不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-del-404")
|
||||
const { 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(
|
||||
[404, 200, 204].includes(response.status()),
|
||||
"删除不存在的标题应返回 404 或幂等 2xx",
|
||||
).toBeTruthy()
|
||||
})
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录创建标题 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
@@ -373,39 +400,39 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
text: "未登录创建标题",
|
||||
category: "default",
|
||||
},
|
||||
})
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("未登录删除标题 - 反向", async ({ request }) => {
|
||||
const response = await request.delete(`${apiBase}/titles/some-id`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
const response = await request.delete(`${apiBase}/titles/some-id`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 分类/标签筛选", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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 })
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const response = await request.get(`${apiBase}/titles`, { headers });
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || data.titles || []
|
||||
expect(Array.isArray(items)).toBeTruthy()
|
||||
const data = await response.json();
|
||||
const items = data.items || data.titles || [];
|
||||
expect(Array.isArray(items)).toBeTruthy();
|
||||
|
||||
// 如果有标题,验证有分类字段
|
||||
if (items.length > 0) {
|
||||
expect(items[0].category !== undefined).toBeTruthy()
|
||||
expect(items[0].category !== undefined).toBeTruthy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("按分类筛选标题", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-filter-cat")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "title-filter-cat");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建不同分类的标题
|
||||
await request.post(`${apiBase}/titles`, {
|
||||
@@ -415,7 +442,7 @@ test.describe("标题库 - 分类/标签筛选", () => {
|
||||
text: "种草内容",
|
||||
category: "种草",
|
||||
},
|
||||
})
|
||||
});
|
||||
await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -423,118 +450,116 @@ test.describe("标题库 - 分类/标签筛选", () => {
|
||||
text: "知识内容",
|
||||
category: "知识",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 按分类筛选
|
||||
const response = await request.get(`${apiBase}/titles`, {
|
||||
headers,
|
||||
params: { category: "种草" },
|
||||
})
|
||||
});
|
||||
|
||||
// 筛选可能支持也可能不支持
|
||||
expect(response.ok(), `筛选请求应成功,实际: ${response.status()}`).toBeTruthy()
|
||||
})
|
||||
})
|
||||
expect(
|
||||
response.ok(),
|
||||
`筛选请求应成功,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 页面交互", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("标题卡片展示完整信息", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"title-card",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-card");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix)
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-card",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/titles")
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
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 })) {
|
||||
// 验证标题文本
|
||||
const titleText = firstCard.locator(".xx-title-card-text")
|
||||
const titleText = firstCard.locator(".xx-title-card-text");
|
||||
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()) {
|
||||
await expect(titleStat).toBeVisible()
|
||||
await expect(titleStat).toBeVisible();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("标题卡片可点击查看详情", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"title-detail",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-detail");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix)
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-detail",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/titles")
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
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 })) {
|
||||
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.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("多选复选框存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"title-batch-ui",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-batch-ui");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建多个标题
|
||||
await createTitle(request, headers, `${suffix}-1`)
|
||||
await createTitle(request, headers, `${suffix}-2`)
|
||||
await createTitle(request, headers, `${suffix}-3`)
|
||||
await createTitle(request, headers, `${suffix}-1`);
|
||||
await createTitle(request, headers, `${suffix}-2`);
|
||||
await createTitle(request, headers, `${suffix}-3`);
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-batch-ui",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/titles")
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 检查是否有批量操作相关 UI
|
||||
const checkboxes = page.locator(".xx-title-card input[type='checkbox']");
|
||||
// 页面正常加载即可,批量操作是可选功能
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+210
-165
@@ -6,29 +6,36 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 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 apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return
|
||||
if (!apiOrigin) return;
|
||||
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({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -41,30 +48,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -72,7 +79,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -83,14 +90,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -104,123 +111,140 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("声音克隆页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("声音克隆页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-load")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-load",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone")
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test("页面标题和描述存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-title")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-title",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-title",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone")
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证页面标题包含"克隆"或"音色"相关文字
|
||||
const pageTitle = page.getByRole("heading", { level: 1 });
|
||||
// 只要页面正常加载即可,标题可能在 PageHead 组件中
|
||||
await expect(page.locator(".vc-page")).toBeVisible()
|
||||
})
|
||||
await expect(page.locator(".vc-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("克隆新音色按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-newbtn")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-newbtn",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-newbtn",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone")
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证克隆新音色按钮存在
|
||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|新建|创建/ });
|
||||
// 按钮可能在不同位置,只要页面加载成功即可
|
||||
await expect(page.locator(".vc-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(page.locator(".vc-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 空状态", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("无克隆音色时显示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-empty")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-empty",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone")
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 新用户应该显示空状态
|
||||
const emptyState = page.locator(".vc-empty")
|
||||
const emptyState = page.locator(".vc-empty");
|
||||
if (await emptyState.isVisible({ timeout: 10_000 })) {
|
||||
await expect(emptyState.locator(".vc-empty-title")).toBeVisible()
|
||||
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible()
|
||||
await expect(emptyState.locator(".vc-empty-title")).toBeVisible();
|
||||
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible();
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.ok(), `获取克隆列表应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取克隆列表应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || data.voice_clones || []
|
||||
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voice_clones || [];
|
||||
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("创建音色克隆 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-create")
|
||||
const suffix = Date.now().toString(36)
|
||||
const { headers } = await createAuthedUser(request, "vc-create");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个克隆任务(上传音频文件)
|
||||
const response = await request.post(`${apiBase}/voice-clones`, {
|
||||
@@ -234,49 +258,52 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
buffer: Buffer.from("fake audio data for e2e test"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误
|
||||
// 只要不是 500 错误即可
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回克隆 ID").toBeTruthy()
|
||||
expect(data.status, "应返回状态").toBeTruthy()
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回克隆 ID").toBeTruthy();
|
||||
expect(data.status, "应返回状态").toBeTruthy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-detail")
|
||||
const { headers } = await createAuthedUser(request, "vc-detail");
|
||||
|
||||
// 先获取列表看看有没有数据
|
||||
const listResp = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
})
|
||||
expect(listResp.ok()).toBeTruthy()
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
|
||||
const listData = await listResp.json()
|
||||
const items = listData.items || listData.voice_clones || []
|
||||
const listData = await listResp.json();
|
||||
const items = listData.items || listData.voice_clones || [];
|
||||
|
||||
if (items.length > 0) {
|
||||
const cloneId = items[0].id
|
||||
const detailResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { headers })
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy()
|
||||
const detail = await detailResp.json()
|
||||
expect(detail.id).toBe(cloneId)
|
||||
const cloneId = items[0].id;
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(cloneId);
|
||||
}
|
||||
// 如果没有数据,测试也通过(新用户正常情况)
|
||||
})
|
||||
});
|
||||
|
||||
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-del")
|
||||
const { 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`, {
|
||||
headers,
|
||||
multipart: {
|
||||
@@ -287,63 +314,75 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
buffer: Buffer.from("delete me"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (createResp.ok()) {
|
||||
const created = await createResp.json()
|
||||
const cloneId = created.id
|
||||
const created = await createResp.json();
|
||||
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(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy()
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
|
||||
headers,
|
||||
})
|
||||
expect([404, 410]).toContain(getResp.status())
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
}
|
||||
// 如果创建失败(比如音频格式问题),测试也通过
|
||||
})
|
||||
});
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
expect(listResp.ok()).toBeTruthy()
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
|
||||
const listData = await listResp.json()
|
||||
const items = listData.items || listData.voice_clones || []
|
||||
const listData = await listResp.json();
|
||||
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) {
|
||||
const retryResp = await request.post(`${apiBase}/voice-clones/${failedClone.id}/retry`, {
|
||||
headers,
|
||||
})
|
||||
expect(retryResp.ok(), `重试应返回 2xx,实际: ${retryResp.status()}`).toBeTruthy()
|
||||
const retryResp = await request.post(
|
||||
`${apiBase}/voice-clones/${failedClone.id}/retry`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
retryResp.ok(),
|
||||
`重试应返回 2xx,实际: ${retryResp.status()}`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
// 如果没有失败的克隆,测试通过
|
||||
})
|
||||
});
|
||||
|
||||
test("获取不存在的克隆详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-404")
|
||||
const { headers } = await createAuthedUser(request, "vc-404");
|
||||
|
||||
const response = await request.get(`${apiBase}/voice-clones/nonexistent-clone-999`, { headers })
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404)
|
||||
})
|
||||
const response = await request.get(
|
||||
`${apiBase}/voice-clones/nonexistent-clone-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("未登录获取克隆列表 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voice-clones`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
const response = await request.get(`${apiBase}/voice-clones`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("未登录创建克隆 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/voice-clones`, {
|
||||
@@ -355,46 +394,47 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
buffer: Buffer.from("test"),
|
||||
},
|
||||
},
|
||||
})
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 克隆列表展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("克隆卡片网格布局展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-grid")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-grid",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-grid",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone")
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证网格容器或空状态存在
|
||||
const grid = page.locator(".vc-grid")
|
||||
const empty = page.locator(".vc-empty")
|
||||
const grid = page.locator(".vc-grid");
|
||||
const empty = page.locator(".vc-empty");
|
||||
|
||||
// 至少一个应该可见
|
||||
const gridVisible = await grid.isVisible().catch(() => false)
|
||||
const emptyVisible = await empty.isVisible().catch(() => false)
|
||||
expect(gridVisible || emptyVisible).toBeTruthy()
|
||||
})
|
||||
const gridVisible = await grid.isVisible().catch(() => false);
|
||||
const emptyVisible = await empty.isVisible().catch(() => false);
|
||||
expect(gridVisible || emptyVisible).toBeTruthy();
|
||||
});
|
||||
|
||||
test("克隆状态标签展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"vc-status",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "vc-status");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个克隆任务
|
||||
await request.post(`${apiBase}/voice-clones`, {
|
||||
@@ -407,61 +447,66 @@ test.describe("声音克隆 - 克隆列表展示", () => {
|
||||
buffer: Buffer.from("status test data"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-status",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone")
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 如果有卡片,验证状态标签存在
|
||||
const cards = page.locator(".vc-card")
|
||||
const cards = page.locator(".vc-card");
|
||||
if ((await cards.count()) > 0) {
|
||||
const firstCard = cards.first()
|
||||
const statusPill = firstCard.locator(".vc-status-pill")
|
||||
const firstCard = cards.first();
|
||||
const statusPill = firstCard.locator(".vc-status-pill");
|
||||
if (await statusPill.isVisible()) {
|
||||
await expect(statusPill).toBeVisible()
|
||||
await expect(statusPill).toBeVisible();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 上传区域", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("克隆弹窗上传区域可打开", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-upload")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-upload",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-upload",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone")
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 尝试点击克隆新音色按钮
|
||||
const cloneBtn = page.getByRole("button", {
|
||||
name: /克隆新音色|立即克隆|新建/,
|
||||
})
|
||||
});
|
||||
if (await cloneBtn.isVisible()) {
|
||||
await cloneBtn.click()
|
||||
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 })) {
|
||||
await expect(modal.first()).toBeVisible()
|
||||
await expect(modal.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
+192
-145
@@ -6,29 +6,36 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 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 apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return
|
||||
if (!apiOrigin) return;
|
||||
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({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
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 限流自动等待重试 */
|
||||
@@ -41,30 +48,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -72,7 +79,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -83,14 +90,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -104,289 +111,329 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("音色库页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/voices")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
await page.goto("/app/voices");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("音色库页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-load")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-load",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test("页面头部和搜索栏存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-head")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-head",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-head",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证搜索框存在
|
||||
const searchInput = page.locator(
|
||||
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
|
||||
)
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
);
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 预设音色", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
// 预设音色接口可能返回数组或包装对象
|
||||
expect(response.ok(), `获取预设音色应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取预设音色应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || data.voices || data
|
||||
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voices || data;
|
||||
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("预设音色卡片在页面中展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-cards")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-cards",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-cards",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
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 })
|
||||
const count = await voiceCards.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 });
|
||||
const count = await voiceCards.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("音色卡片包含名称和信息", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-info")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-info",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-info",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-voice-card").first()
|
||||
await expect(firstCard).toBeVisible({ timeout: 15_000 })
|
||||
const firstCard = page.locator(".xx-voice-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 验证音色名称存在
|
||||
await expect(firstCard.locator(".xx-voice-name")).toBeVisible()
|
||||
await expect(firstCard.locator(".xx-voice-name")).toBeVisible();
|
||||
// 验证头像存在
|
||||
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 我的克隆音色", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
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`, {
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.ok(), `获取克隆音色应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取克隆音色应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || data.voice_clones || []
|
||||
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy()
|
||||
})
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voice_clones || [];
|
||||
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("空状态展示 - 无克隆音色时", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-empty")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-empty",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 切换到"我的克隆"tab(如果有tab的话)
|
||||
const clonedTab = page.getByText("我的克隆").first()
|
||||
const clonedTab = page.getByText("我的克隆").first();
|
||||
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 }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-create")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-create",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-create",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮)
|
||||
const createBtn = page.getByRole("button", {
|
||||
name: /克隆|新建|创建|\+/,
|
||||
});
|
||||
// 不强制断言一定存在,因为不同页面结构可能不同
|
||||
// 只验证页面正常加载即可
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 搜索和筛选", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框存在且可输入", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-search")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-search",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-search",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 查找搜索输入框
|
||||
const searchInput = page.locator(
|
||||
"input[placeholder*='搜索'], input[type='search'], .xx-voices-search input",
|
||||
)
|
||||
const firstInput = searchInput.first()
|
||||
);
|
||||
const firstInput = searchInput.first();
|
||||
|
||||
if (await firstInput.isVisible({ timeout: 5_000 })) {
|
||||
await firstInput.fill("测试搜索")
|
||||
await expect(firstInput).toHaveValue("测试搜索")
|
||||
await firstInput.fill("测试搜索");
|
||||
await expect(firstInput).toHaveValue("测试搜索");
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("性别/语言筛选选项存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-filter")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-filter",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-filter",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
});
|
||||
|
||||
// 验证筛选相关元素存在(可能是下拉选择器或标签)
|
||||
const filterSelect = page.locator("select, .xx-voices-filter");
|
||||
// 页面正常加载即通过
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 播放试听", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("音色播放按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-play")
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-play",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-play",
|
||||
})
|
||||
});
|
||||
|
||||
await page.goto("/app/voices")
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
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 })) {
|
||||
// 验证播放按钮存在
|
||||
const playBtn = firstCard.locator(".xx-voice-play-btn")
|
||||
const playBtn = firstCard.locator(".xx-voice-play-btn");
|
||||
if (await playBtn.isVisible()) {
|
||||
await expect(playBtn).toBeVisible()
|
||||
await expect(playBtn).toBeVisible();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - API 边界测试", () => {
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("未登录获取预设音色 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voices/preset`)
|
||||
const response = await request.get(`${apiBase}/voices/preset`);
|
||||
// 预设音色可能不需要登录,也可能需要,两种情况都接受
|
||||
// 但如果需要登录,应返回 401/403
|
||||
if (!response.ok()) {
|
||||
expect([401, 403]).toContain(response.status())
|
||||
expect([401, 403]).toContain(response.status());
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
test("未登录获取克隆音色 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voice-clones`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
const response = await request.get(`${apiBase}/voice-clones`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("获取不存在的克隆音色详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-404")
|
||||
const { headers } = await createAuthedUser(request, "voice-404");
|
||||
|
||||
const response = await request.get(`${apiBase}/voice-clones/nonexistent-999`, { headers })
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404)
|
||||
})
|
||||
})
|
||||
const response = await request.get(
|
||||
`${apiBase}/voice-clones/nonexistent-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+3
-222
@@ -23,19 +23,16 @@
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^1.6.1",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"jsdom": "^24.1.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
@@ -48,20 +45,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
|
||||
@@ -491,13 +474,6 @@
|
||||
"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": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@@ -1165,16 +1141,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
|
||||
@@ -2007,14 +1973,14 @@
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"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==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"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==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
@@ -2254,34 +2220,6 @@
|
||||
"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": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
|
||||
@@ -3316,19 +3254,6 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
|
||||
@@ -3951,13 +3876,6 @@
|
||||
"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": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -4166,60 +4084,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -4487,34 +4351,6 @@
|
||||
"@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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4992,22 +4828,6 @@
|
||||
"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": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
@@ -6173,45 +5993,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
|
||||
@@ -4,18 +4,16 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"dev": "vite",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "npx playwright test --project=chromium --reporter=line",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -34,19 +32,16 @@
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^1.6.1",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"jsdom": "^24.1.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 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({
|
||||
testDir: "./e2e",
|
||||
@@ -68,4 +68,4 @@ export default defineConfig({
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Executable → Regular
+228
-242
@@ -2,225 +2,213 @@
|
||||
* 素材相关 API
|
||||
* Phase 1 重构:去掉 project_id,素材直接归属用户
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import { getOrCreateDefaultProject } from "./projects"
|
||||
import apiClient from "./client";
|
||||
import { getOrCreateDefaultProject } from "./projects";
|
||||
|
||||
/** 素材元数据 */
|
||||
export interface AssetMetadata {
|
||||
/** 时长(秒) */
|
||||
duration?: number
|
||||
duration?: number;
|
||||
/** 宽度(像素) */
|
||||
width?: number
|
||||
width?: number;
|
||||
/** 高度(像素) */
|
||||
height?: number
|
||||
height?: number;
|
||||
/** 比特率(bps) */
|
||||
bitrate?: number
|
||||
bitrate?: number;
|
||||
/** 编码格式 */
|
||||
codec?: string
|
||||
codec?: string;
|
||||
/** 帧率 */
|
||||
fps?: number
|
||||
fps?: number;
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number
|
||||
sample_rate?: number;
|
||||
/** 声道数 */
|
||||
channels?: number
|
||||
channels?: number;
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 素材分类状态 */
|
||||
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
|
||||
export type AssetClassificationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata: AssetMetadata
|
||||
file_size?: number
|
||||
file_url?: string
|
||||
thumbnail_url?: string
|
||||
id: string;
|
||||
library_id: string;
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata: AssetMetadata;
|
||||
file_size?: number;
|
||||
file_url?: string;
|
||||
thumbnail_url?: string;
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number
|
||||
status?: string
|
||||
classification_status?: AssetClassificationStatus | null
|
||||
quality_score?: number | null
|
||||
tag_ids?: string[]
|
||||
created_at?: string
|
||||
duration?: number;
|
||||
status?: string;
|
||||
classification_status?: AssetClassificationStatus | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 素材库 */
|
||||
export interface AssetLibraryItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
asset_count?: number
|
||||
total_size?: number
|
||||
created_at?: string
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
asset_count?: number;
|
||||
total_size?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 入库任务 */
|
||||
export interface IngestJob {
|
||||
id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
error_message: string
|
||||
result_asset_id: string
|
||||
id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
error_message: string;
|
||||
result_asset_id: string;
|
||||
}
|
||||
|
||||
/** 分类任务 */
|
||||
export interface ClassificationJob {
|
||||
id: string
|
||||
asset_id: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
classification: string
|
||||
confidence: number
|
||||
error_message: string
|
||||
id: string;
|
||||
asset_id: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
classification: string;
|
||||
confidence: number;
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
/** 素材诊断信息 */
|
||||
export interface AssetDiagnosis {
|
||||
readiness_score: number
|
||||
readiness_label: string
|
||||
total_assets: number
|
||||
ready_assets: number
|
||||
video_assets: number
|
||||
image_assets: number
|
||||
voice_assets: number
|
||||
total_duration_seconds: number
|
||||
estimated_video_count: number
|
||||
used_assets: number
|
||||
unused_assets: number
|
||||
pending_review_assets: number
|
||||
readiness_score: number;
|
||||
readiness_label: string;
|
||||
total_assets: number;
|
||||
ready_assets: number;
|
||||
video_assets: number;
|
||||
image_assets: number;
|
||||
voice_assets: number;
|
||||
total_duration_seconds: number;
|
||||
estimated_video_count: number;
|
||||
used_assets: number;
|
||||
unused_assets: number;
|
||||
pending_review_assets: number;
|
||||
smart_views: Array<{
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
description: string
|
||||
}>
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
description: string;
|
||||
}>;
|
||||
gaps: Array<{
|
||||
key: string
|
||||
severity: "critical" | "warning" | "info"
|
||||
message: string
|
||||
recommendation: string
|
||||
}>
|
||||
key: string;
|
||||
severity: "critical" | "warning" | "info";
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (assetId) params.asset_id = assetId
|
||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||
return response.data
|
||||
}
|
||||
export const getAssetDiagnosis = async (
|
||||
assetId?: string,
|
||||
): Promise<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {};
|
||||
if (assetId) params.asset_id = assetId;
|
||||
const response = await apiClient.get("/asset-diagnosis", { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ─── 素材库 ────────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get("/asset-libraries")
|
||||
return response.data.items || []
|
||||
}
|
||||
const response = await apiClient.get("/asset-libraries");
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
export const createAssetLibrary = async (data: {
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
name: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
// 后端要求 project_id,前端自动管理默认项目
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const project = await getOrCreateDefaultProject();
|
||||
const response = await apiClient.post("/asset-libraries", {
|
||||
project_id: project.id,
|
||||
...data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
|
||||
export const ensureDefaultLibrary = async (data: {
|
||||
project_id: string
|
||||
kind: "video" | "voice" | "image"
|
||||
project_id: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
const response = await apiClient.post("/asset-libraries/ensure-default", data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post(
|
||||
"/asset-libraries/ensure-default",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除素材库 */
|
||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`)
|
||||
}
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`);
|
||||
};
|
||||
|
||||
// ─── 素材 ──────────────────────────────────────────────────
|
||||
|
||||
/** 获取素材库下的所有素材 */
|
||||
export const getAssets = async (
|
||||
libraryId: string,
|
||||
options?: { status?: string; page?: number; page_size?: number },
|
||||
): Promise<{ items: AssetItem[]; total: number }> => {
|
||||
const params: Record<string, string | number> = { library_id: libraryId }
|
||||
// 默认拉取所有非删除状态的素材(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 }
|
||||
}
|
||||
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
const response = await apiClient.get("/assets", {
|
||||
params: { library_id: libraryId },
|
||||
});
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
export const getAssetsByKind = async (
|
||||
kind: string,
|
||||
filters?: {
|
||||
keyword?: string
|
||||
gender?: string
|
||||
style?: string
|
||||
tag_ids?: string[]
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string;
|
||||
gender?: string;
|
||||
style?: string;
|
||||
tag_ids?: string[];
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string | number> = { kind }
|
||||
if (filters?.keyword) params.keyword = filters.keyword
|
||||
if (filters?.gender) params.gender = filters.gender
|
||||
if (filters?.style) params.style = filters.style
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||
if (filters?.limit) params.limit = filters.limit
|
||||
if (filters?.page) params.page = filters.page
|
||||
if (filters?.page_size) params.page_size = filters.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
return response.data.items || []
|
||||
}
|
||||
const params: Record<string, string> = { kind };
|
||||
if (filters?.keyword) params.keyword = filters.keyword;
|
||||
if (filters?.gender) params.gender = filters.gender;
|
||||
if (filters?.style) params.style = filters.style;
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",");
|
||||
const response = await apiClient.get("/assets", { params });
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||
export const createAsset = async (data: {
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata?: AssetMetadata
|
||||
library_id: string;
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata?: AssetMetadata;
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post("/assets", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
@@ -229,14 +217,14 @@ export const updateAssetReviewStatus = async (
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}`)
|
||||
}
|
||||
await apiClient.delete(`/assets/${assetId}`);
|
||||
};
|
||||
|
||||
// ─── 上传 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -247,47 +235,47 @@ export const uploadAsset = async (
|
||||
const response = await apiClient.post("/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 30 * 60 * 1000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
project_id: string
|
||||
library_id: string
|
||||
filename: string
|
||||
content_type: string
|
||||
file_size: number
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
file_size: number;
|
||||
}): Promise<{
|
||||
upload_url: string
|
||||
method: string
|
||||
storage_key: string
|
||||
expires_at: string
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
upload_url: string;
|
||||
method: string;
|
||||
storage_key: string;
|
||||
expires_at: string;
|
||||
fields: Record<string, string>;
|
||||
max_size_bytes: number;
|
||||
}> => {
|
||||
const response = await apiClient.post("/upload/direct/prepare", data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post("/upload/direct/prepare", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 直传完成确认 */
|
||||
export const completeDirectUpload = async (data: {
|
||||
project_id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
const response = await apiClient.post("/upload/direct/complete", data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post("/upload/direct/complete", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调 */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
file: File;
|
||||
library_id: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
// 后端要求 project_id,前端自动获取默认项目
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const project = await getOrCreateDefaultProject();
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
@@ -295,149 +283,147 @@ export const uploadAssetDirect = async (data: {
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
})
|
||||
});
|
||||
|
||||
const directForm = new FormData()
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||
directForm.append("file", data.file)
|
||||
const directForm = new FormData();
|
||||
Object.entries(prepared.fields).forEach(([key, value]) =>
|
||||
directForm.append(key, value),
|
||||
);
|
||||
directForm.append("file", data.file);
|
||||
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open(prepared.method, prepared.upload_url)
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(prepared.method, prepared.upload_url);
|
||||
|
||||
// 超时 10 分钟
|
||||
xhr.timeout = 10 * 60 * 1000
|
||||
xhr.timeout = 10 * 60 * 1000;
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
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 = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
resolve();
|
||||
} else {
|
||||
// 解析 OSS 返回的 XML 错误信息
|
||||
let ossError = ""
|
||||
let ossError = "";
|
||||
try {
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
|
||||
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/);
|
||||
const msgMatch = xhr.responseText.match(
|
||||
/<Message>([^<]+)<\/Message>/,
|
||||
);
|
||||
if (codeMatch || msgMatch) {
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`;
|
||||
}
|
||||
} catch {
|
||||
// 无法解析响应体
|
||||
}
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`;
|
||||
console.error("[OSS Upload] 直传失败:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
})
|
||||
reject(new Error(detail))
|
||||
});
|
||||
reject(new Error(detail));
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
console.error("[OSS Upload] 网络错误:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"))
|
||||
}
|
||||
});
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"));
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
console.error("[OSS Upload] 上传超时:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
|
||||
}
|
||||
xhr.send(directForm)
|
||||
})
|
||||
});
|
||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"));
|
||||
};
|
||||
xhr.send(directForm);
|
||||
});
|
||||
|
||||
return completeDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 入库 / 分类任务 ───────────────────────────────────────
|
||||
|
||||
/** 查询入库任务状态 */
|
||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
asset_id: string
|
||||
asset_id: string;
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post("/classification-jobs", data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post("/classification-jobs", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 查询分类任务状态 */
|
||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
export const getClassificationJob = async (
|
||||
jobId: string,
|
||||
): 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 }
|
||||
succeeded: string[];
|
||||
failed: string[];
|
||||
total: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
/** 批量删除素材 */
|
||||
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
|
||||
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>)
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
tags: string[]
|
||||
mode: "add" | "replace"
|
||||
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>)
|
||||
}
|
||||
const response = await apiClient.post("/assets/batch-tag", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
category: string
|
||||
asset_ids: string[];
|
||||
category: string;
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
const response = await apiClient.post("/assets/batch-classify", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
smart_view: "recommended" | "caution" | "high_risk"
|
||||
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>)
|
||||
}
|
||||
const response = await apiClient.post("/assets/batch-mark", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -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
-128
@@ -1,58 +1,58 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
import axios from "axios";
|
||||
import apiClient from "./client";
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
access_token: string;
|
||||
refresh_token?: string | null;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified: boolean;
|
||||
email_verified: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified?: boolean;
|
||||
email_verified?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
const userId = data.id ?? data.user_id ?? "";
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false;
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
@@ -63,46 +63,52 @@ export const normalizeUser = (data: UserResponse): User => {
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post("/auth/login", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
export const refreshAccessToken = async (
|
||||
refreshToken: string,
|
||||
): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? "";
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 注册
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
export const register = async (
|
||||
data: RegisterRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
await apiClient.post("/auth/logout");
|
||||
};
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
const response = await apiClient.get<UserResponse>("/auth/me");
|
||||
return normalizeUser(response.data);
|
||||
};
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
export const requestPasswordReset = async (
|
||||
email: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
@@ -112,77 +118,14 @@ export const resetPassword = async (
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ========== 微信登录 ========== */
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
// 获取微信授权链接
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 微信回调登录
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
export const verifyEmail = async (
|
||||
token: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 绑定联系方式
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post("/auth/verify-email", { token });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
+28
-26
@@ -2,48 +2,48 @@
|
||||
* BGM 预设音乐 API
|
||||
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import apiClient from "./client";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
/** BGM 风格分类 */
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
|
||||
|
||||
/** BGM 预设项 */
|
||||
export interface BgmPreset {
|
||||
id: string
|
||||
name: string
|
||||
category: BgmCategory
|
||||
id: string;
|
||||
name: string;
|
||||
category: BgmCategory;
|
||||
/** 音频文件 URL */
|
||||
url: string
|
||||
url: string;
|
||||
/** 时长(秒) */
|
||||
duration: number
|
||||
duration: number;
|
||||
/** 关键词标签 */
|
||||
tags: string[]
|
||||
tags: string[];
|
||||
/** 封面图 URL */
|
||||
cover_url?: string
|
||||
cover_url?: string;
|
||||
}
|
||||
|
||||
/** BGM 预设列表查询参数 */
|
||||
export interface BgmPresetsQuery {
|
||||
category?: BgmCategory | string
|
||||
keyword?: string
|
||||
category?: BgmCategory | string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/** BGM 混音配置(嵌入模板) */
|
||||
/** BGM 混音配置(嵌入剪辑计划) */
|
||||
export interface BgmMixConfig {
|
||||
/** 是否启用 BGM */
|
||||
enabled: boolean
|
||||
enabled: boolean;
|
||||
/** 选中的 BGM ID */
|
||||
music_id: string
|
||||
music_id: string;
|
||||
/** BGM 音量 0-100 */
|
||||
volume: number
|
||||
volume: number;
|
||||
/** 淡入时长(秒) 0-3 */
|
||||
fade_in: number
|
||||
fade_in: number;
|
||||
/** 淡出时长(秒) 0-3 */
|
||||
fade_out: number
|
||||
fade_out: number;
|
||||
/** 人声闪避(sidechain) */
|
||||
voice_dodge: boolean
|
||||
voice_dodge: boolean;
|
||||
}
|
||||
|
||||
/** 默认 BGM 混音配置 */
|
||||
@@ -54,15 +54,17 @@ export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
||||
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 ?? []
|
||||
}
|
||||
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
-78
@@ -2,10 +2,10 @@
|
||||
* API 客户端配置
|
||||
* 封装 Axios 实例,配置拦截器和 Token 管理
|
||||
*/
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./auth"
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { message } from "antd";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { refreshAccessToken } from "./auth";
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -14,150 +14,161 @@ const apiClient = axios.create({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// ── Token 刷新队列 ─────────────────────────────────────────
|
||||
let isRefreshing = false
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}> = []
|
||||
resolve: (value?: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
const processQueue = (error: unknown | null, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error)
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token)
|
||||
prom.resolve(token);
|
||||
}
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
// 请求拦截器:添加 Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem("access_token")
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config
|
||||
return config;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
return Promise.reject(error);
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// 响应拦截器:统一错误提示 + 401 自动刷新 Token
|
||||
apiClient.interceptors.response.use(
|
||||
(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 & {
|
||||
_retry?: boolean
|
||||
}
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken;
|
||||
|
||||
// 无 refresh_token → 直接登出
|
||||
if (!refreshToken) {
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
return Promise.reject(error)
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 已在刷新中 → 排队等待
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshToken)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken
|
||||
const data = await refreshAccessToken(refreshToken);
|
||||
const newAccessToken = data.access_token;
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken;
|
||||
|
||||
// 更新 Zustand + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
|
||||
.setAuth(
|
||||
useAuthStore.getState().user!,
|
||||
newAccessToken,
|
||||
newRefreshToken,
|
||||
);
|
||||
|
||||
// 处理排队的请求
|
||||
processQueue(null, newAccessToken)
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
// 重试原始请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败 → 登出
|
||||
processQueue(refreshError, null)
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
return Promise.reject(refreshError)
|
||||
processQueue(refreshError, null);
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/";
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取后端返回的错误信息(detail / message / msg)
|
||||
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
|
||||
const data = error.response?.data
|
||||
const rawServerMsg = data?.detail || data?.message || data?.msg
|
||||
const data = error.response?.data;
|
||||
const rawServerMsg = data?.detail || data?.message || data?.msg;
|
||||
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
|
||||
const safeExtractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
// 嵌套对象:递归提取
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return safeExtractString(obj.message)
|
||||
if (typeof obj.msg === "object" && obj.msg !== null) return safeExtractString(obj.msg)
|
||||
return JSON.stringify(val)
|
||||
return safeExtractString(obj.message);
|
||||
if (typeof obj.msg === "object" && obj.msg !== null)
|
||||
return safeExtractString(obj.msg);
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const serverMsg = safeExtractString(rawServerMsg)
|
||||
let handled = false
|
||||
return "";
|
||||
};
|
||||
const serverMsg = safeExtractString(rawServerMsg);
|
||||
let handled = false;
|
||||
|
||||
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
|
||||
message.error("请求超时,请检查网络后重试")
|
||||
handled = true
|
||||
message.error("请求超时,请检查网络后重试");
|
||||
handled = true;
|
||||
} else if (!error.response) {
|
||||
message.error("网络连接异常,请检查网络设置")
|
||||
handled = true
|
||||
message.error("网络连接异常,请检查网络设置");
|
||||
handled = true;
|
||||
} else if (serverMsg) {
|
||||
message.error(serverMsg)
|
||||
handled = true
|
||||
message.error(serverMsg);
|
||||
handled = true;
|
||||
} else {
|
||||
const status = error.response?.status
|
||||
const status = error.response?.status;
|
||||
if (status === 413) {
|
||||
message.error("文件过大,请缩小后重试")
|
||||
handled = true
|
||||
message.error("文件过大,请缩小后重试");
|
||||
handled = true;
|
||||
} else if (status === 415) {
|
||||
message.error("不支持的文件格式")
|
||||
handled = true
|
||||
message.error("不支持的文件格式");
|
||||
handled = true;
|
||||
} else if (status === 503) {
|
||||
message.error("服务暂不可用,请稍后再试")
|
||||
handled = true
|
||||
message.error("服务暂不可用,请稍后再试");
|
||||
handled = true;
|
||||
} else if (status && status >= 500) {
|
||||
message.error("服务器繁忙,请稍后再试")
|
||||
handled = true
|
||||
message.error("服务器繁忙,请稍后再试");
|
||||
handled = true;
|
||||
}
|
||||
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
|
||||
}
|
||||
@@ -165,11 +176,11 @@ apiClient.interceptors.response.use(
|
||||
// 标记已展示过提示,组件 onError 可据此跳过重复 toast
|
||||
if (handled) {
|
||||
// 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;
|
||||
|
||||
@@ -2,98 +2,109 @@
|
||||
* 查重 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 {
|
||||
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) */
|
||||
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 {
|
||||
id: string
|
||||
id: string;
|
||||
/** 在原始视频中的起始时间(秒) */
|
||||
source_start: number
|
||||
source_start: number;
|
||||
/** 在原始视频中的结束时间(秒) */
|
||||
source_end: number
|
||||
source_end: number;
|
||||
/** 匹配到的已有视频 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) */
|
||||
similarity: number
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
/** 查重详情 */
|
||||
export interface DuplicationDetail extends DuplicationRecord {
|
||||
/** 重复片段列表 */
|
||||
segments: DuplicateSegment[]
|
||||
segments: DuplicateSegment[];
|
||||
}
|
||||
|
||||
/** 上传查重响应 */
|
||||
export interface DuplicationUploadResponse {
|
||||
/** 查重记录 ID */
|
||||
id: string
|
||||
id: string;
|
||||
/** 状态 */
|
||||
status: DuplicationStatus
|
||||
status: DuplicationStatus;
|
||||
/** 消息 */
|
||||
message: string
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 上传视频进行查重 */
|
||||
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
export const uploadForDuplication = async (
|
||||
file: File,
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await apiClient.post("/duplication/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取查重记录列表 */
|
||||
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
|
||||
const response = await apiClient.get("/duplication/records")
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.get("/duplication/records");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取查重详情 */
|
||||
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`)
|
||||
return response.data
|
||||
}
|
||||
export const getDuplicationDetail = async (
|
||||
recordId: string,
|
||||
): Promise<DuplicationDetail> => {
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除查重记录 */
|
||||
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
|
||||
await apiClient.delete(`/duplication/records/${recordId}`)
|
||||
}
|
||||
export const deleteDuplicationRecord = async (
|
||||
recordId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/duplication/records/${recordId}`);
|
||||
};
|
||||
|
||||
/** 重新查重 */
|
||||
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
|
||||
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
|
||||
return response.data
|
||||
}
|
||||
export const retryDuplication = async (
|
||||
recordId: string,
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
const response = await apiClient.post(
|
||||
`/duplication/records/${recordId}/retry`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 剪辑计划 API — 对接后端 Edit Plans Schema
|
||||
* 字段名严格匹配后端 API 响应
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { AssetItem } from "./assets";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
|
||||
/* ============================================================
|
||||
* 后端 API 类型(严格匹配后端 Schema)
|
||||
* ============================================================ */
|
||||
|
||||
/** 剪辑计划状态枚举 */
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed";
|
||||
|
||||
/** 标题配置(对齐后端 title_config) */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
}
|
||||
|
||||
/** 片段 TTS 配置 */
|
||||
export interface SegmentTtsConfig {
|
||||
mode: string;
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed: number;
|
||||
pitch: number;
|
||||
volume: number;
|
||||
subtitle_sync: boolean;
|
||||
}
|
||||
|
||||
/** 片段裁剪配置 */
|
||||
export interface SegmentTrimConfig {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}
|
||||
|
||||
/** 片段转场配置 */
|
||||
export interface SegmentTransitionConfig {
|
||||
type: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 剪辑计划中的单个片段(config 内部 segments 项) */
|
||||
export interface EditPlanSegment {
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string;
|
||||
transition?: SegmentTransitionConfig;
|
||||
playback_speed?: number;
|
||||
tts_config?: SegmentTtsConfig;
|
||||
trim_config?: SegmentTrimConfig;
|
||||
}
|
||||
|
||||
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
|
||||
export interface EditPlanConfig {
|
||||
title_config?: TitleConfig;
|
||||
subtitle_config?: SubtitleConfig;
|
||||
bgm_config?: BgmConfig;
|
||||
estimated_duration?: number;
|
||||
segments?: EditPlanSegment[];
|
||||
watermark_config?: WatermarkConfig;
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
pip_config?: PipConfig;
|
||||
filter_config?: FilterConfig;
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
sticker_config?: StickerConfig;
|
||||
cover_config?: CoverConfig;
|
||||
/** 前端扩展:关联的素材 ID 列表 */
|
||||
asset_ids?: string[];
|
||||
/** 配音 ID */
|
||||
voice_id?: string;
|
||||
/** 克隆音色档案 ID */
|
||||
voice_clone_profile_id?: string;
|
||||
/** 自定义配音音频 URL */
|
||||
custom_audio_url?: string;
|
||||
/** 自定义配音文本 */
|
||||
custom_text?: string;
|
||||
/** 视频比例 */
|
||||
ratio?: string;
|
||||
/** 视频风格 */
|
||||
style?: string;
|
||||
/** 目标时长(秒) */
|
||||
duration?: number;
|
||||
/** 是否自动生成字幕 */
|
||||
auto_subtitles?: boolean;
|
||||
/** 是否启用 BGM */
|
||||
bgm?: boolean;
|
||||
/** 生成数量 */
|
||||
generate_count?: number;
|
||||
/** 素材模式 */
|
||||
material_mode?: string;
|
||||
}
|
||||
|
||||
/** 剪辑计划(后端响应) */
|
||||
export interface EditPlan {
|
||||
id: string;
|
||||
template_id: string;
|
||||
name: string;
|
||||
status: EditPlanStatus;
|
||||
total_duration: number;
|
||||
config: EditPlanConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
|
||||
export interface CreateEditPlanRequest {
|
||||
template_id: string;
|
||||
name: string;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||
source_edit_plan_id?: string;
|
||||
}
|
||||
|
||||
/** 更新剪辑计划请求 */
|
||||
export interface UpdateEditPlanRequest {
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
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[];
|
||||
}
|
||||
|
||||
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
generation_task_id?: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
duration?: number;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
status: string;
|
||||
review_status?: string;
|
||||
download_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 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: EditPlanConfig;
|
||||
}
|
||||
|
||||
/** AI 推荐响应 */
|
||||
export interface AIRecommendResponse {
|
||||
plan_id: string;
|
||||
clips: AIRecommendClipItem[];
|
||||
config: EditPlanConfig;
|
||||
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: CoverResult;
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string;
|
||||
asset_id?: string;
|
||||
frame_time?: number;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 前端 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;
|
||||
}
|
||||
|
||||
/** 转场效果(14 种预设) */
|
||||
export interface TransitionEffect {
|
||||
type:
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
duration: number; // 转场时长(秒)
|
||||
/** 播放速度倍率 */
|
||||
playback_speed?: 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 interface EditPlanListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
template_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** 剪辑计划列表分页响应 */
|
||||
export interface EditPlanListResponse {
|
||||
items: EditPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 获取剪辑计划列表(支持分页和筛选) */
|
||||
export async function getEditPlans(
|
||||
params?: EditPlanListParams,
|
||||
): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 获取单个剪辑计划 */
|
||||
export async function getEditPlan(planId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 创建剪辑计划 */
|
||||
export async function createEditPlan(
|
||||
data: CreateEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/edit-plans", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 更新剪辑计划 */
|
||||
export async function updateEditPlan(
|
||||
planId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
): 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 || [];
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(
|
||||
taskId: string,
|
||||
): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items || response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 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 {
|
||||
// 优先取顶层 duration,其次从 metadata 回退
|
||||
const metaDuration =
|
||||
typeof asset.metadata?.duration === "number"
|
||||
? asset.metadata.duration
|
||||
: undefined;
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
type: inferMediaType(asset.mime_type || ""),
|
||||
thumbnail_url: asset.thumbnail_url,
|
||||
duration: asset.duration ?? metaDuration,
|
||||
size: asset.file_size ?? undefined,
|
||||
tags: [],
|
||||
created_at: asset.created_at ?? "",
|
||||
quality_score: asset.quality_score ?? undefined,
|
||||
classification_status: asset.classification_status ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 常量
|
||||
* ============================================================ */
|
||||
|
||||
/** 转场效果选项(14 种预设) */
|
||||
export const TRANSITION_OPTIONS: {
|
||||
value: TransitionEffect["type"];
|
||||
label: string;
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ value: "none", label: "无转场", icon: "⊘" },
|
||||
{ value: "cut", label: "硬切", icon: "✂" },
|
||||
{ value: "fade", label: "淡入淡出", icon: "◐" },
|
||||
{ value: "dissolve", label: "溶解", icon: "◈" },
|
||||
{ value: "zoom", label: "缩放", icon: "⊕" },
|
||||
{ value: "slide_left", label: "左滑", icon: "←" },
|
||||
{ value: "slide_right", label: "右滑", icon: "→" },
|
||||
{ value: "slide_up", label: "上滑", icon: "↑" },
|
||||
{ value: "slide_down", label: "下滑", icon: "↓" },
|
||||
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
|
||||
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
|
||||
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
|
||||
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
|
||||
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
|
||||
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
|
||||
];
|
||||
|
||||
/** 素材类型标签 */
|
||||
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 },
|
||||
];
|
||||
+116
-108
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 模板编辑器 API
|
||||
* 剪辑计划编辑器 API
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import apiClient from "./client";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
@@ -11,20 +11,20 @@ import type {
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
} 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> = {
|
||||
pip: "混剪",
|
||||
pip: "画中画",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
};
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
@@ -32,203 +32,211 @@ export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
}
|
||||
};
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
id?: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null;
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
id: string;
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: TemplateSegment[];
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig
|
||||
watermark_config?: WatermarkConfig;
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
pip_config?: PipConfig
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
filter_config?: FilterConfig;
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig
|
||||
sticker_config?: StickerConfig;
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
cover_config?: CoverConfig;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
id: string;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: Omit<TemplateSegment, "id">[];
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig
|
||||
watermark_config?: WatermarkConfig;
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
pip_config?: PipConfig
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
filter_config?: FilterConfig;
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig
|
||||
sticker_config?: StickerConfig;
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig
|
||||
cover_config?: CoverConfig;
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number
|
||||
voiceover_duration: number;
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
/** 相关字段名 */
|
||||
field?: string
|
||||
field?: string;
|
||||
/** 期望值 */
|
||||
expected?: string | number
|
||||
expected?: string | number;
|
||||
/** 实际值 */
|
||||
actual?: string | number
|
||||
actual?: string | number;
|
||||
/** 建议值 */
|
||||
suggested?: string | number
|
||||
suggested?: string | number;
|
||||
}
|
||||
|
||||
/** 验证/生成响应 */
|
||||
export interface ValidateWarning {
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
code: string;
|
||||
message: string;
|
||||
details?: ValidationWarningDetails;
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
template: EditingTemplate;
|
||||
warnings: ValidateWarning[];
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
items: EditingTemplate[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[]
|
||||
items: TemplateCategory[];
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
category?: string;
|
||||
tag?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
});
|
||||
let list = response.data.items;
|
||||
if (params?.category)
|
||||
list = list.filter((t) => t.category === params.category);
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
|
||||
return list;
|
||||
};
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
export const getEditingTemplate = async (
|
||||
id: string,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.patch<EditingTemplate>(
|
||||
`/templates/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
await apiClient.delete(`/templates/${id}`);
|
||||
};
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
const response = await apiClient.get<ListCategoriesResponse>(
|
||||
"/templates/categories/list",
|
||||
);
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
@@ -238,6 +246,6 @@ export const generateFromTemplate = async (
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
Executable → Regular
+87
-116
@@ -1,174 +1,145 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API
|
||||
* 后端实际接口:/videos
|
||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import apiClient from "./client";
|
||||
import { getGenerationTaskResults } from "./editPlans";
|
||||
import type { GeneratedVideo } from "./editPlans";
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
id: string;
|
||||
title: string;
|
||||
video_url?: string;
|
||||
thumbnail_url?: string;
|
||||
duration_seconds?: number;
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus
|
||||
review_status?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
project_id?: string
|
||||
project_id?: string;
|
||||
/** 所属项目名称 */
|
||||
project_name?: string
|
||||
project_name?: string;
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
project_id?: string;
|
||||
review_status?: ReviewStatus | "all";
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
items: ProductItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
job_id: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string
|
||||
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
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 VideoItem 映射为 ProductItem 格式
|
||||
* 将 generation task 数据映射为 ProductItem 格式
|
||||
*/
|
||||
function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
function mapTaskToProductItem(task: GeneratedVideo): 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,
|
||||
id: task.id,
|
||||
title: task.name || "未命名视频",
|
||||
video_url: task.file_url,
|
||||
thumbnail_url: task.thumbnail_url,
|
||||
duration_seconds: task.duration,
|
||||
file_size: task.file_size,
|
||||
resolution:
|
||||
task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||
status:
|
||||
video.status === "completed"
|
||||
task.status === "completed"
|
||||
? "completed"
|
||||
: video.status === "failed"
|
||||
: task.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,
|
||||
}
|
||||
review_status: task.review_status as ReviewStatus | undefined,
|
||||
project_id: task.project_id,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
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)
|
||||
}
|
||||
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
|
||||
export const getProducts = async (
|
||||
params?: ProductListParams,
|
||||
): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/generation/tasks", { params });
|
||||
const tasks = response.data.items || response.data || [];
|
||||
return tasks.map(mapTaskToProductItem);
|
||||
};
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
/** 获取单个成品详情 — 通过 task ID 获取结果 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`);
|
||||
return mapTaskToProductItem(response.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
/** 删除成品 — 删除 generation task */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/videos/${productId}`)
|
||||
}
|
||||
await apiClient.delete(`/generation/tasks/${productId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取成品下载链接
|
||||
* 直接使用列表返回的 download_url(带OSS签名)
|
||||
*/
|
||||
/** 获取成品下载链接 — 从 generation task results 获取 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
// 优先从列表缓存取;如果没有则调详情接口
|
||||
const product = await getProduct(productId)
|
||||
if (!product.video_url) throw new Error("下载链接不可用")
|
||||
return { url: product.video_url, expires_at: "" }
|
||||
}
|
||||
const videos = await getGenerationTaskResults(productId);
|
||||
const video = videos[0];
|
||||
if (!video?.download_url) throw new Error("下载链接不可用");
|
||||
return { url: video.download_url, expires_at: "" };
|
||||
};
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /videos/{id}/review 端点
|
||||
// 后端暂无 /generation/tasks/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
const product = await getProduct(productId);
|
||||
return { ...product, review_status: status };
|
||||
};
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /videos/batch-download 端点
|
||||
export const batchDownload = async (
|
||||
videoIds: string[],
|
||||
): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /generation/tasks/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
|
||||
return { job_id: `mock-${Date.now()}` };
|
||||
};
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
export const getBatchDownloadStatus = async (
|
||||
jobId: string,
|
||||
): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
|
||||
return { job_id: jobId, status: "processing", progress: 0 };
|
||||
};
|
||||
|
||||
@@ -2,59 +2,60 @@
|
||||
* 项目相关 API
|
||||
* 素材库需要 project_id,前端自动管理默认项目
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import apiClient from "./client";
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 后端 ProjectResponse 只返回 id, name, description */
|
||||
interface BackendProjectResponse {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 后端 ListProjectsResponse 返回 { items: [...] } */
|
||||
interface BackendListProjectsResponse {
|
||||
items: BackendProjectResponse[]
|
||||
items: BackendProjectResponse[];
|
||||
}
|
||||
|
||||
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
})
|
||||
});
|
||||
|
||||
/** 获取当前用户的项目列表 */
|
||||
export const getProjects = async (): Promise<ProjectItem[]> => {
|
||||
const response = await apiClient.get<BackendListProjectsResponse>("/projects")
|
||||
return (response.data.items || []).map(toProjectItem)
|
||||
}
|
||||
const response =
|
||||
await apiClient.get<BackendListProjectsResponse>("/projects");
|
||||
return (response.data.items || []).map(toProjectItem);
|
||||
};
|
||||
|
||||
/** 创建项目 */
|
||||
export const createProject = async (data: {
|
||||
name: string
|
||||
description?: string
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<ProjectItem> => {
|
||||
const response = await apiClient.post<BackendProjectResponse>("/projects", {
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
})
|
||||
return toProjectItem(response.data)
|
||||
}
|
||||
});
|
||||
return toProjectItem(response.data);
|
||||
};
|
||||
|
||||
/** 获取或创建默认项目(素材库需要 project_id) */
|
||||
export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
|
||||
const projects = await getProjects()
|
||||
const projects = await getProjects();
|
||||
if (projects.length > 0) {
|
||||
return projects[0]
|
||||
return projects[0];
|
||||
}
|
||||
// 没有项目时自动创建默认项目
|
||||
return createProject({
|
||||
name: "默认项目",
|
||||
description: "系统自动创建的默认项目",
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,98 +2,100 @@
|
||||
* 订阅 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 {
|
||||
id: PlanType
|
||||
name: string
|
||||
price: number | null
|
||||
yearly_price?: number | null
|
||||
description: string
|
||||
recommended: boolean
|
||||
features: string[]
|
||||
id: PlanType;
|
||||
name: string;
|
||||
price: number | null;
|
||||
yearly_price?: number | null;
|
||||
description: string;
|
||||
recommended: boolean;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/** 当前订阅信息 */
|
||||
export interface SubscriptionInfo {
|
||||
id: string
|
||||
plan_id: PlanType
|
||||
plan_name: string
|
||||
status: SubscriptionStatus
|
||||
billing_cycle: BillingCycle
|
||||
current_period_start: string
|
||||
current_period_end: string
|
||||
amount: number
|
||||
auto_renew: boolean
|
||||
created_at: string
|
||||
id: string;
|
||||
plan_id: PlanType;
|
||||
plan_name: string;
|
||||
status: SubscriptionStatus;
|
||||
billing_cycle: BillingCycle;
|
||||
current_period_start: string;
|
||||
current_period_end: string;
|
||||
amount: number;
|
||||
auto_renew: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 账单记录 */
|
||||
export interface BillingRecord {
|
||||
id: string
|
||||
plan_name: string
|
||||
amount: number
|
||||
billing_cycle: BillingCycle
|
||||
status: BillingStatus
|
||||
payment_method: string
|
||||
created_at: string
|
||||
invoice_url?: string
|
||||
id: string;
|
||||
plan_name: string;
|
||||
amount: number;
|
||||
billing_cycle: BillingCycle;
|
||||
status: BillingStatus;
|
||||
payment_method: string;
|
||||
created_at: string;
|
||||
invoice_url?: string;
|
||||
}
|
||||
|
||||
/** 升级/降级请求 */
|
||||
export interface ChangePlanRequest {
|
||||
target_plan_id: PlanType
|
||||
billing_cycle: BillingCycle
|
||||
target_plan_id: PlanType;
|
||||
billing_cycle: BillingCycle;
|
||||
}
|
||||
|
||||
/** 升级/降级响应 */
|
||||
export interface ChangePlanResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
new_subscription?: SubscriptionInfo
|
||||
success: boolean;
|
||||
message: string;
|
||||
new_subscription?: SubscriptionInfo;
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取当前订阅信息 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
const response = await apiClient.get("/subscription/current")
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.get("/subscription/current");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取账单记录列表 */
|
||||
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
||||
const response = await apiClient.get("/subscription/billing-records")
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.get("/subscription/billing-records");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 升级/降级套餐 */
|
||||
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post("/subscription/change-plan", request)
|
||||
return response.data
|
||||
}
|
||||
export const changePlan = async (
|
||||
request: ChangePlanRequest,
|
||||
): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post("/subscription/change-plan", request);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 取消订阅 */
|
||||
export const cancelSubscription = async (): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> => {
|
||||
const response = await apiClient.post("/subscription/cancel")
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post("/subscription/cancel");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 切换自动续费 */
|
||||
export const toggleAutoRenew = async (
|
||||
@@ -101,6 +103,6 @@ export const toggleAutoRenew = async (
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post("/subscription/toggle-auto-renew", {
|
||||
enabled,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
+26
-20
@@ -2,39 +2,45 @@
|
||||
* 标签 CRUD API
|
||||
* P3 标签体系:对接后端标签表
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import apiClient from "./client";
|
||||
|
||||
export interface TagItem {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
usage_count?: number
|
||||
id: string;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
usage_count?: number;
|
||||
}
|
||||
|
||||
/** 获取当前用户所有标签 */
|
||||
export const getTags = async (): Promise<TagItem[]> => {
|
||||
const response = await apiClient.get("/tags")
|
||||
return response.data.items || []
|
||||
}
|
||||
const response = await apiClient.get("/tags");
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 创建标签(同名返回 409) */
|
||||
export const createTag = async (name: string): Promise<TagItem> => {
|
||||
const response = await apiClient.post("/tags", { name })
|
||||
return response.data
|
||||
}
|
||||
const response = await apiClient.post("/tags", { name });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除标签(同时清理素材关联) */
|
||||
export const deleteTag = async (tagId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tags/${tagId}`)
|
||||
}
|
||||
await apiClient.delete(`/tags/${tagId}`);
|
||||
};
|
||||
|
||||
/** 为素材添加标签(最多 50 个) */
|
||||
export const tagAsset = async (assetId: string, tagIds: string[]): Promise<void> => {
|
||||
if (tagIds.length === 0) return
|
||||
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds })
|
||||
}
|
||||
export const tagAsset = async (
|
||||
assetId: string,
|
||||
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> => {
|
||||
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`)
|
||||
}
|
||||
export const untagAsset = async (
|
||||
assetId: string,
|
||||
tagId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user