Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia ea65033b01 fix(worker): 修复瘦身后PYTHONPATH缺少packages目录导致shared模块无法导入
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m26s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
2026-07-15 08:30:58 +08:00
447 changed files with 17092 additions and 54072 deletions
+14
View File
@@ -0,0 +1,14 @@
[flake8]
max-line-length = 120
exclude =
.git,
.cache,
__pycache__,
.venv,
venv,
node_modules,
alembic
per-file-ignores =
tests/integration/*:F821
tests/unit/*:F821
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +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
- 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
-66
View File
@@ -1,66 +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
- name: Install dependencies
run: |
python3 -m pip install --upgrade pip
python3 -m pip install requests
- name: Run AI Code Review
env:
# Gitea 配置(自动从运行环境获取)
GITEA_API_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REPO_NAME: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
LLM_PROVIDER: "coze"
# 扣子模式配置(默认国内站 api.coze.cn
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
# 可选参数
MAX_DIFF_CHARS: "30000"
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 审查脚本异常不影响 CI 通过
continue-on-error: true
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-60
View File
@@ -106,18 +106,6 @@ jobs:
echo "======================================"
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
-365
View File
@@ -1,365 +0,0 @@
name: PR Automation
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
permissions:
contents: read
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 20
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING review
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑 → 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
auto-merge:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 30
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
echo "检查required门禁(与分支保护一致)"
fi
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 405连续计数器
MERGE_405_COUNT=0
MAX_405_RETRIES=10
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 180); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# 检查审批状态
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
echo " 审批: $APPROVAL_RESULT"
HAS_APPROVAL=false
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
HAS_APPROVAL=true
fi
# 全部满足 → 合并
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
echo
echo "CI全绿 + 审批通过,执行自动合并"
echo "等待60秒冷却,给Gitea内部状态同步时间..."
sleep 60
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
sleep 30
continue
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "等待超时(30分钟)"
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-207
View File
@@ -1,207 +0,0 @@
name: Preview Cleanup
on:
pull_request:
types:
- closed
branches:
- main
- develop
permissions:
contents: read
pull-requests: write
jobs:
cleanup-preview:
name: Cleanup Preview Environment
runs-on: runtime-builder
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Extract PR number
shell: sh
run: |
set -eu
# 优先从event payload中读取(兼容所有PR事件类型)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
fi
# fallback: 从GITHUB_REF中提取
if [ -z "${PR_NUMBER:-}" ]; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
# 再fallback: 兼容纯数字ref
if [ -z "${PR_NUMBER:-}" ] || ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
echo "WARNING: Could not extract PR number cleanly, using raw ref suffix"
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
fi
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "Preview dir: /var/www/preview/pr-${PR_NUMBER}"
- name: Install SSH client
shell: sh
run: |
set -eu
# 先检查是否已存在ssh
if command -v ssh >/dev/null 2>&1 && command -v ssh-keyscan >/dev/null 2>&1; then
echo "SSH client already available: $(ssh -V 2>&1)"
exit 0
fi
# 尝试多种包管理器安装
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client >/dev/null 2>&1
echo "openssh-client installed via apk"
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed via apt-get"
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via yum"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via dnf"
else
echo "ERROR: No package manager found and ssh not pre-installed"
which ssh 2>/dev/null || echo " ssh: not found"
which ssh-keyscan 2>/dev/null || echo " ssh-keyscan: not found"
exit 1
fi
- name: Remove preview directory from server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (builder key)"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (home key)"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
ls -la /root/.ssh/ 2>/dev/null || true
exit 1
fi
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 检查目录是否存在
DIR_EXISTS=$(ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi")
if [ "$DIR_EXISTS" = "yes" ]; then
echo "Removing preview directory: ${preview_dir}"
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"rm -rf ${preview_dir} && echo 'Preview directory removed successfully'"
echo "Cleanup completed: ${preview_dir}"
else
echo "Preview directory does not exist: ${preview_dir}, nothing to clean up"
fi
- name: Comment cleanup notice on PR
if: success()
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
# 从event payload读取PR号(最可靠)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
else
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
export PR_NUMBER
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py cleanup)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Cleanup comment posted"
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-296
View File
@@ -1,296 +0,0 @@
name: Preview Deploy
on:
pull_request:
types:
- opened
- synchronize
- reopened
branches:
- main
- develop
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - 预览环境补跑"
permissions:
contents: read
pull-requests: write
concurrency:
group: preview-deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
name: Deploy Preview Environment
runs-on: runtime-builder
timeout-minutes: 20
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Record job start time
shell: sh
run: |
set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
- name: Extract PR number
shell: sh
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Build frontend
shell: sh
run: |
set -eu
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm ci"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci --include=dev; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci --include=dev
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
- name: Install SSH client and rsync
shell: sh
run: |
set -eu
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client rsync >/dev/null 2>&1
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients rsync >/dev/null 2>&1
else
echo "ERROR: No package manager found"
exit 1
fi
echo "openssh-client and rsync installed"
- name: Deploy preview to server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
preview_user="${PREVIEW_SSH_USER:-root}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (builder key)"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (home key)"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
ls -la /root/.ssh/ 2>/dev/null || true
exit 1
fi
# SSH密钥完整性自检
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
echo "ERROR: SSH密钥损坏(private key contents do not match public"
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
head -2 "$key_path"
exit 1
fi
echo "SSH key integrity check passed"
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 创建预览目录并上传文件
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'"
# 使用rsync上传dist目录内容
rsync -avz --delete -e "ssh -p ${preview_port} -i ${key_path} -o StrictHostKeyChecking=no" \
apps/web/dist/ \
"${preview_user}@${preview_host}:${preview_dir}/"
echo "Preview deployed to: ${preview_dir}"
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Comment preview link on PR
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
export PR_NUMBER PREVIEW_URL
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py deploy)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
EXISTING_COMMENT_ID=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
import sys, json
try:
for c in json.load(sys.stdin):
if '预览环境已部署' in c.get('body', ''):
print(c['id'])
break
except Exception:
pass
")
if [ -n "$EXISTING_COMMENT_ID" ]; then
curl -s -X PATCH \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \
> /dev/null
echo "Comment updated"
else
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Comment posted"
fi
- name: Job duration summary
if: always()
shell: sh
run: |
set +eu
if [ -n "$JOB_START_TIME" ]; then
END_TIME=$(date +%s)
DURATION=$((END_TIME - JOB_START_TIME))
MINS=$((DURATION / 60))
SECS=$((DURATION % 60))
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
echo "=== Job Duration: ${MINS}m${SECS}s ==="
else
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
echo "=== Job Duration: unknown ==="
fi
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-5
View File
@@ -49,8 +49,3 @@ build/
tracker_tasks.json
frontend-v21-ui-prototype-final.html
!.vscode/
!.vscode/settings.json
.vscode/extensions.json
.coverage
-29
View File
@@ -1,29 +0,0 @@
repos:
- repo: https://github.com/psf/black
rev: 26.5.1
hooks:
- id: black
language_version: python3.12
- repo: https://github.com/pycqa/isort
rev: 8.0.1
hooks:
- id: isort
args: ["--profile", "black"]
language_version: python3.12
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff
args: [--fix]
language_version: python3.12
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.11
hooks:
- id: prettier
name: prettier (frontend)
files: ^apps/web/.*\.(ts|tsx|js|jsx|css|scss|less|json|html|md|yaml|yml)$
additional_dependencies:
- prettier@3.4.2
-49
View File
@@ -1,49 +0,0 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"prettier.requireConfig": true,
"isort.args": ["--profile", "black"],
"python.linting.ruffEnabled": true,
"python.analysis.typeCheckingMode": "basic"
}
@@ -1,29 +0,0 @@
"""add result_count to edit_plans
Revision ID: 041_result_count
Revises: 040_playback_speed
Create Date: 2026-07-15 14:05:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "041_result_count"
down_revision = "040_playback_speed"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plans",
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("edit_plans", "result_count")
@@ -1,29 +0,0 @@
"""add storage_key to assets
Revision ID: 042_storage_key
Revises: 041_result_count
Create Date: 2026-07-17 18:10:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "042_storage_key"
down_revision = "041_result_count"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"assets",
sa.Column("storage_key", sa.String(500), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("assets", "storage_key")
@@ -1,33 +0,0 @@
"""add updated_at to generation_tasks
Revision ID: 043_updated_at_generation_tasks
Revises: 042_storage_key
Create Date: 2026-07-18 19:30:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "043_updated_at_generation_tasks"
down_revision = "042_storage_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"updated_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "updated_at")
@@ -1,34 +0,0 @@
"""add user_id to generated_videos
Revision ID: 044_user_id_generated_videos
Revises: 043_updated_at_generation_tasks
Create Date: 2026-07-19 08:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "044_user_id_generated_videos"
down_revision = "043_updated_at_generation_tasks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generated_videos",
sa.Column(
"user_id",
sa.String(36),
nullable=False,
server_default="",
index=True,
),
)
def downgrade() -> None:
op.drop_column("generated_videos", "user_id")
-36
View File
@@ -1,36 +0,0 @@
"""backfill user_id for generated_videos from generation_tasks
Revision ID: 045_backfill_user_id_generated_videos
Revises: 044_user_id_generated_videos
Create Date: 2026-07-19 10:50:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "045_backfill_user_id"
down_revision = "044_user_id_generated_videos"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 回填 generated_videos.user_id:通过 generation_task_id 关联 generation_tasks 表
# 取 generation_tasks.created_by_user_id 作为 user_id
# 回填不到的(无关联task的兜底记录)保持空字符串
op.execute("""
UPDATE generated_videos gv
SET user_id = gt.created_by_user_id
FROM generation_tasks gt
WHERE gv.generation_task_id = gt.id
AND gv.user_id = ''
AND gt.created_by_user_id != ''
""")
def downgrade() -> None:
# 降级不做处理(无法精确区分哪些是回填的)
pass
-33
View File
@@ -1,33 +0,0 @@
"""add video_title to generation_tasks
Revision ID: 046_add_video_title_to_generation_tasks
Revises: 045_backfill_user_id_generated_videos
Create Date: 2026-07-19 11:20:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "046_task_title"
down_revision = "045_backfill_user_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"video_title",
sa.String(255),
nullable=False,
server_default="",
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "video_title")
@@ -1,64 +0,0 @@
"""Phase 2 - 模板发布版本化:version字段 + 发布历史表
Revision ID: 047
Revises: 046
Create Date: 2026-07-20
Changes:
1. edit_templates 加 version 字段(INT,默认1,每次发布+1)
2. 新建 edit_template_versions 表存发布历史快照,支持回滚
"""
import sqlalchemy as sa
from alembic import op
revision = "047_template_versioning"
down_revision = "046_task_title"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# 1. edit_templates 加 version 字段
op.add_column(
"edit_templates",
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
)
# 2. 新建 edit_template_versions 发布历史表
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS edit_template_versions (
id VARCHAR(36) PRIMARY KEY,
template_id VARCHAR(32) NOT NULL,
version INTEGER NOT NULL,
name VARCHAR(200) NOT NULL DEFAULT '',
editing_mode VARCHAR(30) NOT NULL DEFAULT 'one_take',
config JSONB NOT NULL DEFAULT '{}',
clip_configs JSONB NOT NULL DEFAULT '[]',
change_note VARCHAR(500) NOT NULL DEFAULT '',
published_by VARCHAR(36) NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_edit_template_versions_template_id " "ON edit_template_versions(template_id)"
)
)
conn.execute(
sa.text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_edit_template_versions_template_version "
"ON edit_template_versions(template_id, version)"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS edit_template_versions"))
op.drop_column("edit_templates", "version")
View File
-6
View File
@@ -16,7 +16,6 @@ from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
from app.api.routes.templates import router as templates_router
from app.api.routes.templates_editor import router as templates_editor_router
from app.api.routes.titles import router as titles_router
from app.api.routes.tts import router as tts_router
from app.api.routes.upload import router as upload_router
@@ -120,11 +119,6 @@ api_router.include_router(
prefix="/templates",
tags=["Template"],
)
api_router.include_router(
templates_editor_router,
prefix="/templates/{template_id}/editor",
tags=["TemplateEditor"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
-116
View File
@@ -1,6 +1,5 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException, status
@@ -47,118 +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")
# ── Deprecated API 标记 ──────────────────────────────────────────────────────
import logging as _logging
from fastapi import Request as _Request
_deprecated_logger = _logging.getLogger(__name__)
def deprecated_edit_plans_api(request: _Request) -> None:
"""标记 /edit-plans/* 系列 API 为废弃,打 warning 日志。
Phase 2 模板编辑器收敛后,所有剪辑计划 API 迁移到 /templates/{id}/editor/*。
旧路径保留 2 个版本周期兼容,之后会下线。
"""
_deprecated_logger.warning(
"Deprecated API called: %s %s. Use /templates/{template_id}/editor/* instead.",
request.method,
request.url.path,
)
+33 -98
View File
@@ -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]
+2 -4
View File
@@ -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,
+26 -708
View File
@@ -22,15 +22,9 @@ from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.dependencies import get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService, EditTemplateService
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -38,13 +32,11 @@ from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from ._helpers import auto_select_video_assets, check_project_access, deprecated_edit_plans_api
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
@@ -58,7 +50,6 @@ class EditPlanCreateRequest(BaseModel):
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")
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表,创建时自动分配给片段")
class EditPlanUpdateRequest(BaseModel):
@@ -73,15 +64,6 @@ class EditPlanUpdateRequest(BaseModel):
)
class CopyPlanRequest(BaseModel):
"""复制剪辑计划请求体"""
name: Optional[str] = Field(
default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」"
)
project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目")
class EditPlanResponse(BaseModel):
"""剪辑计划响应体"""
@@ -90,7 +72,6 @@ class EditPlanResponse(BaseModel):
name: str
status: str
total_duration: float
result_count: int = 0
project_id: str = ""
created_by_user_id: str = ""
config: dict[str, Any]
@@ -127,10 +108,6 @@ class EditPlanGenerationStatusResponse(BaseModel):
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
generation_task_status: Optional[str] = None
progress: float = 0.0
video_url: str = ""
error_message: str = ""
clips: List[ClipStatusItem]
@@ -254,49 +231,24 @@ class GenerateFromTemplateResponse(BaseModel):
def _to_response(p: EditPlan) -> EditPlanResponse:
# 对 config 中的 rendered_url 做签名转换(私有bucket裸URL会403
config = dict(p.config) if p.config else {}
raw_rendered_url = config.get("rendered_url", "")
if raw_rendered_url:
try:
storage = get_storage_service()
config["rendered_url"] = storage.get_download_url(raw_rendered_url, expires_seconds=86400)
except Exception as e:
logger.warning("剪辑计划rendered_url签名失败,返回原始URL: plan_id=%s error=%s", p.id, e)
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,
result_count=getattr(p, "result_count", 0),
project_id=p.project_id or "",
created_by_user_id=p.created_by_user_id or "",
config=config,
config=p.config,
created_at=p.created_at,
updated_at=p.updated_at,
)
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
# 注意:含静态路径的子路由需放在 CRUD 路由之前,避免被 /{plan_id} 抢先匹配
from .edit_plans_adjustments import router as adjustments_router
from .edit_plans_export import router as export_router
from .edit_plans_filter import router as filter_router
from .edit_plans_transitions import router as transitions_router
router.include_router(export_router)
router.include_router(adjustments_router)
router.include_router(filter_router)
router.include_router(transitions_router)
# ── CRUD Routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanListResponse, deprecated=True)
@router.get("", response_model=EditPlanListResponse)
def list_plans(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
@@ -323,11 +275,11 @@ def list_plans(
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError as _e:
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
) from _e
)
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id:
@@ -355,7 +307,7 @@ def list_plans(
)
@router.get("/{plan_id}", response_model=EditPlanResponse, deprecated=True)
@router.get("/{plan_id}", response_model=EditPlanResponse)
def get_plan(
plan_id: str,
db: Session = Depends(get_db_session),
@@ -370,137 +322,53 @@ def get_plan(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from 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, deprecated=True)
@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),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> EditPlanResponse:
"""创建剪辑计划
基于模板自动生成片段结构:
- 模板存在时:从模板的 clip_configs 生成初始 clips
- 模板不存在时:降级为空计划(保持向后兼容)
- 用户传入的 config 与模板 config 合并(用户配置优先级更高)
- total_duration 自动根据 clips 总时长计算
"""
from app.services import PlanGeneratorService
"""创建剪辑计划"""
# 空串 project_id 统一为 ""
project_id = (body.project_id or "").strip()
# 项目鉴权
if project_id:
check_project_access(project_id, current_user.user.id, project_repository)
# 标准化用户传入的 config
normalized_config = normalize_plan_config(body.config or {})
template_svc = EditTemplateService(db)
svc = EditPlanService(db)
# 尝试从模板生成(模板不存在时降级为空计划)
template = None
clips = []
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_plan_config(body.config)
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError:
# 模板不存在,降级为普通空计划
logger.info("模板不存在,创建空计划: template_id=%s", body.template_id)
plan = svc.create_plan(
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,
total_duration=body.total_duration if body.total_duration > 0 else 0.0,
)
logger.info(
"创建空剪辑计划: id=%s name=%s by user=%s",
plan.id,
plan.name,
current_user.user.id,
)
return _to_response(plan)
# 模板存在,从模板生成计划+片段
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材
resolved_asset_ids = list(body.asset_ids)
if not resolved_asset_ids and project_id:
auto_assets = auto_select_video_assets(
project_id=project_id,
asset_library_repo=asset_library_repository,
asset_repo=asset_repository,
logger=logger,
)
if auto_assets:
resolved_asset_ids = auto_assets
logger.info(
"自动选素材: project_id=%s count=%d",
project_id,
len(auto_assets),
)
generator = PlanGeneratorService(db)
try:
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=resolved_asset_ids,
project_id=project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
plan = result["plan"]
clips = result["clips"]
# 如果用户传入了自定义 config,合并覆盖模板配置
if body.config:
base_config = template.config or {}
merged_config = {**base_config, **normalized_config}
# 重新标准化确保默认值填充正确
merged_config = normalize_plan_config(merged_config)
plan = svc.update_plan(
plan.id,
config=merged_config,
total_duration=body.total_duration if body.total_duration > 0 else None,
)
# 把 asset_ids 写入 plan.config,供生成时兜底分配使用
if resolved_asset_ids:
current_config = plan.config or {}
if current_config.get("asset_ids") != resolved_asset_ids:
current_config["asset_ids"] = resolved_asset_ids
plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config))
logger.info(
"创建剪辑计划: id=%s name=%s clips=%d by user=%s",
plan.id,
plan.name,
len(clips),
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
created.name,
current_user.user.id,
)
return _to_response(plan)
return _to_response(created)
@router.put("/{plan_id}", response_model=EditPlanResponse, deprecated=True)
@router.put("/{plan_id}", response_model=EditPlanResponse)
def update_plan(
plan_id: str,
body: EditPlanUpdateRequest,
@@ -533,11 +401,11 @@ def update_plan(
if body.status is not None:
try:
target_status = EditPlanStatus(body.status)
except ValueError as _e:
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态",
) from _e
)
svc.transition_status(plan_id, target_status)
except ValueError as exc:
err_msg = str(exc)
@@ -545,11 +413,11 @@ def update_plan(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
) from exc
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
) from exc
)
# 返回最新状态
result = svc.get_plan_or_raise(plan_id)
@@ -557,9 +425,7 @@ def update_plan(
return _to_response(result)
@router.delete(
"/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response, deprecated=True
)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
@@ -585,560 +451,12 @@ def delete_plan(
)
@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED, deprecated=True)
def copy_plan(
plan_id: str,
body: CopyPlanRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""复制剪辑计划(含所有片段配置)
新计划状态为 editing,不含生成任务和结果记录。
"""
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)
# 目标项目鉴权(如果指定了不同的项目)
target_project_id = body.project_id if body.project_id is not None else existing.project_id
if target_project_id and target_project_id != existing.project_id:
check_project_access(target_project_id, current_user.user.id, project_repository)
try:
new_plan = svc.copy_plan(
plan_id,
new_name=body.name,
project_id=target_project_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info(
"复制剪辑计划: source=%s target=%s by user=%s",
plan_id,
new_plan.id,
current_user.user.id,
)
return _to_response(new_plan)
# ── 字幕管理 ────────────────────────────────────────────────────────────────
class SubtitleCreateRequest(BaseModel):
"""添加字幕请求体"""
start: float = Field(..., ge=0, description="开始时间(秒)")
end: float = Field(..., gt=0, description="结束时间(秒)")
text: str = Field(..., min_length=1, max_length=500, description="字幕文本")
style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式")
class SubtitleUpdateRequest(BaseModel):
"""更新字幕请求体"""
start: Optional[float] = Field(default=None, ge=0, description="开始时间(秒)")
end: Optional[float] = Field(default=None, gt=0, description="结束时间(秒)")
text: Optional[str] = Field(default=None, min_length=1, max_length=500, description="字幕文本")
style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式")
class SubtitleBatchUpdateRequest(BaseModel):
"""批量更新字幕请求体"""
subtitles: list[dict[str, Any]] = Field(
default_factory=list,
description="字幕列表(全量替换),每条包含 start/end/text,可选 id/style",
)
@router.get(
"/clips/{clip_id}/subtitles",
response_model=list[dict[str, Any]],
summary="获取片段的所有字幕",
deprecated=True,
)
def list_subtitles(
clip_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> list[dict[str, Any]]:
"""获取指定片段的所有字幕,按时间排序。"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
return service.list_subtitles(clip_id)
@router.post(
"/clips/{clip_id}/subtitles",
response_model=dict[str, Any],
summary="添加一条字幕",
status_code=status.HTTP_201_CREATED,
deprecated=True,
)
def add_subtitle(
clip_id: str,
body: SubtitleCreateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""给片段添加一条字幕。"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
try:
subtitle = service.add_subtitle(
clip_id,
start=body.start,
end=body.end,
text=body.text,
style=body.style,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info("添加字幕: clip_id=%s by user=%s", clip_id, current_user.user.id)
return subtitle
@router.put(
"/clips/{clip_id}/subtitles/{subtitle_id}",
response_model=dict[str, Any],
summary="更新一条字幕",
deprecated=True,
)
def update_subtitle(
clip_id: str,
subtitle_id: str,
body: SubtitleUpdateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""更新一条字幕的时间、文本或样式。"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
try:
subtitle = service.update_subtitle(
clip_id,
subtitle_id,
start=body.start,
end=body.end,
text=body.text,
style=body.style,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"更新字幕: clip_id=%s subtitle_id=%s by user=%s",
clip_id,
subtitle_id,
current_user.user.id,
)
return subtitle
@router.delete(
"/clips/{clip_id}/subtitles/{subtitle_id}",
summary="删除一条字幕",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
response_class=Response,
deprecated=True,
)
def delete_subtitle(
clip_id: str,
subtitle_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> None:
"""删除一条字幕。"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
deleted = service.delete_subtitle(clip_id, subtitle_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"字幕不存在: {subtitle_id}",
)
logger.info(
"删除字幕: clip_id=%s subtitle_id=%s by user=%s",
clip_id,
subtitle_id,
current_user.user.id,
)
@router.put(
"/clips/{clip_id}/subtitles",
response_model=list[dict[str, Any]],
summary="批量更新字幕(全量替换)",
deprecated=True,
)
def batch_update_subtitles(
clip_id: str,
body: SubtitleBatchUpdateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> list[dict[str, Any]]:
"""批量更新片段的所有字幕(全量替换)。
用于批量编辑、SRT导入、ASR结果导入等场景。
每条字幕包含 start/end/text,已有 id 则保留,否则生成新 id。
"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
try:
subtitles = service.batch_update_subtitles(clip_id, body.subtitles)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"批量更新字幕: clip_id=%s count=%d by user=%s",
clip_id,
len(subtitles),
current_user.user.id,
)
return subtitles
# ── BGM 背景音乐 ───────────────────────────────────────────────────────────
class BGMConfigUpdateRequest(BaseModel):
"""更新BGM配置请求体"""
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
@router.get(
"/{plan_id}/bgm",
response_model=dict[str, Any],
summary="获取剪辑计划的 BGM 配置",
deprecated=True,
)
def get_plan_bgm(
plan_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""获取指定剪辑计划的 BGM 配置。"""
service = EditPlanService(db)
plan = service.get_plan(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
config = plan.config or {}
bgm_config = config.get("bgm", {})
return {
"plan_id": plan.id,
"bgm": bgm_config,
}
@router.put(
"/{plan_id}/bgm",
response_model=dict[str, Any],
summary="更新剪辑计划的 BGM 配置",
deprecated=True,
)
def update_plan_bgm(
plan_id: str,
body: BGMConfigUpdateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""更新剪辑计划的 BGM 配置。
支持部分更新,只传需要修改的字段即可。
启用 BGM 后需要指定来源(asset_id / preset_id / audio_url 三选一)。
"""
service = EditPlanService(db)
plan = service.get_plan(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
# 读取当前 BGM 配置,合并更新
config = dict(plan.config) if plan.config else {}
current_bgm = dict(config.get("bgm", {}))
update_data = body.model_dump(exclude_none=True)
current_bgm.update(update_data)
# 校验:启用 BGM 时至少有一个有效来源
if current_bgm.get("enabled"):
has_source = any(current_bgm.get(key) for key in ("asset_id", "preset_id", "audio_url") if current_bgm.get(key))
if not has_source:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="启用 BGM 时需要指定素材来源(asset_id / preset_id / audio_url",
)
# 保存到 plan.config.bgm
config["bgm"] = current_bgm
updated_plan = service.update_plan_config(plan_id, config)
logger.info(
"更新BGM配置: plan_id=%s enabled=%s by user=%s",
plan_id,
current_bgm.get("enabled", False),
current_user.user.id,
)
return {
"plan_id": updated_plan.id,
"bgm": current_bgm,
}
# ── BGM 预设库 ────────────────────────────────────────────────────────────────
@router.get(
"/bgm/presets",
response_model=dict[str, Any],
summary="获取预设 BGM 列表",
deprecated=True,
)
def list_bgm_presets(
style: Optional[str] = Query(default=None, description="按风格筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
skip: int = Query(default=0, ge=0, description="分页偏移"),
limit: int = Query(default=50, ge=1, le=200, description="每页数量"),
) -> dict[str, Any]:
"""获取预设 BGM 列表,支持按风格筛选和关键词搜索。
风格可选: upbeat(轻快)、relax(治愈)、tech(科技)、commerce(电商)、
emotional(情感)、cinematic(电影)
"""
from packages.domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
list_preset_bgm_by_style,
search_preset_bgm,
)
bgm_list = PRESET_BGM_LIBRARY
if keyword:
bgm_list = search_preset_bgm(keyword)
elif style:
bgm_list = list_preset_bgm_by_style(style)
total = len(bgm_list)
paged = bgm_list[skip : skip + limit]
return {
"total": total,
"skip": skip,
"limit": limit,
"styles": BGM_STYLES,
"items": [
{
"id": bgm.id,
"name": bgm.name,
"style": bgm.style,
"style_label": BGM_STYLES.get(bgm.style, bgm.style),
"duration": bgm.duration,
"artist": bgm.artist,
"description": bgm.description,
"tags": bgm.tags,
"audio_url": bgm.audio_url,
}
for bgm in paged
],
}
# ── 保存为模板 ────────────────────────────────────────────────────────────────
class SaveAsTemplateRequest(BaseModel):
"""保存为模板请求体"""
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: str = Field(default="", max_length=500, description="模板描述")
template_type: str = Field(default="custom", max_length=50, description="模板类型")
preview_url: str = Field(default="", max_length=500, description="预览图 URL")
@router.post(
"/{plan_id}/save-as-template",
response_model=dict[str, Any],
summary="将剪辑计划保存为模板",
status_code=status.HTTP_201_CREATED,
deprecated=True,
)
def save_plan_as_template(
plan_id: str,
body: SaveAsTemplateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""将指定剪辑计划的配置和片段结构保存为一个新模板。
新模板会复制计划的所有片段配置(类型、时长、转场、文案等),
但不绑定具体素材,可重复用于创建新的剪辑计划。
"""
# 校验计划存在性和项目权限
plan_service = EditPlanService(db)
plan = plan_service.get_plan(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
template_service = EditTemplateService(db)
try:
result = template_service.save_plan_as_template(
plan_id=plan_id,
name=body.name,
description=body.description,
template_type=body.template_type,
preview_url=body.preview_url,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
template = result["template"]
clip_configs = result["clip_configs"]
logger.info(
"保存计划为模板: plan_id=%s template_id=%s name=%s by user=%s",
plan_id,
template.id,
body.name,
current_user.user.id,
)
return {
"id": template.id,
"name": template.name,
"description": template.description,
"template_type": template.template_type,
"editing_mode": template.editing_mode,
"preview_url": template.preview_url,
"status": template.status.value,
"clip_count": len(clip_configs),
"created_at": template.created_at.isoformat(),
}
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
from .edit_plans_ai import router as ai_router
from .edit_plans_clips import router as clips_router
from .edit_plans_clips_batch import router as clips_batch_router
from .edit_plans_cover import router as cover_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)
router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
router.include_router(cover_router)
@@ -1,313 +0,0 @@
"""片段调整 API.
- PUT /clips/{clip_id}/speed 调速
- PUT /clips/{clip_id}/volume 音量调节
- PUT /clips/{clip_id}/trim 裁剪(trim in/out
- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim
- POST /{plan_id}/clips/batch-speed 批量调速
"""
from __future__ import annotations
import logging
from typing import Any, Optional
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 pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ._helpers import check_project_access, deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
# ── Schemas ──────────────────────────────────────────────────────────────────
class SpeedAdjustRequest(BaseModel):
"""调速请求"""
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
class VolumeAdjustRequest(BaseModel):
"""音量调节请求"""
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.01.0=原音量)")
class TrimAdjustRequest(BaseModel):
"""裁剪请求"""
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
class ClipAdjustmentsRequest(BaseModel):
"""统一调整请求"""
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
trim_start: Optional[float] = Field(default=None, ge=0.0)
trim_end: Optional[float] = Field(default=None, ge=0.0)
class BatchSpeedRequest(BaseModel):
"""批量调速请求"""
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
class ClipAdjustResponse(BaseModel):
"""片段调整响应"""
clip_id: str
speed: float
volume: float
trim_start: float
trim_end: float
duration: float
class BatchSpeedResponse(BaseModel):
"""批量调速响应"""
updated_count: int
plan_id: str
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_clip_config(clip) -> dict:
config = getattr(clip, "config", {}) or {}
if not isinstance(config, dict):
config = {}
return config
def _get_volume(clip) -> float:
config = _get_clip_config(clip)
return float(config.get("volume", 1.0))
def _get_trim(clip) -> tuple[float, float]:
config = _get_clip_config(clip)
trim_start = float(config.get("trim_start", 0.0))
trim_end = float(config.get("trim_end", 0.0))
return trim_start, trim_end
def _build_response(clip) -> ClipAdjustResponse:
trim_start, trim_end = _get_trim(clip)
return ClipAdjustResponse(
clip_id=clip.id,
speed=clip.playback_speed,
volume=_get_volume(clip),
trim_start=trim_start,
trim_end=trim_end,
duration=clip.duration,
)
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
"""验证裁剪时长不超过总时长"""
if trim_start + trim_end >= total_duration:
raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s")
# ── Routes ───────────────────────────────────────────────────────────────────
def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
svc = EditPlanService(db)
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = svc.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
return svc, plan, clip
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse, deprecated=True)
def adjust_speed(
clip_id: str,
body: SpeedAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""调整片段播放速度"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
updated = svc.update_clip(clip_id, playback_speed=body.speed)
logger.info(
"调整片段速度: clip_id=%s speed=%.2f by user=%s",
clip_id,
body.speed,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse, deprecated=True)
def adjust_volume(
clip_id: str,
body: VolumeAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""调整片段音量"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
# 更新 config.volume
config = dict(_get_clip_config(clip))
config["volume"] = body.volume
updated = svc.update_clip(clip_id, config=config)
logger.info(
"调整片段音量: clip_id=%s volume=%.2f by user=%s",
clip_id,
body.volume,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse, deprecated=True)
def adjust_trim(
clip_id: str,
body: TrimAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""裁剪片段(trim in/out"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
# 验证裁剪时长
try:
_validate_trim(body.trim_start, body.trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 更新 config
config = dict(_get_clip_config(clip))
config["trim_start"] = body.trim_start
config["trim_end"] = body.trim_end
updated = svc.update_clip(clip_id, config=config)
logger.info(
"裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s",
clip_id,
body.trim_start,
body.trim_end,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse, deprecated=True)
def adjust_all(
clip_id: str,
body: ClipAdjustmentsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""统一调整片段的 speed / volume / trim"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
update_kwargs = {}
config_updates = {}
if body.speed is not None:
update_kwargs["playback_speed"] = body.speed
if body.volume is not None:
config_updates["volume"] = body.volume
if body.trim_start is not None:
config_updates["trim_start"] = body.trim_start
if body.trim_end is not None:
config_updates["trim_end"] = body.trim_end
# 验证 trim
current_trim_start, current_trim_end = _get_trim(clip)
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
if body.trim_start is not None or body.trim_end is not None:
try:
_validate_trim(new_trim_start, new_trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
if config_updates:
config = dict(_get_clip_config(clip))
config.update(config_updates)
update_kwargs["config"] = config
if not update_kwargs:
return _build_response(clip)
updated = svc.update_clip(clip_id, **update_kwargs)
logger.info(
"统一调整片段: clip_id=%s speed=%s volume=%s by user=%s",
clip_id,
body.speed,
body.volume,
current_user.user.id,
)
return _build_response(updated)
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse, deprecated=True)
def batch_adjust_speed(
plan_id: str,
body: BatchSpeedRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> BatchSpeedResponse:
"""批量调整计划内所有片段的播放速度"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id, limit=500, skip=0)
count = 0
for clip in clips:
svc.update_clip(clip.id, playback_speed=body.speed)
count += 1
logger.info(
"批量调速: plan_id=%s count=%d speed=%.2f by user=%s",
plan_id,
count,
body.speed,
current_user.user.id,
)
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
+5 -11
View File
@@ -26,19 +26,14 @@ from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
router = APIRouter()
@router.post(
"/{plan_id}/ai-recommend",
response_model=AIRecommendResponse,
deprecated=True,
)
def ai_recommend_clips(
plan_id: str,
@@ -63,7 +58,7 @@ def ai_recommend_clips(
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)) from 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)
@@ -108,7 +103,7 @@ def ai_recommend_clips(
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception as _e:
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
@@ -121,7 +116,7 @@ def ai_recommend_clips(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
) from _e
)
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
@@ -155,7 +150,6 @@ def ai_recommend_clips(
@router.post(
"/{plan_id}/generate-cover",
response_model=GenerateCoverResponse,
deprecated=True,
)
def generate_cover(
plan_id: str,
@@ -173,7 +167,7 @@ def generate_cover(
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)) from 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)
-423
View File
@@ -1,423 +0,0 @@
"""剪辑计划片段(Clip)CRUD 路由。"""
from __future__ import annotations
import logging
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 fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.domain.edit_plan_clip import EditPlanClipStatus
from ._helpers import deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
# ── Schemas ──────────────────────────────────────────────────────────────────
from pydantic import BaseModel, Field
class EditPlanClipResponse(BaseModel):
"""剪辑片段响应体"""
id: str
plan_id: str
clip_type: str
order: int
asset_id: str = ""
text_content: str = ""
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
status: str
config: dict[str, Any] = Field(default_factory=dict)
created_at: Optional[str] = None
updated_at: Optional[str] = None
class EditPlanClipListResponse(BaseModel):
"""剪辑片段列表响应体"""
items: List[EditPlanClipResponse]
total: int
class EditPlanClipCreateRequest(BaseModel):
"""创建剪辑片段请求体"""
clip_type: str = Field(
..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等"
)
order: int = Field(..., ge=0, description="排序序号")
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
class EditPlanClipUpdateRequest(BaseModel):
"""更新剪辑片段请求体"""
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
返回 plan 对象供后续使用,避免重复查询。
"""
from app.services.edit_plan_service import EditPlanService
from ._helpers import check_project_access
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _clip_to_response(clip) -> EditPlanClipResponse:
"""将领域对象转换为响应体"""
return EditPlanClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
config=clip.config or {},
created_at=clip.created_at.isoformat() if clip.created_at else None,
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
)
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanClipListResponse, deprecated=True)
def list_clips(
plan_id: str,
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
skip: int = Query(0, ge=0, description="分页偏移"),
limit: int = Query(100, ge=1, le=500, description="每页数量"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipListResponse:
"""获取剪辑计划的片段列表"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
total = svc.count_clips(plan_id, status=status_enum)
return EditPlanClipListResponse(
items=[_clip_to_response(c) for c in clips],
total=total,
)
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED, deprecated=True)
def create_clip(
plan_id: str,
body: EditPlanClipCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""创建剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clip = svc.create_clip(
plan_id=plan_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
return _clip_to_response(clip)
@router.get("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True)
def get_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""获取剪辑片段详情"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
return _clip_to_response(clip)
@router.put("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True)
def update_clip(
plan_id: str,
clip_id: str,
body: EditPlanClipUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""更新剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
try:
updated = svc.update_clip(
clip_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return _clip_to_response(updated)
@router.delete(
"/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response, deprecated=True
)
def delete_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
deleted = svc.delete_clip(clip_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return None
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
class SplitClipRequest(BaseModel):
"""分割片段请求体"""
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
class MergeClipsRequest(BaseModel):
"""合并片段请求体"""
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
@router.post(
"/{clip_id}/split",
response_model=dict[str, Any],
summary="分割片段",
status_code=status.HTTP_200_OK,
deprecated=True,
)
def split_clip(
plan_id: str,
clip_id: str,
body: SplitClipRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将一个片段从指定时间点分割为两个片段。
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
try:
result = svc.split_clip(clip_id, body.split_time)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
left = result["left_clip"]
right = result["right_clip"]
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return {
"left_clip": {
"id": left.id,
"plan_id": left.plan_id,
"clip_type": left.clip_type,
"order": left.order,
"duration": left.duration,
"start_time": left.start_time,
},
"right_clip": {
"id": right.id,
"plan_id": right.plan_id,
"clip_type": right.clip_type,
"order": right.order,
"duration": right.duration,
"start_time": right.start_time,
},
}
@router.post(
"/merge",
response_model=dict[str, Any],
summary="合并多个连续片段",
status_code=status.HTTP_200_OK,
deprecated=True,
)
def merge_clips(
plan_id: str,
body: MergeClipsRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将多个连续的同类型片段合并为一个片段。
合并要求:
- 至少 2 个片段
- 属于同一剪辑计划
- order 连续
- 类型相同
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 校验所有片段都属于该 plan
for cid in body.clip_ids:
clip = svc.get_clip(cid)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {cid}",
)
try:
merged = svc.merge_clips(body.clip_ids)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"合并片段: plan_id=%s clip_count=%d by user=%s",
plan_id,
len(body.clip_ids),
current_user.user.id,
)
return {
"id": merged.id,
"plan_id": merged.plan_id,
"clip_type": merged.clip_type,
"order": merged.order,
"duration": merged.duration,
"text_content": merged.text_content,
}
@@ -1,245 +0,0 @@
"""剪辑计划片段批量操作 API。"""
from __future__ import annotations
import logging
from typing import Any, List
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ._helpers import deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
# ── Schemas ──────────────────────────────────────────────────────────────────
class ClipReorderItem(BaseModel):
"""重排序条目"""
clip_id: str
new_order: int = Field(..., ge=0, description="新的排序序号")
class ClipReorderRequest(BaseModel):
"""片段重排序请求"""
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
class ClipReorderResponse(BaseModel):
"""片段重排序响应"""
success: bool
updated_count: int
message: str = ""
class ClipBatchDeleteRequest(BaseModel):
"""批量删除片段请求"""
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
class ClipBatchDeleteResponse(BaseModel):
"""批量删除片段响应"""
success: bool
deleted_count: int
message: str = ""
class ClipsFromAssetsRequest(BaseModel):
"""从素材批量创建片段请求"""
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
clip_type: str = Field(default="main", description="片段类型,默认 main")
class ClipsFromAssetsResponse(BaseModel):
"""从素材批量创建片段响应"""
success: bool
created_count: int
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
from app.services.edit_plan_service import EditPlanService
from ._helpers import check_project_access
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.post("/reorder", response_model=ClipReorderResponse, deprecated=True)
def reorder_clips(
plan_id: str,
body: ClipReorderRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipReorderResponse:
"""批量重排序片段
前端拖拽调整顺序后,一次性提交所有变更的 order。
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
clip_ids = [item.clip_id for item in body.items]
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
invalid_ids = [cid for cid in clip_ids if cid not in existing_ids]
if invalid_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}",
)
# 执行重排序
updated_count = 0
for item in body.items:
try:
svc.update_clip(item.clip_id, order=item.new_order)
updated_count += 1
except ValueError as e:
logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e)
logger.info(
"批量重排序片段: plan_id=%s count=%d by user=%s",
plan_id,
updated_count,
current_user.user.id,
)
return ClipReorderResponse(
success=True,
updated_count=updated_count,
message=f"成功更新 {updated_count} 个片段的顺序",
)
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse, deprecated=True)
def batch_delete_clips(
plan_id: str,
body: ClipBatchDeleteRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipBatchDeleteResponse:
"""批量删除片段
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
valid_ids = [cid for cid in body.clip_ids if cid in existing_ids]
skipped = len(body.clip_ids) - len(valid_ids)
# 执行删除
deleted_count = 0
for clip_id in valid_ids:
if svc.delete_clip(clip_id):
deleted_count += 1
message = f"成功删除 {deleted_count} 个片段"
if skipped > 0:
message += f",跳过 {skipped} 个不存在的片段"
logger.info(
"批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s",
plan_id,
deleted_count,
skipped,
current_user.user.id,
)
return ClipBatchDeleteResponse(
success=True,
deleted_count=deleted_count,
message=message,
)
@router.post("/from-assets", response_model=ClipsFromAssetsResponse, deprecated=True)
def create_clips_from_assets(
plan_id: str,
body: ClipsFromAssetsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(追加到时间线末尾)
一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。
自动触发编辑状态回退(completed/failed → editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clips = svc.create_clips_from_assets(
plan_id=plan_id,
asset_ids=body.asset_ids,
clip_type=body.clip_type,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
clip_ids = [c.id for c in clips]
logger.info(
"从素材批量创建片段: plan_id=%s count=%d by user=%s",
plan_id,
len(clips),
current_user.user.id,
)
return ClipsFromAssetsResponse(
success=True,
created_count=len(clips),
message=f"成功创建 {len(clips)} 个片段",
clip_ids=clip_ids,
)
-317
View File
@@ -1,317 +0,0 @@
"""封面管理 API.
- GET /{plan_id}/cover 获取封面配置
- PUT /{plan_id}/cover 更新封面配置
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
- POST /{plan_id}/cover/smart 智能选帧生成封面
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.services import EditPlanService
from app.services.cover_service import CoverService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access, deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
# ── Schemas ──────────────────────────────────────────────────────────────────
class CoverConfigResponse(BaseModel):
"""封面配置响应"""
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
image_url: str = Field(default="", description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
class CoverUpdateRequest(BaseModel):
"""更新封面配置请求"""
type: Optional[str] = Field(default=None, description="封面类型")
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
class CoverExtractRequest(BaseModel):
"""从片段抽帧生成封面请求"""
clip_id: str = Field(..., description="片段 ID")
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
class CoverSmartRequest(BaseModel):
"""智能选帧请求"""
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
class CoverGenerateResponse(BaseModel):
"""封面生成响应"""
type: str = Field(..., description="封面类型")
image_url: str = Field(..., description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True)
def get_cover(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""获取封面配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
cover = CoverService.get_cover_config(plan.config or {})
return CoverConfigResponse(**cover)
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True)
def update_cover(
plan_id: str,
body: CoverUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""更新封面配置
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current_cover = CoverService.get_cover_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_cover = {**current_cover, **updates}
# 验证 type 值
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
if "type" in updates and updates["type"] not in valid_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
)
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["cover"] = new_cover
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
result = CoverService.get_cover_config(updated_plan.config or {})
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
return CoverConfigResponse(**result)
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse, deprecated=True)
def extract_cover(
plan_id: str,
body: CoverExtractRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""从指定片段的指定时间点抽帧生成封面"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 获取片段对应的素材
clip = svc.get_clip(body.clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {body.clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材,无法抽帧",
)
# 抽帧生成封面
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.extract_cover_from_clip(
plan_id=plan_id,
asset_id=clip.asset_id,
frame_time=body.frame_time,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"封面抽帧失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
plan_id,
body.clip_id,
body.frame_time,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse, deprecated=True)
def smart_cover(
plan_id: str,
body: CoverSmartRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""智能选帧生成封面
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 确定使用哪个片段
clip_id = body.clip_id
asset_id = ""
if clip_id:
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材",
)
asset_id = clip.asset_id
else:
# 找第一个有素材的视频片段
clips = svc.list_clips(plan_id, limit=50, skip=0)
for c in clips:
if c.asset_id and c.clip_type == "video":
asset_id = c.asset_id
clip_id = c.id
break
if not asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有找到可用的视频片段",
)
# 智能选帧
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.generate_smart_cover(
plan_id=plan_id,
asset_id=asset_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"智能封面生成失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
plan_id,
clip_id,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
@@ -1,276 +0,0 @@
"""导出设置 API.
- GET /{plan_id}/export 获取导出配置
- PUT /{plan_id}/export 更新导出配置
- GET /export-presets 导出预设列表
"""
from __future__ import annotations
import logging
import re
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.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, validator
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access, deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
# ── 导出预设 ──────────────────────────────────────────────────────────────────
EXPORT_PRESETS = [
{
"id": "export_1080p_30",
"name": "1080P 高清",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 8000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "balanced",
"description": "竖屏高清,适合短视频平台",
"size_hint": "约 10MB/分钟",
},
{
"id": "export_1080p_60",
"name": "1080P 高帧率",
"resolution": "1080x1920",
"fps": 60,
"video_bitrate": 12000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "high",
"description": "60帧高帧率,流畅运动画面",
"size_hint": "约 18MB/分钟",
},
{
"id": "export_720p_30",
"name": "720P 流畅",
"resolution": "720x1280",
"fps": 30,
"video_bitrate": 4000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "fast",
"description": "快速导出,文件较小",
"size_hint": "约 5MB/分钟",
},
{
"id": "export_4k_30",
"name": "4K 超清",
"resolution": "2160x3840",
"fps": 30,
"video_bitrate": 20000,
"audio_bitrate": 192,
"format": "mp4",
"quality_preset": "best",
"description": "4K超清画质,专业品质",
"size_hint": "约 30MB/分钟",
},
{
"id": "export_1080p_30_mov",
"name": "1080P ProRes",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 15000,
"audio_bitrate": 256,
"format": "mov",
"quality_preset": "high",
"description": "MOV格式,适合后期剪辑",
"size_hint": "约 25MB/分钟",
},
]
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
VALID_FORMATS = {"mp4", "mov"}
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
# ── Schemas ──────────────────────────────────────────────────────────────────
class ExportConfigResponse(BaseModel):
"""导出配置响应"""
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
watermark_enabled: bool
watermark_text: str
class ExportUpdateRequest(BaseModel):
"""更新导出配置请求"""
resolution: Optional[str] = None
fps: Optional[int] = Field(default=None, ge=15, le=60)
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
format: Optional[str] = None
quality_preset: Optional[str] = None
watermark_enabled: Optional[bool] = None
watermark_text: Optional[str] = None
@validator("resolution")
def validate_resolution(cls, v):
if v is None:
return v
if not RESOLUTION_PATTERN.match(v):
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
w, h = v.split("x")
if int(w) < 100 or int(h) < 100:
raise ValueError("分辨率数值过小")
if int(w) > 4096 or int(h) > 4096:
raise ValueError("分辨率数值过大,最大 4096x4096")
return v
@validator("format")
def validate_format(cls, v):
if v is None:
return v
if v not in VALID_FORMATS:
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
return v
@validator("quality_preset")
def validate_quality_preset(cls, v):
if v is None:
return v
if v not in VALID_QUALITY_PRESETS:
raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}")
return v
class ExportPresetItem(BaseModel):
"""导出预设条目"""
id: str
name: str
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
description: str
size_hint: str
class ExportPresetListResponse(BaseModel):
"""导出预设列表响应"""
items: List[ExportPresetItem]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_export_config(plan_config: dict) -> dict:
e = plan_config.get("export", {})
if not isinstance(e, dict):
e = {}
return {
"resolution": e.get("resolution", "1080x1920"),
"fps": e.get("fps", 30),
"video_bitrate": e.get("video_bitrate", 8000),
"audio_bitrate": e.get("audio_bitrate", 128),
"format": e.get("format", "mp4"),
"quality_preset": e.get("quality_preset", "balanced"),
"watermark_enabled": e.get("watermark_enabled", False),
"watermark_text": e.get("watermark_text", ""),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/export-presets", response_model=ExportPresetListResponse, deprecated=True)
def list_export_presets(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ExportPresetListResponse:
"""获取导出预设列表"""
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
return ExportPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True)
def get_export_config(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""获取导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_export_config(plan.config or {})
return ExportConfigResponse(**config)
@router.put("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True)
def update_export_config(
plan_id: str,
body: ExportUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""更新导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current = _get_export_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_export = {**current, **updates}
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["export"] = new_export
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
result = _get_export_config(updated_plan.config or {})
logger.info(
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
plan_id,
result["resolution"],
result["fps"],
current_user.user.id,
)
return ExportConfigResponse(**result)
@@ -1,199 +0,0 @@
"""滤镜调色 API.
- GET /filter-presets 滤镜预设列表
- GET /{plan_id}/filter 获取全局滤镜配置
- PUT /{plan_id}/filter 更新全局滤镜配置
"""
from __future__ import annotations
import logging
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.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.filter_presets import (
FilterPreset,
get_filter_preset,
list_filter_presets,
)
from ._helpers import check_project_access, deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
# ── Schemas ──────────────────────────────────────────────────────────────────
class FilterPresetResponse(BaseModel):
"""滤镜预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
class FilterConfigResponse(BaseModel):
"""滤镜配置响应"""
enabled: bool
preset_id: str
intensity: int
brightness: float
contrast: float
saturation: float
warmth: float
class FilterUpdateRequest(BaseModel):
"""更新滤镜配置请求"""
enabled: Optional[bool] = None
preset_id: Optional[str] = None
intensity: Optional[int] = Field(default=None, ge=0, le=100)
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
class FilterPresetListResponse(BaseModel):
"""滤镜预设列表响应"""
items: List[FilterPresetResponse]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
return FilterPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
)
def _get_filter_config(plan_config: dict) -> dict:
"""从 plan.config 中提取滤镜配置"""
f = plan_config.get("filter", {})
if not isinstance(f, dict):
f = {}
return {
"enabled": f.get("enabled", False),
"preset_id": f.get("preset_id", "filter_none"),
"intensity": f.get("intensity", 100),
"brightness": f.get("brightness", 0.0),
"contrast": f.get("contrast", 1.0),
"saturation": f.get("saturation", 1.0),
"warmth": f.get("warmth", 0.0),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/filter-presets", response_model=FilterPresetListResponse, deprecated=True)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> FilterPresetListResponse:
"""获取滤镜预设列表"""
presets = list_filter_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return FilterPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True)
def get_filter(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""获取剪辑计划的全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_filter_config(plan.config or {})
return FilterConfigResponse(**config)
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True)
def update_filter(
plan_id: str,
body: FilterUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""更新全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证 preset_id
updates = body.model_dump(exclude_none=True)
if "preset_id" in updates:
preset = get_filter_preset(updates["preset_id"])
if preset is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的滤镜预设: {updates['preset_id']}",
)
# 合并更新
current = _get_filter_config(plan.config or {})
new_filter = {**current, **updates}
# 如果设为原图 preset,自动关闭
if new_filter["preset_id"] == "filter_none":
new_filter["enabled"] = False
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["filter"] = new_filter
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
result = _get_filter_config(updated_plan.config or {})
logger.info(
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
plan_id,
result["preset_id"],
result["intensity"],
current_user.user.id,
)
return FilterConfigResponse(**result)
@@ -20,7 +20,6 @@ from app.api.routes.edit_plans import (
)
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.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
@@ -42,13 +41,9 @@ from packages.application.generation_tasks import (
)
from packages.domain.edit_plan import EditPlanStatus
from ._helpers import deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
router = APIRouter()
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
@@ -136,21 +131,17 @@ def _auto_fallback_auto_material_mode(
asset_library_repo: Any,
asset_repo: Any,
) -> None:
"""自动兜底 4: 项目有视频素材库时,自动选取 ready 视频素材分配给无素材片段
注:原先需要 material_mode=="auto" 才触发,但全代码库没有任何地方设置为 auto,
导致这道兜底防线永远不生效。现改为:只要有 project_id 且存在无素材片段,
就自动从项目视频素材库选取素材兜底,确保一键生成等场景能正常出片。
"""
"""自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取"""
if not clips_without_asset:
return
if not plan_check.project_id:
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无素材片段",
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
@@ -192,7 +183,9 @@ def _auto_fallback_auto_material_mode(
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")
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()
@@ -212,7 +205,7 @@ def _check_queue_limits(gen_task_repo, user_id: str) -> None:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse, deprecated=True)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
def generate_plan(
plan_id: str,
db: Session = Depends(get_db_session),
@@ -248,7 +241,7 @@ def generate_plan(
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)) from 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)
@@ -262,15 +255,12 @@ def generate_plan(
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
# 从 plan.config 中读取 asset_ids 并传递给 GenerationTask
config_asset_ids = (plan.config or {}).get("asset_ids", [])
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id=plan.project_id or "",
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
)
)
@@ -296,7 +286,7 @@ def generate_plan(
)
except HTTPException:
raise
except Exception as _e:
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
@@ -305,27 +295,25 @@ def generate_plan(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
) from _e
)
@router.get(
"/{plan_id}/generation-status",
response_model=EditPlanGenerationStatusResponse,
deprecated=True,
)
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),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> 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)) from exc
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
plan = gen_status["plan"]
if plan.project_id:
@@ -345,32 +333,10 @@ def get_generation_status(
for c in clips
]
# 从 plan.config 中取渲染结果 URL,转换为签名 URL
raw_video_url = (plan.config or {}).get("rendered_url", "")
video_url = ""
if raw_video_url:
try:
video_url = storage_service.get_download_url(raw_video_url, expires_seconds=86400)
except Exception as e:
logger.warning("生成视频签名URL失败,返回原始URL: plan_id=%s error=%s", plan_id, e)
video_url = raw_video_url
# 从 gen_status 中取进度、错误信息、任务状态
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
# 如果计划已完成但进度还是0,补100
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan_status_val,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
@@ -378,7 +344,6 @@ def get_generation_status(
@router.get(
"/{plan_id}/generations",
response_model=EditPlanGenerationsResponse,
deprecated=True,
)
def list_plan_generations(
plan_id: str,
+7 -49
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
from typing import Any, List
from app.api.routes._helpers import auto_select_video_assets, check_project_access
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
@@ -18,24 +18,15 @@ from app.api.routes.edit_plans import (
_to_response,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ._helpers import deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
router = APIRouter()
# ── Timeline Schemas ──────────────────────────────────────────────────────────
@@ -114,7 +105,6 @@ def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
@router.get(
"/{plan_id}/timeline",
response_model=TimelineResponse,
deprecated=True,
)
def get_plan_timeline(
plan_id: str,
@@ -165,15 +155,12 @@ def get_plan_timeline(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
deprecated=True,
)
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),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划"""
from app.services import EditTemplateService
@@ -186,32 +173,15 @@ def generate_from_template(
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)) from 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)
# 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材
resolved_asset_ids = list(body.asset_ids)
if not resolved_asset_ids and body.project_id:
auto_assets = auto_select_video_assets(
project_id=body.project_id,
asset_library_repo=asset_library_repository,
asset_repo=asset_repository,
logger=logger,
)
if auto_assets:
resolved_asset_ids = auto_assets
logger.info(
"generate-from-template 自动选素材: project_id=%s count=%d",
body.project_id,
len(auto_assets),
)
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=resolved_asset_ids,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
@@ -220,20 +190,8 @@ def generate_from_template(
plan = result["plan"]
clips = result["clips"]
# 把 asset_ids 写入 plan.config,供生成时兜底分配使用
if resolved_asset_ids:
from app.services import EditPlanService
from packages.domain.config_schemas import normalize_plan_config
svc = EditPlanService(db)
current_config = plan.config or {}
if current_config.get("asset_ids") != resolved_asset_ids:
current_config["asset_ids"] = resolved_asset_ids
plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config))
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%d",
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
@@ -1,274 +0,0 @@
"""转场特效 API.
- GET /transition-presets 转场预设列表
- PUT /clips/{clip_id}/transition 设置单个片段转场
- POST /{plan_id}/transitions/batch 批量设置转场(所有片段)
"""
from __future__ import annotations
import logging
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.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.transition_presets import (
TransitionPreset,
get_transition_preset,
list_transition_presets,
)
from ._helpers import check_project_access, deprecated_edit_plans_api
logger = logging.getLogger(__name__)
router = APIRouter(
dependencies=[Depends(deprecated_edit_plans_api)],
)
# ── Schemas ──────────────────────────────────────────────────────────────────
class TransitionPresetResponse(BaseModel):
"""转场预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
default_duration: float
min_duration: float
max_duration: float
class TransitionUpdateRequest(BaseModel):
"""更新转场请求"""
effect: str = Field(..., description="转场效果 ID")
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
class BatchTransitionRequest(BaseModel):
"""批量设置转场请求"""
effect: str = Field(..., description="转场效果 ID")
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
apply_to: str = Field(
default="all",
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
)
class ClipTransitionResponse(BaseModel):
"""片段转场信息响应"""
clip_id: str
effect: str
duration: float
class BatchTransitionResponse(BaseModel):
"""批量转场响应"""
updated_count: int
plan_id: str
class TransitionPresetListResponse(BaseModel):
"""转场预设列表响应"""
items: List[TransitionPresetResponse]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: TransitionPreset) -> TransitionPresetResponse:
return TransitionPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
default_duration=p.default_duration,
min_duration=p.min_duration,
max_duration=p.max_duration,
)
def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple[str, float]:
"""验证转场效果和时长,返回 (effect, duration)"""
preset = get_transition_preset(effect)
if preset is None:
raise ValueError(f"无效的转场效果: {effect}")
# 硬切特殊处理,时长强制为0
if effect == "transition_none" or preset.transition == "none":
return "cut", 0.0
final_duration = duration if duration is not None else preset.default_duration
if final_duration < preset.min_duration:
final_duration = preset.min_duration
if final_duration > preset.max_duration:
final_duration = preset.max_duration
return preset.transition, round(final_duration, 3)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/transition-presets", response_model=TransitionPresetListResponse, deprecated=True)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> TransitionPresetListResponse:
"""获取转场预设列表"""
presets = list_transition_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return TransitionPresetListResponse(items=items, total=len(items))
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse, deprecated=True)
def update_clip_transition(
clip_id: str,
body: TransitionUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipTransitionResponse:
"""设置单个片段的转场效果"""
svc = EditPlanService(db)
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = svc.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证转场参数
try:
effect, duration = _validate_transition(body.effect, body.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 更新片段
updated_clip = svc.update_clip(
clip_id,
transition_effect=effect,
transition_duration=duration,
)
logger.info(
"更新片段转场: clip_id=%s effect=%s duration=%.3f by user=%s",
clip_id,
effect,
duration,
current_user.user.id,
)
return ClipTransitionResponse(
clip_id=clip_id,
effect=updated_clip.transition_effect,
duration=updated_clip.transition_duration,
)
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse, deprecated=True)
def batch_update_transitions(
plan_id: str,
body: BatchTransitionRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> BatchTransitionResponse:
"""批量设置计划内所有片段的转场效果
apply_to 说明:
- all: 所有片段
- except_first: 除第一个片段外(第一个片段不需要前转场)
- except_last: 除最后一个片段外
- middle: 只设置中间片段(除首尾)
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证转场参数
try:
effect, duration = _validate_transition(body.effect, body.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 获取所有片段
clips = svc.list_clips(plan_id, limit=500, skip=0)
if not clips:
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
# 确定应用范围
total = len(clips)
if total <= 1:
# 只有一个片段时,只有 all 模式才应用
if body.apply_to != "all":
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
# 按 order 排序
clips_sorted = sorted(clips, key=lambda c: c.order)
indices_to_update = []
if body.apply_to == "all":
indices_to_update = list(range(total))
elif body.apply_to == "except_first":
indices_to_update = list(range(1, total))
elif body.apply_to == "except_last":
indices_to_update = list(range(total - 1))
elif body.apply_to == "middle":
if total <= 2:
indices_to_update = []
else:
indices_to_update = list(range(1, total - 1))
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的 apply_to: {body.apply_to}",
)
# 批量更新
count = 0
for idx in indices_to_update:
clip = clips_sorted[idx]
svc.update_clip(
clip.id,
transition_effect=effect,
transition_duration=duration,
)
count += 1
logger.info(
"批量更新转场: plan_id=%s count=%d effect=%s apply_to=%s by user=%s",
plan_id,
count,
effect,
body.apply_to,
current_user.user.id,
)
return BatchTransitionResponse(updated_count=count, plan_id=plan_id)
+9 -7
View File
@@ -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}")
+5 -59
View File
@@ -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)
+3 -3
View File
@@ -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
+1 -6
View File
@@ -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()
+12 -11
View File
@@ -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")
+15 -17
View File
@@ -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
+3 -3
View File
@@ -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)
+7 -7
View File
@@ -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,
+9 -68
View File
@@ -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:
@@ -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",
+8 -8
View File
@@ -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", "")
+5 -65
View File
@@ -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)
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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):
# 如果配置了路径过滤,只对指定路径限流
-1
View File
@@ -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)
-3
View File
@@ -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
+6 -4
View File
@@ -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):
-13
View File
@@ -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]
+1 -2
View File
@@ -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
-276
View File
@@ -1,276 +0,0 @@
"""封面管理服务.
提供封面配置管理和从视频抽帧生成封面的能力。
抽帧使用 FFmpeg,上传使用共享存储服务。
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
COVER_STORAGE_PREFIX = "covers"
class CoverService:
"""封面管理服务."""
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
self._storage = storage_service
self._asset_repo = asset_repository
# ── 配置读写 ──────────────────────────────────────────────────────────
@staticmethod
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
"""从 plan.config 中提取封面配置.
Args:
plan_config: 剪辑计划的 config 字段
Returns:
封面配置 dict
"""
cover = plan_config.get("cover", {})
if not isinstance(cover, dict):
cover = {}
# 确保默认字段存在
return {
"type": cover.get("type", "ai_frame"),
"image_url": cover.get("image_url", ""),
"frame_time": cover.get("frame_time"),
}
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
def extract_cover_from_clip(
self,
plan_id: str,
asset_id: str,
frame_time: float = 1.0,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""从指定素材的指定时间点抽取一帧作为封面.
Args:
plan_id: 剪辑计划 ID(用于生成存储路径)
asset_id: 素材 ID
frame_time: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict,包含 type / image_url / frame_time
Raises:
ValueError: 素材不存在或不是视频
RuntimeError: 抽帧或上传失败
"""
# 1. 获取素材
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
if not asset:
raise ValueError(f"素材不存在: {asset_id}")
storage_key = getattr(asset, "storage_key", "")
if not storage_key:
raise ValueError(f"素材没有文件: {asset_id}")
mime_type = getattr(asset, "mime_type", "")
if mime_type and not mime_type.startswith("video"):
raise ValueError(f"素材不是视频类型: {mime_type}")
# 2. 下载视频到临时目录
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
tmp_path = Path(tmp_dir)
video_path = tmp_path / f"source_{asset_id[:8]}"
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
try:
self._storage.download_file(storage_key, str(video_path))
except Exception as e:
raise RuntimeError(f"下载素材失败: {e}") from e
if not video_path.exists() or video_path.stat().st_size == 0:
raise RuntimeError("下载的素材文件为空")
# 3. FFmpeg 抽帧
output_path = tmp_path / "cover.jpg"
self._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=frame_time,
width=width,
height=height,
quality=quality,
)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError("封面抽帧失败")
# 4. 上传到 OSS
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
logger.info("上传封面到存储: key=%s", cover_key)
try:
self._storage.upload_file(
file_or_path=str(output_path),
storage_key=cover_key,
content_type="image/jpeg",
)
except Exception as e:
raise RuntimeError(f"上传封面失败: {e}") from e
# 5. 获取访问 URL
try:
image_url = self._storage.get_url(cover_key)
except Exception:
image_url = cover_key # 降级为 storage_key
logger.info(
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
plan_id,
asset_id,
frame_time,
output_path.stat().st_size if output_path.exists() else 0,
)
return {
"type": "manual",
"image_url": image_url,
"frame_time": frame_time,
}
def generate_smart_cover(
self,
plan_id: str,
asset_id: str,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
Args:
plan_id: 剪辑计划 ID
asset_id: 素材 ID
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict
"""
# 简单实现:取视频 1/3 处的帧作为智能封面
# 更复杂的多帧选清晰帧可以后续优化
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
result = self.extract_cover_from_clip(
plan_id=plan_id,
asset_id=asset_id,
frame_time=frame_time,
width=width,
height=height,
quality=quality,
)
result["type"] = "ai_frame"
return result
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _extract_frame(
video_path: Path,
output_path: Path,
*,
time_sec: float,
width: int,
height: int,
quality: int,
) -> None:
"""使用 FFmpeg 从视频中抽取一帧.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
"""
import subprocess
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
# 尝试不使用 scale+crop 的简化命令
simple_command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
result2 = subprocess.run(
simple_command,
capture_output=True,
text=True,
timeout=60,
)
if result2.returncode != 0:
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
except subprocess.TimeoutExpired as e:
raise RuntimeError("FFmpeg 抽帧超时") from e
except FileNotFoundError as e:
raise RuntimeError("FFmpeg 不可用") from e
+1 -590
View File
@@ -141,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,
@@ -169,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,
@@ -229,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,
@@ -325,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,
@@ -374,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:
@@ -419,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)
@@ -447,463 +407,7 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count
def create_clips_from_assets(
self,
plan_id: str,
asset_ids: list[str],
*,
clip_type: str = "main",
) -> list[EditPlanClip]:
"""从素材批量创建片段(追加到时间线末尾)。
Args:
plan_id: 计划 ID
asset_ids: 素材 ID 列表(按顺序追加)
clip_type: 片段类型
Returns:
list[EditPlanClip]: 创建的片段列表
"""
if not asset_ids:
return []
# 确保计划存在 + 自动回退状态
self.get_plan_or_raise(plan_id)
self._auto_resume_editing(plan_id)
# 查询素材信息(取 duration)
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = self._clip_repo.session # type: ignore[attr-defined]
assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
asset_map = {a.id: a for a in assets}
# 从现有片段数量开始追加
existing_count = self._clip_repo.count(plan_id=plan_id)
# 批量创建片段
created: list[EditPlanClip] = []
for i, asset_id in enumerate(asset_ids):
asset = asset_map.get(asset_id)
duration = asset.duration if asset and asset.duration else 0.0
clip = self.create_clip(
plan_id=plan_id,
clip_type=clip_type,
order=existing_count + i,
asset_id=asset_id,
duration=duration,
)
created.append(clip)
logger.info(
"从素材批量创建片段: plan_id=%s count=%d",
plan_id,
len(created),
)
return created
# ── 渲染生成流程 ────────────────────────────────────────────────────────
# ── 片段分割与合并 ──────────────────────────────────────────────────────
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 list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]:
"""获取片段的所有字幕
Returns:
List[dict]: 字幕列表,按 start 时间排序
"""
clip = self.get_clip_or_raise(clip_id)
config = clip.config or {}
subtitles = config.get("subtitles", [])
# 按开始时间排序
subtitles.sort(key=lambda s: s.get("start", 0))
return subtitles
def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]:
"""获取单条字幕"""
subtitles = self.list_subtitles(clip_id)
for s in subtitles:
if s.get("id") == subtitle_id:
return s
return None
def add_subtitle(
self,
clip_id: str,
start: float,
end: float,
text: str,
*,
style: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""添加一条字幕
Args:
clip_id: 片段 ID
start: 开始时间(秒,相对于片段)
end: 结束时间(秒)
text: 字幕文本
style: 样式配置(字体、大小、颜色、位置等)
Returns:
dict: 新增的字幕条目
Raises:
ValueError: 时间非法或文本为空
"""
clip = self.get_clip_or_raise(clip_id)
if start < 0 or end <= start:
raise ValueError(f"字幕时间非法: start={start}, end={end}")
if not text.strip():
raise ValueError("字幕文本不能为空")
if end > clip.duration + 0.001:
raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}")
self._auto_resume_editing(clip.plan_id)
from uuid import uuid4
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
subtitle = {
"id": uuid4().hex,
"start": round(start, 3),
"end": round(end, 3),
"text": text.strip(),
"style": style or {},
}
subtitles.append(subtitle)
subtitles.sort(key=lambda s: s.get("start", 0))
config["subtitles"] = subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info(
"添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs",
clip_id,
subtitle["id"],
start,
end,
)
return subtitle
def update_subtitle(
self,
clip_id: str,
subtitle_id: str,
*,
start: Optional[float] = None,
end: Optional[float] = None,
text: Optional[str] = None,
style: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""更新一条字幕
Returns:
dict: 更新后的字幕条目
Raises:
ValueError: 字幕不存在或参数非法
"""
clip = self.get_clip_or_raise(clip_id)
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
found = False
for i, s in enumerate(subtitles):
if s.get("id") == subtitle_id:
# 更新字段
updated_s = dict(s)
if start is not None:
updated_s["start"] = round(start, 3)
if end is not None:
updated_s["end"] = round(end, 3)
if text is not None:
if not text.strip():
raise ValueError("字幕文本不能为空")
updated_s["text"] = text.strip()
if style is not None:
updated_s["style"] = style
# 校验时间
if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]:
raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}")
if updated_s["end"] > clip.duration + 0.001:
raise ValueError("字幕结束时间不能超过片段时长")
subtitles[i] = updated_s
found = True
break
if not found:
raise ValueError(f"字幕不存在: {subtitle_id}")
self._auto_resume_editing(clip.plan_id)
subtitles.sort(key=lambda s: s.get("start", 0))
config["subtitles"] = subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)]
def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool:
"""删除一条字幕
Returns:
bool: 是否删除成功
"""
clip = self.get_clip_or_raise(clip_id)
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
if len(new_subtitles) == len(subtitles):
return False
self._auto_resume_editing(clip.plan_id)
config["subtitles"] = new_subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
return True
def batch_update_subtitles(
self,
clip_id: str,
subtitles: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""批量更新字幕(全量替换,用于批量编辑或导入)
Args:
clip_id: 片段 ID
subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留
Returns:
List[dict]: 更新后的字幕列表
"""
clip = self.get_clip_or_raise(clip_id)
from uuid import uuid4
validated = []
for s in subtitles:
start = float(s.get("start", 0))
end = float(s.get("end", 0))
text = str(s.get("text", ""))
if start < 0 or end <= start:
raise ValueError(f"字幕时间非法: start={start}, end={end}")
if not text.strip():
continue # 跳过空字幕
if end > clip.duration + 0.001:
raise ValueError(f"字幕结束时间不能超过片段时长: end={end}")
subtitle_id = s.get("id") or uuid4().hex
validated.append(
{
"id": subtitle_id,
"start": round(start, 3),
"end": round(end, 3),
"text": text.strip(),
"style": s.get("style", {}),
}
)
validated.sort(key=lambda s: s["start"])
self._auto_resume_editing(clip.plan_id)
config = dict(clip.config) if clip.config else {}
config["subtitles"] = validated
clip.config = config
self._clip_repo.update(clip)
logger.info(
"批量更新字幕: clip_id=%s count=%d",
clip_id,
len(validated),
)
return validated
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
"""获取计划及其所有片段
@@ -927,8 +431,6 @@ class EditPlanService:
"clips": List[EditPlanClip],
"generation_task_id": Optional[str],
"generation_task_status": Optional[str],
"progress": float,
"error_message": str,
}
Raises:
@@ -940,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]:
@@ -1007,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(
@@ -1026,85 +519,3 @@ class EditPlanService:
updated_at=plan.updated_at,
)
return self._plan_repo.update(updated)
# ── 复制计划 ────────────────────────────────────────────────────────────
def copy_plan(
self,
plan_id: str,
*,
new_name: Optional[str] = None,
project_id: Optional[str] = None,
) -> EditPlan:
"""复制一个剪辑计划(含所有片段配置)。
新计划状态为 editing,不含生成任务和结果记录。
Args:
plan_id: 源计划 ID
new_name: 新计划名称,不传则为「原名 - 副本」
project_id: 新计划的项目 ID,不传则复用源计划
Returns:
EditPlan: 新创建的计划
Raises:
ValueError: 源计划不存在
"""
source = self.get_plan_or_raise(plan_id)
source_clips = self._clip_repo.list_by_plan(plan_id)
# 新计划名称
name = new_name or f"{source.name} - 副本"
new_project_id = project_id if project_id is not None else source.project_id
# 复制 plan 配置(去除渲染结果相关字段)
new_config = dict(source.config)
new_config.pop("rendered_url", None)
new_config.pop("rendered_storage_key", None)
new_config.pop("generation_task_id", None)
# 创建新计划
new_plan = EditPlan.create(
template_id=source.template_id,
name=name,
config=new_config,
total_duration=source.total_duration,
project_id=new_project_id,
created_by_user_id=source.created_by_user_id,
source_edit_plan_id=plan_id,
)
# 强制切到 editing 状态
if new_plan.status != EditPlanStatus.EDITING:
try:
new_plan.start_editing()
except ValueError:
pass
created_plan = self._plan_repo.create(new_plan)
logger.info(
"复制剪辑计划: source=%s target=%s name=%s clips=%d",
plan_id,
created_plan.id,
name,
len(source_clips),
)
# 复制所有片段
for clip in source_clips:
new_clip = self.create_clip(
plan_id=created_plan.id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
config=dict(clip.config) if clip.config else None,
)
logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order)
return self.get_plan_or_raise(created_plan.id)
-563
View File
@@ -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)
# 批量删除旧的片段配置(走 repository,保证测试 stub 和真实行为一致)
self._clip_config_repo.delete_by_template(template_id)
# 创建新的片段配置
created_configs: list[TemplateClipConfig] = []
for clip in draft_clips:
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import (
TransitionEffect,
)
transition = TransitionEffect(clip.transition_effect)
except (ValueError, ImportError):
transition = TransitionEffect.CUT # type: ignore
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip.clip_type)
except (ValueError, ImportError):
clip_type = ClipType.MAIN # type: ignore
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(config_obj)
created_configs.append(created)
self._db.commit()
logger.info(
"发布模板草稿: template_id=%s draft_plan_id=%s clip_count=%d",
template_id,
draft_plan_id,
len(created_configs),
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"发布模板草稿失败: template_id=%s draft_plan_id=%s error=%s",
template_id,
draft_plan_id,
exc,
)
raise
# ── 版本历史与回滚 ────────────────────────────────────────────────────
def list_template_versions(self, template_id: str, limit: int = 50) -> list[Any]:
"""列出模板的发布版本历史(按版本号倒序)"""
self.get_template_or_raise(template_id) # 校验存在性
return self._version_repo.list_by_template(template_id, limit=limit)
def rollback_to_version(self, template_id: str, version: int) -> Any:
"""回滚模板到指定历史版本
流程
1. 校验目标版本存在
2. 保存当前状态为新版本快照当前版本号
3. 用目标版本的快照覆盖模板 config + clip_configs
4. 版本号 +1回滚本身也是一次发布
Returns:
EditTemplate: 回滚后的模板
Raises:
ValueError: 模板/版本不存在
"""
from packages.domain.template_clip_config import TemplateClipConfig
template = self.get_template_or_raise(template_id)
# 1. 读取目标版本快照
target_version = self._version_repo.get_by_version(template_id, version)
if target_version is None:
raise ValueError(f"模板 {template_id} 不存在版本 {version}")
current_version = template.version or 1
try:
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = [
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion
current_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=current_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{current_version} 快照(回滚到 v{version} 前)",
published_by="rollback",
)
self._version_repo.create(current_snapshot)
# 3. 覆盖模板配置 + editing_mode + name + preview_url
template.config = dict(target_version.config)
template.editing_mode = target_version.editing_mode
if target_version.name:
template.name = target_version.name
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 4. 先删后插 clip_configs(批量删除避免N+1
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
)
self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete(
synchronize_session=False
)
for clip_snap in target_version.clip_configs:
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip_snap.get("clip_type", "main"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import ClipType
clip_type = ClipType.MAIN
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip_snap.get("order", 0),
min_duration=clip_snap.get("min_duration", 0.0),
max_duration=clip_snap.get("max_duration", 0.0),
text_template=clip_snap.get("text_template", ""),
transition_effect=transition,
config=clip_snap.get("config", {}) or {},
)
self._clip_config_repo.create(config_obj)
self._db.commit()
logger.info(
"模板回滚成功: template_id=%s from_v=%d to_v=%d new_v=%d",
template_id,
current_version,
version,
updated_template.version,
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"模板回滚失败: template_id=%s target_version=%d error=%s",
template_id,
version,
exc,
)
raise
+6 -52
View File
@@ -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_typeoverlay/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 clipsB-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,
+18 -43
View File
@@ -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: 统一像素格式为 yuv420pH.264 标准格式,concat 要求所有输入像素格式一致)
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
filters.append("format=yuv420p")
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
if fps and fps > 0:
filters.append(f"fps={fps}")
# 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
View File
@@ -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: "^_" }],
},
}
};
-12
View File
@@ -1,12 +0,0 @@
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/extensions.json
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-12
View File
@@ -1,12 +0,0 @@
{
"semi": false,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false
}
+4 -4
View File
@@ -91,17 +91,17 @@ VITE_API_URL=http://localhost:8000
使用 Zustand 创建 Store
```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
View File
@@ -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/);
});
});
+5 -5
View File
@@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test"
import { expect, test } from "@playwright/test";
test.describe("App route guard", () => {
test("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/);
});
});
+7 -7
View File
@@ -1,10 +1,10 @@
import { expect, test } from "@playwright/test"
import { expect, test } from "@playwright/test";
test.describe("Authentication page", () => {
test("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();
});
});
+123 -118
View File
@@ -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 5-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,43 +174,43 @@ test.describe("Core generation flow", () => {
email_verified: true,
},
},
)
);
// Navigate to generate page
await page.goto("/app/generate")
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: title
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
const titleText = `E2E Test ${suffix}`
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
await page.getByRole("button", { name: "下一步" }).click()
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()
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()
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible();
// Wait for plan creation API to be called
const createPlanPromise = page.waitForResponse(
@@ -218,64 +219,68 @@ test.describe("Core generation flow", () => {
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 plan was created successfully
const planResp = await createPlanPromise
expect(planResp.ok()).toBeTruthy()
const planData = (await planResp.json()) as { id: string }
expect(planData.id).toBeTruthy()
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);
});
});
+76 -66
View File
@@ -1,19 +1,23 @@
import { expect, test, type APIRequestContext } from "@playwright/test"
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!"
const 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();
});
});
+89 -80
View File
@@ -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
View File
@@ -4,29 +4,33 @@
*
*
*/
import { expect, test, type APIRequestContext } from "@playwright/test"
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!"
const 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/);
});
});
+191 -150
View File
@@ -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
View File
@@ -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_urlvideo 元素应能渲染)
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
View File
@@ -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
View File
@@ -4,17 +4,17 @@
*
* 使
*/
import { expect, test, type APIRequestContext } from "@playwright/test"
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!"
const 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();
});
});
+264 -212
View File
@@ -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
View File
@@ -3,17 +3,17 @@
*
*
*/
import { expect, test, type APIRequestContext } from "@playwright/test"
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!"
const 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
View File
@@ -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
View File
@@ -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
View File
@@ -4,29 +4,29 @@
* //
* 使
*/
import { expect, test, type APIRequestContext } from "@playwright/test"
import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "Test123456!"
const 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 }) => {
// 使用一个伪造的过期 JWTheader.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());
});
});
+94 -84
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
});
+3 -222
View File
@@ -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 -9
View File
@@ -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"
+3 -3
View File
@@ -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
View File
@@ -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;
};
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { normalizeUser } from "./auth";
describe("normalizeUser", () => {
it("normalizes canonical API current-user fields", () => {
expect(
normalizeUser({
user_id: "user-1",
email: "user@example.com",
username: "user",
display_name: "User",
email_verified: true,
}),
).toEqual({
id: "user-1",
user_id: "user-1",
email: "user@example.com",
username: "user",
display_name: "User",
is_email_verified: true,
email_verified: true,
created_at: undefined,
});
});
it("keeps compatibility with legacy UI-shaped user fields", () => {
expect(
normalizeUser({
id: "user-2",
email: "legacy@example.com",
username: "legacy",
display_name: "Legacy",
is_email_verified: false,
created_at: "2026-06-22T00:00:00Z",
}),
).toEqual({
id: "user-2",
user_id: "user-2",
email: "legacy@example.com",
username: "legacy",
display_name: "Legacy",
is_email_verified: false,
email_verified: false,
created_at: "2026-06-22T00:00:00Z",
});
});
});
+72 -64
View File
@@ -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,12 +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 const verifyEmail = async (
token: string,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/verify-email", { token });
return response.data;
};
+28 -26
View File
@@ -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
View File
@@ -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;
+54 -43
View File
@@ -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;
};
Executable → Regular
+272 -440
View File
@@ -1,9 +1,9 @@
/**
* 稿 API Template Editor Schema
* API Edit Plans Schema
* API
*/
import apiClient from "./client"
import type { AssetItem } from "./assets"
import apiClient from "./client";
import type { AssetItem } from "./assets";
import type {
WatermarkConfig,
IntroOutroConfig,
@@ -12,213 +12,202 @@ import type {
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
} from "@/pages/editing-planner/types";
/* ============================================================
* API Schema
* ============================================================ */
/** 模板草稿状态枚举 */
/** 剪辑计划状态枚举 */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
"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
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;
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string
text: string
voice_id: string
speed: number
pitch: number
volume: number
subtitle_sync: boolean
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
start_time: number;
end_time: number;
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string
duration: number
type: string;
duration: number;
}
/** 模板草稿中的单个片段(config 内部 segments 项) */
/** 剪辑计划中的单个片段(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
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 结构) */
/** 剪辑计划 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
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[]
asset_ids?: string[];
/** 配音 ID */
voice_id?: string
voice_id?: string;
/** 克隆音色档案 ID */
voice_clone_profile_id?: string
voice_clone_profile_id?: string;
/** 自定义配音音频 URL */
custom_audio_url?: string
custom_audio_url?: string;
/** 自定义配音文本 */
custom_text?: string
custom_text?: string;
/** 视频比例 */
ratio?: string
ratio?: string;
/** 视频风格 */
style?: string
style?: string;
/** 目标时长(秒) */
duration?: number
duration?: number;
/** 是否自动生成字幕 */
auto_subtitles?: boolean
auto_subtitles?: boolean;
/** 是否启用 BGM */
bgm?: boolean
bgm?: boolean;
/** 生成数量 */
generate_count?: number
generate_count?: number;
/** 素材模式 */
material_mode?: string
material_mode?: string;
}
/** 模板草稿(后端响应) */
/** 剪辑计划(后端响应) */
export interface EditPlan {
id: string
template_id: string
name: string
status: EditPlanStatus
total_duration: number
/** 生成视频数量(后端 EditPlanResponse.result_count */
result_count: number
config: EditPlanConfig
created_at: string
updated_at: string
id: string;
template_id: string;
name: string;
status: EditPlanStatus;
total_duration: number;
config: EditPlanConfig;
created_at: string;
updated_at: string;
}
/** 创建模板草稿请求(后端要求 template_id + name 必填) */
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest {
template_id: string
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源模板草稿 ID(从模板编辑器跳转到智能剪辑时关联) */
source_edit_plan_id?: string
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
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
plan_id: string;
plan_status: EditPlanStatus;
generation_task_id: string;
clip_count: number;
}
/** 模板草稿关联的生成记录(实际是 GenerationTask 对象) */
/** 剪辑计划关联的生成记录 */
export interface EditPlanGeneration {
id: string // 即 generation_task_id
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
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
error_message?: string
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
error_message?: string
clips: ClipStatusItem[]
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
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;
}
/* ============================================================
@@ -227,57 +216,75 @@ export interface GeneratedVideo {
/** AI 推荐请求 */
export interface AIRecommendRequest {
asset_ids: string[]
editing_mode?: string
target_duration?: number
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
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
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
asset_ids: string[];
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate";
frame_time?: number;
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
plan_id: string;
cover: CoverResult;
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
thumbnail_url?: string
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:
@@ -295,96 +302,106 @@ export interface TransitionEffect {
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
duration: number // 转场时长(秒)
| "rectcrop";
duration: number; // 转场时长(秒)
/** 播放速度倍率 */
playback_speed?: number
playback_speed?: number;
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
export interface MediaAsset {
id: string
name: string
type: "video" | "image" | "audio"
id: string;
name: string;
type: "video" | "image" | "audio";
/** 缩略图 URL */
thumbnail_url?: string
thumbnail_url?: string;
/** 时长(秒),仅 video/audio */
duration?: number
duration?: number;
/** 文件大小(字节) */
size?: number
size?: number;
/** 标签 */
tags: string[]
created_at: string
tags: string[];
created_at: string;
/** 质量分 0-100 */
quality_score?: number
quality_score?: number;
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed"
classification_status?: "pending" | "processing" | "completed" | "failed";
}
/* ============================================================
* API
* ============================================================ */
/** 模板草稿列表查询参数 */
/** 剪辑计划列表查询参数 */
export interface EditPlanListParams {
page?: number
page_size?: number
template_id?: string
status?: string
page?: number;
page_size?: number;
template_id?: string;
status?: string;
}
/** 模板草稿列表分页响应 */
/** 剪辑计划列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[]
total: number
page: number
page_size: number
items: EditPlan[];
total: number;
page: number;
page_size: number;
}
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
/** 获取剪辑计划列表(支持分页和筛选) */
export async function getEditPlans(
params?: EditPlanListParams,
): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
params,
})
return response.data
});
return response.data;
}
/** 获取单个模板草稿 */
/** 获取单个剪辑计划 */
export async function getEditPlan(planId: string): Promise<EditPlan> {
const response = await apiClient.get(`/edit-plans/${planId}`)
return response.data
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 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
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}`)
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 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
/** 获取剪辑计划生成状态(轮询用) */
export async function getGenerationStatus(
planId: string,
): Promise<GenerationStatusResponse> {
const response = await apiClient.get(
`/edit-plans/${planId}/generation-status`,
);
return response.data;
}
/** AI 推荐片段方案 */
@@ -392,8 +409,11 @@ export async function aiRecommendClips(
planId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(`/edit-plans/${planId}/ai-recommend`, data)
return response.data
const response = await apiClient.post(
`/edit-plans/${planId}/ai-recommend`,
data,
);
return response.data;
}
/** AI 生成封面 */
@@ -401,236 +421,47 @@ export async function generateCover(
planId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/edit-plans/${planId}/generate-cover`, data)
return response.data
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 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 || []
}
/** 取消生成任务 */
export async function cancelGeneration(planId: string): Promise<void> {
await apiClient.post(`/edit-plans/${planId}/cancel`)
}
/* ============================================================
* CRUD EditPlanClip
* ============================================================ */
/** 片段状态 */
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
/** 剪辑片段(后端响应) */
export interface EditPlanClip {
id: string
plan_id: string
clip_type: string // main / intro / outro / overlay / background / b_roll 等
order: number
asset_id: string
text_content: string
start_time: number
duration: number
transition_effect: string
transition_duration: number
playback_speed: number
status: EditPlanClipStatus
config: Record<string, unknown>
created_at?: string
updated_at?: string
}
/** 创建片段请求 */
export interface CreateEditPlanClipRequest {
clip_type: string
order: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 更新片段请求 */
export interface UpdateEditPlanClipRequest {
clip_type?: string
order?: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 片段列表响应 */
export interface EditPlanClipListResponse {
items: EditPlanClip[]
total: number
}
/** 片段列表查询参数 */
export interface EditPlanClipListParams {
status?: string
skip?: number
limit?: number
}
/** 获取片段列表 */
export async function getEditPlanClips(
planId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(`/edit-plans/${planId}/clips`, {
params,
})
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(planId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
planId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/edit-plans/${planId}/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
planId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`, data)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(planId: string, clipId: string): Promise<void> {
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`)
}
/* ============================================================
*
* ============================================================ */
/** 重排序条目 */
export interface ClipReorderItem {
clip_id: string
new_order: number
}
/** 重排序响应 */
export interface ClipReorderResponse {
success: boolean
updated_count: number
message: string
}
/** 批量删除响应 */
export interface ClipBatchDeleteResponse {
success: boolean
deleted_count: number
message: string
}
/** 从素材批量创建响应 */
export interface ClipsFromAssetsResponse {
success: boolean
created_count: number
message: string
clip_ids: string[]
}
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
planId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/edit-plans/${planId}/clips/reorder`,
{ items },
)
return response.data
}
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
planId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/edit-plans/${planId}/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
planId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/edit-plans/${planId}/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
/* ============================================================
*
* ============================================================ */
/** 复制计划请求 */
export interface CopyEditPlanRequest {
name?: string
project_id?: string
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(planId: string, data?: CopyEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(`/edit-plans/${planId}/copy`, data || {})
return response.data
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[]> {
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)
});
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)
const response = await apiClient.get(`/assets/${id}`);
return mapAssetToMediaAsset(response.data);
}
/* ============================================================
@@ -638,15 +469,17 @@ export async function getMediaAsset(id: string): Promise<MediaAsset> {
* ============================================================ */
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("image/")) return "image"
return "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
typeof asset.metadata?.duration === "number"
? asset.metadata.duration
: undefined;
return {
id: asset.id,
name: asset.name,
@@ -658,7 +491,7 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined,
}
};
}
/* ============================================================
@@ -667,9 +500,9 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
/** 转场效果选项(14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"]
label: string
icon: string
value: TransitionEffect["type"];
label: string;
icon: string;
}[] = [
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
@@ -686,7 +519,7 @@ export const TRANSITION_OPTIONS: {
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
]
];
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
@@ -694,7 +527,7 @@ export const MATERIAL_TYPE_LABELS: Record<string, string> = {
image: "图片",
audio: "音频",
voiceover: "配音",
}
};
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
@@ -702,7 +535,7 @@ export const MATERIAL_TYPE_ICONS: Record<string, string> = {
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
}
};
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
@@ -711,18 +544,17 @@ export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
}
};
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string
label: string
min?: number
max?: number
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
View File
@@ -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
View File
@@ -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_urlOSS签名
*/
/** 获取成品下载链接 — 从 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 };
};
+22 -21
View File
@@ -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: "系统自动创建的默认项目",
})
}
});
};
+55 -53
View File
@@ -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
View File
@@ -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}`);
};
+70 -64
View File
@@ -6,118 +6,124 @@
* - GET /api/v1/tasks/{task_id} error_info
* - POST /api/v1/tasks/{task_id}/retry
*/
import apiClient from "./client"
import apiClient from "./client";
/* ──────────── 类型定义 ──────────── */
/** 任务状态 */
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
export type TaskStatus =
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
/** 任务类型 */
export type TaskType = "ingest" | "generation" | string
export type TaskType = "ingest" | "generation" | string;
/** 错误详情 */
export interface TaskErrorInfo {
error_type: string
error_message: string
failed_step: string
stack_trace?: string
error_type: string;
error_message: string;
failed_step: string;
stack_trace?: string;
}
/** 任务条目(对应用户级 UserTaskResponse */
export interface TaskItem {
id: string
task_type: TaskType
project_id: string
template_id?: string
status: TaskStatus
progress: number
current_step: string
error_message: string
user_message: string
retryable: boolean
source_id: string
id: string;
task_type: TaskType;
project_id: string;
template_id?: string;
status: TaskStatus;
progress: number;
current_step: string;
error_message: string;
user_message: string;
retryable: boolean;
source_id: string;
/** 错误详情(失败任务) */
error_info?: TaskErrorInfo
error_info?: TaskErrorInfo;
/** 耗时(秒) */
duration_seconds?: number
created_at?: string | null
updated_at?: string | null
duration_seconds?: number;
created_at?: string | null;
updated_at?: string | null;
}
/** 任务列表查询参数 */
export interface TaskListParams {
page?: number
page_size?: number
status?: TaskStatus | "all"
task_type?: TaskType | "all"
page?: number;
page_size?: number;
status?: TaskStatus | "all";
task_type?: TaskType | "all";
}
/** 任务列表分页响应 */
export interface TaskListResponse {
items: TaskItem[]
total: number
page: number
page_size: number
items: TaskItem[];
total: number;
page: number;
page_size: number;
}
/** 创建生成任务请求参数 */
export interface CreateGenerationTaskRequest {
template_id: string
asset_ids: string[]
title_ids: string[]
voice_ids: string[]
template_id: string;
asset_ids: string[];
title_ids: string[];
voice_ids: string[];
}
/** 创建生成任务响应(对齐后端 GenerationTaskResponse */
export interface CreateGenerationTaskResponse {
id: string
project_id: string
asset_library_id: string
strategy_id: string
voice_library_id: string
template_id: string
asset_ids: string[]
title_ids: string[]
voice_ids: string[]
status: string
progress: number
result_count: number
error_message: string
id: string;
project_id: string;
asset_library_id: string;
strategy_id: string;
voice_library_id: string;
template_id: string;
asset_ids: string[];
title_ids: string[];
voice_ids: string[];
status: string;
progress: number;
result_count: number;
error_message: string;
}
/* ──────────── API 函数 ──────────── */
/** 创建生成任务(智能剪辑 */
/** 创建生成任务(一键生成 */
export const createGenerationTask = async (
params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
return data
}
const { data } = await apiClient.post<CreateGenerationTaskResponse>(
"/generation/tasks",
params,
);
return data;
};
/** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
export const getTasks = async (
params?: TaskListParams,
): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params,
})
return data
}
});
return data;
};
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks")
return data.items || data || []
}
const { data } = await apiClient.get("/tasks");
return data.items || data || [];
};
/** 获取单个任务详情(含 error_info */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`)
return data
}
const { data } = await apiClient.get(`/tasks/${taskId}`);
return data;
};
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
return data
}
const { data } = await apiClient.post(`/tasks/${taskId}/retry`);
return data;
};

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